Files
Portfolio/src/journey/yard-progress.ts

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;
}