@polycode-projects/the-mechanical-code-talker 5.0.5 → 5.0.7
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.
- package/README.md +78 -19
- package/bin/tmct.mjs +63 -2
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +23 -0
- package/src/domain/ask-vocab.mjs +19 -0
- package/src/domain/ask.mjs +10 -5
- package/src/domain/codegraph.mjs +23 -9
- package/src/domain/game-config.mjs +12 -0
- package/src/domain/interpret/strategies/keywords.mjs +30 -1
- package/src/domain/memory/capability.mjs +15 -11
- package/src/domain/router/drive.mjs +36 -17
- package/src/domain/router/resolver.mjs +63 -17
- package/src/domain/spider-fly-world.mjs +2 -2
- package/src/domain/sprite-templates.mjs +19 -7
- package/src/domain/syllogise.mjs +16 -6
- package/src/domain/town-square-world.mjs +1 -1
- package/src/services/adventure-viz.mjs +5 -2
- package/src/services/adventure.mjs +8 -1
- package/src/services/chat-page-viz.mjs +123 -25
- package/src/services/chat-session.mjs +60 -10
- package/src/services/chat.mjs +328 -36
- package/src/services/code-explorer-viz.mjs +3 -2
- package/src/services/extract-facts.mjs +47 -7
- package/src/services/ingest-viz.mjs +113 -29
- package/src/services/ledger-viz.mjs +9 -4
- package/src/services/memory-panel-viz.mjs +44 -0
- package/src/services/mud-viz.mjs +21 -3
- package/src/services/mudiii-scene.mjs +407 -36
- package/src/services/mudiii-turn.mjs +65 -9
- package/src/services/mudiii-viz.mjs +810 -157
- package/src/services/p2p-room.mjs +1 -1
- package/src/services/plan-viz.mjs +26 -4
- package/src/services/predator-prey.mjs +141 -37
- package/src/services/research-viz.mjs +17 -23
- package/src/services/spider-fly-turn.mjs +7 -1
- package/src/services/spider-fly-viz.mjs +13 -5
- package/src/services/sprite-catalog-viz.mjs +3 -2
- package/src/services/viz-theme.mjs +20 -0
- package/src/services/viz-ticker.mjs +15 -2
- package/src/surfaces/http/server-http.mjs +90 -13
- package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
- package/src/surfaces/web/mud-browser-entry.mjs +33 -1
- package/src/surfaces/web/mudiii-browser-entry.mjs +70 -34
- package/src/surfaces/web/tmct-surface.mjs +18 -6
- package/src/tools/handlers/tmct-ask.mjs +15 -2
- package/src/tools/server.mjs +31 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// mudiii-viz.mjs — mudiii.html: the one-player town-square demo
|
|
2
|
-
//
|
|
1
|
+
// mudiii-viz.mjs — mudiii.html: the one-player town-square demo, over a real
|
|
2
|
+
// three.js scene rather than mud.html's canvas-drawn
|
|
3
3
|
// room boxes. mud.html's whole control deck carries over unchanged (same
|
|
4
4
|
// ids, same ranges, same defaults — see MUDIII_STYLE and renderMudiiiHtml's
|
|
5
5
|
// own header below for the two sliders' new labels); what mudiii adds is a
|
|
@@ -8,13 +8,10 @@
|
|
|
8
8
|
// mud.html's burrow survey.
|
|
9
9
|
//
|
|
10
10
|
// This module owns the PAGE SHELL only. The 3D scene itself — the actual
|
|
11
|
-
// three.js renderer, the model loader, the per-frame camera update —
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// does not exist in every worktree yet (see the guarded import below), so
|
|
16
|
-
// this file also ships a "" stub until it lands — no edit needed here when
|
|
17
|
-
// it does.
|
|
11
|
+
// three.js renderer, the model loader, the per-frame camera update — lives in
|
|
12
|
+
// src/services/mudiii-scene.mjs, reached through exactly one frozen function:
|
|
13
|
+
// `mudiiiSceneScript(opts) -> string`, a standalone inline <script> this page
|
|
14
|
+
// embeds next to its own.
|
|
18
15
|
//
|
|
19
16
|
// The contract runs both ways. Scene -> shell: the scene script calls
|
|
20
17
|
// `window.mudiiiHandleSceneClick(cellId)` on a raycast hit. Shell -> scene:
|
|
@@ -26,46 +23,41 @@
|
|
|
26
23
|
// the map panel, the HUD, the deck or the chat down with it.
|
|
27
24
|
//
|
|
28
25
|
// Deliberately absent, all P2P (mud.html's #statePill, share/join buttons,
|
|
29
|
-
// the share overlay, the wave button
|
|
30
|
-
//
|
|
26
|
+
// the share overlay, the wave button): this is the 1-player page. A later
|
|
27
|
+
// document adds sharing back from the same bundle.
|
|
31
28
|
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// are spliced into the page script from viz-ticker.mjs, not carried by the
|
|
37
|
-
// browser entry.
|
|
29
|
+
// The page publishes through the ONE `globalThis.tmct` surface
|
|
30
|
+
// (tmct-surface.mjs), never a page-scoped bag of its own, and
|
|
31
|
+
// `createTicker`/`createSerialQueue` are spliced into the page script from
|
|
32
|
+
// viz-ticker.mjs rather than carried by the browser entry.
|
|
38
33
|
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// was written against. `runPredatorPreyTick(memoryDir, opts)` returns
|
|
34
|
+
// One ticker drives the whole world, not one per agent:
|
|
35
|
+
// `runPredatorPreyTick(memoryDir, opts)` returns
|
|
42
36
|
// `{ turn, agents, items, ecology }` for the WHOLE WORLD in one call — it has
|
|
43
37
|
// no per-character entry point the way mud-turn.mjs's `runMudTurn(character,
|
|
44
38
|
// ...)` does, so there is nothing for a second or third ticker to drive that
|
|
45
39
|
// the first one has not already advanced. This page runs ONE shared ticker
|
|
46
40
|
// for the whole simulation, serialized through the same createSerialQueue
|
|
47
|
-
// mud.html uses for its own multi-pane writes; every HUD card
|
|
48
|
-
//
|
|
49
|
-
// was actually asking for.
|
|
41
|
+
// mud.html uses for its own multi-pane writes; every HUD card reads its own
|
|
42
|
+
// agent's slice of the one tick result.
|
|
50
43
|
import {
|
|
51
44
|
THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel,
|
|
52
|
-
rowsForWorld, appendLogLine,
|
|
45
|
+
rowsForWorld, appendLogLine, wordBeforeCursor, demoEyebrowHtml, EYEBROW_LINKS_CSS,
|
|
53
46
|
} from "./viz-theme.mjs";
|
|
54
|
-
import { createTicker, createSerialQueue } from "./viz-ticker.mjs";
|
|
47
|
+
import { createTicker, createSerialQueue, prefersReducedMotion } from "./viz-ticker.mjs";
|
|
55
48
|
import { renderMudEditorText, gridWorldEditorState } from "./mud-editor.mjs";
|
|
56
49
|
import {
|
|
57
50
|
pillCandidates, matchPills, pillCompleteMarkup, createPillComplete, PILL_COMPLETE_CSS,
|
|
58
51
|
} from "./pill-complete.mjs";
|
|
59
52
|
import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
|
|
53
|
+
import { DEFAULT_FACING, DEFAULT_GRID_SIZE } from "../domain/town-square-world.mjs";
|
|
54
|
+
import { believedFactSentence } from "./mudiii-turn.mjs";
|
|
60
55
|
|
|
61
56
|
// The scene module and this one import each other: this file embeds the
|
|
62
57
|
// scene's generated IIFE, and the scene splices this file's pure geometry
|
|
63
|
-
// helpers into it.
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// must NOT be a top-level `await import()`: two modules awaiting each other at
|
|
67
|
-
// evaluation time never settle, and the failure is a silent hang rather than
|
|
68
|
-
// an error.
|
|
58
|
+
// helpers into it. The cycle holds because every binding crossing it is a
|
|
59
|
+
// hoisted function declaration that nothing calls at module-evaluation time —
|
|
60
|
+
// the same shape world-teach.mjs and adventure.mjs already rely on.
|
|
69
61
|
import { mudiiiSceneScript } from "./mudiii-scene.mjs";
|
|
70
62
|
|
|
71
63
|
const DEFAULT_TITLE = "tmct — mudiii";
|
|
@@ -84,11 +76,27 @@ const NPC_COUNT_LABELLED = [1, 5, 10];
|
|
|
84
76
|
const DEFAULT_NPC_COUNT = 2;
|
|
85
77
|
const DEFAULT_DELAY_MS = 220;
|
|
86
78
|
const DEFAULT_MAX_TURNS = 400;
|
|
87
|
-
// test/fixtures/mudiii-ticks.json's own board size — the fallback for a
|
|
88
|
-
// scenario that names no gridSize of its own.
|
|
89
|
-
const DEFAULT_GRID_SIZE = 12;
|
|
90
|
-
const DEFAULT_FACING = "south";
|
|
91
79
|
const CAMERA_MODES = ["follow", "pov", "overhead"];
|
|
80
|
+
// Every export below except renderMudiiiHtml is spliced by `.toString()` into
|
|
81
|
+
// a generated script that shares no scope with this module, so none of them
|
|
82
|
+
// may read a binding declared up here. mudiii-viz.test.mjs holds the line.
|
|
83
|
+
// The ring reads ABSOLUTE, not relative to whichever way an agent happens to
|
|
84
|
+
// face: every other direction word on this page is a compass point (a told
|
|
85
|
+
// fact says "the goblin is east", the map is north-up), and driveRequest takes
|
|
86
|
+
// a compass point directly — a cardinal steps and faces that way, an
|
|
87
|
+
// intercardinal turns on the spot. Ordered north-first, clockwise.
|
|
88
|
+
const RING_POINTS = [
|
|
89
|
+
"north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest",
|
|
90
|
+
];
|
|
91
|
+
// mud.html's own glyph vocabulary, widened to the four diagonals. The reading
|
|
92
|
+
// differs from mud's: its ring lights every available exit, because a room has
|
|
93
|
+
// a fixed handful. An open grid grants a step almost everywhere, so lighting
|
|
94
|
+
// what is available would light nearly all of it and say nothing. The one lit
|
|
95
|
+
// glyph here is the followed agent's own facing.
|
|
96
|
+
const DIR_GLYPH = Object.freeze({
|
|
97
|
+
north: "▲ N", northeast: "↗", east: "E ▶", southeast: "↘",
|
|
98
|
+
south: "▼ S", southwest: "↙", west: "◀ W", northwest: "↖",
|
|
99
|
+
});
|
|
92
100
|
|
|
93
101
|
const MUDIII_NOTE_LINES = [
|
|
94
102
|
"One fox and a handful of goblins share a town square, rendered in three dimensions rather than mud.html's flat rooms. The foxes slider picks how many predators are cast; the goblins slider adds more prey. Nothing here is a player either — you watch from whichever camera you pick.",
|
|
@@ -209,6 +217,48 @@ export function occupiedCells(agents, items) {
|
|
|
209
217
|
return cells;
|
|
210
218
|
}
|
|
211
219
|
|
|
220
|
+
/** Who and what stands where right now, as `{ subject, cell }[]` sorted by
|
|
221
|
+
* subject — the edit panel's own read of a fact list that carries the whole
|
|
222
|
+
* tape rather than a board.
|
|
223
|
+
*
|
|
224
|
+
* The engine stamps each turn's placement row `<id>@turn<N>`, or
|
|
225
|
+
* `<id>@epoch<E>@turn<N>` once a reset has moved the epoch on, so a raw
|
|
226
|
+
* `mgx:currently-in` sweep returns one row per agent per turn played. This
|
|
227
|
+
* folds them to the latest row per base subject by `(epoch, turn)`, the same
|
|
228
|
+
* order the engine's own fold ranks by. A bare subject is the world's
|
|
229
|
+
* authored placement — every prop is one — and ranks below any stamped row
|
|
230
|
+
* for the same base rather than competing with it.
|
|
231
|
+
*
|
|
232
|
+
* Anything carrying `mgx:eaten-by` or `mgx:starved` is dropped: it stood
|
|
233
|
+
* somewhere once, and the panel is asked where things stand now. Pure,
|
|
234
|
+
* self-contained. */
|
|
235
|
+
export function currentPlacementsFrom(rows) {
|
|
236
|
+
const STAMP = /^(.*?)(?:@epoch(\d+))?@turn(\d+)$/;
|
|
237
|
+
const latest = new Map();
|
|
238
|
+
const gone = new Set();
|
|
239
|
+
for (const row of rows || []) {
|
|
240
|
+
if (!row || !row.subject) continue;
|
|
241
|
+
const match = STAMP.exec(String(row.subject));
|
|
242
|
+
const base = match ? match[1] : String(row.subject);
|
|
243
|
+
if (row.predicate === "mgx:eaten-by" || row.predicate === "mgx:starved") {
|
|
244
|
+
gone.add(base);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (row.predicate !== "mgx:currently-in") continue;
|
|
248
|
+
const epoch = match ? Number(match[2] || 0) : -1;
|
|
249
|
+
const turn = match ? Number(match[3]) : -1;
|
|
250
|
+
const prior = latest.get(base);
|
|
251
|
+
if (prior && (prior.epoch > epoch || (prior.epoch === epoch && prior.turn > turn))) continue;
|
|
252
|
+
latest.set(base, { epoch, turn, cell: row.object });
|
|
253
|
+
}
|
|
254
|
+
const placements = [];
|
|
255
|
+
for (const [subject, entry] of latest) {
|
|
256
|
+
if (!gone.has(subject)) placements.push({ subject, cell: entry.cell });
|
|
257
|
+
}
|
|
258
|
+
placements.sort((a, b) => a.subject.localeCompare(b.subject));
|
|
259
|
+
return placements;
|
|
260
|
+
}
|
|
261
|
+
|
|
212
262
|
/** Why a click-to-move or a food placement on `cell` would be refused, or
|
|
213
263
|
* null when it is clear — `"cell-4-3 is blocked"`, worded once so both the
|
|
214
264
|
* 3D raycast click and a typed "go there" chat verb read the same refusal.
|
|
@@ -234,16 +284,40 @@ export function blockedCellReason(cell, props, agents) {
|
|
|
234
284
|
* `cellToWorld` does.
|
|
235
285
|
*
|
|
236
286
|
* "overhead" ignores `agent` entirely and looks straight down at the
|
|
237
|
-
* board's own centre
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
|
|
287
|
+
* board's own centre, high enough that the whole board fits the canvas
|
|
288
|
+
* `view` describes (`{ aspect, fovDegrees }`, the live camera's own two
|
|
289
|
+
* numbers). "follow" sits back and above the agent's cell, offset opposite
|
|
290
|
+
* its facing, looking at the agent. "pov" sits AT the agent's cell at eye
|
|
291
|
+
* height, looking the way it is facing. Returns null when a non-overhead
|
|
292
|
+
* mode is asked for with no agent (or an agent standing on no cell) to rig
|
|
293
|
+
* against. Pure, self-contained — calls `cellToWorld` by bare name, spliced
|
|
294
|
+
* alongside it. */
|
|
295
|
+
export function cameraRigFor(mode, agent, gridSize, view) {
|
|
244
296
|
const cellSize = 1;
|
|
297
|
+
// Every fallback here is written out rather than read from this module's own
|
|
298
|
+
// constants: the browser runs a `.toString()` copy of this function in a
|
|
299
|
+
// script that declares none of them, and the drive ring's diagonals set
|
|
300
|
+
// exactly the intercardinal facing that reaches the facing one.
|
|
301
|
+
const FALLBACK_GRID_SIZE = 12;
|
|
245
302
|
if (mode === "overhead") {
|
|
246
|
-
const
|
|
303
|
+
const FALLBACK_FOV_DEGREES = 55;
|
|
304
|
+
// Looking straight down pushes the TOP of a standing model outward from
|
|
305
|
+
// the centre of frame, so a fit measured on the flat ground plane alone
|
|
306
|
+
// cuts the head off whoever stands in an outermost cell. This is the room
|
|
307
|
+
// the tallest of the cast needs out there, counting the id label floating
|
|
308
|
+
// above it, which is the part a visitor most needs to read. Props run
|
|
309
|
+
// taller still and are allowed to overhang.
|
|
310
|
+
const EDGE_ROOM = 1.2;
|
|
311
|
+
const board = Math.max(4, Number(gridSize) || FALLBACK_GRID_SIZE) * cellSize;
|
|
312
|
+
const fovDegrees = Number(view && view.fovDegrees) > 0 ? Number(view.fovDegrees) : FALLBACK_FOV_DEGREES;
|
|
313
|
+
const aspect = Number(view && view.aspect) > 0 ? Number(view.aspect) : 1;
|
|
314
|
+
const tanHalfVertical = Math.tan((Math.min(fovDegrees, 175) * Math.PI) / 360);
|
|
315
|
+
const tanHalfHorizontal = tanHalfVertical * aspect;
|
|
316
|
+
// Fit the TIGHTER axis. A wide canvas runs out of height first, a tall one
|
|
317
|
+
// runs out of width, and taking the larger of the two distances is what
|
|
318
|
+
// stops a portrait window cropping the board's own left and right edges.
|
|
319
|
+
const fitsBothAxes = Math.max(1 / tanHalfVertical, 1 / tanHalfHorizontal);
|
|
320
|
+
const height = (board / 2) * EDGE_ROOM * fitsBothAxes;
|
|
247
321
|
return { mode: "overhead", position: { x: 0, y: height, z: 0 }, lookAt: { x: 0, y: 0, z: 0 } };
|
|
248
322
|
}
|
|
249
323
|
if (!agent || !agent.cell) return null;
|
|
@@ -252,7 +326,7 @@ export function cameraRigFor(mode, agent, gridSize) {
|
|
|
252
326
|
const FACING_VECTOR = {
|
|
253
327
|
north: { x: 0, z: -1 }, south: { x: 0, z: 1 }, east: { x: 1, z: 0 }, west: { x: -1, z: 0 },
|
|
254
328
|
};
|
|
255
|
-
const dir = FACING_VECTOR[agent.facing] || FACING_VECTOR
|
|
329
|
+
const dir = FACING_VECTOR[agent.facing] || FACING_VECTOR.south;
|
|
256
330
|
if (mode === "pov") {
|
|
257
331
|
return {
|
|
258
332
|
mode: "pov",
|
|
@@ -308,6 +382,28 @@ export function nextCameraSelection(prev, agents, ecology) {
|
|
|
308
382
|
};
|
|
309
383
|
}
|
|
310
384
|
|
|
385
|
+
/** What one press on a camera-mode button should do, as
|
|
386
|
+
* `{ mode, selectedId, status }`.
|
|
387
|
+
*
|
|
388
|
+
* `overhead` needs nobody and keeps whatever was selected, so switching back
|
|
389
|
+
* to `follow` resumes the same agent. The other two need one, and after a
|
|
390
|
+
* fallback there is none: the agent that was followed has been eaten and the
|
|
391
|
+
* selection was cleared. The dropdown, meanwhile, still lists the survivors
|
|
392
|
+
* and shows its first one, so a press that did nothing left the deck naming
|
|
393
|
+
* an agent the camera was not on. `offered` is that dropdown value, and a
|
|
394
|
+
* press adopts it when it is still live.
|
|
395
|
+
*
|
|
396
|
+
* With nobody left to adopt the press stays overhead and says why, rather
|
|
397
|
+
* than lighting a button over a camera that did not move. `liveIds` is this
|
|
398
|
+
* turn's roster. Pure, self-contained. */
|
|
399
|
+
export function cameraSelectionForMode(mode, selectedId, offered, liveIds) {
|
|
400
|
+
const live = liveIds || [];
|
|
401
|
+
if (mode === "overhead") return { mode: "overhead", selectedId: selectedId || null, status: null };
|
|
402
|
+
if (selectedId && live.indexOf(selectedId) !== -1) return { mode, selectedId, status: null };
|
|
403
|
+
if (offered && live.indexOf(offered) !== -1) return { mode, selectedId: offered, status: null };
|
|
404
|
+
return { mode: "overhead", selectedId: null, status: "nobody left to follow — staying overhead." };
|
|
405
|
+
}
|
|
406
|
+
|
|
311
407
|
/** Every agent and item's own dot for the 2D top-down map panel, as
|
|
312
408
|
* percentage coordinates within a square panel (`{ id, kind, xPct, yPct
|
|
313
409
|
* }[]`) — never pixel values, so the panel's own CSS controls its actual
|
|
@@ -336,6 +432,30 @@ export function mapDotsFor(agents, items, gridSize) {
|
|
|
336
432
|
return dots;
|
|
337
433
|
}
|
|
338
434
|
|
|
435
|
+
/** Every static prop's own filled cell for the 2D map panel, as percentage
|
|
436
|
+
* coordinates within the same square board `mapDotsFor` draws into
|
|
437
|
+
* (`{ id, xPct, yPct, sizePct }[]`). `props` is `propPlacementsFrom`'s own
|
|
438
|
+
* output. A block is drawn from the cell's own top-left corner and fills it,
|
|
439
|
+
* so the offset is `- 1` where `mapDotsFor`'s dot, centred on the cell, takes
|
|
440
|
+
* `- 0.5`. A placement with no parseable cell is dropped rather than drawn at
|
|
441
|
+
* a guessed position. Pure, self-contained. */
|
|
442
|
+
export function mapBlocksFor(props, gridSize) {
|
|
443
|
+
const size = Number(gridSize);
|
|
444
|
+
if (!Number.isFinite(size) || size <= 0) return [];
|
|
445
|
+
const blocks = [];
|
|
446
|
+
for (const prop of props || []) {
|
|
447
|
+
const match = /^cell-(\d+)-(\d+)$/.exec(String(prop && prop.cell != null ? prop.cell : ""));
|
|
448
|
+
if (!match) continue;
|
|
449
|
+
blocks.push({
|
|
450
|
+
id: prop.id,
|
|
451
|
+
xPct: ((Number(match[1]) - 1) / size) * 100,
|
|
452
|
+
yPct: ((Number(match[2]) - 1) / size) * 100,
|
|
453
|
+
sizePct: 100 / size,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
return blocks;
|
|
457
|
+
}
|
|
458
|
+
|
|
339
459
|
/** One HUD card's own field set, read off `agent` (`{ id, role, goal, mood,
|
|
340
460
|
* plan, mass, belief }`, this turn's slice of the tick payload) and a
|
|
341
461
|
* resolved `mudiiiConfig` (DEFAULT_GAME_CONFIG.mudiii's own shape). `massPct`
|
|
@@ -381,6 +501,7 @@ export function clipForAction(role, action, clipMap) {
|
|
|
381
501
|
const kindFor = {
|
|
382
502
|
wander: "walk",
|
|
383
503
|
forage: "walk",
|
|
504
|
+
driven: "walk",
|
|
384
505
|
chase: "run",
|
|
385
506
|
evade: "run",
|
|
386
507
|
"eat-agent": role === "predator" ? "attack" : "death",
|
|
@@ -412,7 +533,11 @@ export function agentCardMarkup(slot) {
|
|
|
412
533
|
<div class="hud-meter" id="${w}-meter"><div class="hud-meter-fill" id="${w}-meter-fill"></div></div>
|
|
413
534
|
<p class="hud-goal" id="${w}-goal"></p>
|
|
414
535
|
<p class="hud-plan mono" id="${w}-plan"></p>
|
|
415
|
-
<
|
|
536
|
+
<button type="button" class="hud-belief-toggle" id="${w}-belief-toggle"
|
|
537
|
+
aria-expanded="false" aria-controls="${w}-detail" hidden>
|
|
538
|
+
<span class="hud-belief mono" id="${w}-belief"></span>
|
|
539
|
+
</button>
|
|
540
|
+
<div class="hud-detail mono" id="${w}-detail" hidden></div>
|
|
416
541
|
</div>`;
|
|
417
542
|
}
|
|
418
543
|
|
|
@@ -481,7 +606,7 @@ ${PILL_COMPLETE_CSS}
|
|
|
481
606
|
<body>
|
|
482
607
|
<main>
|
|
483
608
|
<header class="mudiii-topbar">
|
|
484
|
-
<h1 class="eyebrow"
|
|
609
|
+
<h1 class="eyebrow">${demoEyebrowHtml("mudiii", "mudiii")}</h1>
|
|
485
610
|
<a class="mudiii-topbar-help" href="./help.html" target="_blank" rel="noopener"
|
|
486
611
|
title="how this demo works, in a new tab" aria-label="how this demo works, opens in a new tab">?</a>
|
|
487
612
|
</header>
|
|
@@ -489,6 +614,8 @@ ${PILL_COMPLETE_CSS}
|
|
|
489
614
|
<section class="deck" aria-label="simulation controls">
|
|
490
615
|
<div class="deck-controls">
|
|
491
616
|
<button type="button" class="deck-play" id="autoToggle" aria-pressed="false">▶ play</button>
|
|
617
|
+
<button type="button" id="stepBtn">step</button>
|
|
618
|
+
<span class="deck-hint" id="stepHint" hidden>pause to step</span>
|
|
492
619
|
<button type="button" id="resetBtn">reset</button>
|
|
493
620
|
${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-select" aria-label="which town square to play">
|
|
494
621
|
${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
|
|
@@ -498,32 +625,55 @@ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " select
|
|
|
498
625
|
<span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
|
|
499
626
|
</div>
|
|
500
627
|
<div class="deck-body">
|
|
501
|
-
<div class="deck-
|
|
502
|
-
<
|
|
503
|
-
<
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
<
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
<
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
628
|
+
<div class="deck-panels">
|
|
629
|
+
<div class="deck-stack deck-stack-1">
|
|
630
|
+
<label class="deck-slider">foxes
|
|
631
|
+
<input type="range" id="playerCountSlider" min="0" max="${PLAYER_COUNTS.length - 1}" step="1"
|
|
632
|
+
value="${Math.max(0, PLAYER_COUNTS.indexOf(DEFAULT_PLAYER_COUNT))}"
|
|
633
|
+
list="playerCountTicks" aria-valuetext="${DEFAULT_PLAYER_COUNT} foxes">
|
|
634
|
+
<datalist id="playerCountTicks">${PLAYER_COUNTS.map((n, i) => `<option value="${i}" label="${n}"></option>`).join("")}</datalist>
|
|
635
|
+
<span class="mono" id="playerCountValue">${DEFAULT_PLAYER_COUNT}</span>
|
|
636
|
+
</label>
|
|
637
|
+
<label class="deck-slider">goblins
|
|
638
|
+
<input type="range" id="npcCountSlider" min="${NPC_COUNT_MIN}" max="${NPC_COUNT_MAX}" step="1"
|
|
639
|
+
value="${DEFAULT_NPC_COUNT}"
|
|
640
|
+
list="npcCountTicks" aria-valuetext="${DEFAULT_NPC_COUNT} goblins">
|
|
641
|
+
<datalist id="npcCountTicks">${Array.from({ length: NPC_COUNT_MAX - NPC_COUNT_MIN + 1 }, (_, i) => {
|
|
642
|
+
const n = NPC_COUNT_MIN + i;
|
|
643
|
+
return NPC_COUNT_LABELLED.includes(n) ? `<option value="${n}" label="${n}"></option>` : `<option value="${n}"></option>`;
|
|
644
|
+
}).join("")}</datalist>
|
|
645
|
+
<span class="mono" id="npcCountValue">${DEFAULT_NPC_COUNT}</span>
|
|
646
|
+
</label>
|
|
647
|
+
<label class="deck-slider">follow
|
|
648
|
+
<select id="agentSelect" class="deck-select" aria-label="which agent to follow"
|
|
649
|
+
aria-describedby="agentSelectHint">
|
|
650
|
+
${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${escapeHtml(a.id)}</option>`).join("\n")}
|
|
651
|
+
</select>
|
|
652
|
+
</label>
|
|
653
|
+
<span class="deck-hint" id="agentSelectHint" hidden>pause to swap</span>
|
|
654
|
+
</div>
|
|
655
|
+
<div class="deck-stack deck-stack-2">
|
|
656
|
+
<label class="deck-slider">delay
|
|
657
|
+
<input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
|
|
658
|
+
<span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
|
|
659
|
+
</label>
|
|
660
|
+
<label class="deck-slider">max turns
|
|
661
|
+
<input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
|
|
662
|
+
<span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
|
|
663
|
+
</label>
|
|
664
|
+
<div class="camera-mode" id="cameraMode" role="group" aria-label="camera mode">
|
|
665
|
+
<button type="button" data-mode="follow" aria-pressed="true">follow</button>
|
|
666
|
+
<button type="button" data-mode="pov" aria-pressed="false">pov</button>
|
|
667
|
+
<button type="button" data-mode="overhead" aria-pressed="false">overhead</button>
|
|
668
|
+
</div>
|
|
669
|
+
</div>
|
|
670
|
+
<div class="deck-stack deck-stack-3">
|
|
671
|
+
<button type="button" class="pill affordance" id="foodPill" data-command="place food" aria-pressed="false">place food</button>
|
|
672
|
+
<label class="deck-teach" title="With this on, a sentence like "The fox is at cell-3-4." writes a fact into the square instead of running as a command.">
|
|
673
|
+
<input type="checkbox" id="teachToggle">
|
|
674
|
+
teach
|
|
675
|
+
</label>
|
|
676
|
+
</div>
|
|
527
677
|
</div>
|
|
528
678
|
<section class="map-panel" id="mapPanel" aria-label="the town square, from above">
|
|
529
679
|
<div class="map-panel-head">
|
|
@@ -531,21 +681,14 @@ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " select
|
|
|
531
681
|
<span class="mono map-panel-turn" id="mapPanelTurn">turn 0</span>
|
|
532
682
|
</div>
|
|
533
683
|
<div class="map-panel-board" id="mapPanelBoard"></div>
|
|
684
|
+
<div class="map-legend">
|
|
685
|
+
<span class="map-key"><i class="map-swatch map-swatch-predator"></i>predator</span>
|
|
686
|
+
<span class="map-key"><i class="map-swatch map-swatch-prey"></i>prey</span>
|
|
687
|
+
<span class="map-key"><i class="map-swatch map-swatch-food"></i>food</span>
|
|
688
|
+
<span class="map-key"><i class="map-swatch map-swatch-prop"></i>building</span>
|
|
689
|
+
</div>
|
|
534
690
|
</section>
|
|
535
691
|
</div>
|
|
536
|
-
<div class="deck-camera">
|
|
537
|
-
<label class="deck-slider">follow
|
|
538
|
-
<select id="agentSelect" class="deck-select" aria-label="which agent to follow">
|
|
539
|
-
${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${escapeHtml(a.id)}</option>`).join("\n")}
|
|
540
|
-
</select>
|
|
541
|
-
</label>
|
|
542
|
-
<div class="camera-mode" id="cameraMode" role="group" aria-label="camera mode">
|
|
543
|
-
<button type="button" data-mode="follow" aria-pressed="true">follow</button>
|
|
544
|
-
<button type="button" data-mode="pov" aria-pressed="false">pov</button>
|
|
545
|
-
<button type="button" data-mode="overhead" aria-pressed="false">overhead</button>
|
|
546
|
-
</div>
|
|
547
|
-
<button type="button" class="pill affordance" id="foodPill" data-command="place food" aria-pressed="false">place food</button>
|
|
548
|
-
</div>
|
|
549
692
|
<div class="deck-info-popup mudiii-note" id="deckInfoPopup" role="dialog" aria-label="about this demo" hidden>
|
|
550
693
|
${MUDIII_NOTE_LINES.map((line) => `<p>${escapeHtml(line)}</p>`).join("\n ")}
|
|
551
694
|
<button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">×</button>
|
|
@@ -554,9 +697,11 @@ ${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${es
|
|
|
554
697
|
</div>
|
|
555
698
|
<section class="scene-stage" id="sceneStage" aria-label="the town square, in three dimensions">
|
|
556
699
|
<canvas id="sceneCanvas"></canvas>
|
|
700
|
+
<div class="dir-ring" id="driveRing" role="group" aria-label="walk the agent the camera follows" hidden>
|
|
701
|
+
${RING_POINTS.map((point) => ` <span class="dir-slot dir-${point}"><button type="button" class="dir-pill" data-drive="${point}" title="walk ${point}" aria-label="walk ${point}" aria-pressed="false">${escapeHtml(DIR_GLYPH[point])}</button></span>`).join("\n")}
|
|
702
|
+
</div>
|
|
557
703
|
<p class="scene-status" id="sceneStatus" role="status"></p>
|
|
558
704
|
</section>
|
|
559
|
-
<div class="hud-row" id="hudRow" aria-label="every agent's own status"></div>
|
|
560
705
|
<div class="edit-stage" id="mudiiiEditStage" aria-label="the square's own facts, in plain sentences">
|
|
561
706
|
<section class="edit-text" aria-label="the world's facts as editable sentences">
|
|
562
707
|
<h2>the square, in plain sentences</h2>
|
|
@@ -586,11 +731,12 @@ ${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${es
|
|
|
586
731
|
<span class="prompt mono">tmct></span>
|
|
587
732
|
${pillCompleteMarkup({
|
|
588
733
|
inputId: "chatInput",
|
|
589
|
-
inputHtml: '<input id="chatInput" type="text" placeholder="@fox
|
|
734
|
+
inputHtml: '<input id="chatInput" type="text" placeholder="@fox the goblin is east" aria-label="type a command" disabled>',
|
|
590
735
|
})}
|
|
591
736
|
</form>
|
|
592
737
|
</div>
|
|
593
738
|
</section>
|
|
739
|
+
<div class="hud-row" id="hudRow" aria-label="every agent's own status"></div>
|
|
594
740
|
</main>
|
|
595
741
|
<script>
|
|
596
742
|
const MUDIII_PAGE_DATA = ${pageData};
|
|
@@ -617,6 +763,7 @@ const MUDIII_STYLE = `
|
|
|
617
763
|
.mono { font-family: ${MONO_STACK}; }
|
|
618
764
|
main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
|
|
619
765
|
.eyebrow { font-family: ${MONO_STACK}; font-weight: 500; font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--square-ink); opacity: .85; margin: 0; }
|
|
766
|
+
${EYEBROW_LINKS_CSS}
|
|
620
767
|
h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; }
|
|
621
768
|
h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--square-stone-dark); }
|
|
622
769
|
button { font: inherit; color: inherit; background: none; cursor: pointer; }
|
|
@@ -637,7 +784,7 @@ const MUDIII_STYLE = `
|
|
|
637
784
|
box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
|
|
638
785
|
padding: .8rem .9rem; display: flex; flex-direction: column; gap: .55rem; min-width: 0;
|
|
639
786
|
}
|
|
640
|
-
.deck-controls
|
|
787
|
+
.deck-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
|
641
788
|
.deck-info-btn {
|
|
642
789
|
font-family: ${MONO_STACK}; font-size: .78rem; line-height: 1; width: 1.5rem; height: 1.5rem;
|
|
643
790
|
border-radius: 50%; border: 1px solid var(--square-stone-dark); background: rgba(255,255,255,.5);
|
|
@@ -653,16 +800,34 @@ const MUDIII_STYLE = `
|
|
|
653
800
|
font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
|
|
654
801
|
padding: .32rem .7rem; border: 1px solid var(--square-stone-dark); border-radius: 3px;
|
|
655
802
|
background: rgba(255,255,255,.5); color: var(--square-ink);
|
|
803
|
+
/* A select is as wide as its longest option, and a square's label runs to
|
|
804
|
+
"town square (12x12, 1 fox, 3 goblins)" — on a phone that alone made the
|
|
805
|
+
whole page scroll sideways. */
|
|
806
|
+
min-width: 0; max-width: 100%;
|
|
656
807
|
}
|
|
808
|
+
#scenarioSelect { flex: 1 1 9rem; }
|
|
657
809
|
.deck-select:hover { border-color: var(--square-accent); }
|
|
810
|
+
.deck-select:disabled { opacity: .45; cursor: default; }
|
|
811
|
+
.deck-select:disabled:hover { border-color: var(--square-stone-dark); }
|
|
812
|
+
.deck-hint { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .08em; color: var(--square-stone-dark); }
|
|
813
|
+
.deck-hint[hidden] { display: none; }
|
|
658
814
|
.deck-play { background: var(--square-ink) !important; color: var(--parchment); border-color: var(--square-ink) !important; padding: .38rem 1.1rem !important; }
|
|
659
815
|
.deck-play[aria-pressed="true"] { background: var(--square-accent) !important; border-color: var(--square-accent) !important; color: var(--square-ink); }
|
|
660
816
|
.deck-turns { margin-left: auto; font-size: .74rem; color: var(--square-stone-dark); background: var(--square-stone-dark); background: rgba(43,35,24,.9); color: var(--square-accent); border-radius: 2px; padding: .1rem .5rem; }
|
|
661
817
|
.deck-body { display: flex; gap: .7rem; align-items: flex-start; }
|
|
662
|
-
.deck-
|
|
818
|
+
.deck-panels { display: flex; flex-wrap: wrap; gap: .6rem 1rem; flex: 1 1 auto; min-width: 0; }
|
|
819
|
+
/* On a narrow viewport a stack is just a grouping label, not a real box:
|
|
820
|
+
its controls flow straight into .deck-panels' own wrap, one per row
|
|
821
|
+
beside the map. Wider breakpoints turn it back into a real column. */
|
|
822
|
+
.deck-stack { display: contents; }
|
|
663
823
|
.deck-slider { display: flex; align-items: center; gap: .35rem; font-family: ${MONO_STACK}; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em; color: var(--square-stone-dark); min-width: 0; }
|
|
664
824
|
.deck-slider input[type="range"] { accent-color: var(--square-accent); flex: 1 1 4rem; min-width: 2.5rem; width: auto; max-width: 8rem; }
|
|
665
|
-
.
|
|
825
|
+
.deck-teach { display: flex; align-items: center; gap: .3rem; font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--square-stone-dark); cursor: pointer; }
|
|
826
|
+
.deck-teach input[type="checkbox"] { accent-color: var(--square-accent); }
|
|
827
|
+
/* A narrow stack column can be too tight for all three button labels on
|
|
828
|
+
one line, and a nowrap row would rather overflow into the next column
|
|
829
|
+
than shrink — wrap keeps it inside its own box. */
|
|
830
|
+
.camera-mode { display: inline-flex; flex-wrap: wrap; gap: .25rem; }
|
|
666
831
|
.camera-mode button[aria-pressed="true"] { background: var(--square-accent); border-color: var(--square-accent); color: var(--square-ink); }
|
|
667
832
|
.deck-info-popup {
|
|
668
833
|
position: absolute; left: .9rem; right: .9rem; top: calc(100% + 8px); z-index: 8;
|
|
@@ -688,11 +853,35 @@ const MUDIII_STYLE = `
|
|
|
688
853
|
.map-panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: .4rem; }
|
|
689
854
|
.map-panel-title { font-family: ${MONO_STACK}; font-size: .54rem; text-transform: uppercase; letter-spacing: .1em; opacity: .85; }
|
|
690
855
|
.map-panel-turn { font-size: .58rem; opacity: .7; }
|
|
691
|
-
.map-panel-board {
|
|
856
|
+
.map-panel-board {
|
|
857
|
+
position: relative; flex: 1; min-height: 110px; aspect-ratio: 1;
|
|
858
|
+
--map-cell-pct: 8.3333%;
|
|
859
|
+
background-color: rgba(124,154,91,.25);
|
|
860
|
+
background-image:
|
|
861
|
+
repeating-linear-gradient(90deg, rgba(233,217,182,.22) 0 1px, transparent 1px var(--map-cell-pct)),
|
|
862
|
+
repeating-linear-gradient(180deg, rgba(233,217,182,.22) 0 1px, transparent 1px var(--map-cell-pct));
|
|
863
|
+
border: 1px solid rgba(233,217,182,.35); border-radius: 3px;
|
|
864
|
+
}
|
|
865
|
+
.map-block { position: absolute; box-sizing: border-box; background: rgba(89,80,63,.9); border: 1px solid rgba(0,0,0,.35); border-radius: 1px; }
|
|
692
866
|
.map-dot { position: absolute; width: .55rem; height: .55rem; margin: -.28rem 0 0 -.28rem; border-radius: 50%; border: 1px solid rgba(0,0,0,.4); }
|
|
693
867
|
.map-dot-predator { background: var(--square-predator); }
|
|
694
868
|
.map-dot-prey { background: var(--square-prey); }
|
|
695
869
|
.map-dot-crumb, .map-dot-morsel, .map-dot-item { background: var(--square-accent); width: .34rem; height: .34rem; margin: -.17rem 0 0 -.17rem; }
|
|
870
|
+
.map-label {
|
|
871
|
+
position: absolute; margin: -.66rem 0 0 .26rem; font-size: .44rem; line-height: 1; letter-spacing: .02em;
|
|
872
|
+
color: var(--parchment); text-shadow: 0 1px 2px rgba(0,0,0,.85); white-space: nowrap; pointer-events: none;
|
|
873
|
+
}
|
|
874
|
+
.map-label-left { margin-left: -.3rem; transform: translateX(-100%); }
|
|
875
|
+
/* Plain inline-block swatches, never .map-dot: that class is absolutely
|
|
876
|
+
positioned with a centring margin, so a legend reusing it would position
|
|
877
|
+
against the board and disappear. */
|
|
878
|
+
.map-legend { display: flex; flex-wrap: wrap; gap: .12rem .5rem; font-family: ${MONO_STACK}; font-size: .5rem; text-transform: uppercase; letter-spacing: .08em; opacity: .85; }
|
|
879
|
+
.map-key { display: inline-flex; align-items: center; gap: .24rem; }
|
|
880
|
+
.map-swatch { display: inline-block; width: .45rem; height: .45rem; border-radius: 50%; border: 1px solid rgba(0,0,0,.4); }
|
|
881
|
+
.map-swatch-predator { background: var(--square-predator); }
|
|
882
|
+
.map-swatch-prey { background: var(--square-prey); }
|
|
883
|
+
.map-swatch-food { background: var(--square-accent); }
|
|
884
|
+
.map-swatch-prop { background: rgba(89,80,63,.9); border-radius: 1px; }
|
|
696
885
|
|
|
697
886
|
.scene-stage { position: relative; margin-bottom: 1rem; border: 1px solid var(--square-stone-dark); border-radius: 4px; overflow: hidden; background: #10161B; min-height: 360px; }
|
|
698
887
|
.scene-stage canvas { display: block; width: 100%; height: 360px; }
|
|
@@ -703,7 +892,31 @@ const MUDIII_STYLE = `
|
|
|
703
892
|
}
|
|
704
893
|
.scene-status:empty { display: none; }
|
|
705
894
|
|
|
706
|
-
|
|
895
|
+
/* Each press sits where it points, mud.html's own ring idiom. */
|
|
896
|
+
.dir-ring { position: absolute; inset: .3rem; pointer-events: none; }
|
|
897
|
+
.dir-ring[hidden] { display: none; }
|
|
898
|
+
.dir-slot { position: absolute; pointer-events: auto; }
|
|
899
|
+
.dir-north { top: 0; left: 50%; transform: translateX(-50%); }
|
|
900
|
+
.dir-south { bottom: 0; left: 50%; transform: translateX(-50%); }
|
|
901
|
+
.dir-west { left: 0; top: 50%; transform: translateY(-50%); }
|
|
902
|
+
.dir-east { right: 0; top: 50%; transform: translateY(-50%); }
|
|
903
|
+
.dir-northwest { top: 0; left: 0; }
|
|
904
|
+
.dir-northeast { top: 0; right: 0; }
|
|
905
|
+
.dir-southwest { bottom: 0; left: 0; }
|
|
906
|
+
.dir-southeast { bottom: 0; right: 0; }
|
|
907
|
+
.dir-pill {
|
|
908
|
+
font-family: ${MONO_STACK}; font-size: .58rem; letter-spacing: .06em; line-height: 1;
|
|
909
|
+
padding: .24rem .42rem; border-radius: 2px; border: 1px solid var(--square-stone-dark);
|
|
910
|
+
background: var(--parchment); color: var(--square-ink); white-space: nowrap;
|
|
911
|
+
}
|
|
912
|
+
.dir-pill:hover:not(:disabled) { border-color: var(--square-accent); background: var(--square-accent); }
|
|
913
|
+
.dir-pill:disabled { opacity: .35; cursor: default; }
|
|
914
|
+
.dir-pill[aria-pressed="true"] {
|
|
915
|
+
background: var(--square-accent); border-color: var(--square-stone-dark);
|
|
916
|
+
box-shadow: 0 0 0 2px rgba(217,138,43,.4);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
.hud-row { display: flex; flex-wrap: wrap; gap: .7rem; margin-top: 1rem; }
|
|
707
920
|
.hud-card {
|
|
708
921
|
flex: 1 1 220px; min-width: 200px; max-width: 320px;
|
|
709
922
|
background: var(--parchment); border: 1px solid var(--square-stone-dark); border-radius: 4px;
|
|
@@ -716,6 +929,14 @@ const MUDIII_STYLE = `
|
|
|
716
929
|
.hud-meter-fill { height: 100%; background: var(--square-accent); width: 0%; transition: width .3s ease; }
|
|
717
930
|
.hud-goal { margin: 0; font-size: .74rem; }
|
|
718
931
|
.hud-plan, .hud-belief { margin: 0; font-size: .62rem; color: var(--square-stone-dark); }
|
|
932
|
+
.hud-belief-toggle { display: flex; align-items: baseline; gap: .25rem; width: 100%; text-align: left; padding: 0; border: 0; background: none; }
|
|
933
|
+
.hud-belief-toggle[hidden] { display: none; }
|
|
934
|
+
.hud-belief-toggle .hud-belief { flex: 1; min-width: 0; }
|
|
935
|
+
.hud-belief-toggle::after { content: "\\25BE"; font-size: .55rem; color: var(--square-stone-dark); }
|
|
936
|
+
.hud-belief-toggle[aria-expanded="true"]::after { content: "\\25B4"; }
|
|
937
|
+
.hud-belief-toggle:hover .hud-belief, .hud-belief-toggle:hover::after { color: var(--square-ink); }
|
|
938
|
+
.hud-detail { display: flex; flex-direction: column; gap: .1rem; font-size: .6rem; color: var(--square-stone-dark); border-top: 1px solid rgba(0,0,0,.12); padding-top: .25rem; }
|
|
939
|
+
.hud-detail[hidden] { display: none; }
|
|
719
940
|
@media (prefers-reduced-motion: reduce) { .hud-meter-fill { transition: none; } }
|
|
720
941
|
|
|
721
942
|
.edit-stage { display: none; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 1rem; align-items: start; margin-bottom: 1rem; }
|
|
@@ -770,24 +991,69 @@ const MUDIII_STYLE = `
|
|
|
770
991
|
.pill:hover:not(:disabled) { border-color: var(--square-accent); background: var(--square-accent); }
|
|
771
992
|
.pill.affordance { border-style: dashed; border-color: var(--square-stone); }
|
|
772
993
|
.pill.affordance[aria-pressed="true"] { background: var(--square-accent); border-style: solid; }
|
|
994
|
+
/* The tick and the cross live in ::before so the tag stays on the screen and
|
|
995
|
+
out of the submitted sentence — a clicked lie must be indistinguishable
|
|
996
|
+
from a typed one by the time the lane reads it. */
|
|
997
|
+
.pill[data-role="dyn-addr"][data-active="1"] { border-color: var(--taught); color: var(--taught); }
|
|
998
|
+
.pill[data-role="dyn-claim"][data-truth="true"] { border-color: var(--taught); }
|
|
999
|
+
.pill[data-role="dyn-claim"][data-truth="true"]::before { content: "\\2713 "; opacity: .55; }
|
|
1000
|
+
.pill[data-role="dyn-claim"][data-truth="false"] { border-style: dashed; border-color: var(--alert); }
|
|
1001
|
+
.pill[data-role="dyn-claim"][data-truth="false"]::before { content: "\\2715 "; opacity: .6; }
|
|
773
1002
|
|
|
774
1003
|
@media (max-width: 900px) {
|
|
775
1004
|
.edit-stage { grid-template-columns: 1fr; }
|
|
776
1005
|
#editorText { min-height: 16rem; }
|
|
777
1006
|
}
|
|
778
1007
|
|
|
779
|
-
/*
|
|
780
|
-
|
|
781
|
-
|
|
1008
|
+
/* Half the deck is right on a phone and absurd on a 2000px window: a
|
|
1009
|
+
percentage has no ceiling, so a square board grew to roughly 950px tall
|
|
1010
|
+
and pushed the 3D view off the screen. Wide viewports get an absolute
|
|
1011
|
+
size instead, and keep the square — there is room for it here.
|
|
1012
|
+
|
|
1013
|
+
The map is also taller than a single row of controls, which used to
|
|
1014
|
+
leave a tall empty stripe of parchment under them. On a landscape phone
|
|
1015
|
+
and a desktop window alike, the deck becomes one grid with three control
|
|
1016
|
+
columns beside the map. display:contents lifts .deck-panels' three
|
|
1017
|
+
.deck-stack children, and .map-panel, out of .deck-body so all four are
|
|
1018
|
+
grid items of the deck itself. A stack sits at its own natural height,
|
|
1019
|
+
top-aligned, rather than stretching its items apart to match the map —
|
|
1020
|
+
stretching them just traded the empty stripe for gaps between controls
|
|
1021
|
+
that belong together, and a hint drifting away from the select it
|
|
1022
|
+
describes. A shorter stack beside a taller map is fine; the leftover
|
|
1023
|
+
space stays honest whitespace instead. */
|
|
1024
|
+
@media (min-width: 901px), (max-width: 900px) and (orientation: landscape) {
|
|
1025
|
+
.deck-body, .deck-panels { display: contents; }
|
|
1026
|
+
.deck-stack {
|
|
1027
|
+
display: flex; flex-direction: column; gap: .5rem;
|
|
1028
|
+
grid-row: 2; align-self: start;
|
|
1029
|
+
}
|
|
1030
|
+
.deck-stack-1 { grid-column: 1; }
|
|
1031
|
+
.deck-stack-2 { grid-column: 2; }
|
|
1032
|
+
.deck-stack-3 { grid-column: 3; }
|
|
1033
|
+
/* Stretch is the flex column's default cross-axis behaviour, and the
|
|
1034
|
+
food pill is the one control here with a visible border — without
|
|
1035
|
+
this it grows to the stack's full width as a wide dashed box. */
|
|
1036
|
+
.deck-stack .pill.affordance { align-self: flex-start; }
|
|
1037
|
+
.map-panel { grid-column: 4; grid-row: 2; flex: 0 0 auto; max-width: none; }
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
@media (min-width: 901px) {
|
|
1041
|
+
.deck { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 240px; grid-template-rows: auto auto; column-gap: .8rem; }
|
|
1042
|
+
.deck-controls { grid-column: 1 / -1; grid-row: 1; }
|
|
1043
|
+
.map-panel { align-self: start; }
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/* A landscape phone is under 900px but wants the same four-column
|
|
1047
|
+
treatment as desktop — just narrower columns and a map that stretches
|
|
1048
|
+
to the stacks' height rather than holding a fixed square, the same
|
|
1049
|
+
trade this breakpoint always made before the grid switch. */
|
|
782
1050
|
@media (max-width: 900px) and (orientation: landscape) {
|
|
783
|
-
.deck
|
|
784
|
-
.deck-
|
|
785
|
-
.map-panel {
|
|
786
|
-
/* The square aspect-ratio that suits a tall portrait column would blow
|
|
787
|
-
the map back up to full column width in a short landscape viewport —
|
|
788
|
-
here it follows the two-row slider stack's own height instead. */
|
|
1051
|
+
.deck { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(140px, 220px); grid-template-rows: auto auto; column-gap: .6rem; }
|
|
1052
|
+
.deck-controls { grid-column: 1 / -1; grid-row: 1; }
|
|
1053
|
+
.map-panel { align-self: stretch; }
|
|
789
1054
|
.map-panel-board { aspect-ratio: auto; min-height: 90px; }
|
|
790
1055
|
}
|
|
1056
|
+
|
|
791
1057
|
`;
|
|
792
1058
|
|
|
793
1059
|
/** The inlined page script, spliced the same way mud-viz.mjs's own
|
|
@@ -803,12 +1069,14 @@ function pageScript() {
|
|
|
803
1069
|
const DATA = MUDIII_PAGE_DATA;
|
|
804
1070
|
const createTicker = ${createTicker.toString()};
|
|
805
1071
|
const createSerialQueue = ${createSerialQueue.toString()};
|
|
1072
|
+
const prefersReducedMotion = ${prefersReducedMotion.toString()};
|
|
806
1073
|
const escapeHtml = ${escapeHtml.toString()};
|
|
807
1074
|
const esc = escapeHtml;
|
|
808
1075
|
const appendLogLine = ${appendLogLine.toString()};
|
|
809
1076
|
const rowsForWorld = ${rowsForWorld.toString()};
|
|
810
1077
|
const renderMudEditorText = ${renderMudEditorText.toString()};
|
|
811
1078
|
const gridWorldEditorState = ${gridWorldEditorState.toString()};
|
|
1079
|
+
const wordBeforeCursor = ${wordBeforeCursor.toString()};
|
|
812
1080
|
const pillCandidates = ${pillCandidates.toString()};
|
|
813
1081
|
const matchPills = ${matchPills.toString()};
|
|
814
1082
|
const createPillComplete = ${createPillComplete.toString()};
|
|
@@ -817,33 +1085,41 @@ function pageScript() {
|
|
|
817
1085
|
const cellFromGroundPoint = ${cellFromGroundPoint.toString()};
|
|
818
1086
|
const propPlacementsFrom = ${propPlacementsFrom.toString()};
|
|
819
1087
|
const occupiedCells = ${occupiedCells.toString()};
|
|
1088
|
+
const currentPlacementsFrom = ${currentPlacementsFrom.toString()};
|
|
820
1089
|
const blockedCellReason = ${blockedCellReason.toString()};
|
|
821
1090
|
const cameraRigFor = ${cameraRigFor.toString()};
|
|
822
1091
|
const nextCameraSelection = ${nextCameraSelection.toString()};
|
|
1092
|
+
const cameraSelectionForMode = ${cameraSelectionForMode.toString()};
|
|
823
1093
|
const mapDotsFor = ${mapDotsFor.toString()};
|
|
1094
|
+
const mapBlocksFor = ${mapBlocksFor.toString()};
|
|
824
1095
|
const hudCardFieldsFor = ${hudCardFieldsFor.toString()};
|
|
825
1096
|
const clipForAction = ${clipForAction.toString()};
|
|
826
1097
|
const agentCardMarkup = ${agentCardMarkup.toString()};
|
|
1098
|
+
const believedFactSentence = ${believedFactSentence.toString()};
|
|
827
1099
|
|
|
828
1100
|
const el = (id) => document.getElementById(id);
|
|
1101
|
+
const SEED_COMMANDS = ["tick", "what does the fox see", "where is the goblin", "what can I do"];
|
|
829
1102
|
let scenarioIndex = 0;
|
|
830
1103
|
const scenario = function () { return DATA.scenarios[scenarioIndex]; };
|
|
831
1104
|
const gridSizeOf = function () { return scenario().gridSize || DATA.gridSize; };
|
|
832
|
-
const rosterOf = function (s, role) {
|
|
833
|
-
return (s.agents || []).filter(function (a) { return !role || a.role === role; }).map(function (a) { return a.id; });
|
|
834
|
-
};
|
|
835
1105
|
|
|
836
|
-
// ---- roster
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
// the
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1106
|
+
// ---- roster minting -----------------------------------------------------
|
|
1107
|
+
// The page asks for a COUNT and names the ids it is about to get back: the
|
|
1108
|
+
// engine mints <prefix>-1..N at seeded cells, so a slider can call for more
|
|
1109
|
+
// animals than the scenario's own opening cast carries and still be met. The
|
|
1110
|
+
// prefix is read off the scenario's own first agent of that role, so a square
|
|
1111
|
+
// that casts something other than foxes and goblins still names its cast
|
|
1112
|
+
// correctly. Drawing from the scenario's list instead capped every square at
|
|
1113
|
+
// whatever its layout happened to build, and a shuffled draw would leave two
|
|
1114
|
+
// loads of the same square with different casts.
|
|
1115
|
+
function rosterPrefixFor(s, role) {
|
|
1116
|
+
const first = (s.agents || []).find(function (a) { return a && a.role === role; });
|
|
1117
|
+
return first ? roleOfAgentId(first.id) : role;
|
|
1118
|
+
}
|
|
1119
|
+
function mintRoster(prefix, count) {
|
|
1120
|
+
const ids = [];
|
|
1121
|
+
for (let i = 1; i <= count; i += 1) ids.push(prefix + "-" + i);
|
|
1122
|
+
return ids;
|
|
847
1123
|
}
|
|
848
1124
|
|
|
849
1125
|
let cast = [];
|
|
@@ -857,8 +1133,22 @@ function pageScript() {
|
|
|
857
1133
|
let tickQueue = createSerialQueue();
|
|
858
1134
|
function serializeTick(fn) { return tickQueue.run(fn); }
|
|
859
1135
|
let camera = { mode: "follow", selectedId: null, status: null };
|
|
1136
|
+
// The mode a despawn fallback took away, held until the visitor picks
|
|
1137
|
+
// another agent. Without it, choosing someone new after a fox ate your
|
|
1138
|
+
// goblin leaves the camera overhead and the follow button unlit.
|
|
1139
|
+
let cameraModeBeforeFallback = null;
|
|
1140
|
+
// The cut to overhead the scene has been told to make NEXT tick. The page's
|
|
1141
|
+
// own camera state moves the moment the followed agent leaves the board,
|
|
1142
|
+
// because the deck must never offer an agent that is gone; the 3D camera
|
|
1143
|
+
// holds one more turn so the visitor watches the kill from the animal they
|
|
1144
|
+
// were riding instead of cutting away from it. A rig with nobody to aim at
|
|
1145
|
+
// freezes rather than drifting, which is what makes the held turn read as a
|
|
1146
|
+
// held shot.
|
|
1147
|
+
let deferredSceneCamera = null;
|
|
860
1148
|
let foodArmed = false;
|
|
861
1149
|
let livePills = [];
|
|
1150
|
+
let selectedAddresseeId = null;
|
|
1151
|
+
const expandedAgents = new Set();
|
|
862
1152
|
let pillComplete = null;
|
|
863
1153
|
let autoOn = false;
|
|
864
1154
|
let editing = false;
|
|
@@ -895,6 +1185,13 @@ function pageScript() {
|
|
|
895
1185
|
}
|
|
896
1186
|
}
|
|
897
1187
|
|
|
1188
|
+
// Every camera change the visitor asks for goes through here, so a pending
|
|
1189
|
+
// one-turn-late cut can never land on top of a mode they picked in between.
|
|
1190
|
+
function sendCameraToScene(state) {
|
|
1191
|
+
deferredSceneCamera = null;
|
|
1192
|
+
callScene("setCamera", state);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
898
1195
|
function applyTickResult(result) {
|
|
899
1196
|
if (!result) return;
|
|
900
1197
|
// The engine owns the count. Anything that advances a turn — the deck, a
|
|
@@ -902,9 +1199,22 @@ function pageScript() {
|
|
|
902
1199
|
if (typeof result.turn === "number") globalTurn = result.turn;
|
|
903
1200
|
if (result.agents) agentsById = result.agents;
|
|
904
1201
|
if (result.items) itemsById = result.items;
|
|
905
|
-
callScene("applyTick", {
|
|
906
|
-
|
|
907
|
-
|
|
1202
|
+
callScene("applyTick", {
|
|
1203
|
+
agents: result.agents, items: result.items, ecology: result.ecology, rungs: result.rungs,
|
|
1204
|
+
});
|
|
1205
|
+
const nextCamera = nextCameraSelection(camera, agentsList(), result.ecology || []);
|
|
1206
|
+
if (nextCamera.status && camera.mode !== "overhead") cameraModeBeforeFallback = camera.mode;
|
|
1207
|
+
camera = nextCamera;
|
|
1208
|
+
if (deferredSceneCamera) {
|
|
1209
|
+
const cut = deferredSceneCamera;
|
|
1210
|
+
deferredSceneCamera = null;
|
|
1211
|
+
callScene("setCamera", cut);
|
|
1212
|
+
} else if (nextCamera.status) {
|
|
1213
|
+
// The status line names the kill NOW; the wide shot lands next turn.
|
|
1214
|
+
deferredSceneCamera = { mode: camera.mode, selectedId: camera.selectedId };
|
|
1215
|
+
} else {
|
|
1216
|
+
callScene("setCamera", camera);
|
|
1217
|
+
}
|
|
908
1218
|
if (camera.status) setSceneStatus(camera.status);
|
|
909
1219
|
}
|
|
910
1220
|
|
|
@@ -926,6 +1236,16 @@ function pageScript() {
|
|
|
926
1236
|
const playBtn = el("autoToggle");
|
|
927
1237
|
playBtn.setAttribute("aria-pressed", state.playing ? "true" : "false");
|
|
928
1238
|
playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
|
|
1239
|
+
// The follow control reads the ticker's own state, never a second
|
|
1240
|
+
// "am I playing" the page keeps for itself, so the two can never
|
|
1241
|
+
// disagree. It closes while the board plays because a redraw lands
|
|
1242
|
+
// on top of the open dropdown and loses the pick.
|
|
1243
|
+
el("agentSelect").disabled = state.playing;
|
|
1244
|
+
el("agentSelectHint").hidden = !state.playing;
|
|
1245
|
+
// Step reads the same ticker state for the same reason: a hand-driven
|
|
1246
|
+
// turn while the board plays itself lands in the middle of one.
|
|
1247
|
+
el("stepBtn").disabled = state.playing || state.animating;
|
|
1248
|
+
el("stepHint").hidden = !state.playing;
|
|
929
1249
|
},
|
|
930
1250
|
hasNext: hasNext,
|
|
931
1251
|
wait: liveWait,
|
|
@@ -971,20 +1291,72 @@ function pageScript() {
|
|
|
971
1291
|
el("chatLogPopupClose").addEventListener("click", function () { el("chatLogPopup").hidden = true; });
|
|
972
1292
|
|
|
973
1293
|
// ---- the pill rail and its typeahead ----------------------------------
|
|
1294
|
+
// The deception rail is tmct.page.pillsForMudiii's own output, rendered and
|
|
1295
|
+
// nothing more: an address pill per live agent, then a true and a false
|
|
1296
|
+
// claim about every individual the addressee could act on. The false cell is
|
|
1297
|
+
// the board's own point reflection, so a lie is always in bounds and never
|
|
1298
|
+
// accidentally true. Which one a pill carries is shown by a glyph in CSS
|
|
1299
|
+
// ::before, never in the submitted text — a clicked lie reads exactly like a
|
|
1300
|
+
// typed one once it is in the input.
|
|
1301
|
+
//
|
|
1302
|
+
// The fixed seeds ahead of it are the town square's OWN verbs, checked
|
|
1303
|
+
// against the lane's regexes rather than borrowed from another page: this
|
|
1304
|
+
// world has no "look".
|
|
974
1305
|
function renderChatPills() {
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
livePills = pills;
|
|
978
|
-
el("chatPills").innerHTML = pills.map(function (p) {
|
|
1306
|
+
const seeds = SEED_COMMANDS.map(function (c) { return { command: c, label: c }; });
|
|
1307
|
+
const seedHtml = seeds.map(function (p) {
|
|
979
1308
|
return '<button type="button" class="pill" data-command="' + esc(p.command) + '">' + esc(p.label) + "</button>";
|
|
980
1309
|
}).join("");
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1310
|
+
const rail = window.tmct.page.pillsForMudiii(agentsById, itemsById, selectedAddresseeId, { gridSize: gridSizeOf() });
|
|
1311
|
+
selectedAddresseeId = rail.addresseeId;
|
|
1312
|
+
const addrHtml = rail.addressPills.map(function (p) {
|
|
1313
|
+
return '<button type="button" class="pill" data-role="dyn-addr" data-id="' + esc(p.id) + '"'
|
|
1314
|
+
+ (p.id === selectedAddresseeId ? ' data-active="1"' : "") + ">" + esc(p.label) + "</button>";
|
|
1315
|
+
}).join("");
|
|
1316
|
+
const claims = rail.claimPills.map(function (p) {
|
|
1317
|
+
return { command: p.sentence, label: p.text, truth: p.truth };
|
|
1318
|
+
});
|
|
1319
|
+
const claimHtml = claims.map(function (p) {
|
|
1320
|
+
return '<button type="button" class="pill" data-role="dyn-claim" data-truth="' + (p.truth ? "true" : "false")
|
|
1321
|
+
+ '" data-command="' + esc(p.command) + '">' + esc(p.label) + "</button>";
|
|
1322
|
+
}).join("");
|
|
1323
|
+
livePills = seeds.concat(claims);
|
|
1324
|
+
el("chatPills").innerHTML = seedHtml + addrHtml + claimHtml;
|
|
985
1325
|
if (pillComplete) pillComplete.refresh();
|
|
986
1326
|
}
|
|
987
1327
|
|
|
1328
|
+
// A pill APPENDS rather than replacing, so two clicks compose one line. The
|
|
1329
|
+
// second click of a double is what submits: the text it would have appended
|
|
1330
|
+
// went in on the first click of that same pair, which is why nothing is
|
|
1331
|
+
// appended again here.
|
|
1332
|
+
function appendToChatInput(text) {
|
|
1333
|
+
const input = el("chatInput");
|
|
1334
|
+
const head = input.value.replace(/\\s+$/, "");
|
|
1335
|
+
input.value = (head ? head + " " : "") + text;
|
|
1336
|
+
input.focus();
|
|
1337
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
el("chatPills").addEventListener("click", function (e) {
|
|
1341
|
+
const btn = e.target.closest(".pill");
|
|
1342
|
+
if (!btn) return;
|
|
1343
|
+
if (btn.getAttribute("data-role") === "dyn-addr") {
|
|
1344
|
+
selectedAddresseeId = btn.getAttribute("data-id");
|
|
1345
|
+
renderChatPills();
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
const command = btn.getAttribute("data-command");
|
|
1349
|
+
if (!command) return;
|
|
1350
|
+
if (e.detail > 1) {
|
|
1351
|
+
const input = el("chatInput");
|
|
1352
|
+
const line = input.value.trim();
|
|
1353
|
+
input.value = "";
|
|
1354
|
+
if (line) sendCommand(line);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
appendToChatInput(command);
|
|
1358
|
+
});
|
|
1359
|
+
|
|
988
1360
|
function wirePillComplete() {
|
|
989
1361
|
pillComplete = createPillComplete({
|
|
990
1362
|
input: el("chatInput"),
|
|
@@ -1015,7 +1387,8 @@ function pageScript() {
|
|
|
1015
1387
|
// still refused entirely client-side — nothing is written, and the food
|
|
1016
1388
|
// pill stays armed for another try.
|
|
1017
1389
|
window.mudiiiHandleSceneClick = function (cellId) {
|
|
1018
|
-
if (!
|
|
1390
|
+
if (!session || editing) return;
|
|
1391
|
+
if (!foodArmed) { walkFollowedTo(cellId); return; }
|
|
1019
1392
|
const reason = blockedCellReason(cellId, props, agentsList());
|
|
1020
1393
|
if (reason) { setSceneStatus(reason); return; }
|
|
1021
1394
|
sendCommand("put food at " + cellId).then(function () {
|
|
@@ -1024,6 +1397,82 @@ function pageScript() {
|
|
|
1024
1397
|
});
|
|
1025
1398
|
};
|
|
1026
1399
|
|
|
1400
|
+
// ---- driving one agent by hand ------------------------------------------
|
|
1401
|
+
// Every press here spends a turn: driveAgent runs the SAME whole-world tick
|
|
1402
|
+
// autoplay runs, so the ecology pass runs and every other agent decides and
|
|
1403
|
+
// moves with it. That is what the status line has to say, or the ring reads
|
|
1404
|
+
// as a free nudge that costs nothing.
|
|
1405
|
+
function followedAgentId() {
|
|
1406
|
+
const id = camera.selectedId;
|
|
1407
|
+
return id && agentsById[id] ? id : null;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// One lit glyph, and it is the followed agent's own facing — the reading
|
|
1411
|
+
// that makes sense on an open grid, where nearly every step is available and
|
|
1412
|
+
// lighting what is available would say nothing. With nobody followed there
|
|
1413
|
+
// is no facing to show and nothing to walk, so the ring goes away.
|
|
1414
|
+
function renderDriveRing() {
|
|
1415
|
+
const followed = followedAgentId();
|
|
1416
|
+
const ring = el("driveRing");
|
|
1417
|
+
ring.hidden = !followed;
|
|
1418
|
+
if (!followed) return;
|
|
1419
|
+
const facing = agentsById[followed].facing || DATA.defaultFacing;
|
|
1420
|
+
const buttons = ring.querySelectorAll("[data-drive]");
|
|
1421
|
+
for (let i = 0; i < buttons.length; i += 1) {
|
|
1422
|
+
buttons[i].setAttribute("aria-pressed", buttons[i].getAttribute("data-drive") === facing ? "true" : "false");
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function drivePress(direction) {
|
|
1427
|
+
const followed = followedAgentId();
|
|
1428
|
+
if (!session || !followed) { setSceneStatus("pick an agent to follow \\u2014 the ring walks whoever the camera is on."); return; }
|
|
1429
|
+
// A hand-driven turn is a deliberate one, so autoplay stands down rather
|
|
1430
|
+
// than racing the press.
|
|
1431
|
+
autoOn = false;
|
|
1432
|
+
if (ticker) ticker.pause();
|
|
1433
|
+
return serializeTick(async function () {
|
|
1434
|
+
const result = await session.driveAgent(followed, direction);
|
|
1435
|
+
applyTickResult(result);
|
|
1436
|
+
renderAll();
|
|
1437
|
+
const driven = result.driven || {};
|
|
1438
|
+
setSceneStatus(driven.accepted
|
|
1439
|
+
? followed + " went " + driven.direction + " to " + driven.cell + " \\u2014 turn " + result.turn + ", and the whole square moved with it."
|
|
1440
|
+
: followed + " could not go " + direction + " \\u2014 the turn was spent anyway, and the whole square moved.");
|
|
1441
|
+
return result;
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
el("driveRing").addEventListener("click", function (e) {
|
|
1446
|
+
const btn = e.target.closest("[data-drive]");
|
|
1447
|
+
if (!btn || btn.disabled) return;
|
|
1448
|
+
drivePress(btn.getAttribute("data-drive"));
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
// A ground click with nothing armed walks the followed agent one step along
|
|
1452
|
+
// the route to the cell, and draws the whole route it is heading down. The
|
|
1453
|
+
// route comes from the world's own exit search, so a cell behind a building
|
|
1454
|
+
// is declined rather than drawn as a line through the wall.
|
|
1455
|
+
async function walkFollowedTo(target) {
|
|
1456
|
+
const followed = followedAgentId();
|
|
1457
|
+
if (!followed) { setSceneStatus("pick an agent to follow \\u2014 a click on the ground walks whoever the camera is on."); return; }
|
|
1458
|
+
const from = agentsById[followed].cell;
|
|
1459
|
+
if (target === from) { setSceneStatus(followed + " is already at " + target + "."); return; }
|
|
1460
|
+
callScene("flashCell", target);
|
|
1461
|
+
const snap = await session.snapshot();
|
|
1462
|
+
const route = window.tmct.page.routeBetweenCells(snap.rows, from, target);
|
|
1463
|
+
if (!route || !route.directions.length) {
|
|
1464
|
+
callScene("clearRoute");
|
|
1465
|
+
setSceneStatus("no way through to " + target + " from " + from + ".");
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
callScene("showRoute", route.cells);
|
|
1469
|
+
await drivePress(route.directions[0]);
|
|
1470
|
+
const left = route.directions.length - 1;
|
|
1471
|
+
setSceneStatus(left
|
|
1472
|
+
? followed + " is heading for " + target + " \\u2014 " + left + " more step" + (left === 1 ? "" : "s") + ", one turn each."
|
|
1473
|
+
: followed + " reached " + target + ".");
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1027
1476
|
// ---- the HUD row --------------------------------------------------------
|
|
1028
1477
|
function renderHudRow() {
|
|
1029
1478
|
const ids = Object.keys(agentsById).sort();
|
|
@@ -1043,19 +1492,69 @@ function pageScript() {
|
|
|
1043
1492
|
card.querySelector(".hud-meter-fill").style.width = (fields.massPct === null ? 0 : fields.massPct) + "%";
|
|
1044
1493
|
card.querySelector(".hud-goal").textContent = fields.goal;
|
|
1045
1494
|
card.querySelector(".hud-plan").textContent = "plan: " + fields.planText;
|
|
1046
|
-
card
|
|
1047
|
-
? "believes: " + fields.beliefEntries.map(function (entry) {
|
|
1048
|
-
return entry[0] + (entry[1] ? " @ " + entry[1] : " unseen");
|
|
1049
|
-
}).join(" \\u00b7 ")
|
|
1050
|
-
: "";
|
|
1495
|
+
renderBelief(card, id, fields.beliefEntries);
|
|
1051
1496
|
}
|
|
1052
1497
|
}
|
|
1053
1498
|
|
|
1499
|
+
// A belief map grows with the cast, so the card shows the first three and a
|
|
1500
|
+
// count and keeps the rest behind a toggle. Which cards are open is held
|
|
1501
|
+
// against the AGENT ID, never the DOM: renderHudRow only rebuilds its
|
|
1502
|
+
// markup when the card count changes, and card slots are positional while
|
|
1503
|
+
// agents re-bind by sorted id, so state left in a card would follow the
|
|
1504
|
+
// slot rather than the animal.
|
|
1505
|
+
const BELIEF_SUMMARY_LIMIT = 3;
|
|
1506
|
+
function renderBelief(card, id, entries) {
|
|
1507
|
+
const toggle = card.querySelector(".hud-belief-toggle");
|
|
1508
|
+
const detail = card.querySelector(".hud-detail");
|
|
1509
|
+
const expanded = expandedAgents.has(id);
|
|
1510
|
+
const shown = entries.slice(0, BELIEF_SUMMARY_LIMIT).map(function (entry) {
|
|
1511
|
+
return entry[0] + (entry[1] ? " @ " + entry[1] : " unseen");
|
|
1512
|
+
}).join(" \\u00b7 ");
|
|
1513
|
+
const rest = entries.length - BELIEF_SUMMARY_LIMIT;
|
|
1514
|
+
card.querySelector(".hud-belief").textContent = entries.length
|
|
1515
|
+
? "believes: " + shown + (rest > 0 ? " +" + rest + " more" : "")
|
|
1516
|
+
: "";
|
|
1517
|
+
toggle.hidden = entries.length === 0;
|
|
1518
|
+
toggle.setAttribute("aria-expanded", expanded && entries.length ? "true" : "false");
|
|
1519
|
+
detail.hidden = !expanded || entries.length === 0;
|
|
1520
|
+
detail.innerHTML = entries.map(function (entry) {
|
|
1521
|
+
return '<div class="hud-detail-line">' + esc(believedFactSentence(entry[0], entry[1])) + "</div>";
|
|
1522
|
+
}).join("");
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
el("hudRow").addEventListener("click", function (e) {
|
|
1526
|
+
const toggle = e.target.closest(".hud-belief-toggle");
|
|
1527
|
+
if (!toggle) return;
|
|
1528
|
+
const card = toggle.closest(".hud-card");
|
|
1529
|
+
const id = card && card.getAttribute("data-agent");
|
|
1530
|
+
if (!id) return;
|
|
1531
|
+
if (expandedAgents.has(id)) expandedAgents.delete(id); else expandedAgents.add(id);
|
|
1532
|
+
renderHudRow();
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1054
1535
|
// ---- the top-down map panel ---------------------------------------------
|
|
1055
1536
|
function renderMapPanel() {
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
1537
|
+
const board = el("mapPanelBoard");
|
|
1538
|
+
const size = gridSizeOf();
|
|
1539
|
+
// The cell divisions are two gradients stepped by this, so the drawn grid
|
|
1540
|
+
// and the dots' own percentages read off the same board size.
|
|
1541
|
+
board.style.setProperty("--map-cell-pct", (100 / size) + "%");
|
|
1542
|
+
const blocks = mapBlocksFor(props, size);
|
|
1543
|
+
const dots = mapDotsFor(agentsList(), itemsList(), size);
|
|
1544
|
+
// Blocks first, dots second: a live agent standing beside a building has
|
|
1545
|
+
// to sit on top of it, not under it.
|
|
1546
|
+
board.innerHTML = blocks.map(function (b) {
|
|
1547
|
+
return '<span class="map-block" style="left:' + b.xPct + '%;top:' + b.yPct + '%;width:' + b.sizePct
|
|
1548
|
+
+ '%;height:' + b.sizePct + '%" title="' + esc(b.id) + '"></span>';
|
|
1549
|
+
}).join("") + dots.map(function (d) {
|
|
1550
|
+
const dot = '<span class="map-dot map-dot-' + esc(d.kind) + '" style="left:' + d.xPct + '%;top:' + d.yPct + '%" title="' + esc(d.id) + '"></span>';
|
|
1551
|
+
// Items are named by their colour in the key; only the cast, which the
|
|
1552
|
+
// HUD and the follow control both name, carries its id on the board.
|
|
1553
|
+
if (d.kind !== "predator" && d.kind !== "prey") return dot;
|
|
1554
|
+
// A label on a dot near the right edge would run off the board, so
|
|
1555
|
+
// those hang to the left of their dot instead.
|
|
1556
|
+
const side = d.xPct > 70 ? " map-label-left" : "";
|
|
1557
|
+
return dot + '<span class="map-label mono' + side + '" style="left:' + d.xPct + '%;top:' + d.yPct + '%">' + esc(d.id) + "</span>";
|
|
1059
1558
|
}).join("");
|
|
1060
1559
|
el("mapPanelTurn").textContent = "turn " + globalTurn;
|
|
1061
1560
|
}
|
|
@@ -1071,8 +1570,12 @@ function pageScript() {
|
|
|
1071
1570
|
}
|
|
1072
1571
|
el("agentSelect").addEventListener("change", function () {
|
|
1073
1572
|
const id = el("agentSelect").value || null;
|
|
1074
|
-
|
|
1075
|
-
|
|
1573
|
+
const mode = cameraModeBeforeFallback || camera.mode;
|
|
1574
|
+
cameraModeBeforeFallback = null;
|
|
1575
|
+
camera = { mode: mode, selectedId: id, status: null };
|
|
1576
|
+
renderCameraButtons();
|
|
1577
|
+
renderDriveRing();
|
|
1578
|
+
sendCameraToScene(camera);
|
|
1076
1579
|
});
|
|
1077
1580
|
|
|
1078
1581
|
function renderCameraButtons() {
|
|
@@ -1084,9 +1587,15 @@ function pageScript() {
|
|
|
1084
1587
|
el("cameraMode").addEventListener("click", function (e) {
|
|
1085
1588
|
const btn = e.target.closest("button[data-mode]");
|
|
1086
1589
|
if (!btn) return;
|
|
1087
|
-
|
|
1590
|
+
cameraModeBeforeFallback = null;
|
|
1591
|
+
camera = cameraSelectionForMode(
|
|
1592
|
+
btn.getAttribute("data-mode"), camera.selectedId, el("agentSelect").value, Object.keys(agentsById),
|
|
1593
|
+
);
|
|
1088
1594
|
renderCameraButtons();
|
|
1089
|
-
|
|
1595
|
+
renderAgentSelect();
|
|
1596
|
+
renderDriveRing();
|
|
1597
|
+
setSceneStatus(camera.status || "");
|
|
1598
|
+
sendCameraToScene(camera);
|
|
1090
1599
|
});
|
|
1091
1600
|
|
|
1092
1601
|
// ---- the control deck ----------------------------------------------------
|
|
@@ -1119,7 +1628,14 @@ function pageScript() {
|
|
|
1119
1628
|
el("playerCountSlider").addEventListener("change", function () { boot(); });
|
|
1120
1629
|
el("npcCountSlider").addEventListener("input", function () { showGoblinCount(chosenGoblinCount()); });
|
|
1121
1630
|
el("npcCountSlider").addEventListener("change", function () { boot(); });
|
|
1122
|
-
|
|
1631
|
+
// One whole turn: every agent decides and moves, the ecology pass runs,
|
|
1632
|
+
// and the turn counter reads exactly one higher. It goes through the same
|
|
1633
|
+
// ticker play() uses, so the two can never run a turn on top of each other.
|
|
1634
|
+
el("stepBtn").addEventListener("click", function () {
|
|
1635
|
+
if (!session || autoOn) return;
|
|
1636
|
+
ensureTicker().stepOnce();
|
|
1637
|
+
});
|
|
1638
|
+
el("resetBtn").addEventListener("click", function () { resetBoard(); });
|
|
1123
1639
|
const scenarioSelect = el("scenarioSelect");
|
|
1124
1640
|
if (scenarioSelect) {
|
|
1125
1641
|
scenarioSelect.addEventListener("change", function () {
|
|
@@ -1154,17 +1670,15 @@ function pageScript() {
|
|
|
1154
1670
|
// graph.
|
|
1155
1671
|
function worldOnlyRows(rows) { return rowsForWorld(rows, scenario().worldPayload.name); }
|
|
1156
1672
|
let editRows = [];
|
|
1673
|
+
// The FULL store, not the world's own rows: a term's synonyms and its is-a
|
|
1674
|
+
// chain mostly live in the background corpus, not in the square's vocabulary.
|
|
1675
|
+
let allStoreRows = [];
|
|
1157
1676
|
|
|
1158
1677
|
function renderEditPlacements() {
|
|
1159
|
-
const placements =
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
}
|
|
1164
|
-
const subjects = Object.keys(placements).sort();
|
|
1165
|
-
el("editPlacements").innerHTML = subjects.length
|
|
1166
|
-
? subjects.map(function (s) {
|
|
1167
|
-
return '<div class="edit-placement-row"><span class="mono">' + esc(s) + '</span><span>' + esc(placements[s]) + "</span></div>";
|
|
1678
|
+
const placements = currentPlacementsFrom(editRows);
|
|
1679
|
+
el("editPlacements").innerHTML = placements.length
|
|
1680
|
+
? placements.map(function (p) {
|
|
1681
|
+
return '<div class="edit-placement-row"><span class="mono">' + esc(p.subject) + '</span><span>' + esc(p.cell) + "</span></div>";
|
|
1168
1682
|
}).join("")
|
|
1169
1683
|
: '<span class="edit-empty">nothing placed yet</span>';
|
|
1170
1684
|
}
|
|
@@ -1178,11 +1692,12 @@ function pageScript() {
|
|
|
1178
1692
|
el("editModeBtn").textContent = "back to playing";
|
|
1179
1693
|
el("editModeBtn").setAttribute("aria-pressed", "true");
|
|
1180
1694
|
const snap = await session.snapshot();
|
|
1695
|
+
allStoreRows = snap.rows;
|
|
1181
1696
|
editRows = worldOnlyRows(snap.rows);
|
|
1182
1697
|
el("editorText").value = renderMudEditorText(editRows, gridWorldEditorState(snap.state));
|
|
1183
1698
|
el("editorStatus").className = "edit-status";
|
|
1184
1699
|
el("editorStatus").textContent = "";
|
|
1185
|
-
|
|
1700
|
+
renderSuggestionPills();
|
|
1186
1701
|
renderEditPlacements();
|
|
1187
1702
|
}
|
|
1188
1703
|
|
|
@@ -1193,9 +1708,74 @@ function pageScript() {
|
|
|
1193
1708
|
el("editModeBtn").setAttribute("aria-pressed", "false");
|
|
1194
1709
|
}
|
|
1195
1710
|
|
|
1711
|
+
// The lateral SKOS neighbourhood plus the vertical is-a chain for whatever
|
|
1712
|
+
// word the cursor sits behind. Nothing found is nothing shown — an honest
|
|
1713
|
+
// miss, never a guessed suggestion.
|
|
1714
|
+
function renderSuggestionPills() {
|
|
1715
|
+
const box = el("editorPills");
|
|
1716
|
+
const term = wordBeforeCursor(el("editorText").value, el("editorText").selectionStart);
|
|
1717
|
+
if (!term || !window.tmct) { box.innerHTML = ""; return; }
|
|
1718
|
+
const related = window.tmct.page.relatedForTerm ? window.tmct.page.relatedForTerm(allStoreRows, term) : null;
|
|
1719
|
+
const chain = window.tmct.page.classAncestorChain ? window.tmct.page.classAncestorChain(term, allStoreRows) : [];
|
|
1720
|
+
const seen = {};
|
|
1721
|
+
seen[term] = true;
|
|
1722
|
+
const out = [];
|
|
1723
|
+
const push = function (label) { if (label && !seen[label]) { seen[label] = true; out.push(label); } };
|
|
1724
|
+
if (related) {
|
|
1725
|
+
related.synonyms.forEach(push);
|
|
1726
|
+
related.related.forEach(function (r) { push(r.prefLabel); });
|
|
1727
|
+
}
|
|
1728
|
+
chain.slice(1).forEach(push);
|
|
1729
|
+
box.innerHTML = out.slice(0, 8).map(function (s) {
|
|
1730
|
+
return '<button type="button" class="pill" data-insert="' + esc(s) + '">' + esc(s) + "</button>";
|
|
1731
|
+
}).join("");
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
el("editorPills").addEventListener("click", function (e) {
|
|
1735
|
+
const btn = e.target.closest(".pill");
|
|
1736
|
+
if (!btn) return;
|
|
1737
|
+
const area = el("editorText");
|
|
1738
|
+
const pos = area.selectionStart;
|
|
1739
|
+
const word = wordBeforeCursor(area.value, pos);
|
|
1740
|
+
const insert = btn.getAttribute("data-insert");
|
|
1741
|
+
area.value = area.value.slice(0, pos - word.length) + insert + area.value.slice(pos);
|
|
1742
|
+
const next = pos - word.length + insert.length;
|
|
1743
|
+
area.setSelectionRange(next, next);
|
|
1744
|
+
area.focus();
|
|
1745
|
+
onEditorChanged();
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
let suggestTimer = null;
|
|
1196
1749
|
let syncTimer = null;
|
|
1750
|
+
function scheduleSuggestions() { clearTimeout(suggestTimer); suggestTimer = setTimeout(renderSuggestionPills, 180); }
|
|
1197
1751
|
function scheduleSync() { clearTimeout(syncTimer); syncTimer = setTimeout(applyEditorText, 450); }
|
|
1198
|
-
|
|
1752
|
+
function onEditorChanged() { scheduleSuggestions(); scheduleSync(); }
|
|
1753
|
+
el("editorText").addEventListener("input", onEditorChanged);
|
|
1754
|
+
el("editorText").addEventListener("click", scheduleSuggestions);
|
|
1755
|
+
el("editorText").addEventListener("keyup", function (e) {
|
|
1756
|
+
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].indexOf(e.key) !== -1) scheduleSuggestions();
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
// An edit changes the facts, so the meshes have to move with them —
|
|
1760
|
+
// otherwise a deleted well stands in the square until the next Reset.
|
|
1761
|
+
// boot() drops the camera back to overhead and empties the scene's own
|
|
1762
|
+
// agent groups, so the visitor's camera is put back and the live cast is
|
|
1763
|
+
// redrawn straight after: autoplay is paused in edit mode, so nothing else
|
|
1764
|
+
// would repopulate them.
|
|
1765
|
+
async function rebuildSceneFromEdit(result) {
|
|
1766
|
+
props = propPlacementsFrom(editRows, DATA.assetManifest);
|
|
1767
|
+
// A prop may now stand where an animal was, so a change re-casts onto the
|
|
1768
|
+
// edited board. An edit that wrote and retracted nothing leaves the cast
|
|
1769
|
+
// exactly where it stood.
|
|
1770
|
+
if (result && (result.added || result.removed)) {
|
|
1771
|
+
applyTickResult(await session.recast({ agents: cast }));
|
|
1772
|
+
}
|
|
1773
|
+
await callScene("boot", {
|
|
1774
|
+
propPlacements: props, assetManifest: DATA.assetManifest, gridSize: gridSizeOf(), cellSize: 1,
|
|
1775
|
+
});
|
|
1776
|
+
sendCameraToScene(camera);
|
|
1777
|
+
callScene("applyTick", { agents: agentsById, items: itemsById, ecology: [] });
|
|
1778
|
+
}
|
|
1199
1779
|
|
|
1200
1780
|
async function applyEditorText() {
|
|
1201
1781
|
if (!session) return;
|
|
@@ -1204,7 +1784,9 @@ function pageScript() {
|
|
|
1204
1784
|
status.textContent = "reading the square\\u2026";
|
|
1205
1785
|
const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
|
|
1206
1786
|
const snap = await session.snapshot();
|
|
1787
|
+
allStoreRows = snap.rows;
|
|
1207
1788
|
editRows = worldOnlyRows(snap.rows);
|
|
1789
|
+
await rebuildSceneFromEdit(result);
|
|
1208
1790
|
if (result && result.unrecognized && result.unrecognized.length) {
|
|
1209
1791
|
status.className = "edit-status pending";
|
|
1210
1792
|
status.textContent = result.unrecognized.length + " line" + (result.unrecognized.length === 1 ? "" : "s")
|
|
@@ -1219,8 +1801,37 @@ function pageScript() {
|
|
|
1219
1801
|
}
|
|
1220
1802
|
|
|
1221
1803
|
// ---- booting ---------------------------------------------------------
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1804
|
+
// The board opens playing: a square standing still reads as broken, and the
|
|
1805
|
+
// first thing anyone does is press play anyway. A visitor who asked for
|
|
1806
|
+
// reduced motion gets the opening board drawn and left still — the play
|
|
1807
|
+
// control is right there — because an autoplaying board is exactly the
|
|
1808
|
+
// unasked-for movement that setting is about.
|
|
1809
|
+
|
|
1810
|
+
// The camera opens riding a goblin. The goblins are the prey, so the first
|
|
1811
|
+
// thing a visitor sees is a chase from inside the animal being chased; when
|
|
1812
|
+
// it is caught the camera cuts wide and the rest of the round plays out
|
|
1813
|
+
// overhead. Falls back to whoever is first by id on a square with no prey.
|
|
1814
|
+
function openingFollowId(agents) {
|
|
1815
|
+
const ids = Object.keys(agents || {}).sort();
|
|
1816
|
+
for (let i = 0; i < ids.length; i += 1) {
|
|
1817
|
+
if (agents[ids[i]] && agents[ids[i]].role === "prey") return ids[i];
|
|
1818
|
+
}
|
|
1819
|
+
return ids[0] || null;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// The play control put back to a stopped board. The ticker draws this
|
|
1823
|
+
// itself while it lives, but a Reset throws the ticker away, so the button
|
|
1824
|
+
// would keep reading "pause" over a board that is standing still.
|
|
1825
|
+
function showStopped() {
|
|
1826
|
+
const playBtn = el("autoToggle");
|
|
1827
|
+
playBtn.setAttribute("aria-pressed", "false");
|
|
1828
|
+
playBtn.textContent = "\\u25B6 play";
|
|
1829
|
+
el("agentSelect").disabled = false;
|
|
1830
|
+
el("agentSelectHint").hidden = true;
|
|
1831
|
+
el("stepBtn").disabled = false;
|
|
1832
|
+
el("stepHint").hidden = true;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1224
1835
|
let bootSeq = 0;
|
|
1225
1836
|
async function boot() {
|
|
1226
1837
|
const seq = bootSeq += 1;
|
|
@@ -1229,14 +1840,21 @@ function pageScript() {
|
|
|
1229
1840
|
globalTurn = 0;
|
|
1230
1841
|
tickQueue = createSerialQueue();
|
|
1231
1842
|
camera = { mode: "follow", selectedId: null, status: null };
|
|
1843
|
+
cameraModeBeforeFallback = null;
|
|
1844
|
+
deferredSceneCamera = null;
|
|
1845
|
+
expandedAgents.clear();
|
|
1232
1846
|
const s = scenario();
|
|
1233
|
-
const foxes =
|
|
1234
|
-
const goblins =
|
|
1847
|
+
const foxes = mintRoster(rosterPrefixFor(s, "predator"), chosenFoxCount());
|
|
1848
|
+
const goblins = mintRoster(rosterPrefixFor(s, "prey"), chosenGoblinCount());
|
|
1235
1849
|
cast = foxes.concat(goblins);
|
|
1236
1850
|
showFoxCount(foxes.length);
|
|
1237
1851
|
showGoblinCount(goblins.length);
|
|
1238
1852
|
props = propPlacementsFrom((s.worldPayload && s.worldPayload.facts) || [], DATA.assetManifest);
|
|
1239
|
-
const opened = await window.tmct.open(s.worldPayload, {
|
|
1853
|
+
const opened = await window.tmct.open(s.worldPayload, {
|
|
1854
|
+
agents: cast,
|
|
1855
|
+
epoch: 0,
|
|
1856
|
+
getTeachEnabled: function () { return el("teachToggle").checked; },
|
|
1857
|
+
});
|
|
1240
1858
|
if (seq !== bootSeq) return;
|
|
1241
1859
|
session = opened;
|
|
1242
1860
|
agentsById = {};
|
|
@@ -1251,9 +1869,43 @@ function pageScript() {
|
|
|
1251
1869
|
// the cells both come back from it rather than being guessed here.
|
|
1252
1870
|
const opening = await session.board();
|
|
1253
1871
|
if (seq !== bootSeq) return;
|
|
1254
|
-
camera.selectedId =
|
|
1872
|
+
camera.selectedId = openingFollowId(opening.agents);
|
|
1255
1873
|
applyTickResult(opening);
|
|
1256
1874
|
renderAll();
|
|
1875
|
+
if (!prefersReducedMotion()) {
|
|
1876
|
+
autoOn = true;
|
|
1877
|
+
ensureTicker().play();
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// Reset re-casts the store it already has rather than opening a new one:
|
|
1882
|
+
// the world's facts, everything taught into it and every editor change all
|
|
1883
|
+
// stand, and only the animals are minted again. Re-opening would throw the
|
|
1884
|
+
// taught facts away with the cast, which is not what "reset the board" says.
|
|
1885
|
+
//
|
|
1886
|
+
// A Reset leaves the board STOPPED, whatever it was doing before. Opening
|
|
1887
|
+
// the page is the one time the square starts itself; after that the visitor
|
|
1888
|
+
// says when it runs. The shared ticker in viz-ticker.mjs already resets this
|
|
1889
|
+
// way, and this page keeps its own play flag, so it says so here too.
|
|
1890
|
+
async function resetBoard() {
|
|
1891
|
+
if (!session) return boot();
|
|
1892
|
+
autoOn = false;
|
|
1893
|
+
if (ticker) { ticker.pause(); ticker = null; }
|
|
1894
|
+
expandedAgents.clear();
|
|
1895
|
+
const s = scenario();
|
|
1896
|
+
const foxes = mintRoster(rosterPrefixFor(s, "predator"), chosenFoxCount());
|
|
1897
|
+
const goblins = mintRoster(rosterPrefixFor(s, "prey"), chosenGoblinCount());
|
|
1898
|
+
cast = foxes.concat(goblins);
|
|
1899
|
+
showFoxCount(foxes.length);
|
|
1900
|
+
showGoblinCount(goblins.length);
|
|
1901
|
+
const board = await serializeTick(function () { return session.recast({ agents: cast }); });
|
|
1902
|
+
camera = { mode: "follow", selectedId: openingFollowId(board.agents), status: null };
|
|
1903
|
+
cameraModeBeforeFallback = null;
|
|
1904
|
+
deferredSceneCamera = null;
|
|
1905
|
+
applyTickResult(board);
|
|
1906
|
+
renderAll();
|
|
1907
|
+
showStopped();
|
|
1908
|
+
setSceneStatus("re-cast and stopped \\u2014 the square's own facts stand, and everything taught into it. Press play.");
|
|
1257
1909
|
}
|
|
1258
1910
|
|
|
1259
1911
|
function renderAll() {
|
|
@@ -1261,6 +1913,7 @@ function pageScript() {
|
|
|
1261
1913
|
renderMapPanel();
|
|
1262
1914
|
renderAgentSelect();
|
|
1263
1915
|
renderCameraButtons();
|
|
1916
|
+
renderDriveRing();
|
|
1264
1917
|
renderChatPills();
|
|
1265
1918
|
el("globalTurnCount").textContent = "turns: " + globalTurn;
|
|
1266
1919
|
}
|