clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

main
marbles / src / client / fanField.ts
fanField.ts 9.4K
1/**2 * Fan wind field (M16): fans are elements marbles physically INTERACT3 * with. Every endless fan placement contributes a capsule of moving air4 * — origin, unit blow direction, length, radius, strength — and the5 * per-marble force pass adds mass · strength · falloff · direction while6 * a racer is inside the capsule.7 *8 * Geometry is DERIVED from the placement (deterministic, no rng):9 * - a STANDING fan blows along its local +z rotated by the placement10 * yaw — the legacy ledge fans face the channel, so their capsules11 * CROSS it: lateral gusts fall out of existing furniture for free;12 * - a TABLE fan lies flat and blows straight up — an updraft column.13 *14 * The optional `placement.wind` hint (shared/types.ts) tunes strength /15 * length / radius and nudges the capsule origin in world units — that is16 * how the boost-corridor fan parks its wash over the channel while its17 * housing stays on the ledge, and how the lip updraft starts low enough18 * to catch a rolling marble.19 *20 * Falloff model: axial linear from 1 at the fan face to 0 at `length`;21 * radial full inside FULL_CORE·radius, feathered linearly to 0 at22 * `radius`.23 *24 * Hot-path contract: volumes are binned per segment and `forceNear`25 * tests only bins segIdx−1 .. segIdx+1, allocation-free — it runs per26 * racer per decision at pack sizes up to N=9999.27 */28import type { Placement, Vec3 } from "../shared/types.ts";2930export interface FanVolume {31 /** Capsule start — the fan face, world coords. */32 origin: Vec3;33 /** Unit blow direction, world frame. */34 dir: Vec3;35 /** Capsule length, world units (axial falloff hits 0 here). */36 length: number;37 /** Capsule radius, world units (radial feather hits 0 here). */38 radius: number;39 /** Acceleration at the fan face, world u/s². */40 strength: number;41 /** Gust wall (M19a): blow only during the duty window each period. */42 pulse?: { period: number; duty: number; phase: number };43 /** Vortex (M19a): force tangential around the axis, not along it. */44 swirl?: boolean;45}4647/** Radial fraction at full strength; feather over the outer band. */48export const FULL_CORE = 0.6;4950/** Edge-ramp fraction of the on-window: force eases in/out over this51 * slice of the duty window instead of stepping (a step force reads as a52 * collision and double-integrates poorly at decision cadence). */53export const PULSE_RAMP = 0.2;5455/** Pulse gain at sim time `t` — 0 outside the duty window, 1 in the56 * middle, cosine-eased over PULSE_RAMP·onTime at both edges. Pure in57 * (pulse, t): replays and headless runs agree by construction. */58export function pulseGain(59 pulse: { period: number; duty: number; phase: number } | undefined,60 t: number,61): number {62 if (!pulse) return 1;63 const period = pulse.period;64 if (!(period > 0)) return 1;65 const on = Math.min(1, Math.max(0, pulse.duty)) * period;66 if (on <= 0) return 0;67 const tau = (((t + pulse.phase) % period) + period) % period;68 if (tau >= on) return 0;69 const ramp = on * PULSE_RAMP;70 const edge = Math.min(tau, on - tau);71 if (edge >= ramp) return 1;72 return 0.5 - 0.5 * Math.cos(Math.PI * (edge / ramp));73}7475// Family defaults (world units, tuned for TRACK_SCALE=2 endless pieces).76// Cross-wind (standing fan): 6.5 u/s² at the face over a 4-unit throw —77// the channel centerline sits ~2 units out, so mid-channel marbles feel78// ~3.3 u/s² laterally against a corridor spring that caps at ±10: lines79// bend visibly without pinning anyone to the wall.80export const CROSS_STRENGTH = 6.5;81export const CROSS_LENGTH = 4.0;82export const CROSS_RADIUS = 1.0;83// Updraft (table fan): 9 u/s² against GRAVITY_Y=−20 — a marble in the84// column runs at roughly half weight (visible lift wobble, not orbit)85// over a short 2.2-unit column.86export const UPDRAFT_STRENGTH = 9.0;87export const UPDRAFT_LENGTH = 2.2;88export const UPDRAFT_RADIUS = 0.9;8990/** Hub height above the placement origin, in TRACK units (×scale at91 * derivation): standing fans are 1 unit tall with the disc centered92 * ~0.55 up; table-fan blades sit ~0.42 up in a 0.5-tall housing. */93export const STANDING_HUB = 0.55;94export const TABLE_HUB = 0.42;9596/** Dir index → world step (0=+x, 1=+z, 2=−x, 3=−z). */97const DIR_VEC: readonly (readonly [number, number, number])[] = [98 [1, 0, 0],99 [0, 0, 1],100 [-1, 0, 0],101 [0, 0, -1],102];103104/** Derive the wind capsule for one placement, or null when the piece is105 * not a fan. Standing fans blow along local +z (world dir (1−r)%4 —106 * the ledge fans' rotation ((1−facing)%4) makes that exactly `facing`);107 * table fans blow straight up regardless of yaw. */108export function fanVolumeFor(p: Placement): FanVolume | null {109 if (!p.pieceId.startsWith("fan-")) return null;110 const s = p.scale ?? 1;111 const standing = p.pieceId.startsWith("fan-standing");112 let dx: number, dy: number, dz: number;113 let hub: number;114 let strength: number, length: number, radius: number;115 if (standing) {116 const d = DIR_VEC[(1 - p.rotation + 4) % 4];117 // Render mirror is scale.x = −s applied after yaw: flip world x.118 dx = p.mirrored ? -d[0] : d[0];119 dy = d[1];120 dz = d[2];121 hub = STANDING_HUB * s;122 strength = CROSS_STRENGTH;123 length = CROSS_LENGTH;124 radius = CROSS_RADIUS;125 } else {126 dx = 0;127 dy = 1;128 dz = 0;129 hub = TABLE_HUB * s;130 strength = UPDRAFT_STRENGTH;131 length = UPDRAFT_LENGTH;132 radius = UPDRAFT_RADIUS;133 }134 const w = p.wind;135 const off = w?.offset;136 const v: FanVolume = {137 origin: [138 p.pos[0] + (off?.[0] ?? 0),139 p.pos[1] + hub + (off?.[1] ?? 0),140 p.pos[2] + (off?.[2] ?? 0),141 ],142 // A swirl column stands on its vertical axis regardless of the fan's143 // yaw — the housing points at the channel, the eddy spins beside it.144 dir: w?.swirl ? [0, 1, 0] : [dx, dy, dz],145 length: w?.length ?? length,146 radius: w?.radius ?? radius,147 strength: w?.strength ?? strength,148 };149 if (w?.pulse) v.pulse = w.pulse;150 if (w?.swirl) v.swirl = true;151 return v;152}153154/** All wind capsules of a segment's placements (build-time, cold path). */155export function fanVolumesOf(placements: readonly Placement[]): FanVolume[] {156 const out: FanVolume[] = [];157 for (const p of placements) {158 const v = fanVolumeFor(p);159 if (v) out.push(v);160 }161 return out;162}163164/** Per-segment wind-volume registry. */165export class FanField {166 private readonly bins = new Map<number, readonly FanVolume[]>();167 /** Accumulated SIM time (seconds) — gates pulsed volumes. The session168 * advances it from the same dt stream advanceTime drives. */169 private t = 0;170171 setTime(t: number): void {172 this.t = t;173 }174175 addSegment(index: number, volumes: readonly FanVolume[]): void {176 if (volumes.length > 0) this.bins.set(index, volumes);177 else this.bins.delete(index);178 }179180 removeSegment(index: number): void {181 this.bins.delete(index);182 }183184 clear(): void {185 this.bins.clear();186 }187188 /** Accumulate the wind force at `pos` for a marble of `mass` into189 * `out` (+=, caller zeroes). Tests only segments segIdx−1..segIdx+1 —190 * O(nearby volumes), allocation-free. Returns true when any capsule191 * contributed. */192 forceNear(193 segIdx: number,194 pos: readonly [number, number, number],195 mass: number,196 out: [number, number, number],197 ): boolean {198 let hit = false;199 for (let k = segIdx - 1; k <= segIdx + 1; k++) {200 const vols = this.bins.get(k);201 if (!vols) continue;202 if (this.probeBin(vols, pos, mass, out)) hit = true;203 }204 return hit;205 }206207 /** Cold-path variant scanning every bin (debug/tests). */208 forceAt(209 pos: readonly [number, number, number],210 mass: number,211 out: [number, number, number],212 ): boolean {213 let hit = false;214 for (const vols of this.bins.values()) {215 if (this.probeBin(vols, pos, mass, out)) hit = true;216 }217 return hit;218 }219220 private probeBin(221 vols: readonly FanVolume[],222 pos: readonly [number, number, number],223 mass: number,224 out: [number, number, number],225 ): boolean {226 let hit = false;227 for (const v of vols) {228 const gain = v.pulse ? pulseGain(v.pulse, this.t) : 1;229 if (gain <= 0) continue;230 const rx = pos[0] - v.origin[0];231 const ry = pos[1] - v.origin[1];232 const rz = pos[2] - v.origin[2];233 const axial = rx * v.dir[0] + ry * v.dir[1] + rz * v.dir[2];234 if (axial < 0 || axial >= v.length) continue;235 const rad2 = rx * rx + ry * ry + rz * rz - axial * axial;236 if (rad2 >= v.radius * v.radius) continue;237 const rad = Math.sqrt(Math.max(0, rad2));238 const core = v.radius * FULL_CORE;239 const feather = rad <= core ? 1 : (v.radius - rad) / (v.radius - core);240 const f = mass * v.strength * (1 - axial / v.length) * feather * gain;241 if (v.swirl) {242 // Vortex: push TANGENTIALLY around the axis — dir × radial, so a243 // marble entering the column is deflected sideways, arcing around244 // the center instead of being blown along the axis. At the exact245 // center there is no tangent (rad ~ 0) and no force.246 if (rad < 1e-6) continue;247 const px = (rx - axial * v.dir[0]) / rad;248 const py = (ry - axial * v.dir[1]) / rad;249 const pz = (rz - axial * v.dir[2]) / rad;250 out[0] += f * (v.dir[1] * pz - v.dir[2] * py);251 out[1] += f * (v.dir[2] * px - v.dir[0] * pz);252 out[2] += f * (v.dir[0] * py - v.dir[1] * px);253 } else {254 out[0] += f * v.dir[0];255 out[1] += f * v.dir[1];256 out[2] += f * v.dir[2];257 }258 hit = true;259 }260 return hit;261 }262}