1/**2 * Trackside fans as TRACK ELEMENTS (M4): placed deliberately against a3 * ground-level straight, blades facing the channel, with a marble boost.4 * (They lived in the decor mid band before — flush against the track their5 * palette-tinted housings read as deck protrusions; deliberate placement +6 * orientation + the boost make them read as machinery instead.)7 */8import type { Dir, Placement } from "../shared/types.ts";9import type { Rng } from "./rng.ts";10import { DIR_STEP } from "./geom.ts";1112const FAN_CHANCE = 0.35;13const BOOST = 6; // marble acceleration (units/s²) while in range1415export interface FanPlan {16 placements: Placement[];17 cells: string[];18}1920export function planFans(21 rng: Rng,22 /** All placements so far (straights located, cells avoided). */23 placements: readonly Placement[],24 occupied: ReadonlySet<string>,25): FanPlan {26 const empty: FanPlan = { placements: [], cells: [] };27 if (!rng.chance(FAN_CHANCE)) return empty;2829 const key = (x: number, z: number) => `${x},${z}`;30 const arched = new Set(31 placements32 .filter((p) => p.pieceId === "tunnel")33 .map((p) => key(Math.round(p.pos[0]), Math.round(p.pos[2]))),34 );35 // Bridge decks stack over channels — a fan under a bridge clips the deck.36 const cellCounts = new Map<string, number>();37 for (const p of placements) {38 if (p.role !== "track") continue;39 const k = key(Math.round(p.pos[0]), Math.round(p.pos[2]));40 cellCounts.set(k, (cellCounts.get(k) ?? 0) + 1);41 }4243 interface Candidate {44 cell: [number, number];45 /** Direction from the fan cell toward the track. */46 facing: Dir;47 }48 const taken = new Set<string>(occupied);49 const candidates: Candidate[] = [];50 for (const p of placements) {51 if (p.pieceId !== "straight" || p.role !== "track") continue;52 if (p.pos[1] !== 0) continue;53 const cx = Math.round(p.pos[0]);54 const cz = Math.round(p.pos[2]);55 if (arched.has(key(cx, cz))) continue;56 if ((cellCounts.get(key(cx, cz)) ?? 0) > 1) continue;57 const flow = ((1 - p.rotation + 4) % 4) as Dir;58 for (const side of [1, 3] as const) {59 const w = ((flow + side) % 4) as Dir;60 const [dx, dz] = DIR_STEP[w];61 const s1: [number, number] = [cx + dx, cz + dz];62 if (taken.has(key(...s1))) continue;63 candidates.push({ cell: s1, facing: ((w + 2) % 4) as Dir });64 }65 }66 if (candidates.length === 0) return empty;6768 const count = rng.int(1, 3);69 const plan: FanPlan = { placements: [], cells: [] };70 for (const cand of rng.shuffle(candidates)) {71 if (plan.placements.length >= count) break;72 if (taken.has(key(...cand.cell))) continue;73 plan.placements.push({74 pieceId: rng.pick([75 "fan-standing-two",76 "fan-standing-four",77 "fan-two",78 "fan-four",79 "fan-simple-two",80 "fan-simple-four",81 ]),82 pos: [cand.cell[0], 0, cand.cell[1]],83 // Local +z (the blade side) faces the track.84 rotation: ((1 - cand.facing + 4) % 4) as Dir,85 role: "track",86 spin: { axis: [0, 0, 1], radPerSec: 4 + rng.float() * 3 },87 boost: BOOST,88 });89 taken.add(key(...cand.cell));90 plan.cells.push(key(...cand.cell));91 }92 return plan;93}