@polycode-projects/the-mechanical-code-talker 5.0.0 → 5.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,766 @@
1
+ // mudiii-scene.mjs — the town square's own three.js scene, reached from
2
+ // mudiii-viz.mjs (the page shell) through exactly one frozen function:
3
+ // `mudiiiSceneScript(opts) -> string`, a standalone inline <script> the page
4
+ // embeds next to its own (see mudiii-viz.mjs's own header on the split: two
5
+ // separate IIFEs, no shared lexical scope). This module owns the 3D scene
6
+ // only — the deck, the HUD, the chat lane and the map panel are the page
7
+ // shell's.
8
+ //
9
+ // Built from exactly two kinds of input, holding no world state of its own:
10
+ // the prop facts once at boot, and the tick payload every tick. Everything
11
+ // this file renders is a read of one of those two — never a second, locally
12
+ // invented notion of where an agent stands.
13
+ //
14
+ // PLAN_MUD_MUDIII.md's own render-layer section is corrected in two places
15
+ // this file follows rather than the doc's original text: GLTFLoader ships in
16
+ // the vendor bundle (handles KHR_mesh_quantization and EXT_texture_webp
17
+ // natively; only EXT_meshopt_compression needs a decoder line), and there is
18
+ // no globalThis.tmctMudiii — pages publish one globalThis.tmct.
19
+ //
20
+ // The page-shell <-> scene handshake, since the two scripts share no scope:
21
+ // - the page shell already defines `window.mudiiiHandleSceneClick(cellId)`
22
+ // (mudiii-viz.mjs) — this file calls it on a raycast hit and owns no
23
+ // other write path into the world; "one code path, one provenance stamp"
24
+ // means the click is never resolved here.
25
+ // - this file defines `window.mudiiiScene`, the reverse direction:
26
+ // .boot({ propPlacements, assetManifest, gridSize?, cellSize? })
27
+ // once per world (fresh load, reset, scenario switch) — assetManifest
28
+ // is data/mudiii-assets.json's own `assets` rows; propPlacements is
29
+ // mudiii-viz.mjs's own propPlacementsFrom(...) output (each entry
30
+ // already carries its resolved `.asset` row). gridSize/cellSize
31
+ // override the values baked into this script at build time, for a
32
+ // scenario whose own board differs from the opening one.
33
+ // .applyTick({ agents, items, ecology }) every tick, the exact shape
34
+ // test/fixtures/mudiii-ticks.json's own _readme.tickPayload
35
+ // documents (agents/items keyed by id, ecology a tagged-union
36
+ // array).
37
+ // .setCamera({ mode, selectedId }) whenever the page's own camera
38
+ // state changes — a mode button, the agent-select dropdown, or a
39
+ // nextCameraSelection fallback the page shell already computes and
40
+ // writes into #sceneStatus.
41
+ // .cellOf(id) — read-only, the AUTHORITATIVE stored cell for a live
42
+ // agent or item, never the mid-lerp position. This is what an e2e
43
+ // assertion reads, and what a later screenshot ready-check reads too
44
+ // (see test-e2e/pages-mudiii.test.mjs's own header).
45
+ // .ready() — whether boot() has finished at least once.
46
+ // These three calls are not yet wired into mudiii-viz.mjs's own
47
+ // boot()/applyTickResult()/camera handlers — that file is owned by the viz
48
+ // track, not this one; the coordinator's own report names the exact call
49
+ // sites needed, mirroring how window.mudiiiHandleSceneClick was already
50
+ // added for the reverse direction.
51
+ //
52
+ // Reused from mudiii-viz.mjs rather than re-derived, off its own frozen
53
+ // exports: `roleOfAgentId`, `cellToWorld`, `cellFromGroundPoint`,
54
+ // `cameraRigFor`, `clipForAction`. The two files import each other by design,
55
+ // which a static cycle handles: every binding crossing it is a hoisted
56
+ // `export function` declaration, a live binding available at link time, and
57
+ // nothing calls one while either module's top-level body is still running.
58
+ //
59
+ // It must stay a static import. A top-level `await import()` on both sides
60
+ // turns the same cycle into a deadlock — each module waits for the other to
61
+ // finish evaluating, neither ever does, and the symptom is a silent hang with
62
+ // no error rather than a resolution failure.
63
+ import {
64
+ roleOfAgentId, cellToWorld, cellFromGroundPoint, cameraRigFor, clipForAction, modelUrlFor,
65
+ } from "./mudiii-viz.mjs";
66
+ import { prefersReducedMotion } from "./viz-ticker.mjs";
67
+
68
+ /** The movement/camera tween's duration under normal motion — three.js has
69
+ * no CSS transition, so this is the explicit lerp duration spider-fly-viz.mjs's
70
+ * own `transition: left .25s ease` becomes in a requestAnimationFrame loop. */
71
+ export const TWEEN_DURATION_MS = 250;
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Pure geometry/tween helpers — unit-tested directly in Node, then spliced
75
+ // via `.toString()` into the standalone browser IIFE below, the same
76
+ // splice-safe convention viz-boot.mjs/viz-ticker.mjs already establish.
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** Linear interpolation between `a` and `b` at `t` (0..1), no clamping — a
80
+ * caller past the tween's own duration already clamps `t` to 1 upstream. */
81
+ export function lerp(a, b, t) {
82
+ return a + (b - a) * t;
83
+ }
84
+
85
+ /** One tween's position at `now`: `{ from: {..fields}, to: {..fields},
86
+ * startedAt, durationMs }`, every field in `to` interpolated the same way
87
+ * (works for `{x,z}` ground positions and `{x,y,z}` camera points alike).
88
+ * Returns `{ ...point, t, done }` — `t` is the eased-nothing linear
89
+ * fraction, `done` is whether `now` has reached the tween's own end.
90
+ * `durationMs <= 0` (the reduced-motion collapse) jumps straight to `to`
91
+ * regardless of `now` — invariant 3 of the movement contract. Pure. */
92
+ export function tweenStep(tween, now) {
93
+ if (!tween) return null;
94
+ const { from, to, startedAt, durationMs } = tween;
95
+ if (!durationMs || durationMs <= 0) return { ...to, t: 1, done: true };
96
+ const elapsed = now - startedAt;
97
+ if (elapsed <= 0) return { ...from, t: 0, done: false };
98
+ if (elapsed >= durationMs) return { ...to, t: 1, done: true };
99
+ const t = elapsed / durationMs;
100
+ const point = {};
101
+ for (const key of Object.keys(to)) point[key] = lerp(from[key] ?? to[key], to[key], t);
102
+ return { ...point, t, done: false };
103
+ }
104
+
105
+ /** A fresh tween from `from` to `to`, starting at `now`. Pure. */
106
+ export function startTween(from, to, now, durationMs = TWEEN_DURATION_MS) {
107
+ return { from, to, startedAt: now, durationMs };
108
+ }
109
+
110
+ /** Invariant 2 of the movement contract: a tick arriving mid-lerp re-seeds
111
+ * the start from wherever the mesh CURRENTLY is, not from the old tween's
112
+ * own start or end. `existingTween` may be null (the agent was not
113
+ * tweening); `to` is the new destination point. Pure — `tweenStep` is
114
+ * called on the OLD tween at `now` to read the current position before
115
+ * building the new one. */
116
+ export function reseedTween(existingTween, to, now, durationMs = TWEEN_DURATION_MS) {
117
+ const current = existingTween ? tweenStep(existingTween, now) : null;
118
+ const from = current ? Object.fromEntries(Object.keys(to).map((k) => [k, current[k]])) : to;
119
+ return startTween(from, to, now, durationMs);
120
+ }
121
+
122
+ /** Chebyshev distance between two `cell-<x>-<y>` ids, or `Infinity` for a
123
+ * malformed id — used only to tell a one-cell hop (tweened) apart from a
124
+ * multi-cell move or teleport (snapped, spawn flourish, no path animation —
125
+ * the movement contract's third invariant). Pure. */
126
+ export function chebyshevDistanceBetweenCells(a, b) {
127
+ const ma = /^cell-(\d+)-(\d+)$/.exec(String(a == null ? "" : a));
128
+ const mb = /^cell-(\d+)-(\d+)$/.exec(String(b == null ? "" : b));
129
+ if (!ma || !mb) return Infinity;
130
+ return Math.max(Math.abs(Number(ma[1]) - Number(mb[1])), Math.abs(Number(ma[2]) - Number(mb[2])));
131
+ }
132
+
133
+ /** A facing word to a Y rotation in radians, `south` (the world's own
134
+ * `defaultFacing`) at zero — matches `cameraRigFor`'s own FACING_VECTOR
135
+ * convention (`south: {x:0,z:1}`, `north: {x:0,z:-1}`, `east: {x:1,z:0}`,
136
+ * `west: {x:-1,z:0}`) so an agent mesh turns to face the same way its own
137
+ * follow/pov camera rig would sit behind it. A rig's own forward axis may
138
+ * need a per-model offset once real assets are in place; this is the
139
+ * world-facing convention, not a guarantee about any one GLB's neutral
140
+ * pose. Pure. */
141
+ export function yawForFacing(facing) {
142
+ const YAW = { south: 0, north: Math.PI, east: -Math.PI / 2, west: Math.PI / 2 };
143
+ return YAW[facing] ?? YAW.south;
144
+ }
145
+
146
+ /** Whether `agentId` (predator or prey) currently believes an agent of the
147
+ * opposing role — the one belief-derived bit that separates a predator's
148
+ * "chase" rung from "wander", and a prey's "evade" rung from "forage"/
149
+ * "wander" (PLAN_MUD_MUDIII.md's own priority chains). Read entirely off
150
+ * the tick payload's own `role`/`belief` fields — no rung name travels on
151
+ * the wire, so this reconstructs which rung applied rather than trusting a
152
+ * label. Pure. */
153
+ export function threatEngagedFor(agentId, agentsById) {
154
+ const agent = agentsById[agentId];
155
+ if (!agent || !agent.belief) return false;
156
+ const opposingRole = agent.role === "predator" ? "prey" : agent.role === "prey" ? "predator" : null;
157
+ if (!opposingRole) return false;
158
+ return Object.keys(agentsById).some(
159
+ (id) => id !== agentId && agentsById[id] && agentsById[id].role === opposingRole && agent.belief[id],
160
+ );
161
+ }
162
+
163
+ /** The movement rung driving clip selection while `agentId` is actually
164
+ * moving: "chase" for a predator that believes prey, "evade" for a prey
165
+ * that believes a predator, "wander" otherwise (forage and wander both
166
+ * resolve to the same walk clip in `clipForAction`'s own table, so this
167
+ * never needs to tell them apart). Pure. */
168
+ export function movementRungFor(agentId, agentsById) {
169
+ const agent = agentsById[agentId];
170
+ if (!agent) return "wander";
171
+ if (threatEngagedFor(agentId, agentsById)) return agent.role === "predator" ? "chase" : "evade";
172
+ return "wander";
173
+ }
174
+
175
+ /** The clip-selection ACTION word for one agent this tick: "idle" whenever
176
+ * it is not tweening (invariant 1 of the animation rule — "walk while the
177
+ * tween runs, idle otherwise"), `movementRungFor`'s own result while it is.
178
+ * Pure, and independent of `clipForAction` itself — this only decides which
179
+ * action word to ask for, not which literal clip name answers it. */
180
+ export function currentActionFor(agentId, agentsById, moving) {
181
+ return moving ? movementRungFor(agentId, agentsById) : "idle";
182
+ }
183
+
184
+ /** One ecology event to the flourish it drives, or null for an event this
185
+ * scene has nothing visual to do for on its own (`spawn-food`/`spawn-prey`/
186
+ * `place-food` need no separate action here — the agent/item already gets
187
+ * its spawn flourish the moment it is new to a tick's own agents/items map,
188
+ * so acting on the ecology row too would double it). `kind` is one of
189
+ * "death" (the rig's own Death clip, then scale to zero) or "consume" (an
190
+ * eaten item scales to zero, no clip — items are primitive geometry).
191
+ * Pure. */
192
+ export function flourishForEcologyEvent(event) {
193
+ if (!event || !event.type) return null;
194
+ switch (event.type) {
195
+ case "eat-agent":
196
+ return { kind: "death", targetId: event.prey, cell: event.cell };
197
+ case "starve":
198
+ return { kind: "death", targetId: event.agent, cell: event.cell };
199
+ case "eat-item":
200
+ return { kind: "consume", targetId: event.item, cell: event.cell };
201
+ default:
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /** A bounded-concurrency job queue — three.js's own recommendation for a
207
+ * GLTFLoader shared across many models is to not open every request at
208
+ * once. `run(fn)` queues `fn` (returning a promise) and resolves/rejects
209
+ * with its own settlement once a slot frees up; at most `limit` run at a
210
+ * time. Pure enough to unit-test with fake async jobs, no three.js needed. */
211
+ export function createConcurrencyQueue(limit) {
212
+ let active = 0;
213
+ const waiting = [];
214
+ function pump() {
215
+ if (active >= limit || waiting.length === 0) return;
216
+ active += 1;
217
+ const { fn, resolve, reject } = waiting.shift();
218
+ fn().then(
219
+ (value) => { active -= 1; resolve(value); pump(); },
220
+ (err) => { active -= 1; reject(err); pump(); },
221
+ );
222
+ }
223
+ function run(fn) {
224
+ return new Promise((resolve, reject) => {
225
+ waiting.push({ fn, resolve, reject });
226
+ pump();
227
+ });
228
+ }
229
+ return { run };
230
+ }
231
+
232
+ /** A promise cache keyed by whatever key `loadFn` is called with, WITH
233
+ * rejected-promise eviction: a failed load is removed from the cache the
234
+ * moment it rejects, so the next call for the same key retries instead of
235
+ * replaying the same failure forever (world-of-claudecraft's own "black
236
+ * void" bug this discipline exists to avoid — a cached rejection reads as a
237
+ * model that will never load, when the real fix might be one dropped
238
+ * request away). A resolved entry stays cached for the life of the page.
239
+ * Pure enough to unit-test with a fake `loadFn`. */
240
+ export function createCachedLoader(loadFn) {
241
+ const cache = new Map();
242
+ function load(key) {
243
+ if (cache.has(key)) return cache.get(key);
244
+ const promise = Promise.resolve().then(() => loadFn(key));
245
+ promise.catch(() => cache.delete(key));
246
+ cache.set(key, promise);
247
+ return promise;
248
+ }
249
+ return { load };
250
+ }
251
+
252
+ /** The memoized, timeout-raced, never-throwing lazy-vendor-loader pattern
253
+ * viz-boot.mjs's `loadWinkVendor` establishes, followed here for
254
+ * `./vendor/three.js` rather than `./vendor/wink.js` — a different vendor
255
+ * shape (`{ THREE, GLTFLoader, OrbitControls, MeshoptDecoder }`, not
256
+ * `{ winkNLP, model }`), so this is a sibling implementation of the same
257
+ * shape rather than a call into that one. Resolves `{ ok: true, THREE,
258
+ * GLTFLoader, OrbitControls, MeshoptDecoder }` on success or `{ ok: false,
259
+ * error }` on a timeout or load failure — never throws, never blocks the
260
+ * rest of the page on a stalled 3D asset. `importVendor` is injectable so a
261
+ * test can supply a resolved/rejected/never-settling promise with no real
262
+ * vendor asset on disk. */
263
+ export function createThreeVendorLoader({
264
+ timeoutMs = 15000,
265
+ importVendor = () => import("./vendor/three.js"),
266
+ } = {}) {
267
+ let ready = null;
268
+ return function loadThreeVendorOnce() {
269
+ if (ready) return ready;
270
+ ready = (async () => {
271
+ let timer;
272
+ const timeout = new Promise((_, reject) => {
273
+ timer = setTimeout(() => reject(new Error("three vendor asset load timed out")), timeoutMs);
274
+ });
275
+ try {
276
+ const mod = await Promise.race([importVendor(), timeout]);
277
+ return {
278
+ ok: true, THREE: mod.THREE, GLTFLoader: mod.GLTFLoader,
279
+ OrbitControls: mod.OrbitControls, MeshoptDecoder: mod.MeshoptDecoder,
280
+ };
281
+ } catch (error) {
282
+ // eslint-disable-next-line no-console
283
+ console.warn("tmct: the three.js vendor asset failed to load, the town square cannot render", error);
284
+ return { ok: false, error };
285
+ } finally {
286
+ clearTimeout(timer);
287
+ }
288
+ })();
289
+ return ready;
290
+ };
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // The frozen contract.
295
+ // ---------------------------------------------------------------------------
296
+
297
+ /** The town square's own standalone <script> source, a self-contained IIFE
298
+ * with no lexical scope shared with the page shell (see this module's own
299
+ * header on the handshake). `opts`:
300
+ * - `canvasId`/`statusId`: the page shell's own element ids
301
+ * (`#sceneCanvas`, `#sceneStatus` in mudiii-viz.mjs's markup) this
302
+ * script mounts the renderer into and writes a load failure to.
303
+ * - `gridSize`/`cellSize`: the OPENING scenario's own board size, baked
304
+ * in at page-build time; `window.mudiiiScene.boot()` accepts its own
305
+ * `gridSize`/`cellSize` to override this for a later scenario whose
306
+ * board differs, since `opts` itself is fixed once the page is built.
307
+ * Pure — identical output for identical input; nothing here reads the DOM
308
+ * or three.js until the returned string actually runs in a browser. */
309
+ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {}) {
310
+ return `(function () {
311
+ "use strict";
312
+ var CANVAS_ID = ${JSON.stringify(canvasId)};
313
+ var STATUS_ID = ${JSON.stringify(statusId)};
314
+ var GRID_SIZE = ${JSON.stringify(Number(gridSize) || 12)};
315
+ var CELL_SIZE = ${JSON.stringify(Number(cellSize) || 1)};
316
+
317
+ var lerp = ${lerp.toString()};
318
+ var tweenStep = ${tweenStep.toString()};
319
+ var startTween = ${startTween.toString()};
320
+ var reseedTween = ${reseedTween.toString()};
321
+ var chebyshevDistanceBetweenCells = ${chebyshevDistanceBetweenCells.toString()};
322
+ var yawForFacing = ${yawForFacing.toString()};
323
+ var threatEngagedFor = ${threatEngagedFor.toString()};
324
+ var movementRungFor = ${movementRungFor.toString()};
325
+ var currentActionFor = ${currentActionFor.toString()};
326
+ var flourishForEcologyEvent = ${flourishForEcologyEvent.toString()};
327
+ var createConcurrencyQueue = ${createConcurrencyQueue.toString()};
328
+ var createCachedLoader = ${createCachedLoader.toString()};
329
+ var createThreeVendorLoader = ${createThreeVendorLoader.toString()};
330
+ var prefersReducedMotion = ${prefersReducedMotion.toString()};
331
+ var roleOfAgentId = ${roleOfAgentId.toString()};
332
+ var cellToWorld = ${cellToWorld.toString()};
333
+ var cellFromGroundPoint = ${cellFromGroundPoint.toString()};
334
+ var cameraRigFor = ${cameraRigFor.toString()};
335
+ var clipForAction = ${clipForAction.toString()};
336
+ var modelUrlFor = ${modelUrlFor.toString()};
337
+
338
+ var canvas = document.getElementById(CANVAS_ID);
339
+ var statusEl = document.getElementById(STATUS_ID);
340
+ if (!canvas) return;
341
+
342
+ function setStatus(text) { if (statusEl) statusEl.textContent = text || ""; }
343
+
344
+ var reduced = prefersReducedMotion();
345
+ var tweenDurationMs = reduced ? 0 : ${TWEEN_DURATION_MS};
346
+ if (typeof window.matchMedia === "function") {
347
+ window.matchMedia("(prefers-reduced-motion: reduce)").addEventListener("change", function (e) {
348
+ reduced = e.matches;
349
+ tweenDurationMs = reduced ? 0 : ${TWEEN_DURATION_MS};
350
+ });
351
+ }
352
+
353
+ var loadThreeVendor = createThreeVendorLoader();
354
+ var THREE = null, GLTFLoaderCtor = null, OrbitControlsCtor = null, MeshoptDecoderRef = null;
355
+ var gltfLoader = null, loadQueue = createConcurrencyQueue(4), rawLoader = null;
356
+ var scene = null, camera3 = null, renderer = null, orbitControls = null, groundMesh = null, raycaster = null;
357
+ var agentGroups = {};
358
+ var itemMeshes = {};
359
+ var propTemplates = {};
360
+ var propInstances = [];
361
+ var manifestByKind = {};
362
+ var lastAgentsById = {};
363
+ var lastItemsById = {};
364
+ var cameraState = { mode: "overhead", selectedId: null };
365
+ var cameraTween = null, lookAtTween = null;
366
+ var lastFrameTs = null;
367
+ var booted = false;
368
+
369
+ function loadGlbRaw(url) {
370
+ return loadQueue.run(function () {
371
+ return new Promise(function (resolve, reject) {
372
+ gltfLoader.load(url, resolve, undefined, reject);
373
+ });
374
+ });
375
+ }
376
+ var glbCache = null;
377
+ function loadGlb(url) { return glbCache.load(url); }
378
+
379
+ async function ensureThree() {
380
+ if (THREE) return true;
381
+ var result = await loadThreeVendor();
382
+ if (!result.ok) { setStatus("the town square could not load its 3D scene"); return false; }
383
+ THREE = result.THREE; GLTFLoaderCtor = result.GLTFLoader;
384
+ OrbitControlsCtor = result.OrbitControls; MeshoptDecoderRef = result.MeshoptDecoder;
385
+ gltfLoader = new GLTFLoaderCtor();
386
+ gltfLoader.setMeshoptDecoder(MeshoptDecoderRef);
387
+ glbCache = createCachedLoader(loadGlbRaw);
388
+ setUpScene();
389
+ return true;
390
+ }
391
+
392
+ function setUpScene() {
393
+ scene = new THREE.Scene();
394
+ scene.background = new THREE.Color(0x10161b);
395
+ camera3 = new THREE.PerspectiveCamera(55, (canvas.clientWidth || 640) / Math.max(1, canvas.clientHeight || 360), 0.1, 500);
396
+ camera3.position.set(0, 8, 8);
397
+ renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
398
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2));
399
+ var sun = new THREE.DirectionalLight(0xffffff, 0.8);
400
+ sun.position.set(5, 10, 3);
401
+ scene.add(sun);
402
+
403
+ rebuildGround();
404
+
405
+ raycaster = new THREE.Raycaster();
406
+ orbitControls = new OrbitControlsCtor(camera3, renderer.domElement);
407
+ orbitControls.enabled = false;
408
+ orbitControls.target.set(0, 0, 0);
409
+
410
+ canvas.addEventListener("pointerdown", onPointerDown);
411
+ window.addEventListener("resize", onResize);
412
+ watchFoodPill();
413
+ onResize();
414
+ requestAnimationFrame(renderLoop);
415
+ }
416
+
417
+ function rebuildGround() {
418
+ if (groundMesh) { scene.remove(groundMesh); groundMesh.geometry.dispose(); groundMesh.material.dispose(); }
419
+ var groundSize = GRID_SIZE * CELL_SIZE;
420
+ var groundMat = new THREE.MeshStandardMaterial({ color: 0x7c9a5b });
421
+ groundMesh = new THREE.Mesh(new THREE.PlaneGeometry(groundSize, groundSize), groundMat);
422
+ groundMesh.name = "ground";
423
+ groundMesh.rotation.x = -Math.PI / 2;
424
+ scene.add(groundMesh);
425
+ var existingGrid = scene.getObjectByName("townSquareGrid");
426
+ if (existingGrid) scene.remove(existingGrid);
427
+ var grid = new THREE.GridHelper(groundSize, GRID_SIZE);
428
+ grid.name = "townSquareGrid";
429
+ grid.position.y = 0.01;
430
+ scene.add(grid);
431
+ }
432
+
433
+ function onResize() {
434
+ if (!renderer || !camera3) return;
435
+ var w = canvas.clientWidth || 640, h = canvas.clientHeight || 360;
436
+ renderer.setSize(w, h, false);
437
+ camera3.aspect = w / Math.max(1, h);
438
+ camera3.updateProjectionMatrix();
439
+ }
440
+
441
+ function pointFromEvent(evt) {
442
+ var rect = canvas.getBoundingClientRect();
443
+ return {
444
+ x: ((evt.clientX - rect.left) / rect.width) * 2 - 1,
445
+ y: -((evt.clientY - rect.top) / rect.height) * 2 + 1,
446
+ };
447
+ }
448
+
449
+ // The raycast target is the ground mesh ALONE — never scene.children — so
450
+ // a click a hair off a prop or an agent still resolves to the cell under
451
+ // the cursor rather than hitting whatever mesh happens to sit in front.
452
+ function onPointerDown(evt) {
453
+ if (!scene || !camera3 || !groundMesh) return;
454
+ var ndc = pointFromEvent(evt);
455
+ raycaster.setFromCamera(ndc, camera3);
456
+ var hits = raycaster.intersectObject(groundMesh, false);
457
+ if (!hits.length) return;
458
+ var cellId = cellFromGroundPoint({ x: hits[0].point.x, z: hits[0].point.z }, GRID_SIZE, CELL_SIZE);
459
+ if (!cellId) return;
460
+ if (typeof window.mudiiiHandleSceneClick === "function") window.mudiiiHandleSceneClick(cellId);
461
+ }
462
+
463
+ // Cursor feedback while the food pill is armed reads the pill's own
464
+ // aria-pressed attribute directly (a read-only coupling to a stable markup
465
+ // id the page shell already exposes) rather than needing a page-script
466
+ // edit just to mirror one boolean.
467
+ function watchFoodPill() {
468
+ var foodPill = document.getElementById("foodPill");
469
+ if (!foodPill || typeof MutationObserver === "undefined") return;
470
+ function sync() { canvas.style.cursor = foodPill.getAttribute("aria-pressed") === "true" ? "crosshair" : ""; }
471
+ sync();
472
+ new MutationObserver(sync).observe(foodPill, { attributes: true, attributeFilter: ["aria-pressed"] });
473
+ }
474
+
475
+ // ---- props ---------------------------------------------------------------
476
+ function normalizeToHeight(object3D, targetHeight) {
477
+ var box = new THREE.Box3().setFromObject(object3D);
478
+ var size = new THREE.Vector3();
479
+ box.getSize(size);
480
+ var height = size.y || 1;
481
+ var scale = targetHeight && height ? targetHeight / height : 1;
482
+ object3D.scale.setScalar(scale);
483
+ var seated = new THREE.Box3().setFromObject(object3D);
484
+ object3D.position.y -= seated.min.y;
485
+ }
486
+
487
+ async function loadPropTemplate(asset) {
488
+ var url = modelUrlFor(asset.destPath);
489
+ if (propTemplates[url]) return propTemplates[url];
490
+ var gltf = await loadGlb(url);
491
+ normalizeToHeight(gltf.scene, asset.targetHeight);
492
+ propTemplates[url] = gltf.scene;
493
+ return gltf.scene;
494
+ }
495
+
496
+ async function placeProps(propPlacements) {
497
+ for (var i = 0; i < propInstances.length; i += 1) scene.remove(propInstances[i]);
498
+ propInstances = [];
499
+ for (var j = 0; j < (propPlacements || []).length; j += 1) {
500
+ var placement = propPlacements[j];
501
+ if (!placement.asset || !placement.asset.destPath) continue;
502
+ var world = cellToWorld(placement.cell, GRID_SIZE, CELL_SIZE);
503
+ if (!world) continue;
504
+ try {
505
+ var template = await loadPropTemplate(placement.asset);
506
+ var instance = template.clone();
507
+ instance.position.set(world.x, template.position.y, world.z);
508
+ instance.rotation.y = ((Number(placement.rotation) || 0) * Math.PI) / 180;
509
+ scene.add(instance);
510
+ propInstances.push(instance);
511
+ } catch (err) {
512
+ // eslint-disable-next-line no-console
513
+ console.warn("tmct: a prop model failed to load", placement.model, err);
514
+ }
515
+ }
516
+ }
517
+
518
+ // ---- items: crumbs and morsels, primitive geometry ------------------------
519
+ var CRUMB_RADIUS = 0.08, MORSEL_RADIUS = 0.18;
520
+ function itemGeometryFor(kind) { return new THREE.SphereGeometry(kind === "morsel" ? MORSEL_RADIUS : CRUMB_RADIUS, 10, 8); }
521
+ function itemMaterialFor(kind) { return new THREE.MeshStandardMaterial({ color: kind === "morsel" ? 0xd98a2b : 0x8c6a3f }); }
522
+ function itemHeightFor(kind) { return kind === "morsel" ? MORSEL_RADIUS : CRUMB_RADIUS; }
523
+
524
+ function animateScaleTo(object3D, target, now) {
525
+ var duration = tweenDurationMs;
526
+ var start = object3D.scale.x;
527
+ function step(ts) {
528
+ var elapsed = duration <= 0 ? duration : ts - now;
529
+ var t = duration <= 0 ? 1 : Math.min(1, Math.max(0, elapsed / duration));
530
+ var v = lerp(start, target, t);
531
+ object3D.scale.setScalar(v);
532
+ if (t < 1) requestAnimationFrame(step);
533
+ else if (target === 0 && object3D.parent) object3D.parent.remove(object3D);
534
+ }
535
+ requestAnimationFrame(step);
536
+ }
537
+
538
+ function applyItemTick(id, item, now) {
539
+ var world = cellToWorld(item.cell, GRID_SIZE, CELL_SIZE);
540
+ if (!world) return;
541
+ var entry = itemMeshes[id];
542
+ if (!entry) {
543
+ var mesh = new THREE.Mesh(itemGeometryFor(item.kind), itemMaterialFor(item.kind));
544
+ mesh.name = "item-" + id;
545
+ mesh.position.set(world.x, itemHeightFor(item.kind), world.z);
546
+ mesh.scale.setScalar(0);
547
+ scene.add(mesh);
548
+ entry = { mesh: mesh, cell: item.cell, kind: item.kind };
549
+ itemMeshes[id] = entry;
550
+ animateScaleTo(mesh, 1, now);
551
+ return;
552
+ }
553
+ entry.cell = item.cell;
554
+ entry.mesh.position.set(world.x, itemHeightFor(item.kind), world.z);
555
+ }
556
+
557
+ function removeItem(id, withFlourish) {
558
+ var entry = itemMeshes[id];
559
+ if (!entry) return;
560
+ delete itemMeshes[id];
561
+ if (withFlourish) animateScaleTo(entry.mesh, 0, performance.now());
562
+ else if (entry.mesh.parent) entry.mesh.parent.remove(entry.mesh);
563
+ }
564
+
565
+ // ---- agents: one persistent Group per live id, one GLTF INSTANCE per
566
+ // live agent (never a cloned template — Object3D.clone() does not
567
+ // correctly clone a SkinnedMesh, and at this roster size — a handful of
568
+ // foxes and goblins — the extra memory of a separate load per agent is
569
+ // trivial next to that whole class of bug). ---------------------------------
570
+ function ensureAgent(id, agent) {
571
+ if (agentGroups[id]) return agentGroups[id];
572
+ var kind = roleOfAgentId(id);
573
+ var entry = {
574
+ group: new THREE.Group(), tween: null, cell: null, facing: agent.facing, role: agent.role,
575
+ kind: kind, mixer: null, actions: {}, currentClip: null, clipMap: null,
576
+ };
577
+ entry.group.visible = false;
578
+ scene.add(entry.group);
579
+ agentGroups[id] = entry;
580
+ var asset = manifestByKind[kind];
581
+ if (asset) {
582
+ loadGlb(modelUrlFor(asset.destPath)).then(function (gltf) {
583
+ normalizeToHeight(gltf.scene, asset.targetHeight);
584
+ entry.group.add(gltf.scene);
585
+ entry.group.visible = true;
586
+ entry.clipMap = asset.clips || {};
587
+ entry.mixer = new THREE.AnimationMixer(gltf.scene);
588
+ for (var i = 0; i < (gltf.animations || []).length; i += 1) {
589
+ entry.actions[gltf.animations[i].name] = entry.mixer.clipAction(gltf.animations[i]);
590
+ }
591
+ }).catch(function (err) {
592
+ // eslint-disable-next-line no-console
593
+ console.warn("tmct: an agent model failed to load", id, err);
594
+ });
595
+ }
596
+ return entry;
597
+ }
598
+
599
+ function playClip(entry, clipName) {
600
+ if (!entry.mixer || !clipName || entry.currentClip === clipName) return;
601
+ var next = entry.actions[clipName];
602
+ if (!next) return;
603
+ var prev = entry.currentClip ? entry.actions[entry.currentClip] : null;
604
+ next.reset().fadeIn(0.15).play();
605
+ if (prev && prev !== next) prev.fadeOut(0.15);
606
+ entry.currentClip = clipName;
607
+ }
608
+
609
+ function playSpawnFlourish(group, now) {
610
+ group.scale.setScalar(0);
611
+ animateScaleTo(group, 1, now);
612
+ }
613
+
614
+ function applyAgentTick(id, agent, now) {
615
+ var entry = ensureAgent(id, agent);
616
+ var world = cellToWorld(agent.cell, GRID_SIZE, CELL_SIZE);
617
+ if (!world) return;
618
+ var previousCell = entry.cell;
619
+ var isFirstSighting = previousCell == null;
620
+ var singleHop = !isFirstSighting && previousCell !== agent.cell
621
+ && chebyshevDistanceBetweenCells(previousCell, agent.cell) === 1;
622
+ var held = !isFirstSighting && previousCell === agent.cell;
623
+ entry.cell = agent.cell;
624
+ entry.facing = agent.facing;
625
+ entry.group.rotation.y = yawForFacing(agent.facing);
626
+ if (singleHop) {
627
+ entry.tween = reseedTween(entry.tween, { x: world.x, z: world.z }, now, tweenDurationMs);
628
+ } else {
629
+ entry.tween = null;
630
+ entry.group.position.set(world.x, entry.group.position.y, world.z);
631
+ if (!held) playSpawnFlourish(entry.group, now);
632
+ }
633
+ if (entry.clipMap) {
634
+ var moving = singleHop;
635
+ playClip(entry, clipForAction(agent.role, currentActionFor(id, lastAgentsById, moving), entry.clipMap));
636
+ }
637
+ }
638
+
639
+ function removeAgent(id) {
640
+ var entry = agentGroups[id];
641
+ if (!entry) return;
642
+ delete agentGroups[id];
643
+ var deathClip = entry.clipMap && entry.clipMap.death;
644
+ var deathClipName = Array.isArray(deathClip) ? deathClip[0] : deathClip;
645
+ if (deathClipName) {
646
+ playClip(entry, deathClipName);
647
+ animateScaleTo(entry.group, 0, performance.now());
648
+ } else if (entry.group.parent) {
649
+ entry.group.parent.remove(entry.group);
650
+ }
651
+ }
652
+
653
+ function applyEcology(ecology) {
654
+ for (var i = 0; i < (ecology || []).length; i += 1) {
655
+ var flourish = flourishForEcologyEvent(ecology[i]);
656
+ if (!flourish) continue;
657
+ if (flourish.kind === "death") removeAgent(flourish.targetId);
658
+ else if (flourish.kind === "consume") removeItem(flourish.targetId, true);
659
+ }
660
+ }
661
+
662
+ // ---- camera ---------------------------------------------------------------
663
+ function agentSnapshotFor(id) {
664
+ var entry = agentGroups[id];
665
+ return entry ? { cell: entry.cell, facing: entry.facing } : null;
666
+ }
667
+
668
+ function setCamera(state) {
669
+ cameraState = { mode: (state && state.mode) || "overhead", selectedId: (state && state.selectedId) || null };
670
+ var agent = cameraState.selectedId ? agentSnapshotFor(cameraState.selectedId) : null;
671
+ var rig = cameraRigFor(cameraState.mode, agent, GRID_SIZE) || cameraRigFor("overhead", null, GRID_SIZE);
672
+ if (!rig) return;
673
+ var now = performance.now();
674
+ cameraTween = reseedTween(cameraTween, rig.position, now, tweenDurationMs);
675
+ lookAtTween = reseedTween(lookAtTween, rig.lookAt, now, tweenDurationMs);
676
+ if (orbitControls) orbitControls.enabled = cameraState.mode === "overhead";
677
+ }
678
+
679
+ // Re-tween the camera toward its own rig after a tick lands, in case the
680
+ // followed agent moved — follow/pov track the agent, so their own rig
681
+ // target changes every tick even with no explicit setCamera() call.
682
+ function refreshCameraTween(now) {
683
+ if (cameraState.mode === "overhead") return;
684
+ if (!cameraState.selectedId) return;
685
+ var agent = agentSnapshotFor(cameraState.selectedId);
686
+ var rig = cameraRigFor(cameraState.mode, agent, GRID_SIZE);
687
+ if (!rig) return;
688
+ cameraTween = reseedTween(cameraTween, rig.position, now, tweenDurationMs);
689
+ lookAtTween = reseedTween(lookAtTween, rig.lookAt, now, tweenDurationMs);
690
+ }
691
+
692
+ // ---- boot / tick / render loop --------------------------------------------
693
+ function buildManifestByKind(assetManifest) {
694
+ var byKind = {};
695
+ for (var i = 0; i < (assetManifest || []).length; i += 1) {
696
+ if (assetManifest[i] && assetManifest[i].key) byKind[assetManifest[i].key] = assetManifest[i];
697
+ }
698
+ return byKind;
699
+ }
700
+
701
+ async function boot(input) {
702
+ var ok = await ensureThree();
703
+ if (!ok) return;
704
+ if (input && input.gridSize) { GRID_SIZE = Number(input.gridSize) || GRID_SIZE; }
705
+ if (input && input.cellSize) { CELL_SIZE = Number(input.cellSize) || CELL_SIZE; }
706
+ rebuildGround();
707
+ for (var id in agentGroups) if (agentGroups[id].group.parent) agentGroups[id].group.parent.remove(agentGroups[id].group);
708
+ agentGroups = {};
709
+ for (var itemId in itemMeshes) if (itemMeshes[itemId].mesh.parent) itemMeshes[itemId].mesh.parent.remove(itemMeshes[itemId].mesh);
710
+ itemMeshes = {};
711
+ lastAgentsById = {};
712
+ lastItemsById = {};
713
+ manifestByKind = buildManifestByKind(input && input.assetManifest);
714
+ await placeProps((input && input.propPlacements) || []);
715
+ setCamera({ mode: "overhead", selectedId: null });
716
+ booted = true;
717
+ }
718
+
719
+ function applyTick(tick) {
720
+ if (!scene) return;
721
+ var now = performance.now();
722
+ var agents = (tick && tick.agents) || {};
723
+ var items = (tick && tick.items) || {};
724
+ for (var id in agents) if (Object.prototype.hasOwnProperty.call(agents, id)) applyAgentTick(id, agents[id], now);
725
+ for (var itemId in items) if (Object.prototype.hasOwnProperty.call(items, itemId)) applyItemTick(itemId, items[itemId], now);
726
+ for (var goneAgent in lastAgentsById) if (!(goneAgent in agents)) removeAgent(goneAgent);
727
+ for (var goneItem in lastItemsById) if (!(goneItem in items)) removeItem(goneItem, false);
728
+ applyEcology(tick && tick.ecology);
729
+ lastAgentsById = agents;
730
+ lastItemsById = items;
731
+ refreshCameraTween(now);
732
+ }
733
+
734
+ function renderLoop(ts) {
735
+ requestAnimationFrame(renderLoop);
736
+ if (!scene || !renderer || !camera3) return;
737
+ var deltaSec = lastFrameTs == null ? 0 : (ts - lastFrameTs) / 1000;
738
+ lastFrameTs = ts;
739
+ for (var id in agentGroups) {
740
+ var entry = agentGroups[id];
741
+ if (entry.tween) {
742
+ var p = tweenStep(entry.tween, ts);
743
+ entry.group.position.set(p.x, entry.group.position.y, p.z);
744
+ if (p.done) entry.tween = null;
745
+ }
746
+ if (entry.mixer) entry.mixer.update(deltaSec);
747
+ }
748
+ if (cameraTween) { var cp = tweenStep(cameraTween, ts); camera3.position.set(cp.x, cp.y, cp.z); }
749
+ if (lookAtTween) { var lp = tweenStep(lookAtTween, ts); camera3.lookAt(lp.x, lp.y, lp.z); }
750
+ if (orbitControls && orbitControls.enabled) orbitControls.update();
751
+ renderer.render(scene, camera3);
752
+ }
753
+
754
+ window.mudiiiScene = {
755
+ boot: boot,
756
+ applyTick: applyTick,
757
+ setCamera: setCamera,
758
+ cellOf: function (id) {
759
+ if (agentGroups[id]) return agentGroups[id].cell;
760
+ if (itemMeshes[id]) return itemMeshes[id].cell;
761
+ return null;
762
+ },
763
+ ready: function () { return booted; },
764
+ };
765
+ })();`;
766
+ }