feat: polish nightdrive road sun and signage
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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> };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user