/** * NIGHT FREIGHT: the Additional work chapter is a working container yard. A * rail-mounted gantry crane rides the left verge through the chapter and * stops once per additional project: the spreader lowers onto a stack, the * stack-top container's end plate lights and the crane's work lamps strike. * Pure helpers for the lazy scene chunk only (src/scene/world/yard.ts): * nothing in the initial bundle imports this module. Every placement is a * function of section-local progress, so scrolling back lifts the spreader, * dims the plate and runs the crane back. */ /** Section-local progress of each stop (one per additional project). */ export const YARD_STOPS = [0.1, 0.33, 0.56, 0.79] as const; /** The crane stands still this far either side of a stop while the spreader works. */ export const YARD_HOLD = 0.06; /** * Rail coordinates: the crane rails run in a straight line from the camera's * pivot into the distance (yard.ts turns the line by the viewport aspect so it * stays inside the left reading margin), so a rail coordinate s is the metres * from the camera along that line, negative ahead; world z ~ cameraZ + s * cos(angle). * Where the crane enters and leaves its rails. */ export const CRANE_FROM_Z = -150; export const CRANE_TO_Z = -62; /** The stacks along the rails, one under each stop, far to near. */ export const STACK_ZS = [-134, -112, -94, -80] as const; /** Containers per stack, far to near: the yard steps down toward the camera so every stack top and the crane stay in view. */ export const STACK_TIERS = [4, 3, 2, 1] as const; /** Work lamps: no strike ramps faster than this, and no more than one strike per gap. */ export const LAMP_RAMP_SECONDS = 0.35; export const LAMP_STRIKE_GAP_SECONDS = 0.6; /** High-mast floods: HID warm-up from orange to blue-white, and back on exit. */ export const FLOOD_WARMUP_SECONDS = 1.5; const clamp01 = (value: number): number => (Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0); const smooth = (from: number, to: number, value: number): number => { const t = clamp01((value - from) / (to - from)); return t * t * (3 - 2 * t); }; /** * Crane z for progress p: parked over stack i within YARD_HOLD of stop i, * easing between stacks, from CRANE_FROM_Z at 0 to CRANE_TO_Z at 1. * Non-decreasing in p. */ export function craneZ(p: number): number { const t = clamp01(p); const knots: Array<[number, number, number]> = []; let fromP = 0; let fromZ = CRANE_FROM_Z; YARD_STOPS.forEach((stop, i) => { knots.push([fromP, stop - YARD_HOLD, fromZ]); fromP = stop + YARD_HOLD; fromZ = STACK_ZS[i]!; }); knots.push([fromP, 1, fromZ]); let z = CRANE_FROM_Z; knots.forEach(([a, b, startZ], i) => { const endZ = i < STACK_ZS.length ? STACK_ZS[i]! : CRANE_TO_Z; if (t >= a) z = startZ + (endZ - startZ) * smooth(a, b, t); }); return z; } /** 0..1: how far the spreader is lowered (1 on the stack at a stop). */ export function spreaderDrop(p: number | null): number { if (p === null) return 0; return Math.max(0, ...YARD_STOPS.map((stop) => 1 - smooth(0.015, YARD_HOLD, Math.abs(p - stop)))); } /** 0..1: stack i's end plate, lit as the spreader lands and from then on. */ export function plateLit(p: number | null, index: number): number { const stop = YARD_STOPS[index]; if (p === null || stop === undefined) return 0; return smooth(stop - 0.03, stop, p); } /** Stops passed at progress p (plates fully lit). */ export const yardStopsPassed = (p: number | null): number => p === null ? 0 : YARD_STOPS.filter((stop) => p >= stop).length; /** Moves the flood warm-up level (0 off, 1 full blue-white) toward on/off over FLOOD_WARMUP_SECONDS. */ export function floodWarmup(level: number, on: boolean, deltaSeconds: number): number { const step = Math.max(0, deltaSeconds) / FLOOD_WARMUP_SECONDS; return clamp01(on ? level + step : level - step); } /** A warming HID lamp: output and hue (0 sodium orange, 1 blue-white) for a warm-up level. */ export function floodLight(level: number): { output: number; hue: number } { const t = clamp01(level); return { output: smooth(0, 0.12, t) * (0.3 + 0.7 * t), hue: smooth(0.15, 1, t) }; } export interface LampState { level: number; /** Seconds since the last strike began. */ since: number; strikes: number; } export const createLamp = (): LampState => ({ level: 0, since: Number.POSITIVE_INFINITY, strikes: 0 }); /** * A work lamp following target (0..1). A strike (a dark lamp coming on) ramps * over at least LAMP_RAMP_SECONDS and begins at most once per * LAMP_STRIKE_GAP_SECONDS, however fast the scroll: inside the gap a dark * lamp stays dark. Mutates and returns state. */ export function stepLamp(state: LampState, target: number, deltaSeconds: number): LampState { const dt = Math.max(0, deltaSeconds); const goal = clamp01(target); state.since += dt; if (goal > state.level) { if (state.level < 0.02) { if (state.since < LAMP_STRIKE_GAP_SECONDS) return state; state.since = 0; state.strikes += 1; } state.level = Math.min(goal, state.level + dt / LAMP_RAMP_SECONDS); } else { state.level = Math.max(goal, state.level - dt / LAMP_RAMP_SECONDS); } return state; }