feat: weave speed-reactive terrain and ground roadside signs

Anchor desktop signs beside the road and feather the compact reading edge with measured chapter landings. Add a continuous cyan-to-coral terrain grid and a bounded rear wake that builds with existing drive speed.

Preserve approved content, media, soundtrack, and canonical journey contracts.
This commit is contained in:
2026-09-05 06:22:49 +03:00
parent b188cac7ed
commit 51a9c743da
7 changed files with 416 additions and 53 deletions

View File

@@ -9,6 +9,7 @@ import {
type SignViewportProfile,
} from '@/journey/sign-routes';
import type { SceneResourceBag } from '@/scene/resources';
import { roadsideSignMetrics, usesRoadsideSign } from './sign-layout';
interface ProjectingCamera {
position: { x: number; y: number; z: number };
@@ -32,6 +33,7 @@ export interface SignWorld {
height: number,
navigationBottom: number,
now: number,
carAnchor?: { x: number; z: number },
): void;
setViewportAspect(aspect: number): void;
setViewportSize(width: number, height: number): void;
@@ -407,6 +409,11 @@ export function createSign(
let screenY = 120;
let screenWidth = 340;
let initialized = false;
let roadsideMix = 0;
let previousPhase: SignSnapshot['phase'] | null = null;
let dwellDrift = 0;
let departureDrift = 0;
const physical = roadsideSignMetrics(900);
const point = new THREE.Vector3();
const ray = new THREE.Vector3();
const forward = new THREE.Vector3();
@@ -498,9 +505,13 @@ export function createSign(
if (!sign) {
group.visible = false;
initialized = false;
previousPhase = null;
dwellDrift = 0;
return;
}
if (target !== sign.targetId) {
previousPhase = null;
dwellDrift = 0;
target = sign.targetId;
face.userData.textureUpdateCount += 1;
face.userData.fontReadyWhenDrawn =
@@ -516,7 +527,14 @@ export function createSign(
initialized = false;
}
},
compose(camera, width, height, navigationBottom, now) {
compose(
camera,
width,
height,
navigationBottom,
now,
carAnchor = { x: 0, z: 0.72 },
) {
if (!snapshot) return;
viewportWidth = width;
viewportHeight = height;
@@ -546,6 +564,8 @@ export function createSign(
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
previousNow = now;
const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1;
const roadside = usesRoadsideSign(width, height);
roadsideMix += ((roadside ? 1 : 0) - roadsideMix) * mix;
screenX += (targetX - screenX) * mix;
screenY += (targetY - screenY) * mix;
screenWidth += (targetWidth - screenWidth) * mix;
@@ -583,9 +603,43 @@ export function createSign(
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
if (lowerStrip) point.y = Math.max(1.4, point.y);
// A wide-screen sign is a real roadside object: fixed lateral offset,
// fixed mast height, constant size, and travel only along the road axis.
// Camera movement changes its projection, never its world-space anchor.
const desiredPhysical = roadsideSignMetrics(height);
for (const key of ['x', 'y', 'scale', 'dwellZ'] as const) {
physical[key] += (desiredPhysical[key] - physical[key]) * mix;
}
// Canonical dwell progress is always 1; visual drift must integrate time.
if (snapshot.phase === 'dwell')
dwellDrift = Math.min(3, dwellDrift + dt * 1.7);
if (snapshot.phase === 'depart' && previousPhase !== 'depart')
departureDrift = dwellDrift;
previousPhase = snapshot.phase;
const roadZ =
snapshot.phase === 'depart'
? THREE.MathUtils.lerp(physical.dwellZ + departureDrift, 48, p)
: THREE.MathUtils.lerp(-230, physical.dwellZ, arrival) + dwellDrift;
point.x = THREE.MathUtils.lerp(
point.x,
carAnchor.x + side * physical.x,
roadsideMix,
);
point.y = THREE.MathUtils.lerp(point.y, physical.y, roadsideMix);
point.z = THREE.MathUtils.lerp(point.z, carAnchor.z + roadZ, roadsideMix);
group.position.set(point.x, point.y, point.z);
group.scale.setScalar(worldRight.distanceTo(worldLeft) / 8.64);
group.rotation.y = camera.rotation.y - side * 0.065;
group.scale.setScalar(
THREE.MathUtils.lerp(
worldRight.distanceTo(worldLeft) / 8.64,
physical.scale,
roadsideMix,
),
);
group.rotation.y = THREE.MathUtils.lerp(
camera.rotation.y - side * 0.065,
-side * 0.1,
roadsideMix,
);
plantMounting();
const opacity = clamp01(snapshot.opacity);
for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight;
@@ -596,6 +650,7 @@ export function createSign(
sweepMaterial.uniforms.uOpacity.value =
snapshot.phase === 'approach' ? opacity * 0.14 : 0;
group.visible = opacity > 0;
group.userData.anchoring = roadside ? 'roadside' : 'compact-shelf';
},
};
}