feat: night freight container yard works through the additional work chapter
This commit is contained in:
@@ -41,6 +41,7 @@ import { createMilestones } from '@/scene/world/milestones';
|
||||
import { billboardNear, createBillboard, textureSize } from '@/scene/world/billboard';
|
||||
import { BILLBOARD_NUDGE, RELAY_NUDGE } from '@/scene/world/composition';
|
||||
import { createRelayMasts } from '@/scene/world/relay-masts';
|
||||
import { createYard } from '@/scene/world/yard';
|
||||
import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
|
||||
import { dawnStep, type DawnStep } from '@/journey/dawn';
|
||||
import { dawnLightUniforms } from '@/scene/world/dawn-light';
|
||||
@@ -207,12 +208,16 @@ export async function createProceduralSceneGeneration(
|
||||
&& (globalThis.navigator as { connection?: { saveData?: boolean } } | undefined)?.connection?.saveData !== true,
|
||||
fogColor: scene.fog.color,
|
||||
});
|
||||
// NIGHT FREIGHT: a gantry crane works the Additional work container yard.
|
||||
// Low tier and touch: crane, stacks and plates; no floods, lamps or pulse.
|
||||
const yard = createYard(context.resources, maintenanceTier === 'full');
|
||||
let underpassShade = 0;
|
||||
let underpassKey = '';
|
||||
let underpassAttr = false;
|
||||
scene.add(
|
||||
underpass.group,
|
||||
relay.group,
|
||||
yard.group,
|
||||
billboard.mesh,
|
||||
maintenance.group,
|
||||
milestones.mesh,
|
||||
@@ -427,6 +432,16 @@ export async function createProceduralSceneGeneration(
|
||||
musicPulse,
|
||||
deltaSeconds,
|
||||
);
|
||||
// The deck from the previous frame (underpass updates after the sign).
|
||||
const yardDeck = underpass.inspect();
|
||||
yard.update(
|
||||
snapshot.activeSectionId,
|
||||
sectionLocalProgress(snapshot, 'additional-work'),
|
||||
musicPulse,
|
||||
deltaSeconds,
|
||||
yardDeck.visible ? yardDeck.z : null,
|
||||
camera.aspect,
|
||||
);
|
||||
const relayNudge = relay.update(
|
||||
snapshot.activeSectionId,
|
||||
snapshot.activeSectionId === 'toolkit' ? sectionLocalProgress(snapshot, 'toolkit') : null,
|
||||
|
||||
452
src/scene/world/yard.ts
Normal file
452
src/scene/world/yard.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SectionId } from '@/content/types';
|
||||
import { SIGN_ACCENTS } from '@/journey/sign-routes';
|
||||
import {
|
||||
CRANE_FROM_Z,
|
||||
CRANE_TO_Z,
|
||||
craneZ,
|
||||
createLamp,
|
||||
floodLight,
|
||||
floodWarmup,
|
||||
plateLit,
|
||||
spreaderDrop,
|
||||
stepLamp,
|
||||
STACK_TIERS,
|
||||
STACK_ZS,
|
||||
} from '@/journey/yard-progress';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
|
||||
|
||||
type ThreeBufferGeometry = InstanceType<typeof THREE.BufferGeometry>;
|
||||
type ThreeGroup = InstanceType<typeof THREE.Group>;
|
||||
type ThreeInstancedMesh = InstanceType<typeof THREE.InstancedMesh>;
|
||||
type ThreeInstancedBufferAttribute = InstanceType<typeof THREE.InstancedBufferAttribute>;
|
||||
|
||||
/**
|
||||
* NIGHT FREIGHT, scene side. A container yard on the free left verge of the
|
||||
* Additional work chapter (the reading panel covers 15-95% of the width): four
|
||||
* stacks (four, three, two and one container, far to near) on a raised apron
|
||||
* along a pair of crane rails, two high-mast floods, and a rail-mounted gantry
|
||||
* crane that rides the chapter. The rails are a straight siding that runs from
|
||||
* the camera's pivot into the distance at the angle that keeps the whole yard
|
||||
* at the same screen x for the viewport aspect (RAIL_NDC: the middle of the
|
||||
* left margin), so every stop, plate and the crane stay inside the margin at
|
||||
* any aspect, and the stacks step up with distance so none hides the next. Placements are pure
|
||||
* functions of section-local progress (journey/yard-progress.ts). At each stop
|
||||
* the spreader lowers onto the stack, the top container's end plate lights in
|
||||
* the chapter accent and the two work lamps strike. The floods warm up from
|
||||
* sodium orange to blue-white HID over 1.5 s at chapter entry and cool on the
|
||||
* way out. Structure stays under the 0.85 bloom threshold (round 9); only the
|
||||
* lamps bloom, modestly. The crane fades out (screen door) while an underpass
|
||||
* deck is within reach, so it never crosses a deck or its piers.
|
||||
* Lite (low tier, touch): crane, stacks and plates; no floods, pools, lamps or pulse.
|
||||
*
|
||||
* The world is drawn ~2.3x life size: a 20 ft box is 14 x 5.6 x 6 m here.
|
||||
*/
|
||||
const LENS = CHAPTER_COMPOSITIONS['additional-work'];
|
||||
export const DESIGN_ASPECT = 1.6;
|
||||
/** Screen x of the rail centre line (NDC): the middle of the 15% left margin, clear of its edge at -0.7. */
|
||||
const RAIL_NDC = -0.86;
|
||||
/** The yard floor (apron top): a quay above the verge, over most of the terrain swell. */
|
||||
const YARD_Y = 5;
|
||||
const BOX_L = 14;
|
||||
const BOX_W = 5.6;
|
||||
const BOX_H = 6;
|
||||
/** Portal: legs at +- SPAN/2 around the rail line, girders at PORTAL_H, legs +- LEG_Z along the rails. */
|
||||
const SPAN = 12;
|
||||
const PORTAL_H = 29;
|
||||
const LEG_Z = 5.5;
|
||||
const SPREADER_UP = PORTAL_H - 3;
|
||||
const FLOOD_X = -8.5;
|
||||
const FLOOD_ZS = [-124, -88] as const;
|
||||
const FLOOD_H = 36;
|
||||
const APRON = { halfWidth: SPAN / 2 + 5, depth: 12, from: CRANE_FROM_Z - 10, to: CRANE_TO_Z + 12 } as const;
|
||||
|
||||
/**
|
||||
* The rail line's yaw for a viewport aspect: the angle whose projection lands
|
||||
* at RAIL_NDC once the chapter's lens shift (setViewOffset) is applied.
|
||||
*/
|
||||
export function railYaw(aspect: number = DESIGN_ASPECT): number {
|
||||
const tanHalfHeight = Math.tan(THREE.MathUtils.degToRad(LENS.fov / 2));
|
||||
const portrait = aspect < 1.18;
|
||||
const raw = portrait ? -0.9 : RAIL_NDC - 2 * LENS.shift;
|
||||
return Math.atan(-raw * tanHalfHeight * aspect);
|
||||
}
|
||||
/** The crane hides (screen door) while a deck is this close along the road (m). */
|
||||
const DECK_CLEAR = { from: 16, to: 34 } as const;
|
||||
const PULSE_GAIN = 0.08;
|
||||
const ORANGE = new THREE.Color(1.0, 0.42, 0.12);
|
||||
const HID = new THREE.Color(0.72, 0.84, 1.0);
|
||||
const TINTS = [[0.42, 0.13, 0.08], [0.08, 0.26, 0.3], [0.3, 0.31, 0.34], [0.44, 0.3, 0.08]] as const;
|
||||
|
||||
type Box = readonly [w: number, h: number, d: number, x: number, y: number, z: number, part?: number];
|
||||
|
||||
/** Boxes merged into one indexed geometry, with a part id per vertex. */
|
||||
function boxes(list: readonly Box[]): ThreeBufferGeometry {
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const parts: number[] = [];
|
||||
const indices: number[] = [];
|
||||
for (const [w, h, d, x, y, z, part = 0] of list) {
|
||||
const piece = new THREE.BoxGeometry(w, h, d).translate(x, y, z);
|
||||
const offset = positions.length / 3;
|
||||
const position = piece.getAttribute('position');
|
||||
positions.push(...position.array);
|
||||
normals.push(...piece.getAttribute('normal').array);
|
||||
for (let i = 0; i < position.count; i += 1) parts.push(part);
|
||||
indices.push(...Array.from(piece.getIndex()!.array as ArrayLike<number>, (i) => i + offset));
|
||||
piece.dispose();
|
||||
}
|
||||
const merged = new THREE.BufferGeometry();
|
||||
merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
||||
merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1));
|
||||
merged.setIndex(indices);
|
||||
return merged;
|
||||
}
|
||||
|
||||
const legs = (y: number, h: number, t: number): Box[] =>
|
||||
[-1, 1].flatMap((sx) => [-1, 1].map((sz) => [t, h, t, (sx * SPAN) / 2, y, sz * LEG_Z] as Box));
|
||||
|
||||
/** Portal frame around the rail line (x 0): legs, sills, girders, end ties, cab. */
|
||||
const portalBoxes = (): Box[] => [
|
||||
...legs(PORTAL_H / 2, PORTAL_H, 1.3),
|
||||
...[-1, 1].map((sx) => [1.6, 1.4, LEG_Z * 2 + 3, (sx * SPAN) / 2, 1.2, 0] as Box),
|
||||
...[-1, 1].map((sx) => [1.3, 1.3, LEG_Z * 2, (sx * SPAN) / 2, PORTAL_H - 4, 0] as Box),
|
||||
...[-1, 1].map((sz) => [SPAN + 3, 2, 1.5, 0, PORTAL_H, sz * LEG_Z] as Box),
|
||||
...[-1, 1].map((sx) => [1.5, 2, LEG_Z * 2 + 1.5, (sx * SPAN) / 2, PORTAL_H, 0] as Box),
|
||||
[3, 3, 3.4, -SPAN / 2 + 2.3, PORTAL_H - 5, LEG_Z + 1.2],
|
||||
];
|
||||
|
||||
const SCREEN_DOOR = /* glsl */ `
|
||||
vec2 cell = mod(floor(gl_FragCoord.xy), 4.0);
|
||||
if (presence < (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0) discard;
|
||||
`;
|
||||
|
||||
/** Dark steel and containers, lit by a key, the floods (uFlood) and, for plates, the accent. */
|
||||
function solid(uniforms: Record<string, { value: unknown }>, instanced: boolean) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: /* glsl */ `
|
||||
attribute float aPart;
|
||||
${instanced ? 'attribute vec2 aState;' : ''}
|
||||
varying float vPart;
|
||||
varying vec2 vState;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vWorld;
|
||||
void main() {
|
||||
mat4 model = modelMatrix${instanced ? ' * instanceMatrix' : ''};
|
||||
vec4 world = model * vec4(position, 1.0);
|
||||
vWorld = world.xyz;
|
||||
vNormal = normalize(mat3(model) * normal);
|
||||
vPart = aPart;
|
||||
vState = ${instanced ? 'aState' : 'vec2(0.0)'};
|
||||
gl_Position = projectionMatrix * viewMatrix * world;
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform float uPresence;
|
||||
uniform vec3 uFlood;
|
||||
uniform vec3 uAccent;
|
||||
uniform float uPulse;
|
||||
varying float vPart;
|
||||
varying vec2 vState;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vWorld;
|
||||
void main() {
|
||||
float presence = uPresence;
|
||||
${SCREEN_DOOR}
|
||||
vec3 n = normalize(vNormal);
|
||||
float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35))));
|
||||
float floodSide = max(0.0, dot(n, normalize(vec3(-0.6, 0.6, 0.5))));
|
||||
vec3 light = vec3(0.1, 0.1, 0.13) + vec3(0.16, 0.15, 0.2) * key + uFlood * (0.25 + 0.55 * floodSide);
|
||||
vec3 color;
|
||||
${instanced ? /* glsl */ `
|
||||
int tintIndex = int(vState.y + 0.5);
|
||||
vec3 tint = tintIndex == 0 ? vec3(${TINTS[0].join(', ')}) : tintIndex == 1 ? vec3(${TINTS[1].join(', ')})
|
||||
: tintIndex == 2 ? vec3(${TINTS[2].join(', ')}) : vec3(${TINTS[3].join(', ')});
|
||||
float rib = 0.82 + 0.18 * step(0.5, fract((abs(n.x) > 0.5 ? vWorld.z : vWorld.x) * 0.9));
|
||||
color = tint * light * rib * 1.6;
|
||||
if (vPart > 0.5) {
|
||||
// The end plate: a door at rest, the chapter accent once the spreader has set down (< 0.85).
|
||||
color = mix(color * 0.7, uAccent * (0.2 + 0.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse)), vState.x);
|
||||
}` : /* glsl */ `
|
||||
color = vec3(0.16, 0.17, 0.2) * light * 1.5 + uAccent * 0.025;
|
||||
if (vPart > 0.5) color = vec3(0.55, 0.42, 0.1) * light * 1.8;
|
||||
if (vPart > 1.5) {
|
||||
// The apron: poured concrete with a painted bay line every 14 m.
|
||||
float bay = step(0.985, fract(vWorld.z * 0.0714)) * step(0.5, n.y);
|
||||
color = vec3(0.09, 0.095, 0.12) * light * 1.4 + uAccent * (0.02 + 0.12 * bay);
|
||||
}`}
|
||||
gl_FragColor = vec4(min(color, vec3(0.82)), 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
fog: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Additive lights: camera-facing glows (billboard) or ground pools; aGlow = rgb, level. */
|
||||
function additive(billboard: boolean) {
|
||||
return new THREE.ShaderMaterial({
|
||||
vertexShader: /* glsl */ `
|
||||
attribute vec4 aGlow;
|
||||
varying vec2 vUv;
|
||||
varying vec4 vGlow;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vGlow = aGlow;
|
||||
${billboard ? /* glsl */ `
|
||||
float size = length(instanceMatrix[0].xyz);
|
||||
vec4 centre = viewMatrix * modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
||||
gl_Position = projectionMatrix * (centre + vec4(position.xy * size, 0.0, 0.0));` : `
|
||||
gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);`}
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
varying vec4 vGlow;
|
||||
void main() {
|
||||
float r = 1.0 - min(1.0, length(vUv - 0.5) * 2.0);
|
||||
float shape = ${billboard ? 'pow(r, 3.0) * 1.3 + pow(r, 1.2) * 0.22' : 'pow(r, 1.8) * 0.7'};
|
||||
gl_FragColor = vec4(vGlow.rgb * shape * vGlow.w, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
fog: false,
|
||||
});
|
||||
}
|
||||
|
||||
export interface YardInspection {
|
||||
visible: boolean;
|
||||
presence: number;
|
||||
cranePresence: number;
|
||||
/** Rail coordinate (metres from the lens along the rails, negative ahead) and its world z. */
|
||||
craneZ: number;
|
||||
craneWorldZ: number;
|
||||
spreader: number;
|
||||
/** Stack-top plates fully lit (= stops passed). */
|
||||
litPlates: number;
|
||||
lamp: number;
|
||||
strikes: number;
|
||||
flood: number;
|
||||
}
|
||||
|
||||
export interface YardWorld {
|
||||
readonly group: ThreeGroup;
|
||||
/**
|
||||
* Section-local progress of 'additional-work' (null outside), bass pulse,
|
||||
* frame delta, the underpass deck z (null: none) and the viewport aspect.
|
||||
*/
|
||||
update(section: SectionId, p: number | null, pulse: number, deltaSeconds: number, deckZ?: number | null, aspect?: number): void;
|
||||
inspect(): YardInspection;
|
||||
}
|
||||
|
||||
export function createYard(resources: SceneResourceBag, full: boolean): YardWorld {
|
||||
const own = <T extends { dispose(): void }>(kind: 'geometry' | 'material', value: T): T => resources.own(kind, value);
|
||||
const accent = new THREE.Color(SIGN_ACCENTS['additional-work']);
|
||||
const flood = new THREE.Color(0, 0, 0);
|
||||
const pulse = { value: 0 };
|
||||
const shared = { uFlood: { value: flood }, uAccent: { value: accent }, uPulse: pulse };
|
||||
const yardPresence = { value: 0 };
|
||||
const cranePresence = { value: 0 };
|
||||
const group = new THREE.Group();
|
||||
group.name = 'night-freight';
|
||||
group.visible = false;
|
||||
// The group's -z axis is the rail line, pivoting on the chapter lens.
|
||||
group.position.set(LENS.x, 0, LENS.z);
|
||||
let yaw = Number.NaN;
|
||||
const place = (aspect: number) => {
|
||||
const next = railYaw(aspect);
|
||||
if (next === yaw) return;
|
||||
yaw = next;
|
||||
group.rotation.y = yaw;
|
||||
};
|
||||
place(DESIGN_ASPECT);
|
||||
const yardMaterial = own('material', solid({ ...shared, uPresence: yardPresence }, false));
|
||||
const craneMaterial = own('material', solid({ ...shared, uPresence: cranePresence }, false));
|
||||
const mesh = (geometry: ThreeBufferGeometry, material = yardMaterial) => {
|
||||
const m = new THREE.Mesh(own('geometry', geometry), material);
|
||||
m.frustumCulled = false;
|
||||
return m;
|
||||
};
|
||||
|
||||
// Apron (part 2), rails and the two flood masts (heads are part 1: warm metal housings).
|
||||
const apronLength = APRON.to - APRON.from;
|
||||
group.add(mesh(boxes([
|
||||
[APRON.halfWidth * 2, APRON.depth, apronLength, 0, YARD_Y - APRON.depth / 2, APRON.from + apronLength / 2, 2],
|
||||
...[-1, 1].map((sx) => [0.9, 0.5, apronLength - 4, (sx * SPAN) / 2, YARD_Y + 0.25, APRON.from + apronLength / 2] as Box),
|
||||
...FLOOD_ZS.flatMap((z): Box[] => [
|
||||
[1.1, FLOOD_H, 1.1, FLOOD_X, YARD_Y + FLOOD_H / 2, z],
|
||||
[6, 0.5, 0.5, FLOOD_X + 1, YARD_Y + FLOOD_H - 0.6, z],
|
||||
[5.2, 2, 1.6, FLOOD_X + 1.2, YARD_Y + FLOOD_H + 0.6, z + 0.4, 1],
|
||||
]),
|
||||
])));
|
||||
|
||||
// The stacks: one InstancedMesh, the end plate (part 1) faces the camera.
|
||||
const stackGeometry = own('geometry', boxes([
|
||||
[BOX_W, BOX_H - 0.1, BOX_L, 0, BOX_H / 2, 0],
|
||||
[BOX_W - 0.7, BOX_H - 0.8, 0.16, 0, BOX_H / 2, BOX_L / 2 + 0.06, 1],
|
||||
]));
|
||||
const topIndex: number[] = [];
|
||||
let count = 0;
|
||||
STACK_TIERS.forEach((tiers) => {
|
||||
count += tiers;
|
||||
topIndex.push(count - 1);
|
||||
});
|
||||
const stackState = new THREE.InstancedBufferAttribute(new Float32Array(count * 2), 2);
|
||||
stackState.setUsage(THREE.DynamicDrawUsage);
|
||||
stackGeometry.setAttribute('aState', stackState);
|
||||
const stacks = new THREE.InstancedMesh(
|
||||
stackGeometry,
|
||||
own('material', solid({ ...shared, uPresence: yardPresence }, true)),
|
||||
count,
|
||||
);
|
||||
stacks.name = 'yard-stacks';
|
||||
stacks.frustumCulled = false;
|
||||
const matrix = new THREE.Matrix4();
|
||||
let i = 0;
|
||||
STACK_ZS.forEach((z, s) => {
|
||||
for (let tier = 0; tier < STACK_TIERS[s]!; tier += 1, i += 1) {
|
||||
// A little stacking slop so the column reads as boxes, not a slab.
|
||||
matrix.makeRotationY(((i * 7) % 5 - 2) * 0.012).setPosition(((i * 3) % 4 - 1.5) * 0.18, YARD_Y + tier * BOX_H, z);
|
||||
stacks.setMatrixAt(i, matrix);
|
||||
stackState.setXY(i, 0, (s + tier * 3) % 4);
|
||||
}
|
||||
});
|
||||
group.add(stacks);
|
||||
|
||||
// The crane: portal, trolley, hoist ropes (unit height, hung from the top) and spreader.
|
||||
const crane = new THREE.Group();
|
||||
crane.position.y = YARD_Y;
|
||||
const ropes = mesh(boxes([-1, 1].flatMap((sx) => [-1, 1].map((sz) => [0.18, 1, 0.18, sx * 2, -0.5, sz * 4.5] as Box))), craneMaterial);
|
||||
ropes.position.y = PORTAL_H - 1;
|
||||
const spreader = mesh(boxes([
|
||||
[BOX_W + 0.3, 0.7, BOX_L - 0.4, 0, 0.35, 0],
|
||||
[1.2, 1.2, BOX_L - 2, 0, 1.2, 0, 1],
|
||||
]), craneMaterial);
|
||||
crane.add(
|
||||
mesh(boxes(portalBoxes()), craneMaterial),
|
||||
mesh(boxes([[5, 2.2, LEG_Z * 2 + 1, 0, PORTAL_H + 2, 0, 1]]), craneMaterial),
|
||||
ropes,
|
||||
spreader,
|
||||
);
|
||||
group.add(crane);
|
||||
|
||||
// Lamps (two work lamps, two flood heads) and two flood pools on the apron: full only.
|
||||
let glows: ThreeInstancedMesh | null = null;
|
||||
let pools: ThreeInstancedMesh | null = null;
|
||||
let glowState: ThreeInstancedBufferAttribute | null = null;
|
||||
let poolState: ThreeInstancedBufferAttribute | null = null;
|
||||
if (full) {
|
||||
const glowGeometry = own('geometry', new THREE.PlaneGeometry(1, 1));
|
||||
glowState = new THREE.InstancedBufferAttribute(new Float32Array(16), 4);
|
||||
glowState.setUsage(THREE.DynamicDrawUsage);
|
||||
glowGeometry.setAttribute('aGlow', glowState);
|
||||
glows = new THREE.InstancedMesh(glowGeometry, own('material', additive(true)), 4);
|
||||
const poolGeometry = own('geometry', new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2));
|
||||
poolState = new THREE.InstancedBufferAttribute(new Float32Array(8), 4);
|
||||
poolState.setUsage(THREE.DynamicDrawUsage);
|
||||
poolGeometry.setAttribute('aGlow', poolState);
|
||||
pools = new THREE.InstancedMesh(poolGeometry, own('material', additive(false)), 2);
|
||||
const position = new THREE.Vector3();
|
||||
const scale = new THREE.Vector3();
|
||||
const none = new THREE.Quaternion();
|
||||
FLOOD_ZS.forEach((z, i) => {
|
||||
glows!.setMatrixAt(2 + i, matrix.compose(position.set(FLOOD_X + 1.2, YARD_Y + FLOOD_H + 0.2, z + 1.4), none, scale.setScalar(13)));
|
||||
pools!.setMatrixAt(i, matrix.compose(position.set(-1.5, YARD_Y + 0.06, z + 8), none, scale.set(24, 1, 40)));
|
||||
});
|
||||
for (const m of [glows, pools]) {
|
||||
m.frustumCulled = false;
|
||||
m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
group.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
const lamp = createLamp();
|
||||
const lampMatrix = new THREE.Matrix4();
|
||||
const hue = new THREE.Color();
|
||||
let warm = 0;
|
||||
let state: YardInspection = {
|
||||
visible: false, presence: 0, cranePresence: 0, craneZ: craneZ(0), craneWorldZ: LENS.z + craneZ(0) * Math.cos(yaw), spreader: 0, litPlates: 0, lamp: 0, strikes: 0, flood: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
group,
|
||||
inspect: () => ({ ...state }),
|
||||
update(section, p, beat, deltaSeconds, deckZ = null, aspect = DESIGN_ASPECT) {
|
||||
place(Number.isFinite(aspect) && aspect > 0 ? aspect : DESIGN_ASPECT);
|
||||
const live = section === 'additional-work' && p !== null && Number.isFinite(p) ? Math.min(1, Math.max(0, p)) : null;
|
||||
const edge = (from: number, to: number, v: number) => {
|
||||
const t = Math.min(1, Math.max(0, (v - from) / (to - from)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
const presence = live === null ? 0 : edge(0, 0.05, live) * (1 - edge(0.95, 1, live));
|
||||
const z = craneZ(live ?? 0);
|
||||
const worldZ = LENS.z + z * Math.cos(yaw);
|
||||
const clear = deckZ === null || !Number.isFinite(deckZ) ? 1 : edge(DECK_CLEAR.from, DECK_CLEAR.to, Math.abs(deckZ - worldZ));
|
||||
const drop = spreaderDrop(live);
|
||||
// The spreader lands on whichever stack the crane is over (the nearest by rail distance).
|
||||
let over = 0;
|
||||
STACK_ZS.forEach((stackZ, s) => { if (Math.abs(stackZ - z) < Math.abs(STACK_ZS[over]! - z)) over = s; });
|
||||
const spreaderDown = STACK_TIERS[over]! * BOX_H + 0.45;
|
||||
const visible = presence > 0;
|
||||
|
||||
yardPresence.value = presence;
|
||||
cranePresence.value = presence * clear;
|
||||
crane.position.z = z;
|
||||
crane.visible = cranePresence.value > 0;
|
||||
const spreaderY = SPREADER_UP + (spreaderDown - SPREADER_UP) * drop;
|
||||
spreader.position.y = spreaderY;
|
||||
ropes.scale.y = Math.max(0.1, PORTAL_H - 1 - spreaderY - 1.8);
|
||||
|
||||
let litPlates = 0;
|
||||
STACK_ZS.forEach((_, s) => {
|
||||
const lit = plateLit(live, s);
|
||||
if (lit >= 1) litPlates += 1;
|
||||
stackState.setX(topIndex[s]!, lit);
|
||||
});
|
||||
stackState.needsUpdate = true;
|
||||
|
||||
warm = floodWarmup(warm, full && live !== null && live < 0.93, deltaSeconds);
|
||||
const light = floodLight(warm);
|
||||
flood.copy(ORANGE).lerp(HID, light.hue).multiplyScalar(full ? light.output * 0.5 : 0);
|
||||
pulse.value = full ? Math.min(1, Math.max(0, beat)) : 0;
|
||||
stepLamp(lamp, full && crane.visible && drop > 0.6 ? 1 : 0, deltaSeconds);
|
||||
|
||||
if (glows !== null && pools !== null && glowState !== null && poolState !== null) {
|
||||
const workLamp = [1.0, 0.86, 0.66] as const;
|
||||
hue.copy(flood).multiplyScalar(1 / 0.5);
|
||||
[-1, 1].forEach((sx, i) => {
|
||||
lampMatrix.makeScale(5, 5, 5).setPosition(sx * (SPAN / 2 + 0.8), YARD_Y + PORTAL_H - 1.4, z + LEG_Z + 1.2);
|
||||
glows!.setMatrixAt(i, lampMatrix);
|
||||
glowState!.setXYZW(i, workLamp[0], workLamp[1], workLamp[2], (0.12 + 0.88 * lamp.level) * cranePresence.value);
|
||||
glowState!.setXYZW(2 + i, hue.r, hue.g, hue.b, presence);
|
||||
poolState!.setXYZW(i, hue.r, hue.g, hue.b, presence * (1 + PULSE_GAIN * pulse.value));
|
||||
});
|
||||
glows.instanceMatrix.needsUpdate = true;
|
||||
glowState.needsUpdate = true;
|
||||
poolState.needsUpdate = true;
|
||||
}
|
||||
|
||||
group.visible = visible;
|
||||
state = {
|
||||
visible,
|
||||
presence,
|
||||
cranePresence: cranePresence.value,
|
||||
craneZ: z,
|
||||
craneWorldZ: worldZ,
|
||||
spreader: drop,
|
||||
litPlates,
|
||||
lamp: lamp.level,
|
||||
strikes: lamp.strikes,
|
||||
flood: warm,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user