// 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, **182 triangles** against the ≤900 allowance — sphere(8,6) 80 + sphere(6,5) 48 + // cone(5) 10 + 3 boxes 36 + 4 planes 8. *(This comment and `:123` both said 154 for two rounds; Lane // E re-measured it three ways in R39, including a dump of this file's own `birdGeometry()` — 216 // verts, index length 546. Corrected.)* The fallback bird needs no atlas, no GLB and no fetch, 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: ADOPTED IN R39 AS THE USE-IF-READY PATH (Fable ruled on the // picture, `docs/shots/laneE/r39_magpie_ab.png`) ─────────────────────────────────────────────── // R38 refused the GLB because it read as a crow. E re-tinted it, then shot both birds through the // same rig at the size the bird is actually seen and found the thing neither of us had: **the player // is looking at the PERCH, not the swoop** — simulated on this file's own clock, the bird is drawn in // 54.1% of frames and of those it is **perched 80.5%**, swooping 7.7%. E's asset IS a perch pose (a // standing bird with legs); this file's perch is the flight geometry squashed 0.42× in X. // And E found the mechanism behind "reads as a dark aeroplane", which is geometric, not a tint: // **the white wing bars sat 3 mm ABOVE horizontal wing planes**, so from underneath — and the player // is ALWAYS underneath, perch 4.32 m against EYE 1.62 m — the black wing occluded its own white bar. // The flash was painted on the roof of the wing. That is fixed below, and it is fixed *whatever* the // GLB does, because `?noassets` and `?classic` show the procedural bird forever. // // So: **two paths, one draw.** `birdGeometry()`/`birdMaterial()` stay the fallback; the GLB is loaded // once at module init exactly the way `furniture.js` upgrades the depot props (`loadGLB('depot:…')` // → a synchronous ready-check, never an async write into a live build) and swapped into the ONE // InstancedMesh when it lands. **+712 tris at one instance; draws are a wash** — both are the same // town-wide InstancedMesh at count 0 or 1. It is not a blind probe: `magpie` has been in // `manifest.json` and on the depot since R38 §7a, which is precisely the thing R26's ruling asked for. // // TWO THINGS I HAD TO MEASURE MYSELF BEFORE WIRING IT, and both would have shipped wrong: // • **E's bird faces +Z; this file flies −Z.** `place()` builds its basis with `Matrix4.lookAt`, // whose −Z column is the travel direction, and the primitive bird's nose is modelled along −Z. // Sliced the GLB's 894 tris along Z: the +Z end is high and narrow (mean y 0.31, half-width // 0.009 — the head and bill) and the −Z end is low and thin (mean y 0.028 — the tail). Without // `rotateY(π)` the magpie would swoop tail-first. *This is the fifth −Z/+Z instance and it was // caught by pointing the measurement at it — see §39's lineage table.* // • **its origin is at the feet** (GLB law: minY = 0), while `PERCH_Y = 4.32` is a body-centre // height for the primitive. Dropped by `GLB_FOOT_DROP` so the feet land on the lamp arm (4.12, // `furniture.js`: pole to 4.2, arm at 4.1) instead of hovering 0.22 m over it. // The GLB is rigid (no `applyWind`) and is NOT X-squashed when perched — it is already the perch. // // 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'; import { loadGLB } from '../core/loaders.js'; // [R39] Lane E's magpie GLB, use-if-ready 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. **182 triangles** (was written as 154 here for two rounds — see the header). // One geometry, one material, one InstancedMesh. // // [R39] THE WHITE IS NOW WHERE THE PLAYER IS. Every mark was placed for a bird seen from the side at // eye level; the bird is seen from BELOW, on a 4.32 m lamp arm, and 80.5% of its drawn frames are the // perch. Three placements changed, all **+0 triangles** (a box is a box, a plane is a plane): // 1. the wing bar y 0.048 → **0.042**: under the wing plane (y 0.045) instead of 3 mm over it, so // the black wing stops occluding its own flash. Both are DoubleSide, so nothing is lost above. // 2. the nape moved off the head it was buried in. Measured: the head sphere's surface at z = −0.10 // is y **0.0728** and the old box topped out at 0.067 — only its rear ~12 mm was ever proud. It // now sits at z −0.07, *behind* the head (head spans z −0.195…−0.085), where the body's back is // at y 0.0675, and it is **0.030 thick instead of 0.014** so it has a face you can see edge-on. // 3. the rump likewise: 0.034 thick at y 0.062, sitting on the body's back (y 0.0566 at z = 0.10) // rather than half-buried in it. // Thickness is the point, not height: from the flank — 80% of viewing — a horizontal slab 14 mm // thick on a 166 mm bird is **1.3 px of white at 64 px**. A slab you can see the side of is not. 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.030, 0.055).translate(0, 0.0745, -0.07), WHITE)); // nape (R39: off the head, thick enough to read edge-on) parts.push(paint(new THREE.BoxGeometry(0.11, 0.034, 0.13).translate(0, 0.062, 0.10), WHITE)); // rump/back (R39: on the back, not in it) 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.042, 0.03), WHITE)); // [R39] the wing bar — BELOW the wing } 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; } // ── [R39] Lane E's GLB, use-if-ready — `furniture.js`'s pattern, not a probe ───────────────────── // Loaded ONCE at module init (skipped entirely under ?noassets, like furniture.js's depot props); // `_glb` is checked SYNCHRONOUSLY by createMagpie and again when the promise lands, so a bird built // before the GLB arrives keeps the primitive and swaps to the GLB the moment it exists. One mesh in, // one mesh out: this stays ONE InstancedMesh and therefore one draw either way. const GLB_FILE = 'procity_street_magpie_01.glb'; // manifest.furniture.magpie (depot since R38 §7a) const GLB_FOOT_DROP = 0.20; // GLB origin is the FEET (minY=0); PERCH_Y is a body-centre height // NOT-IN-A-BROWSER is its own arm, and it is not pedantry: Lane E's `pipeline/dump_bird.mjs` imports // THIS MODULE unmodified to dump `birdGeometry()` for the A/B render, and a module-scope `loadGLB` // fires GLTFLoader's fetch path there — `ProgressEvent is not defined`, E's tool dead, my file's // fault. Caught by running E's tool against my own edit before committing it. const IN_BROWSER = typeof window !== 'undefined' && typeof document !== 'undefined'; const NOASSETS = !IN_BROWSER || (() => { try { const p = new URLSearchParams(location.search); return p.has('noassets') && p.get('noassets') !== '0'; } catch { return false; } })(); let _glb = null; // { geo, mat, tris } once (and if) it loads const _glbSubs = []; // createMagpie instances waiting for it function extractBird(gltf) { const geos = []; let material = null; gltf.scene.updateWorldMatrix(true, true); gltf.scene.traverse((o) => { if (!o.isMesh || !o.geometry) return; const g = o.geometry.clone(); g.applyMatrix4(o.matrixWorld); for (const name of Object.keys(g.attributes)) if (name !== 'position' && name !== 'normal' && name !== 'uv') g.deleteAttribute(name); g.morphAttributes = {}; if (!g.attributes.normal) g.computeVertexNormals(); if (!g.attributes.uv) g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(g.attributes.position.count * 2), 2)); geos.push(g); if (!material) material = Array.isArray(o.material) ? o.material[0] : o.material; }); if (!geos.length) return null; let merged = null; try { merged = mergeGeometries(geos, false); } catch (e) { merged = null; } geos.forEach((g) => g.dispose()); if (!merged || !material) return null; // heterogeneous → the primitive stays // E's bird is modelled head-to-+Z and standing on its feet; this file flies −Z from a body centre. merged.rotateY(Math.PI).translate(0, -GLB_FOOT_DROP, 0); merged.computeBoundingSphere(); const idx = merged.getIndex(); return { geo: merged, mat: material, tris: (idx ? idx.count : merged.attributes.position.count) / 3 }; } // THE FETCH IS ARMED BY createMagpie(), NOT BY THE IMPORT — and that is the classic covenant, not // tidiness. `index.html` imports this module unconditionally; only the CONSTRUCTION is gated // (`?classic=1` / `?magpie=0` never call createMagpie). A module-scope load would therefore have put // a brand-new GLB request on the classic boot — a fetch delta on the one boot that is not allowed one. // furniture.js can load at module scope because furniture is built on every boot; this cannot. let _glbAsked = false; function ensureGLB() { if (_glbAsked || NOASSETS) return; _glbAsked = true; loadGLB('depot:' + GLB_FILE) .then((g) => { const ex = g && extractBird(g); if (!ex) return; // fail-soft: no file, no merge → primitive forever _glb = ex; for (const fn of _glbSubs.splice(0)) { try { fn(ex); } catch (e) { /* one bird's problem */ } } }) .catch(() => { /* the primitive is the answer, silently */ }); } /** * 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, // [R39] which bird is actually bound — 'primitive' (182 tris) or 'glb' (Lane E's 894). F's gate // reads this rather than guessing from a fetch: ?noassets must stay 'primitive' forever. asset: 'primitive', tris: 182, // 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); // [R39] the use-if-ready swap. `foldScale` is 0.42 for the primitive (whose perch is the flight // geometry squashed in X) and 1 for the GLB, which IS the perch pose. Geometry/material are // swapped on the ONE InstancedMesh — no second mesh, no second draw — and the primitive singletons // are NOT disposed: they are module-level and a second createMagpie would want them. let foldScale = 0.42; function adoptGLB(ex) { mesh.geometry = ex.geo; mesh.material = ex.mat; foldScale = 1; state.asset = 'glb'; state.tris = ex.tris; } if (_glb) adoptGLB(_glb); else if (!NOASSETS) { _glbSubs.push(adoptGLB); ensureGLB(); } // ── 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 ? foldScale : 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; } }; }