453 lines
19 KiB
TypeScript
453 lines
19 KiB
TypeScript
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(max(r, 0.0), 3.0) * 1.3 + pow(max(r, 0.0), 1.2) * 0.22' : 'pow(max(r, 0.0), 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,
|
|
};
|
|
},
|
|
};
|
|
}
|