// PROCITY Lane B — magpie.js (v8 WAVE 1 §1.3 — THE MAGPIE) // // For about six weeks of the game's year, one bird on one street will not let you walk past it. // // ── WHAT IT COSTS ─────────────────────────────────────────────────────────────────────────────── // ONE town-wide `InstancedMesh`, `count` 0 or 1 ⇒ **+1 draw while a bird is on screen, exactly 0 // otherwise** (three.module.js:2193-2199 returns before the GL call at count 0; `render.calls++` is // downstream at :4094 — the charter's own vendored-line verification). Geometry is a merged // primitive bird, **154 triangles** against the ≤900 allowance. There is no atlas, no GLB, no fetch: // procedural by construction, so `?noassets` is the same code path as everything else. // // ── TERRITORY SCALES WITH THE ROAD GRAPH (Fable's R38 binding correction 4) ───────────────────── // A fixed handful of anchors is the trap: the synthetic default has ~6.8 km of street and // `bendigo_real` has ~188 km across 2,593 edges, so a capacity-4 magpie on a real town is a swooping // town where you never meet the magpie. Territories are therefore derived by **road length** — one // per `TERRITORY_SPACING` metres — and only the NEAREST one is ever instantiated, refreshed off // `chunks.onChunkBuilt` (the seam R37 §0.4 wired for exactly this) so the cap is spent where the // player is. One bird at a time, on roughly one street per 1.2 km, for six weeks of the year. // The 1-per-1.2 km figure is the *swooping* density, not the magpie density: territories are ~2–4 ha // so a suburb is thick with magpies, and ~90% of males never swoop — so the sparse layer IS the 10%. // // ── THE HAT IS CUT ────────────────────────────────────────────────────────────────────────────── // The ice-cream-container helmet is not here and must not come back without a street verb: `index.html` // binds `] [ P M T` plus a click that enters a shop, and nothing else. (Charter cut list; R38 // binding correction 4.) // // ── THE LATCH IS RUNTIME-ONLY ─────────────────────────────────────────────────────────────────── // "It got me today" is a plain field on this module's state. It is cleared when the day rolls and it // is **never written to localStorage** — the delta law says a v8 content layer does not grow the save // file, and F's gate for this item is exactly "the day-roll clears the latch and localStorage gained // no new key". There is no persistence code in this file to go wrong. // // ── POSITION IS A PURE FUNCTION ───────────────────────────────────────────────────────────────── // `swoopPos(launch, t)` is exported and side-effect-free — the tram's proven contract (R9/R21), the // thing that makes a moving object gate-checkable at all. The only stateful part is *when* a launch // starts, which is recorded as three numbers. // // ── LANE E'S 894-TRI `magpie` GLB: NOT ADOPTED THIS ROUND, AND THE REASON IS THE WHITE ────────── // E shipped it mid-round with a caveat traced (not guessed) to the operator's PBR bake: **it reads // as a CROW** — the white nape, wing bar and tail are gone, and E confirms the loss is already in // the raw 500k mesh, so our decimation is exonerated. Measured against what is in this file: // procedural bird **182 tris**, white nape + rump + wing bars baked as vertex colours, 0 fetches // E's GLB **894 tris** (4.9×), no white, +1 fetch, +1 material // A magpie you see for 1.2 seconds in your peripheral vision at speed IS its white bar — that black // -and-white flash is the entire read. A uniformly dark bird is a currawong or a crow, and the joke // this item exists for does not land. So **§1.3 does not hold for the tint; the GLB does.** When E // hand-tints the 512² atlas, the swap is `birdGeometry()` / `birdMaterial()` below — two factory // singletons, one call site each, the same use-if-ready shape `furniture.js` uses for the depot // props. Deliberately NO speculative `loadGLB` here: the asset is not in the manifest, and a boot // that probes for a file nobody has shipped is the blind probe R26 spent a round killing. // // The other two Lane E R38 props are a decided NO, on arithmetic rather than taste: `paling_fence` // is 1,121 tris and this town wants **905 fence runs** (5 × 181 lots) ⇒ **1.01 M triangles** against // ~75 k spare, and it cannot ride `boxMats` (BoxGeometry, one shared untextured shell material), so // it would also pay a draw. `water_tank` at 500 tris replaces a 24-tri pair. The primitives are not // a stopgap here, they are the answer. `hills_hoist` and `tv_aerial` were killed outright by E's own // spike (thin wire returns as thousands of disconnected fragments; a voxel remesh coarse enough for // 600 tris erases the pole) — so those two are permanently primitive, which is what shipped. import * as THREE from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; import { rng, frange } from '../core/prng.js'; import { resolveEdges } from './planutil.js'; import { applyWind } from './wind.js'; const TERRITORY_SPACING = 1200; // metres of street per swooping bird (see header) const TERRITORY_MAX = 400; // sanity cap on the candidate list (bendigo lands ~157) const DEFEND_R = 34; // he starts at the edge of his patch, not across the suburb const PERCH_Y = 4.32; // on the streetlight arm (furniture.js: pole to 4.2, arm at 4.1) const SWOOP_T = 1.25, RETURN_T = 1.9, COOLDOWN = 5.5; const WING_SPAN = 0.34; // the `wing` wind mode's reference half-span // ── the season, and the vacuous version of it that this file shipped first ────────────────────── // A 364-day year (the game already runs a 7-day week: `weekNightOf(day) = (day-1) % 7`), swooping // from ~1 August for six weeks — the real window, 42/364 = 11.5% of the year. Straight, that is a // **feature nobody will ever see**: day 1 is January, a game day is 240 seconds, and no playtest has // reached day 212. So the year carries a seeded offset — you arrive in a town and it is whatever // time of year it is THERE. // // **The first cut of that offset was `rng(citySeed, 'swoopseason', 0)` and it was VACUOUS — measured, // not suspected.** `plan.citySeed` is the `?seed=` value and **the town key is deliberately NOT in // it** (plan_osm.js:515 keeps it off the plan so the Melbourne golden stays frozen), so all 23 real // towns share one seed, therefore one offset, therefore ONE answer: on the default seed, **24 of 24 // towns were 214 days from swooping season**. The mechanism produced a constant. A magpie nobody can // meet is the same as no magpie, and it would have gated green because `?magpie=1` forces it. // // The offset is therefore keyed on **(citySeed, townKey)**: 23 towns, 23 positions in the year. The // synthetic default has no town key and uses its own arm, so `?seed=` still moves it. Measured over // the shipped corpus below — see LANE_B_NOTES §38 for the count that is actually in season. // `?magpie=1` forces the season open for gates and for John. const YEAR_DAYS = 364, SEASON_START = 212, SEASON_LEN = 42; export function seasonOffset(citySeed, townKey = null) { return Math.floor(rng(citySeed >>> 0, 'swoopseason:' + (townKey || 'synthetic'), 0)() * YEAR_DAYS); } export function inSwoopSeason(day, citySeed, townKey = null) { const yd = (((day | 0) - 1 + seasonOffset(citySeed, townKey)) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS; return yd >= SEASON_START && yd < SEASON_START + SEASON_LEN; } /** Days until this town's season opens (0 while it is open). Diagnostic for the notes + gates. */ export function daysToSeason(day, citySeed, townKey = null) { if (inSwoopSeason(day, citySeed, townKey)) return 0; const yd = (((day | 0) - 1 + seasonOffset(citySeed, townKey)) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS; return yd < SEASON_START ? SEASON_START - yd : YEAR_DAYS - yd + SEASON_START; } /** * swoopPos(launch, t) → [x, y, z] — PURE. `launch` is {ax,ay,az, tx,ty,tz, back:boolean}; `t` is * 0..1 through the leg. Out along a parabola that crests above the player and drops onto the back of * the head; back on a lazier, higher arc. No module state is read; no state is written. */ export function swoopPos(launch, t) { const u = Math.max(0, Math.min(1, t)); const { ax, ay, az, tx, ty, tz } = launch; const x = ax + (tx - ax) * u; const z = az + (tz - az) * u; const lift = launch.back ? 2.6 : 1.25; const y = ay + (ty - ay) * u + lift * 4 * u * (1 - u); // the arc, crest at u = 0.5 return [x, y, z]; } // ── the bird ──────────────────────────────────────────────────────────────────────────────────── // Merged primitives with baked vertex colours: black body/head/wings, white nape, white wing bar, // white rump. 154 triangles. One geometry, one material, one InstancedMesh. const BLACK = new THREE.Color('#191a1c'), WHITE = new THREE.Color('#e6e4dd'), BILL = new THREE.Color('#b9bcc0'); function paint(geo, color) { const n = geo.attributes.position.count; const c = new Float32Array(n * 3); for (let i = 0; i < n; i++) { c[i * 3] = color.r; c[i * 3 + 1] = color.g; c[i * 3 + 2] = color.b; } geo.setAttribute('color', new THREE.BufferAttribute(c, 3)); return geo; } let _geo = null; function birdGeometry() { if (_geo) return _geo; const parts = []; // body — nose along −Z (three's forward), so a lookAt-style basis points the bird where it flies parts.push(paint(new THREE.SphereGeometry(0.085, 8, 6).scale(1, 0.9, 1.75), BLACK)); parts.push(paint(new THREE.SphereGeometry(0.055, 6, 5).translate(0, 0.035, -0.14), BLACK)); // head parts.push(paint(new THREE.ConeGeometry(0.021, 0.08, 5).rotateX(-Math.PI / 2).translate(0, 0.03, -0.22), BILL)); parts.push(paint(new THREE.BoxGeometry(0.052, 0.014, 0.055).translate(0, 0.06, -0.10), WHITE)); // nape parts.push(paint(new THREE.BoxGeometry(0.11, 0.02, 0.13).translate(0, 0.055, 0.10), WHITE)); // rump/back parts.push(paint(new THREE.BoxGeometry(0.05, 0.022, 0.30).translate(0, 0.0, 0.26), BLACK)); // tail // wings: flat, spanning ±WING_SPAN in x — the `wing` wind weight is |x| / WING_SPAN for (const s of [-1, 1]) { parts.push(paint(new THREE.PlaneGeometry(WING_SPAN, 0.15).rotateX(-Math.PI / 2) .translate(s * WING_SPAN / 2, 0.045, -0.01), BLACK)); parts.push(paint(new THREE.PlaneGeometry(WING_SPAN * 0.42, 0.05).rotateX(-Math.PI / 2) .translate(s * WING_SPAN * 0.72, 0.048, 0.03), WHITE)); // the wing bar } for (const p of parts) { p.deleteAttribute('uv'); if (!p.attributes.normal) p.computeVertexNormals(); } _geo = mergeGeometries(parts, false); parts.forEach((p) => p.dispose()); return _geo; } let _mat = null; function birdMaterial() { if (_mat) return _mat; _mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.62, metalness: 0, side: THREE.DoubleSide }); applyWind(_mat, { topY: WING_SPAN, mode: 'wing' }); // R38: the |x|-weighted beat; exactly 0 at dead calm return _mat; } /** * createMagpie({ scene, plan, citySeed, camera, chunks, force }) → { group, update, dispose, state } * `force` (?magpie=1) forces the season open. The shell never constructs this under ?classic=1 or * ?magpie=0, so both are byte-identical by absence. */ export function createMagpie({ scene, plan, citySeed, townKey = null, camera, chunks, lighting, force = false }) { const group = new THREE.Group(); group.name = 'magpie'; const state = { territories: 0, roadKm: 0, active: null, mode: 'absent', swoopsToday: 0, gotYouToday: false, day: 1, dayRolls: 0, inSeason: false, forced: !!force, launches: 0, // Diagnostics so "is there a magpie in this town?" is answerable without a 200-day soak. townKey, seasonOffset: seasonOffset(citySeed >>> 0, townKey), get daysToSeason() { return daysToSeason(this.day, citySeed >>> 0, townKey); }, }; // ── candidate territories, scaled to the graph ── const edges = resolveEdges(plan); let total = 0; for (const e of edges) total += Math.hypot(e.bx - e.ax, e.bz - e.az); state.roadKm = +(total / 1000).toFixed(2); const want = Math.max(1, Math.min(TERRITORY_MAX, Math.round(total / TERRITORY_SPACING))); // Walk the graph accumulating length and drop an anchor every `step` metres — length-proportional, // so it is the ROAD GRAPH that sets the count, not the edge count (a real town's edges are short // and numerous; the synthetic town's are few and long, and both must land somewhere sane). const step = total / want; const terr = []; let acc = 0, next = step * 0.5; for (const e of edges) { const dx = e.bx - e.ax, dz = e.bz - e.az, len = Math.hypot(dx, dz) || 1; const ux = dx / len, uz = dz / len, px = -uz, pz = ux; while (next < acc + len && terr.length < TERRITORY_MAX) { // HE PERCHES ON A POLE THAT EXISTS. The first cut put the anchor at an arbitrary distance // along the edge and the bird hovered in open air over the road — every number green, the // picture wrong, which is precisely R37's lesson and it took a screenshot to see it again. // `furniture.js` plants streetlights at `s = 8, 26, 44, …` (every 18 m from 8), alternating // sides starting at +1, offset `width/2 + 0.7` — so snap to the NEAREST of those and reuse the // same side rule. Same literal in two files is the R27 trap, so if the cadence ever moves this // is the second place to change; the alternative (reading live furniture instances) can't work // because territories are chosen before the chunk that holds the pole is built. const raw = next - acc; const k = Math.max(0, Math.round((raw - 8) / 18)); const s = Math.min(Math.max(8, 8 + k * 18), Math.max(8, len - 4)); const side = (k % 2 === 0) ? 1 : -1; const r = rng(citySeed >>> 0, 'magpie', terr.length); const off = (e.width / 2 + 0.7) * side; terr.push({ // half a metre out along the lamp arm, which reaches over the road x: e.ax + ux * s + px * off * 0.88, z: e.az + uz * s + pz * off * 0.88, y: PERCH_Y, yaw: frange(r, -Math.PI, Math.PI), edgeId: e.id, poleS: s, seed: (citySeed ^ (terr.length * 2654435761)) >>> 0, }); next += step; } acc += len; } state.territories = terr.length; const mesh = new THREE.InstancedMesh(birdGeometry(), birdMaterial(), 1); mesh.name = 'magpie-bird'; mesh.castShadow = false; mesh.receiveShadow = false; mesh.frustumCulled = false; // one instance, moving fast, town-wide bounds — culling it is wrong mesh.count = 0; // 0 ⇒ three returns before the GL call ⇒ exactly 0 draws group.add(mesh); scene.add(group); // ── proximity-first territory selection, refreshed on the R37 §0.4 chunk seam ── let dirty = true, home = null; const markDirty = () => { dirty = true; }; const offBuilt = chunks && chunks.onChunkBuilt ? chunks.onChunkBuilt(markDirty) : () => {}; const offGone = chunks && chunks.onChunkDisposed ? chunks.onChunkDisposed(markDirty) : () => {}; function pickHome(px, pz) { let best = null, bd = Infinity; for (const t of terr) { const d = (t.x - px) ** 2 + (t.z - pz) ** 2; if (d < bd) { bd = d; best = t; } } home = best; state.active = best ? { x: +best.x.toFixed(2), z: +best.z.toFixed(2), edgeId: best.edgeId } : null; } // ── the swoop ── let phase = 'perched', tPhase = 0, cool = 0, launch = null; const _m = new THREE.Matrix4(), _p = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3(1, 1, 1); const _fwd = new THREE.Vector3(), _prev = new THREE.Vector3(), _look = new THREE.Matrix4(); const _upv = new THREE.Vector3(0, 1, 0); let lastPos = null; function seasonOpen() { const day = (typeof window !== 'undefined' && window.PROCITY && window.PROCITY.game) ? (window.PROCITY.game.day | 0) : 1; if (day !== state.day) { // THE DAY ROLL CLEARS THE LATCH. No storage is touched here or anywhere in this file. // It also puts him back on his pole: you slept, it is dawn, and a swoop cannot be "in // progress" across a night. Without that reset the latch clears and is then re-set half a // second later by the leg that was already in the air — which is exactly what the first run // of this measurement showed (`swoopsToday` 1 → 0 but `gotYouToday` still true), and it would // have made F's gate read as a failed clear when the clear had in fact worked. state.day = day; state.gotYouToday = false; state.swoopsToday = 0; state.dayRolls++; phase = 'perched'; tPhase = 0; cool = COOLDOWN; launch = null; lastPos = null; } state.inSeason = force || inSwoopSeason(day, citySeed >>> 0, townKey); return state.inSeason; } // `folded` squashes the instance in X. The wings are baked into the one shared geometry (they have // to be — one geometry is what makes this 1 draw), so there is no pose to switch to; scaling the // ±0.34 m wing planes to ±0.14 m reads as a bird sitting with its wings in, which is what a // perched magpie looks like and what the first screenshot showed it emphatically was not. function place(x, y, z, fx, fz, folded = false) { _s.set(folded ? 0.42 : 1, 1, 1); _p.set(x, y, z); _fwd.set(fx, 0, fz); if (_fwd.lengthSq() < 1e-6) _fwd.set(0, 0, 1); _fwd.normalize(); _look.lookAt(new THREE.Vector3(0, 0, 0), _fwd, _upv); _q.setFromRotationMatrix(_look); mesh.setMatrixAt(0, _m.compose(_p, _q, _s)); mesh.instanceMatrix.needsUpdate = true; mesh.count = 1; } function update(dt) { if (!terr.length) { mesh.count = 0; state.mode = 'absent'; return; } const p = camera.position; if (dirty || !home) { dirty = false; pickHome(p.x, p.z); } if (!seasonOpen()) { mesh.count = 0; state.mode = 'out-of-season'; return; } // He roosts at night like every other magpie. if (lighting && lighting.isNight && lighting.isNight()) { mesh.count = 0; state.mode = 'roosting'; return; } const d = Math.hypot(home.x - p.x, home.z - p.z); if (d > DEFEND_R * 2.2) { mesh.count = 0; state.mode = 'far'; return; } // not even in sight cool = Math.max(0, cool - dt); if (phase === 'perched') { state.mode = 'perched'; place(home.x, home.y, home.z, Math.cos(home.yaw), Math.sin(home.yaw), true); if (d < DEFEND_R && cool <= 0) { // launch: the target is the player's position AT LAUNCH, frozen — so the whole leg stays a // pure function of three recorded numbers rather than of a live camera. launch = { ax: home.x, ay: home.y, az: home.z, tx: p.x, ty: Math.max(1.15, p.y - 0.35), tz: p.z, back: false }; phase = 'out'; tPhase = 0; state.launches++; state.swoopsToday++; try { window.PROCITY?.audio?.playSfx?.('magpie-carol', { gain: 1 }); } catch { /* silent-and-happy */ } } lastPos = null; return; } tPhase += dt; const dur = phase === 'out' ? SWOOP_T : RETURN_T; const u = Math.min(1, tPhase / dur); const [x, y, z] = swoopPos(launch, u); if (lastPos) place(x, y, z, x - lastPos[0], z - lastPos[2]); else place(x, y, z, launch.tx - launch.ax, launch.tz - launch.az); lastPos = [x, y, z]; state.mode = phase === 'out' ? 'swooping' : 'returning'; if (phase === 'out' && u >= 0.62 && !state.gotYouToday) state.gotYouToday = true; // runtime latch only if (u >= 1) { if (phase === 'out') { launch = { ax: launch.tx, ay: launch.ty, az: launch.tz, tx: home.x, ty: home.y, tz: home.z, back: true }; phase = 'back'; tPhase = 0; lastPos = null; } else { phase = 'perched'; cool = COOLDOWN; lastPos = null; } } } function dispose() { offBuilt(); offGone(); mesh.dispose && mesh.dispose(); group.remove(mesh); scene.remove(group); // geometry + material are module-shared singletons (one town per session) — not disposed here } return { group, update, dispose, state, swoopPos, get count() { return mesh.count; }, get territories() { return terr; } }; }