PROCITY/web/js/world/magpie.js
m3ultra f0b9a1d30f Lane B R39 (Fable's ruling): the magpie takes E's GLB use-if-ready, and the fallback's white moves
+1 draw / +894 tris with the bird on screen, +0 with it off — measured same page, same frame, by
toggling the InstancedMesh count. ?noassets keeps the 182-tri procedural bird (+1 / +182), so this is
furniture.js's use-if-ready SECOND path, not a substitution. state.asset reports which bird is bound.

THREE THINGS MEASURED BEFORE THIS WAS SAFE, AND ALL THREE WOULD HAVE SHIPPED WRONG:
 · E's bird faces +Z and this file flies -Z (Matrix4.lookAt's -Z column is the travel direction).
   Sliced the GLB's 894 tris along Z in ten bins: +Z end mean y 0.31, half-width 0.009 (head + bill);
   -Z end mean y 0.028 (tail). Without rotateY(PI) the magpie swoops TAIL-FIRST. This is the FIFTH
   -Z/+Z instance and the first caught BEFORE it shipped — because R39's lineage table said to point
   a measurement at it. Verified in pictures: perched head-leading, and a swoop frame taken head-on.
 · Its origin is at the FEET (GLB law minY=0) while PERCH_Y is a body-centre height: dropped 0.20 m
   so it stands on the lamp arm (4.12) instead of hovering. `folded` is 1 for the GLB — it IS the perch.
 · THE FETCH IS ARMED BY createMagpie(), NOT BY THE IMPORT. index.html imports this module
   unconditionally and gates only the CONSTRUCTION, so a module-scope loadGLB (furniture.js's shape)
   would have put a brand-new GLB request on the ?classic=1 boot — a fetch delta on the one boot that
   is not allowed one. Asserted on the network log, not on the code: default 1 request (asset 'glb',
   894 tris) · ?classic=1 0 · ?magpie=0 0 · ?noassets=1 0 (asset 'primitive', 182).

AND ONE MY EDIT INTRODUCED INTO ANOTHER LANE'S TOOL: a module-scope loadGLB also fires GLTFLoader's
fetch path under node, so Lane E's pipeline/dump_bird.mjs (which imports this module unmodified) died
with "ProgressEvent is not defined". Gated on IN_BROWSER as well as ?noassets; E's tool then ran
against my edit and reported 182 triangles / 216 verts — the placement fixes are exactly +0 tris.

THE FALLBACK, FIXED AND THEN MEASURED — a half-win, stated as one. E is right that the white bars sat
3 mm ABOVE horizontal wing planes, so from below (the player is always below: perch 4.32 m, EYE 1.62)
the black wing occluded its own flash: bar y 0.048 -> 0.042. Same class of fix, my own arithmetic, for
the other two marks: the nape was BURIED IN THE HEAD (head-sphere surface at z=-0.10 is y 0.0728, the
box topped out at 0.067) so it moved behind the head to z -0.07; nape and rump thickened 0.014->0.030
and 0.02->0.034, because from the flank a 14 mm slab on a 166 mm bird is 1.3 px at 64 px. All +0 tris.
Measured in the same crop of the same pose: pale area 180 -> 399 px, dark 3,017 -> 2,755 — but TRUE
white 33 -> 28, because a downward-facing surface gets no sun. E's GLB reads 755. The fallback cannot
be made pied with primitives at zero cost; the problem is photometric as well as geometric.

Stale comments corrected: magpie.js:9 and :123 said 154 triangles; it is 182 (E's ask 1).

FILED, NOT FIXED: the arcade's awning posts land +/-0.50 m from the centreline — two rows 1.00 m
apart, 34 posts down a 5.00 m lane (my own measurement from the plan, confirming E's). AWNING_DEPTH
2.2 is tuned for a 9 m footpath. One clamp fixes it, but it changes default-boot geometry for a
district whose whole treatment is a scheduled item, and the post line should be designed with the roof.

Lane E's transmission recall checked, not assumed: longbench, streetlight and magpie as staged in
web/assets/models/ carry no KHR_materials_transmission. My lane wires neither of the first two today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:37:24 +10:00

447 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 ~24 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; } };
}