The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it, a magpie owns one street for six weeks of the year, and six point sources put a life off-screen. MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`, :8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre spine steps at yaw 0, each frame fully streamed, through the shell's own composer: worst draws 224 -> 225 (+1) worst tris 89,502 -> 93,298 (+3,796 of ~75k spare) sum draws over 102 frames 16,676 -> 16,663 (-13) The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time. `?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw / +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws. §1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION. `townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key, so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path. The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`, so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns. `gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had; making the slot addressable fixed it at the root, and both are selected now. E's three new grounds landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for. §1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands). THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37 chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin. Zero on every real town (no hoists ⇒ no mesh) and zero at night. §1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never mentions it. `swoopPos()` is exported and pure. The hat stays cut. MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on (citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%. §1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff, AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot; 3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability control — one manifest key renamed away, served from a third port — makes that source report `no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors. TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256 instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height, and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to furniture.js's own streetlight rule. LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The `magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch. furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000. Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38. 0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets, magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones. Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
334 lines
20 KiB
JavaScript
334 lines
20 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, **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; } };
|
||
}
|