+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>
447 lines
28 KiB
JavaScript
447 lines
28 KiB
JavaScript
// 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; } };
|
||
}
|