feat: polish nightdrive road sun and signage

This commit is contained in:
2026-09-03 00:29:26 +03:00
parent bc18bc2043
commit a03d4d8dba
44 changed files with 1208 additions and 202 deletions

View File

@@ -1,4 +1,4 @@
import { expect, test } from '../helpers/serve-dist';
import { clickNavigationLink, expect, test } from '../helpers/serve-dist';
import {
installSceneInstrumentation,
readSceneInstrumentation,
@@ -104,6 +104,65 @@ test('draws only the independently eligible next roadside sign during passive fo
expect([...new Set(labels)]).toEqual([oracle.expectedLabel]);
});
test('keeps a readable sign immutable through reversal, resize, and a multi-section jump', async ({
page,
distUrl,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
const oracle = await page.evaluate(() => {
const profile = document.getElementById('profile-heading');
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
return {
destination: Math.max(1, headingTop - viewportHeight * (0.35 + 0.8)),
viewportHeight,
};
});
await page.mouse.wheel(0, 1);
for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) {
await page.evaluate((nextY) => window.scrollTo(0, nextY), y);
await waitForOneAnimationFrame(page);
}
await page.evaluate((destination) => window.scrollTo(0, destination), oracle.destination);
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn)
.toContain('Profile');
await page.waitForTimeout(1_250);
await page.mouse.wheel(0, -120);
await page.setViewportSize({ width: 844, height: 390 });
await page.evaluate(() => {
const target = document.getElementById('selected-builds-heading');
if (!(target instanceof HTMLElement)) throw new Error('Missing selected builds heading');
window.scrollTo(0, target.getBoundingClientRect().top + window.scrollY);
});
await page.waitForTimeout(450);
const labels = (await readSceneInstrumentation(page)).routeLabelsDrawn;
expect([...new Set(labels)]).toEqual(['Profile']);
expect((await readSceneInstrumentation(page)).maximumCanvasCount).toBeLessThanOrEqual(1);
});
test('suppresses roadside signs for explicit navigation and history restoration', async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await clickNavigationLink(page, '#selected-builds');
await expect(page.locator('#selected-builds-heading')).toBeFocused();
await page.waitForTimeout(400);
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
await page.goBack({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
});
function colorEnergy(color: string): number {
const value = Number.parseInt(color.slice(1), 16);
return ((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff);

View File

@@ -101,16 +101,32 @@ describe('clock-driven sign lifecycle', () => {
expect(controller.needsFrame()).toBe(true);
});
it('cancels an unreadable sign on reverse and departs a readable sign cleanly', () => {
it('cancels a concealed sign on reverse and departs a visible approaching sign cleanly', () => {
const early = createSignController();
early.sample(sample());
expect(early.sample(sample({ now: 500, direction: -1, deltaY: -20 }))).toBeNull();
expect(early.sample(sample({ now: 310, direction: -1, deltaY: -20 }))).toBeNull();
expect(early.needsFrame()).toBe(false);
const readable = createSignController();
readable.sample(sample());
readable.sample(sample({ now: 1_200, direction: 0 }));
expect(readable.sample(sample({ now: 1_500, direction: -1, deltaY: -20 }))).toMatchObject({
expect(readable.sample(sample({ now: 700, direction: -1, deltaY: -20 }))).toMatchObject({
phase: 'depart',
progress: 0,
});
});
it('preserves the minimum readable dwell when reverse input requests departure', () => {
const controller = createSignController();
controller.sample(sample());
controller.sample(sample({ now: 1_200, direction: 0 }));
expect(controller.sample(sample({ now: 1_500, direction: -1, deltaY: -20 }))).toMatchObject({
phase: 'dwell',
});
expect(controller.sample(sample({ now: 2_599, direction: 0, deltaY: 0 }))).toMatchObject({
phase: 'dwell',
});
expect(controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }))).toMatchObject({
phase: 'depart',
progress: 0,
});
@@ -132,18 +148,22 @@ describe('clock-driven sign lifecycle', () => {
it.each([
['multiple crossed sections', { crossedBoundaries: 2 }],
['a jump over 0.75 viewport heights', { deltaY: 601 }],
])('departs a readable stale sign cleanly after %s', (_label, overrides) => {
])('coalesces %s while preserving the readable dwell', (_label, overrides) => {
const controller = createSignController();
controller.sample(sample());
controller.sample(sample({ now: 1_200, direction: 0 }));
expect(controller.sample(sample({ now: 1_400, ...overrides }))).toMatchObject({
targetId: 'profile',
phase: 'dwell',
});
expect(controller.needsFrame()).toBe(true);
expect(controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }))).toMatchObject({
targetId: 'profile',
phase: 'depart',
progress: 0,
opacity: 1,
});
expect(controller.needsFrame()).toBe(true);
});
it('departs when the authoritative section has already passed the latched target', () => {
@@ -161,6 +181,33 @@ describe('clock-driven sign lifecycle', () => {
});
});
it('does not truncate dwell when the authoritative section passes the target', () => {
const controller = createSignController();
controller.sample(sample());
controller.sample(sample({ now: 1_200, direction: 0 }));
expect(controller.sample(sample({
now: 1_350,
direction: 1,
activeSectionId: 'experience',
candidateId: 'infrastructure',
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
expect(controller.sample(sample({
now: 2_599,
direction: 0,
deltaY: 0,
activeSectionId: 'experience',
candidateId: 'infrastructure',
}))).toMatchObject({ phase: 'dwell' });
expect(controller.sample(sample({
now: 2_600,
direction: 0,
deltaY: 0,
activeSectionId: 'experience',
candidateId: 'infrastructure',
}))).toMatchObject({ phase: 'depart', progress: 0 });
});
it('retargets a changed far-away candidate only while the sign is concealed', () => {
const controller = createSignController();
controller.sample(sample());

View File

@@ -14,11 +14,42 @@ import { createSun } from "@/scene/world/sun";
import { createAtmosphere } from "@/scene/world/atmosphere";
import { createPostProcessor } from "@/scene/post-processing";
import { createCar } from "@/scene/world/car";
import { createChaseCamera } from "@/scene/world/environment";
import { createChaseCamera, createWorldFog } from "@/scene/world/environment";
import {
HORIZON_FOG_END,
HORIZON_FOG_START,
} from "@/scene/world/metrics";
import { createSign, routeLabelFor } from "@/scene/world/sign";
import { createDriveDynamics } from "@/scene/drive-dynamics";
import { createTravelDistance } from "@/scene/world/travel-distance";
describe("procedural world models", () => {
it("uses the same linear horizon interval for scene-owned materials", () => {
const fog = createWorldFog(0x07101c);
expect(fog).toBeInstanceOf(THREE.Fog);
expect(fog.near).toBe(HORIZON_FOG_START);
expect(fog.far).toBe(HORIZON_FOG_END);
});
it("owns one monotonic forward-distance clock across cruise, input release, and delayed frames", () => {
const travel = createTravelDistance();
const cruise = travel.advance(0.1, 0.18, 0);
const accelerated = travel.advance(0.1, 1, 1);
const coast = travel.advance(0.1, 0.18, 0);
const delayed = travel.advance(4, 0.18, 0);
const malformed = travel.advance(Number.NaN, -1, Number.POSITIVE_INFINITY);
expect(cruise).toBeGreaterThan(0);
expect(accelerated).toBeGreaterThan(cruise);
expect(coast).toBeGreaterThan(accelerated);
expect(delayed).toBeGreaterThan(coast);
expect(delayed - coast).toBeCloseTo(cruise, 10);
expect(malformed).toBe(delayed);
expect(travel.snapshot()).toBe(delayed);
});
it("scales geometry and atmosphere work monotonically by live quality tier", () => {
const high = renderQualityFor("high", 3, false);
const medium = renderQualityFor("medium", 3, false);
@@ -44,10 +75,13 @@ describe("procedural world models", () => {
expect(surface.geometry.getAttribute("position").count).toBeGreaterThan(
1_000,
);
surface.geometry.computeBoundingBox();
expect(surface.geometry.boundingBox?.min.z).toBeLessThan(-700);
expect(surface.material.uniforms).toMatchObject({
uDistance: { value: 0 },
uSpeed: { value: 0 },
uAcceleration: { value: 0 },
uFogColor: { value: expect.any(THREE.Color) },
});
expect(surface.material.fragmentShader).toContain("centerLane");
expect(surface.material.fragmentShader).toContain("outerEdge");
@@ -80,6 +114,7 @@ describe("procedural world models", () => {
expect(lines.geometry.index).not.toBeNull();
lines.geometry.computeBoundingBox();
expect(lines.geometry.boundingBox).not.toBeNull();
expect(lines.geometry.boundingBox?.min.z).toBeLessThan(-700);
expect(
(lines.geometry.boundingBox?.max.x ?? 0) -
(lines.geometry.boundingBox?.min.x ?? 0),
@@ -93,8 +128,10 @@ describe("procedural world models", () => {
expect(lines.material.uniforms).toMatchObject({
uDistance: { value: 0 },
uSpeed: { value: 0 },
uFlowDistance: { value: 0 },
uRouteProgress: { value: 0 },
uRelief: { value: expect.any(Number) },
uFogColor: { value: expect.any(THREE.Color) },
});
expect(lines.material.vertexShader).toContain("multiOctaveNoise");
expect(lines.material.vertexShader).toContain("mod(point, 256.0)");
@@ -163,21 +200,28 @@ describe("procedural world models", () => {
const sun = createSun(resources);
const disc = sun.group.getObjectByName("horizon-sun") as ShaderRenderable;
const halo = sun.group.getObjectByName("horizon-halo");
const limb = sun.group.getObjectByName("sun-limb-glow");
const bleed = sun.group.getObjectByName("horizon-light-bleed");
expect(disc).toBeInstanceOf(THREE.Mesh);
expect(halo).toBeInstanceOf(THREE.Sprite);
expect(limb).toBeInstanceOf(THREE.Mesh);
expect(bleed).toBeInstanceOf(THREE.Sprite);
expect((disc as unknown as { position: { z: number } }).position.z).toBeLessThan(-650);
expect(disc.material.uniforms).toMatchObject({
uScanBands: { value: 11 },
uWarmth: { value: CHAPTER_PALETTES["deep-night"].warmth },
uTime: { value: 0 },
uDriveEnergy: { value: 0 },
});
expect(disc.material.fragmentShader).toContain("scanBand");
expect(resources.snapshot().owned.texture).toBe(1);
const palette = createMutableChapterPalette("destination");
sun.update(palette, 1, 3.2);
sun.update(palette, 1, 3.2, 1);
expect(disc.material.uniforms.uWarmth?.value).toBe(1);
expect(disc.material.uniforms.uRouteProgress?.value).toBe(1);
expect(disc.material.uniforms.uDriveEnergy?.value).toBe(1);
resources.dispose();
});
@@ -266,6 +310,12 @@ describe("procedural world models", () => {
expect(highStreaks.material.uniforms.uAcceleration?.value).toBe(0.8);
expect(highStreaks.material.uniforms.uVisibility?.value).toBeGreaterThan(0);
high.update(100, 1, 1, palette);
const acceleratedPhase = highStreaks.material.uniforms.uDistance?.value as number;
high.update(102, 0.18, 0, palette);
const coastPhase = highStreaks.material.uniforms.uDistance?.value as number;
expect(coastPhase).toBeGreaterThan(acceleratedPhase);
high.setViewportAspect(32 / 9);
expect(sky.material.uniforms.uAspect?.value).toBeCloseTo(32 / 9, 6);
@@ -510,22 +560,39 @@ describe("procedural world models", () => {
resources.dispose();
});
it("renders one physical roadside sign and updates its plain label once per target", () => {
it("renders one mounted roadside sign without mutating a visible label", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
for (const name of [
"sign-backplate",
"sign-face",
"sign-frame",
"sign-post-left",
"sign-post-right",
"sign-housing",
"sign-edge-light",
"sign-post",
"sign-bracket",
"sign-footing",
]) {
expect(sign.group.getObjectByName(name), name).toBeDefined();
}
const face = sign.group.getObjectByName("sign-face") as {
userData: { textureUpdateCount: number };
material: { opacity: number };
geometry: { getAttribute(name: string): { array: ArrayLike<number> } };
position: { z: number };
};
const backplate = sign.group.getObjectByName("sign-backplate") as {
geometry: {
boundingBox: { max: { z: number } } | null;
computeBoundingBox(): void;
};
position: { z: number };
};
backplate.geometry.computeBoundingBox();
const backplateFront = backplate.position.z + (backplate.geometry.boundingBox?.max.z ?? 0);
expect(face.position.z - backplateFront).toBeGreaterThan(0.05);
const faceUvs = Array.from(face.geometry.getAttribute("uv").array);
expect(Math.min(...faceUvs)).toBeGreaterThanOrEqual(0);
expect(Math.max(...faceUvs)).toBeLessThanOrEqual(1);
const allocation = resources.snapshot();
sign.update(null);
@@ -550,7 +617,8 @@ describe("procedural world models", () => {
expect(face.userData.textureUpdateCount).toBe(1);
expect(face.material.opacity).toBeCloseTo(0.7, 6);
const desktopX = (sign.group as { position: { x: number } }).position.x;
const visiblePosition = sign.group.position.clone();
const visibleScale = sign.group.scale.x;
sign.setViewportAspect(0.6);
sign.update({
targetId: "selected-builds",
@@ -558,14 +626,47 @@ describe("procedural world models", () => {
progress: 1,
opacity: 1,
});
expect(face.userData.textureUpdateCount).toBe(2);
expect(face.userData.textureUpdateCount).toBe(1);
expect(routeLabelFor("selected-builds")).toBe("Selected builds");
expect((sign.group as { position: { x: number } }).position.x).toBeLessThan(
desktopX,
);
expect(sign.group.position).toEqual(visiblePosition);
expect(sign.group.scale.x).toBeCloseTo(visibleScale, 6);
sign.update(null);
sign.update({
targetId: "selected-builds",
phase: "anticipate",
progress: 0,
opacity: 0,
});
expect(face.userData.textureUpdateCount).toBe(2);
expect(resources.snapshot()).toEqual(allocation);
resources.dispose();
});
it("begins sign departure from the rendered approach pose without a teleport", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
sign.setViewportAspect(16 / 9);
sign.update({
targetId: "profile",
phase: "approach",
progress: 0.62,
opacity: 1,
});
const approachPosition = sign.group.position.clone();
const approachScale = sign.group.scale.x;
sign.update({
targetId: "profile",
phase: "depart",
progress: 0,
opacity: 1,
});
expect(sign.group.position.distanceTo(approachPosition)).toBeLessThan(0.001);
expect(sign.group.scale.x).toBeCloseTo(approachScale, 6);
resources.dispose();
});
});
function paletteSnapshot(
@@ -588,7 +689,7 @@ function paletteSnapshot(
interface ShaderRenderable {
geometry: {
index: { array: ArrayLike<number> } | null;
boundingBox: { min: { x: number }; max: { x: number } } | null;
boundingBox: { min: { x: number; z: number }; max: { x: number; z: number } } | null;
computeBoundingBox(): void;
getAttribute(name: string): { count: number; array: ArrayLike<number> };
};