125 lines
5.1 KiB
TypeScript
125 lines
5.1 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|