@polycode-projects/the-mechanical-code-talker 3.2.0 → 4.0.0
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/corpus/sprites/src/sprite-facts.jsonl +28 -0
- package/corpus/tier2/generate.mjs +10 -1
- package/corpus/tier2/human.jsonl +23 -0
- package/corpus/tier2/manifest.json +3 -3
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +15 -5
- package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
- package/corpus/worlds/src/mud-garden.jsonl +101 -0
- package/package.json +2 -1
- package/src/adapters/p2p/webrtc-transport.mjs +146 -0
- package/src/domain/game-config.mjs +67 -0
- package/src/domain/grammar/ace.mjs +11 -3
- package/src/domain/grammar/lexicon-core.json +3 -0
- package/src/domain/grammar/lexicon.mjs +13 -0
- package/src/domain/memory/trust.mjs +15 -0
- package/src/domain/p2p/facts.mjs +81 -0
- package/src/domain/p2p/peer-id.mjs +32 -0
- package/src/domain/p2p/provenance-relabel.mjs +26 -0
- package/src/domain/p2p/sync-filter.mjs +31 -0
- package/src/domain/p2p/wire.mjs +123 -0
- package/src/domain/sprite-map.mjs +10 -2
- package/src/services/adventure-editor.mjs +10 -2
- package/src/services/adventure-viz.mjs +192 -39
- package/src/services/adventure.mjs +989 -69
- package/src/services/chat-page-viz.mjs +1060 -7
- package/src/services/chat-session.mjs +28 -3
- package/src/services/chat.mjs +2 -2
- package/src/services/mud-editor.mjs +313 -0
- package/src/services/mud-turn.mjs +572 -0
- package/src/services/mud-viz.mjs +2055 -0
- package/src/services/p2p-room.mjs +559 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +77 -77
- package/src/surfaces/web/mud-browser-entry.mjs +330 -0
- package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
|
@@ -0,0 +1,2055 @@
|
|
|
1
|
+
// mud-viz.mjs — mud.html: the self-contained proof of the shared,
|
|
2
|
+
// multi-character shape PLAN_MUD.md's "Demo phase" section describes. One,
|
|
3
|
+
// two or four burrowing animals — the deck's own players slider picks how
|
|
4
|
+
// many — are drawn from the world's roster and each get their own pane over
|
|
5
|
+
// ONE shared live world (mud-browser-entry.mjs's createMudSession); an
|
|
6
|
+
// omniscient survey of the whole burrow sits in the top row beside the
|
|
7
|
+
// controls with no fog of war at all, the one deliberate exception
|
|
8
|
+
// PLAN_MUD.md names.
|
|
9
|
+
//
|
|
10
|
+
// The deck's second cast slider adds NPCs: one to ten more animals in the
|
|
11
|
+
// same world, drawn from the same roster, running the same scripted turn as
|
|
12
|
+
// a pane's own character and differing in exactly one way — no pane. They are
|
|
13
|
+
// ordinary world individuals throughout, so every reader here (the survey,
|
|
14
|
+
// a pane's room view, the talk affordances, the room caption) picks them up
|
|
15
|
+
// as "another character present" without knowing they exist. Two things do
|
|
16
|
+
// know: the survey draws them in one shared colour because the four actor
|
|
17
|
+
// hues belong to the panes, and the eaten scene needs a pane to play in, so a
|
|
18
|
+
// taken NPC just stops. They carry no mgx:is-npc marker either — that
|
|
19
|
+
// predicate drives adventure.mjs's scripted-by-data scheduler, which is a
|
|
20
|
+
// different mechanism from these.
|
|
21
|
+
//
|
|
22
|
+
// The stage is built in the browser, never fixed at build time: paneMarkup is
|
|
23
|
+
// spliced into the page script alongside the render glue, and every boot
|
|
24
|
+
// rebuilds #mudStage with one call per cast member. The page ships the
|
|
25
|
+
// default cast's panes so the first paint is not an empty box, and the grid
|
|
26
|
+
// reads how many to lay out off the stage's own data-panes attribute.
|
|
27
|
+
//
|
|
28
|
+
// Shaped after adventure-viz.mjs/spider-fly-viz.mjs: one inlined <style>
|
|
29
|
+
// over viz-theme.mjs's shared tokens, behaviour as an inlined IIFE, the
|
|
30
|
+
// engine arriving via a sibling <script src="./mud-browser.bundle.js">
|
|
31
|
+
// (mirroring adventure-viz.mjs's own worldPayload-embedding rationale — the
|
|
32
|
+
// world's canonical source is a Node-only gzipped JSONL shard the browser
|
|
33
|
+
// cannot read, so it is read ONCE at build time through the real worlds-pack
|
|
34
|
+
// provider and embedded as page data). `createTicker` and a small set of
|
|
35
|
+
// self-contained, `.toString()`-splice-safe room-scene helpers are spliced
|
|
36
|
+
// into the inline script exactly the way spider-fly-viz.mjs splices its own
|
|
37
|
+
// render-glue — `roomSceneObjects`/`scenePlacement`/`spriteClassForObject`/
|
|
38
|
+
// `spriteAncestryRows`/`factsForSubject`/`visibleRoomOf`/`roomKindForRoom`
|
|
39
|
+
// are REUSED directly from adventure-viz.mjs rather than re-derived:
|
|
40
|
+
// mud-garden ships no individual
|
|
41
|
+
// named "player" (the whole point of the multi-character demo), so those
|
|
42
|
+
// functions' own hardcoded "player" exclusion never fires for a mud
|
|
43
|
+
// character — every character reads back as an ordinary visible object of
|
|
44
|
+
// its room until this page's own code filters the CURRENT viewing character
|
|
45
|
+
// out by name (mudRoomSceneObjects, below).
|
|
46
|
+
//
|
|
47
|
+
// A room's graphic is a rendering of worldDigestRows'/roomAffordances' own
|
|
48
|
+
// text digest, never a replacement for it (PLAN_MUD.md's own "Room view"
|
|
49
|
+
// spec): a soil-toned canvas backdrop (roomKindForRoom picks outdoor/
|
|
50
|
+
// underground), the viewing character's own sprite standing right, every
|
|
51
|
+
// room-mate entering from the left, and every loose object hung on the back
|
|
52
|
+
// wall in a portrait frame (scenePlacement decides how high it hangs). The
|
|
53
|
+
// six compass affordances sit around that box where their own directions
|
|
54
|
+
// point, each one either the "go" a written exit already allows or the "dig"
|
|
55
|
+
// that would open one.
|
|
56
|
+
//
|
|
57
|
+
// The burrow survey is ONE svg routine (burrowSvg over burrowGraph) drawn
|
|
58
|
+
// twice: the omniscient board in the top row, and each pane's own
|
|
59
|
+
// visited-only "known ground". Same layout, same treatment, one code path —
|
|
60
|
+
// adapted from adventure-viz.mjs's own roomMapSvg/visitedRoomGraph pair, with
|
|
61
|
+
// a cell-collision nudge burrowGraph needs and that page does not (a mud
|
|
62
|
+
// character can dig down AND south out of one room, and both land on the
|
|
63
|
+
// same grid cell otherwise).
|
|
64
|
+
//
|
|
65
|
+
// ONE ticker instance per pane (viz-ticker.mjs's createTicker), so
|
|
66
|
+
// each can play/step independently, plus the deck's own "play" control that
|
|
67
|
+
// calls .play()/.pause() on both pane tickers at once. Nothing plays until
|
|
68
|
+
// that control is clicked. Every tick, from ANY pane, is funneled through one
|
|
69
|
+
// shared async queue (serializeTick, spliced below) so two characters' turns
|
|
70
|
+
// can never interleave their reads/writes of the one shared memoryDir —
|
|
71
|
+
// mud-browser-entry.mjs's own header names this as the caller's
|
|
72
|
+
// responsibility, and this is where that responsibility is discharged. The
|
|
73
|
+
// queue is also what makes the deck's GLOBAL turn counter well-defined: it
|
|
74
|
+
// increments in the exact order turns actually executed, never a race
|
|
75
|
+
// between panes.
|
|
76
|
+
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
|
|
77
|
+
import { createTicker } from "./viz-ticker.mjs";
|
|
78
|
+
import {
|
|
79
|
+
roomSceneObjects, scenePlacement, spriteClassForObject, spriteAncestryRows, factsForSubject,
|
|
80
|
+
visibleRoomOf, roomKindForRoom, allRoomIds,
|
|
81
|
+
} from "./adventure-viz.mjs";
|
|
82
|
+
import { renderMudEditorText, wordBeforeCursor } from "./mud-editor.mjs";
|
|
83
|
+
import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
|
|
84
|
+
|
|
85
|
+
const DEFAULT_TITLE = "tmct — the mud";
|
|
86
|
+
// No display-face embedding pipeline exists anywhere in this project yet
|
|
87
|
+
// (grep turns up no @font-face/font-display in any *-viz.mjs) — Fraunces
|
|
88
|
+
// itself never ships, so DISPLAY_STACK is SERIF_STACK's own web-safe serif
|
|
89
|
+
// fallback, named separately only so a later session that DOES add a font
|
|
90
|
+
// pipeline has one obvious constant to point at Fraunces instead of typing
|
|
91
|
+
// "Georgia" into headings by hand.
|
|
92
|
+
const DISPLAY_STACK = SERIF_STACK;
|
|
93
|
+
const SANS_STACK = `"IBM Plex Sans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif`;
|
|
94
|
+
|
|
95
|
+
const ROOT_ROOM = "garden";
|
|
96
|
+
const PANE_SLOTS = ["a", "b", "c", "d"];
|
|
97
|
+
// The players slider's own detents. Every count here divides the two-column
|
|
98
|
+
// grid evenly, so no layout ever carries a half-empty row.
|
|
99
|
+
const PLAYER_COUNTS = [1, 2, 4];
|
|
100
|
+
const DEFAULT_PLAYER_COUNT = 2;
|
|
101
|
+
// The npcs slider is a plain count, not a detent list: nothing lays an NPC
|
|
102
|
+
// out, so every number between its ends is as good as any other.
|
|
103
|
+
const NPC_COUNT_MIN = 1;
|
|
104
|
+
const NPC_COUNT_MAX = 10;
|
|
105
|
+
const NPC_COUNT_LABELLED = [1, 5, 10];
|
|
106
|
+
const DEFAULT_NPC_COUNT = 2;
|
|
107
|
+
const DEFAULT_DELAY_MS = 650;
|
|
108
|
+
const DEFAULT_MAX_TURNS = 400;
|
|
109
|
+
|
|
110
|
+
const MUD_NOTE_LINES = [
|
|
111
|
+
"Burrowing animals share one world here. The players slider picks how many get a window of their own; the npcs slider adds more animals that dig and forage without one. Each one only knows what it has dug up, asked about, or been told. Nobody sees the whole burrow except you, watching from the survey above.",
|
|
112
|
+
`This is a MUD, short for Multi Underground creature Dig. The name nods to MUD, or in its current form MUDII (mudii.co.uk), one of the first multiplayer text games. The dig-your-own-rooms idea came from a skim of Wikipedia's Colossal Cave Adventure article, the game that started the genre.`,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
|
|
116
|
+
* "groundhog" — mirroring spider-fly-viz.mjs's own classOfAgentId. Self-
|
|
117
|
+
* contained, `.toString()`-splice safe. */
|
|
118
|
+
export function speciesOfCharacter(id) {
|
|
119
|
+
return String(id).replace(/-\d+$/, "");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Every object visible in `here` EXCEPT `viewer` itself — `roomSceneObjects`
|
|
123
|
+
* (adventure-viz.mjs) only ever excludes the literal id "player", which no
|
|
124
|
+
* mud-garden character is ever named, so every OTHER character sharing the
|
|
125
|
+
* room (and every loose object) comes back exactly like any prop; this page
|
|
126
|
+
* draws the viewer's own sprite separately, so it is the one subject this
|
|
127
|
+
* wrapper drops. Pure, self-contained (roomSceneObjects is spliced
|
|
128
|
+
* alongside it). */
|
|
129
|
+
export function mudRoomSceneObjects(rows, state, here, viewer) {
|
|
130
|
+
return roomSceneObjects(rows, state, here).filter((o) => o.subject !== viewer);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Every object `character` carries, sorted, each with its sprite class —
|
|
134
|
+
* mud's own version of adventure-viz.mjs's carriedItems, parametrized by
|
|
135
|
+
* character instead of hardcoded to "player" (mud-garden ships no such
|
|
136
|
+
* individual). Pure, self-contained. */
|
|
137
|
+
export function carriedItemsFor(rows, state, character) {
|
|
138
|
+
return [...state.placements]
|
|
139
|
+
.filter(([, p]) => p.predicate === "mgx:located-in" && p.object === character)
|
|
140
|
+
.map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
|
|
141
|
+
.sort((a, b) => a.subject.localeCompare(b.subject));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Every room's depth level, `Map<roomId, number>`, BFS from `root` at level
|
|
145
|
+
* 0: an "up"/"down" exit moves the level by ∓ 1, any other direction
|
|
146
|
+
* keeps the level unchanged — what tells the survey where the turf line
|
|
147
|
+
* falls. A room unreachable from `root` (should not happen — every dug room
|
|
148
|
+
* writes a two-way exit back) is simply absent from the map rather than
|
|
149
|
+
* guessed at. Pure, self-contained. */
|
|
150
|
+
export function levelsOf(state, root = "garden") {
|
|
151
|
+
const levels = new Map([[root, 0]]);
|
|
152
|
+
const queue = [root];
|
|
153
|
+
while (queue.length) {
|
|
154
|
+
const room = queue.shift();
|
|
155
|
+
const level = levels.get(room);
|
|
156
|
+
for (const [direction, target] of state.exits.get(room) ?? []) {
|
|
157
|
+
if (levels.has(target)) continue;
|
|
158
|
+
const delta = direction === "down" ? -1 : direction === "up" ? 1 : 0;
|
|
159
|
+
levels.set(target, level + delta);
|
|
160
|
+
queue.push(target);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return levels;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Which characters currently stand in `room` — the survey's own per-room
|
|
167
|
+
* roster, both for the omniscient board and a pane's own visited-only one.
|
|
168
|
+
* Pure. */
|
|
169
|
+
export function charactersInRoom(state, room, characters) {
|
|
170
|
+
return characters.filter((c) => state.placements.get(c)?.object === room);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The rooms in `roomIds` laid out on an integer grid FROM the world's own
|
|
174
|
+
* has-exit-* directions — never a force-directed guess, so a room north of
|
|
175
|
+
* another sits one row above it and a room dug DOWN sits one row below.
|
|
176
|
+
* Adapted from adventure-viz.mjs's `visitedRoomGraph`, with two differences
|
|
177
|
+
* a burrow needs and a manor does not: a cell already taken nudges right
|
|
178
|
+
* rather than stacking two rooms on one square (dig down and dig south from
|
|
179
|
+
* the same room otherwise collide), and every node carries its own `level`
|
|
180
|
+
* so the renderer can draw the turf line between the surface and the soil.
|
|
181
|
+
* An edge is drawn only between two rooms BOTH in `roomIds`; `hints` names
|
|
182
|
+
* every exit from an included room toward one that is not — the direction
|
|
183
|
+
* only, never the excluded room's own name, so a fog-of-war caller can draw
|
|
184
|
+
* "there's a way on" and nothing more. Pure. */
|
|
185
|
+
export function burrowGraph(state, roomIds, root = "garden") {
|
|
186
|
+
const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
|
|
187
|
+
const known = new Set(roomIds || []);
|
|
188
|
+
const positions = new Map();
|
|
189
|
+
const taken = new Set();
|
|
190
|
+
const edges = [];
|
|
191
|
+
const edgeKeys = new Set();
|
|
192
|
+
const hints = [];
|
|
193
|
+
const place = (room, x, y) => {
|
|
194
|
+
let px = x;
|
|
195
|
+
for (let guard = 0; taken.has(px + "," + y) && guard < 64; guard += 1) px += 1;
|
|
196
|
+
positions.set(room, { x: px, y });
|
|
197
|
+
taken.add(px + "," + y);
|
|
198
|
+
};
|
|
199
|
+
let offsetX = 0;
|
|
200
|
+
for (const start of [root, ...[...known].sort()]) {
|
|
201
|
+
if (!known.has(start) || positions.has(start)) continue;
|
|
202
|
+
place(start, offsetX, 0);
|
|
203
|
+
const queue = [start];
|
|
204
|
+
while (queue.length) {
|
|
205
|
+
const room = queue.shift();
|
|
206
|
+
const pos = positions.get(room);
|
|
207
|
+
const dirs = state.exits.get(room);
|
|
208
|
+
for (const direction of [...(dirs?.keys() ?? [])].sort()) {
|
|
209
|
+
const target = dirs.get(direction);
|
|
210
|
+
if (!known.has(target)) { hints.push({ from: room, direction }); continue; }
|
|
211
|
+
const key = [room, target].sort().join("\0");
|
|
212
|
+
if (!edgeKeys.has(key)) { edgeKeys.add(key); edges.push({ from: room, to: target, direction }); }
|
|
213
|
+
if (positions.has(target)) continue;
|
|
214
|
+
const d = DELTA[direction] ?? [0, 0];
|
|
215
|
+
place(target, pos.x + d[0], pos.y + d[1]);
|
|
216
|
+
queue.push(target);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
offsetX = Math.max(...[...positions.values()].map((p) => p.x)) + 2;
|
|
220
|
+
}
|
|
221
|
+
const levels = levelsOf(state, root);
|
|
222
|
+
const xs = [...positions.values()].map((p) => p.x);
|
|
223
|
+
const ys = [...positions.values()].map((p) => p.y);
|
|
224
|
+
const minX = Math.min(0, ...xs);
|
|
225
|
+
const minY = Math.min(0, ...ys);
|
|
226
|
+
const nodes = [...known].filter((room) => positions.has(room)).sort().map((room) => {
|
|
227
|
+
const p = positions.get(room);
|
|
228
|
+
return { id: room, x: p.x - minX, y: p.y - minY, level: levels.has(room) ? levels.get(room) : null };
|
|
229
|
+
});
|
|
230
|
+
return { nodes, edges, hints };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The directions `here` can still be dug in: every direction the room's kind
|
|
234
|
+
* allows that has no written exit yet. Above ground there is nothing to
|
|
235
|
+
* tunnel sideways THROUGH, so a garden digs straight down and no other way;
|
|
236
|
+
* underground, all six are open soil. The engine is the authority on this —
|
|
237
|
+
* the page prefers `tmctMud.diggableDirections` whenever the bundle exposes
|
|
238
|
+
* one and only falls back to the rule below. Pure, self-contained
|
|
239
|
+
* (roomKindForRoom is spliced alongside it). */
|
|
240
|
+
export function diggableDirections(rows, state, here) {
|
|
241
|
+
const exits = state.exits.get(here);
|
|
242
|
+
const kind = roomKindForRoom(rows, here);
|
|
243
|
+
const allowed = kind === "outdoor" ? ["down"] : ["north", "south", "east", "west", "up", "down"];
|
|
244
|
+
return allowed.filter((direction) => !exits || !exits.has(direction));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A carried or loose object's display name. A dug object is minted as
|
|
248
|
+
* `<kind>-<the full nested room id it was dug from>` (adventure.mjs's
|
|
249
|
+
* freshRoomId), which reads as a path, not a thing — so a subject that ends
|
|
250
|
+
* in "-<a room this world actually has>" shows as its kind alone. Every
|
|
251
|
+
* other id (the world's hand-authored props: "carrot", "worm-1") is already
|
|
252
|
+
* a name and passes straight through. Pure. */
|
|
253
|
+
export function itemLabel(subject, roomIds) {
|
|
254
|
+
const id = String(subject);
|
|
255
|
+
for (const room of roomIds || []) {
|
|
256
|
+
const suffix = "-" + room;
|
|
257
|
+
if (id.length > suffix.length && id.endsWith(suffix)) return id.slice(0, -suffix.length);
|
|
258
|
+
}
|
|
259
|
+
return id;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Whether `subject` is a creature rather than a prop: the world places a
|
|
263
|
+
* character with `mgx:currently-in` and everything else with a containment
|
|
264
|
+
* predicate, the same split mud-turn.mjs's own "what is there to examine"
|
|
265
|
+
* filter reads. Pure — so the room view can put animals on the floor and
|
|
266
|
+
* props on the wall without a roster to check against, and a character the
|
|
267
|
+
* page is not itself driving (an NPC) still reads as an animal. */
|
|
268
|
+
export function isCreature(state, subject) {
|
|
269
|
+
return state.placements.get(subject)?.predicate === "mgx:currently-in";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The self-contained mud.html page. Pure — identical output for identical
|
|
273
|
+
* input; every other piece of state (the live world, chat, ticks) is
|
|
274
|
+
* computed in the browser once the sibling bundle loads.
|
|
275
|
+
* `characters` is the ROSTER this page may draw from, `[{ id, species }]`;
|
|
276
|
+
* the page ships the default cast's panes and rebuilds the whole stage at
|
|
277
|
+
* every boot, so how many of the roster play, and which of them, is decided
|
|
278
|
+
* live by the players slider and the engine's own pickMudRoster — the same
|
|
279
|
+
* draw the shared session is then opened for, never a second one. A count
|
|
280
|
+
* the roster cannot fill draws as many panes as it has animals.
|
|
281
|
+
* `worldPayload` is `{ name, facts, rules, opening }`, read once at build
|
|
282
|
+
* time through the real worlds-pack provider (see this module's own
|
|
283
|
+
* header). `spriteTemplates` is the
|
|
284
|
+
* large-tier sprite set (data/sprites-large/*.toml) so every species
|
|
285
|
+
* resolves its own art instead of falling back to the flat animal icon.
|
|
286
|
+
* `mudConfig` defaults to game-config.mjs's own DEFAULT_GAME_CONFIG.mud —
|
|
287
|
+
* the per-species mass/speed/dig-reach reference table each pane's stat line
|
|
288
|
+
* reads for flavor. `engineBundleJs` inlines the built mud-browser bundle
|
|
289
|
+
* instead of the sibling `<script src>`, mirroring spider-fly-viz.mjs's own
|
|
290
|
+
* standalone-export knob; default empty keeps the site build's sibling-file
|
|
291
|
+
* arrangement unchanged. */
|
|
292
|
+
export function renderMudHtml({
|
|
293
|
+
title = DEFAULT_TITLE,
|
|
294
|
+
worldPayload,
|
|
295
|
+
characters = [],
|
|
296
|
+
spriteTemplates = [],
|
|
297
|
+
mudConfig = DEFAULT_GAME_CONFIG.mud,
|
|
298
|
+
engineBundleJs = "",
|
|
299
|
+
} = {}) {
|
|
300
|
+
const openingCount = Math.max(1, Math.min(DEFAULT_PLAYER_COUNT, characters.length || DEFAULT_PLAYER_COUNT));
|
|
301
|
+
const pageData = embedJson({
|
|
302
|
+
worldPayload, characters, spriteTemplates, mudConfig,
|
|
303
|
+
paneSlots: PANE_SLOTS,
|
|
304
|
+
playerCounts: PLAYER_COUNTS,
|
|
305
|
+
defaultPlayerCount: DEFAULT_PLAYER_COUNT,
|
|
306
|
+
defaultNpcCount: DEFAULT_NPC_COUNT,
|
|
307
|
+
rootRoom: ROOT_ROOM,
|
|
308
|
+
defaultDelayMs: DEFAULT_DELAY_MS,
|
|
309
|
+
defaultMaxTurns: DEFAULT_MAX_TURNS,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const paneHtml = PANE_SLOTS.slice(0, openingCount).map((slot) => paneMarkup(slot)).join("\n");
|
|
313
|
+
|
|
314
|
+
return `<!doctype html>
|
|
315
|
+
<html lang="en">
|
|
316
|
+
<head>
|
|
317
|
+
<meta charset="utf-8">
|
|
318
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
319
|
+
<title>${escapeHtml(title)}</title>
|
|
320
|
+
${engineBundleJs ? "" : `<link rel="icon" href="./favicon.svg" type="image/svg+xml">
|
|
321
|
+
<link rel="icon" href="./favicon.ico" sizes="any">
|
|
322
|
+
<link rel="apple-touch-icon" href="./apple-touch-icon.png">`}
|
|
323
|
+
<style>
|
|
324
|
+
${THEME_TOKENS_CSS}
|
|
325
|
+
${MUD_STYLE}
|
|
326
|
+
</style>
|
|
327
|
+
</head>
|
|
328
|
+
<body>
|
|
329
|
+
<main>
|
|
330
|
+
<h1 class="eyebrow">tmct · mud</h1>
|
|
331
|
+
<div class="deck-row">
|
|
332
|
+
<section class="deck" aria-label="simulation controls">
|
|
333
|
+
<div class="deck-controls">
|
|
334
|
+
<button type="button" class="deck-play" id="autoToggle" aria-pressed="false">▶ play</button>
|
|
335
|
+
<button type="button" id="resetBtn">reset</button>
|
|
336
|
+
<button type="button" id="editModeBtn" aria-pressed="false">edit</button>
|
|
337
|
+
<button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
|
|
338
|
+
<span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
|
|
339
|
+
</div>
|
|
340
|
+
<div class="deck-sliders">
|
|
341
|
+
<label class="deck-slider">players
|
|
342
|
+
<input type="range" id="playerCountSlider" min="0" max="${PLAYER_COUNTS.length - 1}" step="1"
|
|
343
|
+
value="${Math.max(0, PLAYER_COUNTS.indexOf(DEFAULT_PLAYER_COUNT))}"
|
|
344
|
+
list="playerCountTicks" aria-valuetext="${DEFAULT_PLAYER_COUNT} players">
|
|
345
|
+
<datalist id="playerCountTicks">${PLAYER_COUNTS.map((n, i) => `<option value="${i}" label="${n}"></option>`).join("")}</datalist>
|
|
346
|
+
<span class="mono" id="playerCountValue">${DEFAULT_PLAYER_COUNT}</span>
|
|
347
|
+
</label>
|
|
348
|
+
<label class="deck-slider">npcs
|
|
349
|
+
<input type="range" id="npcCountSlider" min="${NPC_COUNT_MIN}" max="${NPC_COUNT_MAX}" step="1"
|
|
350
|
+
value="${DEFAULT_NPC_COUNT}"
|
|
351
|
+
list="npcCountTicks" aria-valuetext="${DEFAULT_NPC_COUNT} npcs">
|
|
352
|
+
<datalist id="npcCountTicks">${Array.from({ length: NPC_COUNT_MAX - NPC_COUNT_MIN + 1 }, (_, i) => {
|
|
353
|
+
const n = NPC_COUNT_MIN + i;
|
|
354
|
+
return NPC_COUNT_LABELLED.includes(n) ? `<option value="${n}" label="${n}"></option>` : `<option value="${n}"></option>`;
|
|
355
|
+
}).join("")}</datalist>
|
|
356
|
+
<span class="mono" id="npcCountValue">${DEFAULT_NPC_COUNT}</span>
|
|
357
|
+
</label>
|
|
358
|
+
<label class="deck-slider">delay
|
|
359
|
+
<input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
|
|
360
|
+
<span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
|
|
361
|
+
</label>
|
|
362
|
+
<label class="deck-slider">max turns
|
|
363
|
+
<input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
|
|
364
|
+
<span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
|
|
365
|
+
</label>
|
|
366
|
+
</div>
|
|
367
|
+
<div class="deck-info-popup mud-note" id="deckInfoPopup" role="dialog" aria-label="about this demo" hidden>
|
|
368
|
+
<p>${escapeHtml(MUD_NOTE_LINES[0])}</p>
|
|
369
|
+
<p>${escapeHtml(MUD_NOTE_LINES[1])}</p>
|
|
370
|
+
<button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">×</button>
|
|
371
|
+
</div>
|
|
372
|
+
</section>
|
|
373
|
+
<section class="world-map" id="worldMap" aria-label="the whole burrow, every room, every character">
|
|
374
|
+
<div class="world-map-head">
|
|
375
|
+
<span class="world-map-title">the whole burrow</span>
|
|
376
|
+
<span class="mono world-map-turn" id="worldMapTurn">turn 0</span>
|
|
377
|
+
</div>
|
|
378
|
+
<div class="world-map-board" id="worldMapBoard"></div>
|
|
379
|
+
<div class="world-map-key" id="worldMapKey"></div>
|
|
380
|
+
</section>
|
|
381
|
+
</div>
|
|
382
|
+
<div class="mud-stage" id="mudStage" data-panes="${openingCount}">
|
|
383
|
+
${paneHtml}
|
|
384
|
+
</div>
|
|
385
|
+
<div class="edit-stage" id="mudEditStage" aria-label="the burrow's own facts, in plain sentences">
|
|
386
|
+
<section class="edit-text" aria-label="the world's facts as editable sentences">
|
|
387
|
+
<h2>the burrow, in plain sentences</h2>
|
|
388
|
+
<p class="edit-lede">Every fact this world is built from. Change a line and the burrow changes with it — the survey beside you redraws as you type.</p>
|
|
389
|
+
<textarea id="editorText" spellcheck="false" aria-label="the world's own facts as plain sentences, one per line"></textarea>
|
|
390
|
+
<div class="chatpills" id="editorPills" aria-label="related words for the term before the cursor"></div>
|
|
391
|
+
<p class="edit-status" id="editorStatus" role="status"></p>
|
|
392
|
+
</section>
|
|
393
|
+
<aside class="edit-side" aria-label="the burrow as written, a clicked room, and the legend">
|
|
394
|
+
<section class="world-map edit-map" aria-label="every room this world writes">
|
|
395
|
+
<div class="world-map-head"><span class="world-map-title">the burrow as written</span></div>
|
|
396
|
+
<div class="world-map-board" id="editMapBoard"></div>
|
|
397
|
+
</section>
|
|
398
|
+
<section class="edit-panel" id="roomDetailPanel">
|
|
399
|
+
<h2 id="roomDetailTitle">click a room</h2>
|
|
400
|
+
<div class="room-detail-cast" id="roomDetailCast"></div>
|
|
401
|
+
<p class="edit-caption" id="roomDetailCaption">Click a room on the survey to see what stands in it.</p>
|
|
402
|
+
</section>
|
|
403
|
+
<section class="edit-panel">
|
|
404
|
+
<h2>legend</h2>
|
|
405
|
+
<div class="edit-legend" id="editLegend"></div>
|
|
406
|
+
</section>
|
|
407
|
+
</aside>
|
|
408
|
+
</div>
|
|
409
|
+
</main>
|
|
410
|
+
<script>
|
|
411
|
+
const MUD_PAGE_DATA = ${pageData};
|
|
412
|
+
</script>
|
|
413
|
+
${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `<script src="./mud-browser.bundle.js"></script>`}
|
|
414
|
+
<script>
|
|
415
|
+
${embedScriptText(pageScript())}
|
|
416
|
+
</script>
|
|
417
|
+
</body>
|
|
418
|
+
</html>
|
|
419
|
+
`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** One pane, character-agnostic: which animal it shows is stamped in at boot
|
|
423
|
+
* (`data-character`, the heading, the chat dock's own labels), so the page
|
|
424
|
+
* can draw a different cast out of the roster on every reset without a
|
|
425
|
+
* rebuild. Pure and self-contained (escapeHtml is spliced alongside it) —
|
|
426
|
+
* the page script calls this same function to build the stage in the
|
|
427
|
+
* browser, so the panes a count change adds are the panes this file ships. */
|
|
428
|
+
export function paneMarkup(slot) {
|
|
429
|
+
const w = `window-${slot}`;
|
|
430
|
+
return ` <section class="mud-window pane-${escapeHtml(slot)}" id="${w}" data-slot="${escapeHtml(slot)}" data-character="">
|
|
431
|
+
<div class="pane-head">
|
|
432
|
+
<h2 id="${w}-name">waiting</h2>
|
|
433
|
+
<span class="mono pane-turn" id="${w}-turn">turn 0</span>
|
|
434
|
+
</div>
|
|
435
|
+
<div class="room-stage">
|
|
436
|
+
<div class="room-view" id="${w}-room">
|
|
437
|
+
<canvas id="${w}-canvas" width="420" height="152" aria-hidden="true"></canvas>
|
|
438
|
+
<div class="wall-band" id="${w}-wall"></div>
|
|
439
|
+
<div class="floor-band">
|
|
440
|
+
<div class="floor-others" id="${w}-others"></div>
|
|
441
|
+
<div class="floor-self" id="${w}-self"></div>
|
|
442
|
+
</div>
|
|
443
|
+
<div class="bubbles" id="${w}-bubbles"></div>
|
|
444
|
+
<div class="dig-flourish" id="${w}-flourish" hidden></div>
|
|
445
|
+
<div class="strike-flash" id="${w}-strike" aria-hidden="true"></div>
|
|
446
|
+
</div>
|
|
447
|
+
<div class="dir-ring" id="${w}-dirs" aria-label="ways out of this room">
|
|
448
|
+
<div class="dir-slot dir-north" id="${w}-dir-north"></div>
|
|
449
|
+
<div class="dir-slot dir-up" id="${w}-dir-up"></div>
|
|
450
|
+
<div class="dir-slot dir-west" id="${w}-dir-west"></div>
|
|
451
|
+
<div class="dir-slot dir-east" id="${w}-dir-east"></div>
|
|
452
|
+
<div class="dir-slot dir-south" id="${w}-dir-south"></div>
|
|
453
|
+
<div class="dir-slot dir-down" id="${w}-dir-down"></div>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
<p class="room-caption" id="${w}-caption"></p>
|
|
457
|
+
<div class="pane-columns">
|
|
458
|
+
<div class="pouch" aria-label="pouch">
|
|
459
|
+
<h3>pouch</h3>
|
|
460
|
+
<ul class="pouch-list" id="${w}-pouch-list"></ul>
|
|
461
|
+
<p class="mono stat-line" id="${w}-stats"></p>
|
|
462
|
+
</div>
|
|
463
|
+
<div class="minimap" aria-label="what this character has dug or walked">
|
|
464
|
+
<h3>known ground</h3>
|
|
465
|
+
<div class="minimap-board" id="${w}-minimap"></div>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
<div class="chat">
|
|
469
|
+
<div class="chatlog" id="${w}-chatlog" aria-live="polite"></div>
|
|
470
|
+
<div class="log-popup" id="${w}-logpop" role="dialog" aria-label="the whole reply" hidden>
|
|
471
|
+
<p class="log-popup-text" id="${w}-logpop-text"></p>
|
|
472
|
+
<button type="button" class="log-popup-close" id="${w}-logpop-close" aria-label="close the whole reply">×</button>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
<div class="chat-console">
|
|
476
|
+
<div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands"></div>
|
|
477
|
+
<form class="chatask" id="${w}-chatform">
|
|
478
|
+
<span class="prompt mono">tmct></span>
|
|
479
|
+
<input id="${w}-chatq" type="text" placeholder="look" aria-label="type a command" disabled>
|
|
480
|
+
</form>
|
|
481
|
+
</div>
|
|
482
|
+
<div class="pane-controls">
|
|
483
|
+
<button type="button" id="${w}-play" disabled>▶ play</button>
|
|
484
|
+
<button type="button" id="${w}-step" disabled>step</button>
|
|
485
|
+
<p class="pane-fate" id="${w}-fate" role="status" hidden></p>
|
|
486
|
+
</div>
|
|
487
|
+
</section>`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const MUD_STYLE = `
|
|
491
|
+
:root {
|
|
492
|
+
--soil-deep: #241710; --soil-mid: #4A3324; --soil-light: #7A5A3D;
|
|
493
|
+
--root-moss: #6B7A4F; --parchment: #EFE6D8; --mud-ink: #2A211A;
|
|
494
|
+
--burrow-glow: #E8A33D; --chalk: #D9CDB9;
|
|
495
|
+
/* Four hues that stay apart at the size an occupant dot is drawn, and all
|
|
496
|
+
four sit on the dark soil board as well as on parchment. Nothing green
|
|
497
|
+
enough to sink into a turf-filled surface room. */
|
|
498
|
+
--actor-a: #E0912A; --actor-b: #5F97B3; --actor-c: #BFC85F; --actor-d: #AE749E;
|
|
499
|
+
--pane-height: 618px;
|
|
500
|
+
--room-height: 152px;
|
|
501
|
+
}
|
|
502
|
+
html { background: var(--soil-deep); }
|
|
503
|
+
body { margin: 0; background: linear-gradient(180deg, var(--root-moss) 0%, var(--soil-light) 14%, var(--soil-mid) 48%, var(--soil-deep) 100%) fixed; color: var(--mud-ink); font-family: ${SANS_STACK}; font-size: 15px; line-height: 1.5; }
|
|
504
|
+
.mono { font-family: ${MONO_STACK}; }
|
|
505
|
+
main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
|
|
506
|
+
.eyebrow { font-family: ${MONO_STACK}; font-weight: 500; font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--parchment); opacity: .9; margin: 0 0 .8rem; }
|
|
507
|
+
h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; text-transform: capitalize; }
|
|
508
|
+
h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--soil-mid); }
|
|
509
|
+
button { font: inherit; color: inherit; background: none; cursor: pointer; }
|
|
510
|
+
button:focus-visible, input:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 2px; }
|
|
511
|
+
button:disabled { opacity: .4; cursor: default; }
|
|
512
|
+
|
|
513
|
+
/* ---- top row: the control deck, and the survey beside it ---- */
|
|
514
|
+
.deck-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: stretch; margin-bottom: 1rem; }
|
|
515
|
+
.deck {
|
|
516
|
+
position: relative;
|
|
517
|
+
background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
|
|
518
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
|
|
519
|
+
padding: .8rem .9rem; display: flex; flex-direction: column; gap: .55rem; min-width: 0;
|
|
520
|
+
}
|
|
521
|
+
.deck-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
|
522
|
+
.deck-info-btn {
|
|
523
|
+
font-family: ${MONO_STACK}; font-size: .78rem; line-height: 1; width: 1.5rem; height: 1.5rem;
|
|
524
|
+
border-radius: 50%; border: 1px solid var(--soil-mid); background: rgba(255,255,255,.5);
|
|
525
|
+
color: var(--soil-mid); padding: 0; flex: 0 0 auto;
|
|
526
|
+
}
|
|
527
|
+
.deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { border-color: var(--burrow-glow); color: var(--mud-ink); }
|
|
528
|
+
.deck button, .pane-controls button {
|
|
529
|
+
font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
|
|
530
|
+
padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px; background: rgba(255,255,255,.5);
|
|
531
|
+
}
|
|
532
|
+
.deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled) { border-color: var(--burrow-glow); }
|
|
533
|
+
.deck-play { background: var(--mud-ink) !important; color: var(--parchment); border-color: var(--mud-ink) !important; padding: .38rem 1.1rem !important; }
|
|
534
|
+
.deck-play[aria-pressed="true"] { background: var(--burrow-glow) !important; border-color: var(--burrow-glow) !important; color: var(--mud-ink); }
|
|
535
|
+
.deck-turns { margin-left: auto; font-size: .74rem; color: var(--soil-mid); }
|
|
536
|
+
.deck-sliders { display: flex; flex-wrap: wrap; gap: 1rem; }
|
|
537
|
+
.deck-slider { display: flex; align-items: center; gap: .35rem; font-family: ${MONO_STACK}; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em; color: var(--soil-mid); }
|
|
538
|
+
.deck-slider input[type="range"] { accent-color: var(--burrow-glow); width: 8rem; max-width: 34vw; }
|
|
539
|
+
.deck-info-popup {
|
|
540
|
+
position: absolute; left: .9rem; right: .9rem; top: calc(100% + 8px); z-index: 8;
|
|
541
|
+
background: var(--soil-deep); color: var(--parchment);
|
|
542
|
+
border: 1px solid var(--burrow-glow); border-radius: 4px;
|
|
543
|
+
padding: .55rem 1.6rem .6rem .65rem; box-shadow: 0 8px 18px rgba(0,0,0,.5);
|
|
544
|
+
animation: popup-rise .16s ease-out;
|
|
545
|
+
}
|
|
546
|
+
.deck-info-popup::before {
|
|
547
|
+
content: ""; position: absolute; left: 1.1rem; top: -6px; width: 0; height: 0;
|
|
548
|
+
border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid var(--burrow-glow);
|
|
549
|
+
}
|
|
550
|
+
.deck-info-popup p { margin: 0 0 .4rem; max-width: 62ch; font-size: .78rem; line-height: 1.4; }
|
|
551
|
+
.deck-info-popup p:last-child { margin-bottom: 0; }
|
|
552
|
+
.deck-info-popup-close { position: absolute; top: .1rem; right: .3rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
|
|
553
|
+
.deck-info-popup-close:hover { color: var(--burrow-glow); }
|
|
554
|
+
@media (prefers-reduced-motion: reduce) { .deck-info-popup { animation: none; } }
|
|
555
|
+
|
|
556
|
+
.world-map {
|
|
557
|
+
background: var(--soil-deep); color: var(--parchment);
|
|
558
|
+
border: 1px solid var(--burrow-glow); border-radius: 4px; padding: .55rem .65rem .6rem;
|
|
559
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.3); display: flex; flex-direction: column; gap: .4rem; min-width: 0;
|
|
560
|
+
}
|
|
561
|
+
.world-map-head { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
|
|
562
|
+
.world-map-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
|
|
563
|
+
.world-map-turn { font-size: .62rem; opacity: .7; }
|
|
564
|
+
.world-map-board { flex: 1; min-height: 168px; display: flex; align-items: center; justify-content: center; }
|
|
565
|
+
.world-map-board svg { width: 100%; height: 100%; max-height: 230px; }
|
|
566
|
+
.world-map-key { display: flex; flex-wrap: wrap; gap: .5rem; font-family: ${MONO_STACK}; font-size: .58rem; opacity: .85; }
|
|
567
|
+
.key-actor, .key-npcs { display: inline-flex; align-items: center; gap: .28rem; }
|
|
568
|
+
.key-npcs { opacity: .75; }
|
|
569
|
+
.key-dot { width: .5rem; height: .5rem; border-radius: 50%; display: inline-block; }
|
|
570
|
+
|
|
571
|
+
/* ---- the survey itself, drawn twice at two sizes ---- */
|
|
572
|
+
.burrow svg { display: block; }
|
|
573
|
+
.burrow .turf { fill: var(--root-moss); opacity: .3; }
|
|
574
|
+
.burrow .ground-line { stroke: var(--root-moss); stroke-width: 1.2; stroke-dasharray: 5 3; opacity: .9; }
|
|
575
|
+
.burrow .tunnel { stroke: var(--soil-light); stroke-width: 5.5; stroke-linecap: round; opacity: .85; }
|
|
576
|
+
.burrow .tunnel.shaft { stroke-dasharray: 3 3.5; stroke-width: 4; }
|
|
577
|
+
.burrow .hint { fill: var(--chalk); opacity: .45; }
|
|
578
|
+
.burrow .room rect { fill: var(--soil-mid); stroke: var(--chalk); stroke-width: .8; }
|
|
579
|
+
.burrow .room.surface rect { fill: var(--root-moss); }
|
|
580
|
+
.burrow .room text { fill: var(--parchment); font-family: ${MONO_STACK}; font-size: 7px; text-anchor: middle; }
|
|
581
|
+
.burrow .room.freshly-dug rect { stroke: var(--burrow-glow); stroke-width: 1.8; animation: dig-pulse 1.2s ease-out; }
|
|
582
|
+
.burrow .occupant { stroke: rgba(0,0,0,.45); stroke-width: .6; }
|
|
583
|
+
@keyframes dig-pulse { 0% { opacity: .25; } 100% { opacity: 1; } }
|
|
584
|
+
@media (prefers-reduced-motion: reduce) { .burrow .room.freshly-dug rect { animation: none; } }
|
|
585
|
+
|
|
586
|
+
/* ---- the panes ----
|
|
587
|
+
Two columns hold every count the players slider offers: four fills them
|
|
588
|
+
twice over, two fills them once. One is the exception, and it earns it —
|
|
589
|
+
a lone animal takes a single centred column at a reading measure and
|
|
590
|
+
spends the width it saves on a taller room and a longer log, so the page
|
|
591
|
+
reads as one portrait rather than one pane marooned in an empty grid. */
|
|
592
|
+
.mud-stage { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; align-items: start; }
|
|
593
|
+
.mud-stage[data-panes="1"] {
|
|
594
|
+
grid-template-columns: minmax(0, 46rem); justify-content: center;
|
|
595
|
+
--pane-height: 700px; --room-height: 196px;
|
|
596
|
+
}
|
|
597
|
+
.mud-window {
|
|
598
|
+
background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
|
|
599
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
|
|
600
|
+
padding: .7rem .8rem; display: flex; flex-direction: column; gap: .5rem;
|
|
601
|
+
min-width: 0; height: var(--pane-height); box-sizing: border-box; overflow: hidden;
|
|
602
|
+
border-top: 3px solid var(--actor-a);
|
|
603
|
+
}
|
|
604
|
+
.mud-window.pane-b { border-top-color: var(--actor-b); }
|
|
605
|
+
.mud-window.pane-c { border-top-color: var(--actor-c); }
|
|
606
|
+
.mud-window.pane-d { border-top-color: var(--actor-d); }
|
|
607
|
+
.pane-head { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; }
|
|
608
|
+
.char-id { font-size: .66rem; color: var(--soil-mid); margin-left: .35rem; text-transform: none; }
|
|
609
|
+
.pane-turn { font-size: .68rem; color: var(--soil-mid); }
|
|
610
|
+
|
|
611
|
+
/* ---- room view: one row of soil, the compass around it ---- */
|
|
612
|
+
.room-stage { position: relative; padding: 19px 34px; flex: 0 0 auto; }
|
|
613
|
+
.room-view { position: relative; height: var(--room-height); border-radius: 3px; overflow: hidden; border: 1px solid var(--soil-mid); }
|
|
614
|
+
.room-view canvas { display: block; width: 100%; height: 100%; }
|
|
615
|
+
.wall-band { position: absolute; left: 0; right: 0; top: 4px; height: 62px; display: flex; align-items: flex-start; justify-content: center; gap: .5rem; }
|
|
616
|
+
.wall-item { display: flex; flex-direction: column; align-items: center; min-width: 34px; max-width: 72px; }
|
|
617
|
+
.wall-item .hook { width: 1px; height: 5px; background: rgba(0,0,0,.45); }
|
|
618
|
+
.wall-item .sprite-frame { width: 30px; padding: 2px; box-sizing: border-box; background: rgba(239,230,216,.85); border: 1px solid var(--soil-deep); border-radius: 1px; box-shadow: 0 1px 2px rgba(0,0,0,.35); }
|
|
619
|
+
.wall-item.hangs-high { margin-top: 0; }
|
|
620
|
+
.wall-item.hangs-low { margin-top: 14px; }
|
|
621
|
+
.wall-item svg { width: 100%; display: block; }
|
|
622
|
+
.floor-band { position: absolute; left: 0; right: 0; bottom: 0; height: 86px; display: flex; align-items: flex-end; justify-content: space-between; padding: 0 6px 4px; box-sizing: border-box; }
|
|
623
|
+
/* A room can hold a dozen animals once the npcs slider is up, and the floor
|
|
624
|
+
is one row wide either way — so the crowd shares the row it has instead of
|
|
625
|
+
the surplus running off the edge under the overflow clip. --mate-width is
|
|
626
|
+
set per redraw from the room's own measured width. */
|
|
627
|
+
.floor-others { display: flex; align-items: flex-end; gap: 6px; min-width: 0; }
|
|
628
|
+
.floor-others .sprite-card { max-width: var(--mate-width, 76px); }
|
|
629
|
+
.floor-others .sprite { width: min(40px, var(--mate-width, 40px)); }
|
|
630
|
+
.sprite { width: 40px; }
|
|
631
|
+
.sprite svg { width: 100%; display: block; }
|
|
632
|
+
.floor-self .sprite { width: 48px; }
|
|
633
|
+
|
|
634
|
+
/* ---- sprite cards: adventure.html's own frame-plus-nameplate treatment,
|
|
635
|
+
shrunk to the one soil row a mud pane has for it. The plate names the
|
|
636
|
+
real subject (mole-1, carrot), never a class word. ---- */
|
|
637
|
+
.sprite-card { display: flex; flex-direction: column; align-items: center; max-width: 76px; }
|
|
638
|
+
.sprite-label {
|
|
639
|
+
margin-top: 2px; max-width: 100%; box-sizing: border-box;
|
|
640
|
+
font-family: ${MONO_STACK}; font-size: .5rem; line-height: 1.25; letter-spacing: .01em; text-align: center;
|
|
641
|
+
color: var(--mud-ink); background: rgba(239,230,216,.92); border: 1px solid var(--soil-mid); border-radius: 2px;
|
|
642
|
+
padding: 0 .22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
643
|
+
}
|
|
644
|
+
.sprite-mass {
|
|
645
|
+
margin-top: 1px; font-family: ${MONO_STACK}; font-size: .46rem; line-height: 1.3; letter-spacing: .02em;
|
|
646
|
+
color: var(--parchment); background: rgba(36,23,16,.72); border-radius: 2px; padding: 0 .2rem; white-space: nowrap;
|
|
647
|
+
}
|
|
648
|
+
.sprite-card.actionable { cursor: pointer; }
|
|
649
|
+
.sprite-card.actionable:hover .sprite-frame, .sprite-card.actionable:focus-visible .sprite-frame { border-color: var(--burrow-glow); }
|
|
650
|
+
.sprite-card.actionable:hover .sprite-label, .sprite-card.actionable:focus-visible .sprite-label { border-color: var(--burrow-glow); background: var(--burrow-glow); }
|
|
651
|
+
.sprite-card.actionable:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 1px; }
|
|
652
|
+
.wall-band .sprite-card.actionable { pointer-events: auto; }
|
|
653
|
+
|
|
654
|
+
/* ---- the compass ring: each way out sits where it points ---- */
|
|
655
|
+
.dir-ring { position: absolute; inset: 0; pointer-events: none; }
|
|
656
|
+
.dir-slot { position: absolute; pointer-events: auto; }
|
|
657
|
+
.dir-north { top: 0; left: 50%; transform: translateX(-102%); }
|
|
658
|
+
.dir-up { top: 0; left: 50%; transform: translateX(6px); }
|
|
659
|
+
.dir-south { bottom: 0; left: 50%; transform: translateX(-102%); }
|
|
660
|
+
.dir-down { bottom: 0; left: 50%; transform: translateX(6px); }
|
|
661
|
+
.dir-west { left: 0; top: 50%; transform: translateY(-50%); }
|
|
662
|
+
.dir-east { right: 0; top: 50%; transform: translateY(-50%); }
|
|
663
|
+
.dir-pill {
|
|
664
|
+
font-family: ${MONO_STACK}; font-size: .58rem; letter-spacing: .06em; line-height: 1;
|
|
665
|
+
padding: .22rem .42rem; border-radius: 2px; border: 1px solid var(--soil-mid);
|
|
666
|
+
background: var(--parchment); color: var(--mud-ink); white-space: nowrap;
|
|
667
|
+
}
|
|
668
|
+
.dir-pill.dig { border-style: dashed; border-color: var(--burrow-glow); color: var(--soil-mid); background: rgba(232,163,61,.14); }
|
|
669
|
+
.dir-pill.vertical { border-radius: 50%; width: 1.35rem; height: 1.35rem; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: .68rem; }
|
|
670
|
+
.dir-pill:hover:not(:disabled) { border-color: var(--burrow-glow); background: var(--burrow-glow); color: var(--mud-ink); }
|
|
671
|
+
|
|
672
|
+
.bubbles { position: absolute; inset: 0; pointer-events: none; }
|
|
673
|
+
.bubble {
|
|
674
|
+
position: absolute; max-width: 46%;
|
|
675
|
+
background: var(--parchment); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 7px;
|
|
676
|
+
padding: .22rem .45rem; font-size: .66rem; line-height: 1.25; box-shadow: 0 2px 4px rgba(0,0,0,.3);
|
|
677
|
+
}
|
|
678
|
+
.bubble.from-self { right: 4%; bottom: 52%; }
|
|
679
|
+
.bubble.from-other { left: 4%; bottom: 52%; }
|
|
680
|
+
.bubble::after { content: ""; position: absolute; bottom: -5px; width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 5px solid var(--parchment); }
|
|
681
|
+
.bubble.from-self::after { right: 12%; }
|
|
682
|
+
.bubble.from-other::after { left: 12%; }
|
|
683
|
+
|
|
684
|
+
.dig-flourish { position: absolute; inset: 0; pointer-events: none; opacity: 0; background: radial-gradient(circle at 50% 80%, var(--burrow-glow) 0%, transparent 62%); transition: opacity .5s ease; }
|
|
685
|
+
.dig-flourish.shown { opacity: .5; }
|
|
686
|
+
@media (prefers-reduced-motion: reduce) { .dig-flourish { transition: none; opacity: 0 !important; } }
|
|
687
|
+
|
|
688
|
+
/* Two lines clamped, and a box tall enough to hold both of them: at 1.5
|
|
689
|
+
line-height that is 3em, and anything shorter cuts the second line's
|
|
690
|
+
descenders off. */
|
|
691
|
+
.room-caption { flex: 0 0 auto; font-size: .74rem; margin: 0; color: var(--soil-mid); height: 3em; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
692
|
+
|
|
693
|
+
.pane-columns { flex: 0 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; height: 86px; }
|
|
694
|
+
.pouch, .minimap { background: rgba(255,255,255,.35); border: 1px solid var(--soil-light); border-radius: 3px; padding: .35rem .45rem; overflow: hidden; display: flex; flex-direction: column; }
|
|
695
|
+
.pouch-list { list-style: none; margin: 0; padding: 0; font-family: ${MONO_STACK}; font-size: .64rem; display: flex; flex-wrap: wrap; gap: .2rem .3rem; overflow-y: auto; }
|
|
696
|
+
.pouch-list li { background: var(--soil-light); color: var(--parchment); border-radius: 2px; padding: .06rem .3rem; }
|
|
697
|
+
.pouch-list:empty::after { content: "carrying nothing"; color: var(--soil-mid); font-family: ${SANS_STACK}; font-style: italic; font-size: .68rem; }
|
|
698
|
+
.stat-line { margin: .25rem 0 0; font-size: .58rem; color: var(--soil-mid); }
|
|
699
|
+
.minimap-board { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; }
|
|
700
|
+
.minimap-board svg { width: 100%; height: 100%; max-height: 56px; }
|
|
701
|
+
.minimap-board:empty::after { content: "nothing dug yet"; color: var(--soil-mid); font-style: italic; font-size: .68rem; }
|
|
702
|
+
.minimap .burrow .room text { fill: var(--parchment); font-size: 6px; }
|
|
703
|
+
.minimap .burrow .room.here rect { fill: var(--burrow-glow); }
|
|
704
|
+
.minimap .burrow .room.here text { fill: var(--mud-ink); }
|
|
705
|
+
|
|
706
|
+
/* The log is the ONLY thing in a pane allowed to grow, and it grows inward:
|
|
707
|
+
flex-basis 0 keeps its own content out of the pane's height arithmetic, so
|
|
708
|
+
a hundred turns of narration scroll inside it instead of pushing the
|
|
709
|
+
command console off the bottom of a fixed-height pane. */
|
|
710
|
+
.chat { position: relative; flex: 1 1 0; min-height: 2.6rem; display: flex; flex-direction: column; }
|
|
711
|
+
/* Block flow, deliberately, not a flex column: a scrolled log's own lines
|
|
712
|
+
must keep their natural height, and flex items inside a height-capped
|
|
713
|
+
column shrink toward zero instead. */
|
|
714
|
+
.chatlog {
|
|
715
|
+
flex: 1 1 0; min-height: 0; display: block;
|
|
716
|
+
overflow-y: scroll; overscroll-behavior: contain; padding-right: .25rem;
|
|
717
|
+
}
|
|
718
|
+
.chatlog > * { margin: 0 0 .28rem; }
|
|
719
|
+
.chatlog > *:last-child { margin-bottom: 0; }
|
|
720
|
+
/* The log carries typed exchanges only — a scripted turn narrates through
|
|
721
|
+
the room view — so an untouched pane's log says what would fill it. */
|
|
722
|
+
.chatlog:empty::after { content: "Type a command below and the answer lands here."; color: var(--soil-mid); font-style: italic; font-size: .72rem; }
|
|
723
|
+
/* A styled webkit scrollbar reserves its own gutter, where the overlay one
|
|
724
|
+
Chromium defaults to on macOS shows nothing until you already scroll. The
|
|
725
|
+
standard properties go behind @supports because setting them at all makes
|
|
726
|
+
Blink ignore the pseudo-elements and fall back to that overlay bar. */
|
|
727
|
+
.chatlog::-webkit-scrollbar { width: 9px; }
|
|
728
|
+
.chatlog::-webkit-scrollbar-track { background: rgba(122,90,61,.18); border-radius: 5px; }
|
|
729
|
+
.chatlog::-webkit-scrollbar-thumb { background: var(--soil-light); border-radius: 5px; border: 2px solid var(--parchment); }
|
|
730
|
+
.chatlog::-webkit-scrollbar-thumb:hover { background: var(--soil-mid); }
|
|
731
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
732
|
+
.chatlog { scrollbar-width: thin; scrollbar-color: var(--soil-light) rgba(122,90,61,.18); }
|
|
733
|
+
}
|
|
734
|
+
.chatlog .u { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--soil-mid); overflow-wrap: anywhere; }
|
|
735
|
+
.chatlog .u::before { content: "tmct> "; color: var(--burrow-glow); }
|
|
736
|
+
/* A reply is capped at four rendered lines. A capped one says so and opens the
|
|
737
|
+
whole text in the popup above, so no single answer can take the log over.
|
|
738
|
+
The unclamped variant exists only to be measured against: a line-clamped box
|
|
739
|
+
reports its clamped height as its scrollHeight. */
|
|
740
|
+
.chatlog .a { font-size: .74rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
741
|
+
.chatlog .a.unclamped { display: block; -webkit-line-clamp: none; overflow: visible; }
|
|
742
|
+
.chatlog .clipped { position: relative; cursor: pointer; padding-right: 2.4rem; }
|
|
743
|
+
.chatlog .clipped::after {
|
|
744
|
+
content: "more"; position: absolute; right: 0; bottom: 0;
|
|
745
|
+
font-family: ${MONO_STACK}; font-size: .5rem; letter-spacing: .06em; text-transform: uppercase;
|
|
746
|
+
color: var(--mud-ink); background: var(--burrow-glow); border-radius: 99px; padding: 0 .32rem;
|
|
747
|
+
}
|
|
748
|
+
.chatlog .clipped:hover::after, .chatlog .clipped:focus-visible::after { background: var(--mud-ink); color: var(--parchment); }
|
|
749
|
+
.chatlog .clipped:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 1px; }
|
|
750
|
+
|
|
751
|
+
.log-popup {
|
|
752
|
+
position: absolute; left: 0; right: 0; bottom: calc(100% - 1.4rem); z-index: 8;
|
|
753
|
+
background: var(--soil-deep); color: var(--parchment);
|
|
754
|
+
border: 1px solid var(--burrow-glow); border-radius: 4px;
|
|
755
|
+
padding: .45rem 1.5rem .5rem .55rem; box-shadow: 0 8px 18px rgba(0,0,0,.5);
|
|
756
|
+
max-height: 13rem; overflow-y: auto; animation: popup-rise .16s ease-out;
|
|
757
|
+
}
|
|
758
|
+
.log-popup::after {
|
|
759
|
+
content: ""; position: absolute; left: 1.4rem; bottom: -6px; width: 0; height: 0;
|
|
760
|
+
border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid var(--burrow-glow);
|
|
761
|
+
}
|
|
762
|
+
.log-popup-text { margin: 0; font-size: .74rem; line-height: 1.4; }
|
|
763
|
+
.log-popup-close { position: absolute; top: .1rem; right: .25rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
|
|
764
|
+
.log-popup-close:hover { color: var(--burrow-glow); }
|
|
765
|
+
@keyframes popup-rise { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
|
|
766
|
+
@media (prefers-reduced-motion: reduce) { .log-popup { animation: none; } }
|
|
767
|
+
|
|
768
|
+
/* The console never moves: pills and prompt sit outside the log's own flex
|
|
769
|
+
track, so no amount of narration can reach them. */
|
|
770
|
+
.chat-console { flex: 0 0 auto; display: flex; flex-direction: column; gap: .3rem; margin-top: .3rem; }
|
|
771
|
+
.chatask { display: flex; align-items: center; gap: .4rem; }
|
|
772
|
+
.chatask .prompt { color: var(--burrow-glow); font-size: .7rem; }
|
|
773
|
+
.chatask input { flex: 1; min-width: 0; font-family: ${MONO_STACK}; font-size: .7rem; background: rgba(255,255,255,.6); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 2px; padding: .26rem .45rem; box-sizing: border-box; }
|
|
774
|
+
.chatpills { display: flex; flex-wrap: wrap; gap: .22rem; max-height: 3.9rem; overflow-y: auto; scrollbar-width: thin; }
|
|
775
|
+
.chatpills:empty { display: none; }
|
|
776
|
+
.pill { font-family: ${MONO_STACK}; font-size: .58rem; padding: .14rem .45rem; border: 1px solid var(--soil-mid); border-radius: 99px; background: rgba(255,255,255,.5); white-space: nowrap; }
|
|
777
|
+
.pill:hover:not(:disabled) { border-color: var(--burrow-glow); background: var(--burrow-glow); }
|
|
778
|
+
.pill.affordance { border-style: dashed; border-color: var(--soil-light); }
|
|
779
|
+
.pill.way { border-style: solid; border-color: var(--soil-mid); background: rgba(232,163,61,.16); }
|
|
780
|
+
.pane-controls { flex: 0 0 auto; display: flex; align-items: center; gap: .4rem; }
|
|
781
|
+
.pane-fate {
|
|
782
|
+
margin: 0; font-family: ${MONO_STACK}; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em;
|
|
783
|
+
padding: .3rem .6rem; border-radius: 3px; background: var(--soil-deep); color: var(--parchment);
|
|
784
|
+
}
|
|
785
|
+
.mud-window.out-of-play { border-top-color: var(--soil-mid); }
|
|
786
|
+
.mud-window.out-of-play .pane-controls button { display: none; }
|
|
787
|
+
.room-view, .pane-columns { transition: filter .4s ease, opacity .4s ease; }
|
|
788
|
+
.mud-window.out-of-play .room-view, .mud-window.out-of-play .pane-columns { filter: grayscale(1); opacity: .5; }
|
|
789
|
+
.mud-window.out-of-play .dir-ring { display: none; }
|
|
790
|
+
|
|
791
|
+
/* ---- the pounce: the one violent beat on a page that otherwise only fades.
|
|
792
|
+
Every other motion here eases out; this one accelerates into the prey and
|
|
793
|
+
stops dead. It plays once, on the tick a character is first out of play,
|
|
794
|
+
and the pane grays out underneath it when it lands. ---- */
|
|
795
|
+
.strike-flash {
|
|
796
|
+
position: absolute; inset: 0; pointer-events: none; opacity: 0;
|
|
797
|
+
background: radial-gradient(circle at 76% 72%, rgba(158,32,16,.95) 0%, rgba(36,23,16,.62) 40%, transparent 72%);
|
|
798
|
+
}
|
|
799
|
+
@keyframes strike-flash { 0%, 42% { opacity: 0; } 50% { opacity: .92; } 100% { opacity: 0; } }
|
|
800
|
+
@keyframes fox-pounce {
|
|
801
|
+
0% { transform: translateX(0) scale(1); }
|
|
802
|
+
16% { transform: translateX(-7px) scale(.94); }
|
|
803
|
+
54% { transform: translateX(var(--pounce-x, 180px)) scale(1.48); }
|
|
804
|
+
100% { transform: translateX(var(--pounce-x, 180px)) scale(1.44); }
|
|
805
|
+
}
|
|
806
|
+
@keyframes prey-taken {
|
|
807
|
+
0%, 40% { transform: none; opacity: 1; }
|
|
808
|
+
47% { transform: translateX(-6px) rotate(-8deg); }
|
|
809
|
+
54% { transform: translateX(6px) rotate(7deg); }
|
|
810
|
+
61% { transform: translateX(-4px) rotate(-4deg); }
|
|
811
|
+
100% { transform: translateX(3px) rotate(4deg) scale(.82); opacity: 0; }
|
|
812
|
+
}
|
|
813
|
+
.room-view.pounce .sprite-card.lunging { position: relative; z-index: 3; transform-origin: 50% 100%; animation: fox-pounce 1.05s cubic-bezier(.6,0,.85,.4) forwards; }
|
|
814
|
+
.room-view.pounce .floor-self { transform-origin: 50% 100%; animation: prey-taken 1.05s cubic-bezier(.4,0,.2,1) forwards; }
|
|
815
|
+
.room-view.pounce .strike-flash { animation: strike-flash 1.05s ease-out forwards; }
|
|
816
|
+
.room-view.pounce .bubbles, .room-view.pounce .wall-band { opacity: .35; }
|
|
817
|
+
@keyframes fate-drop { from { opacity: 0; transform: translateY(-7px); } to { opacity: 1; transform: none; } }
|
|
818
|
+
.pane-fate { animation: fate-drop .3s ease-out; }
|
|
819
|
+
@media (prefers-reduced-motion: reduce) {
|
|
820
|
+
.room-view, .pane-columns { transition: none; }
|
|
821
|
+
.pane-fate { animation: none; }
|
|
822
|
+
.room-view.pounce .sprite-card.lunging, .room-view.pounce .floor-self, .room-view.pounce .strike-flash { animation: none; }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/* ---- edit mode ----
|
|
826
|
+
One page, two stages. Editing hides the panes AND the deck's own live
|
|
827
|
+
survey: the survey in here is drawn from the edit buffer, and two boards
|
|
828
|
+
showing two different burrows side by side would just be a puzzle. The deck
|
|
829
|
+
itself stays, because the way back out is a button on it. */
|
|
830
|
+
.edit-stage { display: none; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
|
|
831
|
+
body.editing .mud-stage, body.editing .deck-row .world-map { display: none; }
|
|
832
|
+
body.editing .deck-row { grid-template-columns: 1fr; }
|
|
833
|
+
body.editing .edit-stage { display: grid; }
|
|
834
|
+
#editModeBtn[aria-pressed="true"] { background: var(--burrow-glow); border-color: var(--burrow-glow); }
|
|
835
|
+
|
|
836
|
+
.edit-text, .edit-panel {
|
|
837
|
+
background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
|
|
838
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
|
|
839
|
+
padding: .7rem .8rem; min-width: 0;
|
|
840
|
+
}
|
|
841
|
+
.edit-text { display: flex; flex-direction: column; gap: .45rem; }
|
|
842
|
+
.edit-text h2, .edit-panel h2 {
|
|
843
|
+
font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em;
|
|
844
|
+
color: var(--soil-mid); margin: 0;
|
|
845
|
+
}
|
|
846
|
+
.edit-lede { margin: 0; font-size: .74rem; color: var(--soil-mid); max-width: 62ch; }
|
|
847
|
+
#editorText {
|
|
848
|
+
width: 100%; box-sizing: border-box; min-height: 26rem; flex: 1 1 auto; resize: vertical;
|
|
849
|
+
font-family: ${MONO_STACK}; font-size: .74rem; line-height: 1.6;
|
|
850
|
+
color: var(--mud-ink); background: rgba(255,255,255,.6);
|
|
851
|
+
border: 1px solid var(--soil-mid); border-radius: 3px; padding: .55rem .6rem;
|
|
852
|
+
}
|
|
853
|
+
/* Three states, three colours, and the middle one is the important one: a
|
|
854
|
+
document that does not yet parse is holding its retractions back, and has to
|
|
855
|
+
look different from one that synced clean. */
|
|
856
|
+
.edit-status { margin: 0; font-family: ${MONO_STACK}; font-size: .62rem; min-height: 1.2em; color: var(--soil-mid); }
|
|
857
|
+
.edit-status.pending { color: #9A5B12; }
|
|
858
|
+
.edit-status.ok { color: #4E6B2E; }
|
|
859
|
+
|
|
860
|
+
.edit-side { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
|
861
|
+
.edit-map .world-map-board { min-height: 190px; }
|
|
862
|
+
.edit-map .burrow .room { cursor: pointer; }
|
|
863
|
+
.edit-map .burrow .room.here rect { stroke: var(--burrow-glow); stroke-width: 1.8; }
|
|
864
|
+
.edit-panel { display: flex; flex-direction: column; gap: .35rem; }
|
|
865
|
+
.edit-caption { margin: 0; font-size: .74rem; color: var(--soil-mid); }
|
|
866
|
+
.room-detail-cast, .edit-legend { display: flex; flex-wrap: wrap; gap: .4rem; }
|
|
867
|
+
.edit-legend .sprite-card, .room-detail-cast .sprite-card { max-width: 58px; }
|
|
868
|
+
.edit-legend .sprite-frame, .room-detail-cast .sprite-frame {
|
|
869
|
+
width: 34px; padding: 2px; box-sizing: border-box; background: rgba(255,255,255,.6);
|
|
870
|
+
border: 1px solid var(--soil-mid); border-radius: 2px;
|
|
871
|
+
}
|
|
872
|
+
.edit-legend .sprite-frame svg, .room-detail-cast .sprite-frame svg { width: 100%; display: block; }
|
|
873
|
+
.edit-empty { font-size: .74rem; font-style: italic; color: var(--soil-mid); }
|
|
874
|
+
|
|
875
|
+
/* Narrow, every pane stacks and every count reads the same — including the
|
|
876
|
+
single-pane one, whose centred column and extra height only buy anything
|
|
877
|
+
when there is width to spare. */
|
|
878
|
+
@media (max-width: 900px) {
|
|
879
|
+
.deck-row { grid-template-columns: 1fr; }
|
|
880
|
+
.mud-stage, .mud-stage[data-panes="1"] {
|
|
881
|
+
grid-template-columns: 1fr; justify-content: stretch;
|
|
882
|
+
--pane-height: 596px; --room-height: 152px;
|
|
883
|
+
}
|
|
884
|
+
.world-map-board { min-height: 150px; }
|
|
885
|
+
.edit-stage { grid-template-columns: 1fr; }
|
|
886
|
+
#editorText { min-height: 18rem; }
|
|
887
|
+
}
|
|
888
|
+
`;
|
|
889
|
+
|
|
890
|
+
/** The inlined page script, as a plain function body handed to
|
|
891
|
+
* embedScriptText — mirrors spider-fly-viz.mjs's own `(function () {
|
|
892
|
+
* "use strict"; ... })()` IIFE shape, with every spliced helper listed at
|
|
893
|
+
* the top of the closure exactly like that page's own const bindings. */
|
|
894
|
+
function pageScript() {
|
|
895
|
+
return `(function () {
|
|
896
|
+
"use strict";
|
|
897
|
+
const DATA = MUD_PAGE_DATA;
|
|
898
|
+
const createTicker = ${createTicker.toString()};
|
|
899
|
+
const escapeHtml = ${escapeHtml.toString()};
|
|
900
|
+
const esc = escapeHtml;
|
|
901
|
+
const paneMarkup = ${paneMarkup.toString()};
|
|
902
|
+
const speciesOfCharacter = ${speciesOfCharacter.toString()};
|
|
903
|
+
const mudRoomSceneObjects = ${mudRoomSceneObjects.toString()};
|
|
904
|
+
const carriedItemsFor = ${carriedItemsFor.toString()};
|
|
905
|
+
const levelsOf = ${levelsOf.toString()};
|
|
906
|
+
const charactersInRoom = ${charactersInRoom.toString()};
|
|
907
|
+
const burrowGraph = ${burrowGraph.toString()};
|
|
908
|
+
const diggableDirections = ${diggableDirections.toString()};
|
|
909
|
+
const itemLabel = ${itemLabel.toString()};
|
|
910
|
+
const isCreature = ${isCreature.toString()};
|
|
911
|
+
const roomSceneObjects = ${roomSceneObjects.toString()};
|
|
912
|
+
const scenePlacement = ${scenePlacement.toString()};
|
|
913
|
+
const spriteClassForObject = ${spriteClassForObject.toString()};
|
|
914
|
+
const spriteAncestryRows = ${spriteAncestryRows.toString()};
|
|
915
|
+
const factsForSubject = ${factsForSubject.toString()};
|
|
916
|
+
const visibleRoomOf = ${visibleRoomOf.toString()};
|
|
917
|
+
const roomKindForRoom = ${roomKindForRoom.toString()};
|
|
918
|
+
const allRoomIds = ${allRoomIds.toString()};
|
|
919
|
+
const renderMudEditorText = ${renderMudEditorText.toString()};
|
|
920
|
+
const wordBeforeCursor = ${wordBeforeCursor.toString()};
|
|
921
|
+
|
|
922
|
+
const el = (id) => document.getElementById(id);
|
|
923
|
+
const roster = DATA.characters.map(function (c) { return c.id; });
|
|
924
|
+
let slots = [];
|
|
925
|
+
const ACTOR_COLORS = ["var(--actor-a)", "var(--actor-b)", "var(--actor-c)", "var(--actor-d)"];
|
|
926
|
+
const NPC_COLOR = "var(--chalk)";
|
|
927
|
+
const COMPASS = ["north", "south", "east", "west", "up", "down"];
|
|
928
|
+
const DIR_GLYPH = { north: "\\u25B2 N", south: "\\u25BC S", west: "\\u25C0 W", east: "E \\u25B6", up: "\\u21E1", down: "\\u21E3" };
|
|
929
|
+
const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
930
|
+
|
|
931
|
+
// ---- the one shared tick queue -----------------------------------------
|
|
932
|
+
// Every pane's ticker calls into this instead of session.windows[...]
|
|
933
|
+
// directly, so two characters' turns can never interleave their reads and
|
|
934
|
+
// writes of the one shared memoryDir, and the global turn counter always
|
|
935
|
+
// increments in real execution order.
|
|
936
|
+
let tickChain = Promise.resolve();
|
|
937
|
+
function serializeTick(fn) {
|
|
938
|
+
const run = tickChain.then(fn, fn);
|
|
939
|
+
tickChain = run.catch(function () {});
|
|
940
|
+
return run;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
let session = null;
|
|
944
|
+
let cast = [];
|
|
945
|
+
// The animals with no pane. They act through the same session windows and
|
|
946
|
+
// the same tick queue as the cast; every list below that says "everyone"
|
|
947
|
+
// means both, and every list that says "cast" means the panes alone.
|
|
948
|
+
let npcs = [];
|
|
949
|
+
let slotOf = {};
|
|
950
|
+
let globalTurn = 0;
|
|
951
|
+
let turnsTaken = {};
|
|
952
|
+
let finished = {};
|
|
953
|
+
let maxTurns = DATA.defaultMaxTurns;
|
|
954
|
+
let delayMs = DATA.defaultDelayMs;
|
|
955
|
+
const wait = function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); };
|
|
956
|
+
const liveWait = function () { return wait(delayMs); };
|
|
957
|
+
|
|
958
|
+
let freshlyDugRoom = null;
|
|
959
|
+
// The last omniscient read, kept so the pounce can draw the predator's own
|
|
960
|
+
// den after the engine has already moved the character out of every room.
|
|
961
|
+
let lastSnapshot = null;
|
|
962
|
+
const speechBubbles = new Map(); // room -> [{ speaker, text, expiresAtTurn }]
|
|
963
|
+
|
|
964
|
+
let tickers = {};
|
|
965
|
+
let autoOn = false;
|
|
966
|
+
|
|
967
|
+
function hasNext() { return globalTurn < maxTurns; }
|
|
968
|
+
function everyone() { return cast.concat(npcs); }
|
|
969
|
+
function hasPane(character) { return Boolean(slotOf[character]); }
|
|
970
|
+
function paneIdFor(character) { return "window-" + slotOf[character]; }
|
|
971
|
+
function colorFor(character) {
|
|
972
|
+
const seat = cast.indexOf(character);
|
|
973
|
+
return seat === -1 ? NPC_COLOR : ACTOR_COLORS[seat % ACTOR_COLORS.length];
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// The engine keeps each character's own tally (its scripted ticks AND its
|
|
977
|
+
// typed commands), which is the only one that can be right: a typed "dig
|
|
978
|
+
// north" is a turn the page never drove. The page's own count of the ticks it
|
|
979
|
+
// fired is the fallback, so a pane never falls back to showing the shared
|
|
980
|
+
// global figure as if it were its own.
|
|
981
|
+
function turnsFor(character) {
|
|
982
|
+
const w = session && session.windows[character];
|
|
983
|
+
const own = w && typeof w.turnsTaken === "function" ? w.turnsTaken() : null;
|
|
984
|
+
return typeof own === "number" ? own : (turnsTaken[character] || 0);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async function runOneTurn(character) {
|
|
988
|
+
// A tick already queued when a reset lands belongs to a world that no
|
|
989
|
+
// longer exists, and a dozen animals make that overlap ordinary rather
|
|
990
|
+
// than rare.
|
|
991
|
+
const bootAt = bootSeq;
|
|
992
|
+
return serializeTick(async function () {
|
|
993
|
+
if (bootAt !== bootSeq || finished[character]) return { outOfPlay: true };
|
|
994
|
+
globalTurn += 1;
|
|
995
|
+
const result = await session.windows[character].autoplayTick(globalTurn);
|
|
996
|
+
if (!result.outOfPlay) turnsTaken[character] = (turnsTaken[character] || 0) + 1;
|
|
997
|
+
afterEngineTurn(character, result);
|
|
998
|
+
return result;
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// A character whose run has ended keeps its pane, its final turn count and
|
|
1003
|
+
// its chat log, and loses everything that would advance it — the engine
|
|
1004
|
+
// declines its turns from here on, so offering the controls would promise a
|
|
1005
|
+
// turn that never runs. An NPC has none of that to lose: it stops ticking and
|
|
1006
|
+
// the survey stops drawing it, which is the whole of its ending. WHICH ending
|
|
1007
|
+
// it was is never cached here — every reader asks the engine fresh, so a pane
|
|
1008
|
+
// that says "eaten" over an animal that quietly starved is never possible.
|
|
1009
|
+
function markOutOfPlay(character, reason, note) {
|
|
1010
|
+
if (finished[character]) return;
|
|
1011
|
+
finished[character] = true;
|
|
1012
|
+
const ticker = tickers[character];
|
|
1013
|
+
if (ticker) ticker.pause();
|
|
1014
|
+
if (!hasPane(character)) return;
|
|
1015
|
+
const w = paneIdFor(character);
|
|
1016
|
+
el(w + "-play").disabled = true;
|
|
1017
|
+
el(w + "-step").disabled = true;
|
|
1018
|
+
el(w + "-chatq").disabled = true;
|
|
1019
|
+
el(w + "-chatpills").innerHTML = "";
|
|
1020
|
+
const fate = reason === "starved" ? "starved" : "eaten";
|
|
1021
|
+
// A run that ended on a TYPED command carries no note of its own — the
|
|
1022
|
+
// world's ending sentence comes from the engine either way, never a second
|
|
1023
|
+
// wording of the same event.
|
|
1024
|
+
appendChat(character, "a", note
|
|
1025
|
+
|| (window.tmctMud.outOfPlayPhrase(character, fate) + ". It takes no more turns."));
|
|
1026
|
+
playFateScene(character, fate);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Whichever individual the WORLD marks dangerous, never a species this page
|
|
1030
|
+
// hardcodes — the same fact adventure.mjs's own predator check reads.
|
|
1031
|
+
function predatorInRows(rows) {
|
|
1032
|
+
for (const row of rows) {
|
|
1033
|
+
if (row.predicate === "mgx:is-predator" && row.object === "true") return row.subject;
|
|
1034
|
+
}
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// The pane holds on the den for the length of one pounce before it grays out.
|
|
1039
|
+
// Cutting straight to the banner throws away the only moment this demo has to
|
|
1040
|
+
// show what took the character: the character's last drawn room is the one it
|
|
1041
|
+
// walked out OF, so the scene is redrawn against the den first, then the fox
|
|
1042
|
+
// crosses it. An animal that ran out of mass was taken by nothing, so it
|
|
1043
|
+
// settles straight into its banner — as does reduced motion, or a world with
|
|
1044
|
+
// no marked predator.
|
|
1045
|
+
function playFateScene(character, fate) {
|
|
1046
|
+
const w = paneIdFor(character);
|
|
1047
|
+
const settle = function () {
|
|
1048
|
+
el(w).classList.add("out-of-play");
|
|
1049
|
+
const banner = el(w + "-fate");
|
|
1050
|
+
banner.hidden = false;
|
|
1051
|
+
banner.textContent = fate + " \\u00b7 " + turnsFor(character) + " turns";
|
|
1052
|
+
};
|
|
1053
|
+
const predator = lastSnapshot ? predatorInRows(lastSnapshot.rows) : null;
|
|
1054
|
+
const den = predator ? (lastSnapshot.state.placements.get(predator) || {}).object : null;
|
|
1055
|
+
if (fate === "starved" || reduceMotion || !den) { settle(); return; }
|
|
1056
|
+
|
|
1057
|
+
const rows = lastSnapshot.rows;
|
|
1058
|
+
const state = lastSnapshot.state;
|
|
1059
|
+
renderRoomView(character, rows, state, den, allRoomIds(rows),
|
|
1060
|
+
window.tmctMud.castInRoom(rows, state, den, character));
|
|
1061
|
+
|
|
1062
|
+
const room = el(w + "-room");
|
|
1063
|
+
const lunger = room.querySelector('.floor-others .sprite-card[data-subject="' + predator + '"]');
|
|
1064
|
+
const prey = el(w + "-self");
|
|
1065
|
+
if (!lunger || !prey) { settle(); return; }
|
|
1066
|
+
const gap = prey.getBoundingClientRect().left - lunger.getBoundingClientRect().left;
|
|
1067
|
+
lunger.style.setProperty("--pounce-x", Math.max(0, Math.round(gap) - 8) + "px");
|
|
1068
|
+
room.classList.add("pounce");
|
|
1069
|
+
lunger.classList.add("lunging");
|
|
1070
|
+
setTimeout(settle, 1000);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// A bubble carries what was SAID, not the sentence: the answer names a
|
|
1074
|
+
// thing, and the thing's own display label is only resolvable against the
|
|
1075
|
+
// fact rows a redraw has and a finished turn does not.
|
|
1076
|
+
function noteSpeech(room, speaker, bubble) {
|
|
1077
|
+
if (!speechBubbles.has(room)) speechBubbles.set(room, []);
|
|
1078
|
+
speechBubbles.get(room).push(Object.assign({ speaker: speaker, expiresAtTurn: globalTurn + 2 }, bubble));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function afterEngineTurn(character, result) {
|
|
1082
|
+
if (result && result.outOfPlay) {
|
|
1083
|
+
markOutOfPlay(character, result.outOfPlayReason, result.text);
|
|
1084
|
+
renderSoon();
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
if (!result || !result.room) { renderSoon(); return; }
|
|
1088
|
+
if (result.roomAfter && result.roomAfter !== result.room) freshlyDugRoom = result.roomAfter;
|
|
1089
|
+
for (const action of result.actions || []) {
|
|
1090
|
+
if (action.kind !== "ask" && action.kind !== "talk") continue;
|
|
1091
|
+
const other = action.teller || action.target || action.object;
|
|
1092
|
+
noteSpeech(result.room, character, { text: "what food do you know about?" });
|
|
1093
|
+
if (other && action.thing) noteSpeech(result.room, other, { thing: action.thing });
|
|
1094
|
+
else if (other && action.text) noteSpeech(result.room, other, { text: action.text });
|
|
1095
|
+
}
|
|
1096
|
+
renderSoon();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function ensureTicker(character) {
|
|
1100
|
+
if (tickers[character]) return tickers[character];
|
|
1101
|
+
const playBtn = hasPane(character) ? el(paneIdFor(character) + "-play") : null;
|
|
1102
|
+
tickers[character] = createTicker({
|
|
1103
|
+
onTick: function () { return runOneTurn(character); },
|
|
1104
|
+
onRender: function (state) {
|
|
1105
|
+
if (playBtn) playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
|
|
1106
|
+
},
|
|
1107
|
+
hasNext: hasNext,
|
|
1108
|
+
wait: liveWait,
|
|
1109
|
+
});
|
|
1110
|
+
return tickers[character];
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ---- chat docks ---------------------------------------------------------
|
|
1114
|
+
// The line's own text is never shortened, only its rendered height — the whole
|
|
1115
|
+
// thing stays readable through the popup, and stays in the DOM for anything
|
|
1116
|
+
// reading the log.
|
|
1117
|
+
function appendChat(character, cls, text) {
|
|
1118
|
+
const log = el(paneIdFor(character) + "-chatlog");
|
|
1119
|
+
const d = document.createElement("div");
|
|
1120
|
+
d.className = cls;
|
|
1121
|
+
d.textContent = text;
|
|
1122
|
+
log.appendChild(d);
|
|
1123
|
+
// A line-clamped box reports scrollHeight EQUAL to its clamped height, so
|
|
1124
|
+
// the overflow has to be read against the same box with the clamp lifted.
|
|
1125
|
+
d.classList.add("unclamped");
|
|
1126
|
+
const wholeHeight = d.scrollHeight;
|
|
1127
|
+
d.classList.remove("unclamped");
|
|
1128
|
+
if (wholeHeight - d.clientHeight > 2) {
|
|
1129
|
+
d.classList.add("clipped");
|
|
1130
|
+
d.setAttribute("role", "button");
|
|
1131
|
+
d.setAttribute("tabindex", "0");
|
|
1132
|
+
d.setAttribute("title", "read the whole line");
|
|
1133
|
+
}
|
|
1134
|
+
log.scrollTop = log.scrollHeight;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function openLogPopup(slot, text) {
|
|
1138
|
+
const w = "window-" + slot;
|
|
1139
|
+
el(w + "-logpop-text").textContent = text;
|
|
1140
|
+
el(w + "-logpop").hidden = false;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function closeLogPopup(slot) {
|
|
1144
|
+
el("window-" + slot + "-logpop").hidden = true;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function sendCommand(character, line) {
|
|
1148
|
+
appendChat(character, "u", line);
|
|
1149
|
+
return serializeTick(function () { return session.windows[character].turn(line); }).then(function (res) {
|
|
1150
|
+
appendChat(character, "a", res.answer);
|
|
1151
|
+
renderSoon();
|
|
1152
|
+
return res;
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Wired as the pane is built. The stage is rebuilt on every boot, so a
|
|
1157
|
+
// pane's listeners live and die with it; each one still reads its slot's
|
|
1158
|
+
// character at click time, because the cast is bound after the markup.
|
|
1159
|
+
function wirePane(slot) {
|
|
1160
|
+
const w = "window-" + slot;
|
|
1161
|
+
const input = el(w + "-chatq");
|
|
1162
|
+
el(w + "-chatform").addEventListener("submit", function (e) {
|
|
1163
|
+
e.preventDefault();
|
|
1164
|
+
const character = characterInSlot(slot);
|
|
1165
|
+
const line = input.value.trim();
|
|
1166
|
+
if (!character || !line) return;
|
|
1167
|
+
input.value = "";
|
|
1168
|
+
sendCommand(character, line);
|
|
1169
|
+
});
|
|
1170
|
+
el(w + "-play").addEventListener("click", function () {
|
|
1171
|
+
const character = characterInSlot(slot);
|
|
1172
|
+
if (!character) return;
|
|
1173
|
+
const ticker = ensureTicker(character);
|
|
1174
|
+
if (ticker.getState().playing) ticker.pause(); else ticker.play();
|
|
1175
|
+
});
|
|
1176
|
+
el(w + "-step").addEventListener("click", function () {
|
|
1177
|
+
const character = characterInSlot(slot);
|
|
1178
|
+
if (character) ensureTicker(character).stepOnce();
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
const log = el(w + "-chatlog");
|
|
1182
|
+
log.addEventListener("click", function (e) {
|
|
1183
|
+
const line = e.target.closest(".clipped");
|
|
1184
|
+
if (line) openLogPopup(slot, line.textContent);
|
|
1185
|
+
});
|
|
1186
|
+
log.addEventListener("keydown", function (e) {
|
|
1187
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
1188
|
+
const line = e.target.closest(".clipped");
|
|
1189
|
+
if (!line) return;
|
|
1190
|
+
e.preventDefault();
|
|
1191
|
+
openLogPopup(slot, line.textContent);
|
|
1192
|
+
});
|
|
1193
|
+
el(w + "-logpop-close").addEventListener("click", function () { closeLogPopup(slot); });
|
|
1194
|
+
el(w).addEventListener("keydown", function (e) {
|
|
1195
|
+
if (e.key === "Escape") closeLogPopup(slot);
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// Clicking a drawn thing runs the same grounded command its pill would, so
|
|
1199
|
+
// the room view is a second way into one action set rather than its own.
|
|
1200
|
+
// A sprite the room grants no action on carries no command and does nothing.
|
|
1201
|
+
el(w + "-room").addEventListener("click", function (e) {
|
|
1202
|
+
const card = e.target.closest(".sprite-card[data-command]");
|
|
1203
|
+
const character = characterInSlot(slot);
|
|
1204
|
+
if (!card || !character || finished[character]) return;
|
|
1205
|
+
sendCommand(character, card.getAttribute("data-command"));
|
|
1206
|
+
});
|
|
1207
|
+
el(w + "-room").addEventListener("keydown", function (e) {
|
|
1208
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
1209
|
+
const card = e.target.closest(".sprite-card[data-command]");
|
|
1210
|
+
const character = characterInSlot(slot);
|
|
1211
|
+
if (!card || !character || finished[character]) return;
|
|
1212
|
+
e.preventDefault();
|
|
1213
|
+
sendCommand(character, card.getAttribute("data-command"));
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function characterInSlot(slot) {
|
|
1218
|
+
return cast[slots.indexOf(slot)] || null;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ---- room-view rendering -------------------------------------------------
|
|
1222
|
+
function hashOf(text) {
|
|
1223
|
+
let h = 0;
|
|
1224
|
+
for (let i = 0; i < String(text).length; i += 1) h = (h * 31 + String(text).charCodeAt(i)) % 100000;
|
|
1225
|
+
return h;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function spriteSvgFor(species, rows, instanceKey) {
|
|
1229
|
+
if (window.tmctMud && window.tmctMud.resolveSpriteAsset) {
|
|
1230
|
+
return window.tmctMud.resolveSpriteAsset(species, rows, [], DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY, { instanceKey: instanceKey });
|
|
1231
|
+
}
|
|
1232
|
+
return "";
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// A wall-hung object resolves through the SAME property-aware, large-tier
|
|
1236
|
+
// resolver the characters do, keyed on the object's OWN name (via
|
|
1237
|
+
// spriteAncestryRows' synthetic subClassOf edge to its declared class), so
|
|
1238
|
+
// a carrot draws a carrot and only a class with no template anywhere falls
|
|
1239
|
+
// back. The fallback root is "portable", not the resolver's default
|
|
1240
|
+
// "animal": everything hung on this wall is a thing, and a thing with no
|
|
1241
|
+
// sprite should read as a plain parcel rather than a creature.
|
|
1242
|
+
function objectSvgFor(subject, rows) {
|
|
1243
|
+
if (window.tmctMud && window.tmctMud.resolveSpriteAsset) {
|
|
1244
|
+
return window.tmctMud.resolveSpriteAsset(
|
|
1245
|
+
subject, spriteAncestryRows(rows, subject), factsForSubject(rows, subject),
|
|
1246
|
+
DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY,
|
|
1247
|
+
{ rootFallback: "portable", instanceKey: subject },
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
return "";
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// The backdrop is a drawing of what the digest already says about the room:
|
|
1254
|
+
// turf and sky above ground, packed strata and pebbles below it. Every
|
|
1255
|
+
// stroke is placed off a hash of the room's own id, so a room looks the
|
|
1256
|
+
// same every redraw and two rooms never look alike.
|
|
1257
|
+
function drawRoomBackdrop(canvas, kind, room) {
|
|
1258
|
+
const ctx = canvas.getContext("2d");
|
|
1259
|
+
const w = canvas.width, h = canvas.height;
|
|
1260
|
+
ctx.clearRect(0, 0, w, h);
|
|
1261
|
+
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
|
1262
|
+
if (kind === "outdoor") {
|
|
1263
|
+
grad.addColorStop(0, "#A9BE83"); grad.addColorStop(0.42, "#7E9159"); grad.addColorStop(1, "#5C6B41");
|
|
1264
|
+
} else {
|
|
1265
|
+
grad.addColorStop(0, "#63482F"); grad.addColorStop(0.55, "#42301F"); grad.addColorStop(1, "#241710");
|
|
1266
|
+
}
|
|
1267
|
+
ctx.fillStyle = grad;
|
|
1268
|
+
ctx.fillRect(0, 0, w, h);
|
|
1269
|
+
|
|
1270
|
+
const seed = hashOf(room);
|
|
1271
|
+
ctx.save();
|
|
1272
|
+
if (kind === "outdoor") {
|
|
1273
|
+
ctx.strokeStyle = "rgba(45,62,30,.5)";
|
|
1274
|
+
ctx.lineWidth = 1.4;
|
|
1275
|
+
for (let i = 0; i < 26; i += 1) {
|
|
1276
|
+
const x = ((seed * (i + 3)) % w);
|
|
1277
|
+
const base = h - 6 - ((seed >> (i % 5)) % 8);
|
|
1278
|
+
ctx.beginPath();
|
|
1279
|
+
ctx.moveTo(x, base);
|
|
1280
|
+
ctx.lineTo(x + (i % 2 ? 3 : -3), base - 9 - (i % 4) * 2);
|
|
1281
|
+
ctx.stroke();
|
|
1282
|
+
}
|
|
1283
|
+
} else {
|
|
1284
|
+
ctx.strokeStyle = "rgba(0,0,0,.22)";
|
|
1285
|
+
ctx.lineWidth = 1;
|
|
1286
|
+
for (let i = 1; i < 4; i += 1) {
|
|
1287
|
+
const y = (h / 4) * i + ((seed >> i) % 6) - 3;
|
|
1288
|
+
ctx.beginPath();
|
|
1289
|
+
ctx.moveTo(0, y);
|
|
1290
|
+
for (let x = 0; x <= w; x += 30) ctx.lineTo(x, y + (((seed + x) % 7) - 3));
|
|
1291
|
+
ctx.stroke();
|
|
1292
|
+
}
|
|
1293
|
+
ctx.fillStyle = "rgba(255,255,255,.07)";
|
|
1294
|
+
for (let i = 0; i < 14; i += 1) {
|
|
1295
|
+
const x = ((seed * (i + 7)) % w);
|
|
1296
|
+
const y = ((seed * (i + 2)) % h);
|
|
1297
|
+
ctx.beginPath();
|
|
1298
|
+
ctx.arc(x, y, 1 + (i % 3), 0, Math.PI * 2);
|
|
1299
|
+
ctx.fill();
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
ctx.restore();
|
|
1303
|
+
const floor = ctx.createLinearGradient(0, h - 26, 0, h);
|
|
1304
|
+
floor.addColorStop(0, "rgba(0,0,0,0)");
|
|
1305
|
+
floor.addColorStop(1, "rgba(0,0,0,.35)");
|
|
1306
|
+
ctx.fillStyle = floor;
|
|
1307
|
+
ctx.fillRect(0, h - 26, w, 26);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// The world's own hasMass fold, the same one the pouch's stat line reads.
|
|
1311
|
+
// Null for anything the world never weighed, which draws no figure at all
|
|
1312
|
+
// rather than a guessed one.
|
|
1313
|
+
function massOf(state, subject) {
|
|
1314
|
+
const mass = state.masses.get(subject);
|
|
1315
|
+
return mass ? mass.value : null;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// A sprite card: adventure.html's own frame-plus-nameplate, carrying the one
|
|
1319
|
+
// command this room currently grants on its subject (none, for a thing the
|
|
1320
|
+
// room offers nothing on) and the subject's own mass under the name.
|
|
1321
|
+
function spriteCardHtml(subject, label, inner, command, extraClass, mass) {
|
|
1322
|
+
const action = command
|
|
1323
|
+
? ' actionable" data-command="' + esc(command) + '" role="button" tabindex="0" title="' + esc(command) + '"'
|
|
1324
|
+
: '" title="' + esc(label) + '"';
|
|
1325
|
+
return '<div class="sprite-card ' + (extraClass || "") + action + ' data-subject="' + esc(subject) + '">'
|
|
1326
|
+
+ inner + '<div class="sprite-label">' + esc(label) + "</div>"
|
|
1327
|
+
+ (mass === null || mass === undefined ? "" : '<div class="sprite-mass">mass ' + esc(mass) + "</div>")
|
|
1328
|
+
+ "</div>";
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function renderRoomView(character, rows, state, here, roomIds, roomMates) {
|
|
1332
|
+
const w = paneIdFor(character);
|
|
1333
|
+
drawRoomBackdrop(el(w + "-canvas"), roomKindForRoom(rows, here), here);
|
|
1334
|
+
const commands = commandsBySubject(affordancesFor(rows, state, here, character));
|
|
1335
|
+
|
|
1336
|
+
el(w + "-self").innerHTML = spriteCardHtml(
|
|
1337
|
+
character, character,
|
|
1338
|
+
'<div class="sprite">' + spriteSvgFor(speciesOfCharacter(character), rows, character) + "</div>",
|
|
1339
|
+
null, "self", massOf(state, character),
|
|
1340
|
+
);
|
|
1341
|
+
|
|
1342
|
+
const others = roomMates.map(function (mate) {
|
|
1343
|
+
return spriteCardHtml(mate, mate,
|
|
1344
|
+
'<div class="sprite">' + spriteSvgFor(speciesOfCharacter(mate), rows, mate) + "</div>",
|
|
1345
|
+
commands[mate], "", massOf(state, mate));
|
|
1346
|
+
});
|
|
1347
|
+
const wall = [];
|
|
1348
|
+
for (const obj of mudRoomSceneObjects(rows, state, here, character)) {
|
|
1349
|
+
if (isCreature(state, obj.subject)) continue;
|
|
1350
|
+
const plane = scenePlacement(rows, state, obj.subject).plane;
|
|
1351
|
+
const hang = plane === "ceiling" || plane === "wall" ? "hangs-high" : "hangs-low";
|
|
1352
|
+
// The caption is always the THING'S own name, never the class whose
|
|
1353
|
+
// sprite ended up drawn — an item wearing an ancestor's icon because
|
|
1354
|
+
// its own class has no sprite yet still has to say what it is.
|
|
1355
|
+
wall.push(spriteCardHtml(obj.subject, labelForItem(rows, obj.subject, roomIds),
|
|
1356
|
+
'<div class="hook"></div><div class="sprite-frame">' + objectSvgFor(obj.subject, rows) + "</div>",
|
|
1357
|
+
commands[obj.subject], "wall-item " + hang, massOf(state, obj.subject)));
|
|
1358
|
+
}
|
|
1359
|
+
const othersEl = el(w + "-others");
|
|
1360
|
+
othersEl.innerHTML = others.join("");
|
|
1361
|
+
const floorForMates = el(w + "-room").clientWidth - 68;
|
|
1362
|
+
othersEl.style.setProperty("--mate-width",
|
|
1363
|
+
Math.max(18, Math.min(76, Math.floor(floorForMates / Math.max(1, others.length)) - 6)) + "px");
|
|
1364
|
+
el(w + "-wall").innerHTML = wall.join("");
|
|
1365
|
+
|
|
1366
|
+
const live = (speechBubbles.get(here) || []).filter(function (b) { return b.expiresAtTurn >= globalTurn; });
|
|
1367
|
+
el(w + "-bubbles").innerHTML = live.map(function (b) {
|
|
1368
|
+
const side = b.speaker === character ? "from-self" : "from-other";
|
|
1369
|
+
const said = b.thing ? "there's " + labelForItem(rows, b.thing, roomIds) + ", if you can reach it." : b.text;
|
|
1370
|
+
return '<div class="bubble ' + side + '">' + esc(said) + "</div>";
|
|
1371
|
+
}).join("");
|
|
1372
|
+
|
|
1373
|
+
const flourishEl = el(w + "-flourish");
|
|
1374
|
+
if (freshlyDugRoom && freshlyDugRoom === here && !reduceMotion) {
|
|
1375
|
+
flourishEl.hidden = false;
|
|
1376
|
+
flourishEl.classList.add("shown");
|
|
1377
|
+
setTimeout(function () { flourishEl.classList.remove("shown"); flourishEl.hidden = true; }, 900);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
el(w + "-caption").textContent = roomCaptionFor(rows, state, here, character, roomMates);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// The digest's own room sentences say what the ROOM is, and nothing about who
|
|
1384
|
+
// is standing in it — a "Badger-2 is in the sett-1" row is filed under the
|
|
1385
|
+
// badger, not the sett, so filtering the view to this room drops every mate.
|
|
1386
|
+
// The cast sentence comes from the same list the talk pills and the floor
|
|
1387
|
+
// sprites read, so the description can never name a different set of animals
|
|
1388
|
+
// than the pane offers to talk to.
|
|
1389
|
+
function roomCaptionFor(rows, state, here, character, roomMates) {
|
|
1390
|
+
const parts = [];
|
|
1391
|
+
const view = window.tmctMud.worldDigestRows(rows, state, character);
|
|
1392
|
+
const lines = view.filter(function (r) { return r.subject.toLowerCase() === here.toLowerCase(); })
|
|
1393
|
+
.map(function (r) { return r.subject + " " + r.predicate + " " + r.object + "."; });
|
|
1394
|
+
parts.push(lines.length ? lines.join(" ") : "You are in the " + here + ".");
|
|
1395
|
+
if (roomMates.length) {
|
|
1396
|
+
parts.push("Here with you: " + roomMates.map(function (mate) {
|
|
1397
|
+
const mass = massOf(state, mate);
|
|
1398
|
+
return "the " + mate + (mass === null ? "" : " (mass " + mass + ")");
|
|
1399
|
+
}).join(", ") + ".");
|
|
1400
|
+
}
|
|
1401
|
+
return parts.join(" ");
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// ---- the compass ring ----------------------------------------------------
|
|
1405
|
+
// One button per direction, in the slot that direction points at: the "go"
|
|
1406
|
+
// a written exit already allows, or the "dig" that would open one. A
|
|
1407
|
+
// direction with neither draws nothing, so the ring never offers a command
|
|
1408
|
+
// the world would refuse.
|
|
1409
|
+
function diggableFor(rows, state, here) {
|
|
1410
|
+
const fromEngine = window.tmctMud && window.tmctMud.diggableDirections;
|
|
1411
|
+
return fromEngine ? fromEngine(rows, state, here) : diggableDirections(rows, state, here);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function renderDirections(character, rows, state, here) {
|
|
1415
|
+
const w = paneIdFor(character);
|
|
1416
|
+
const exits = state.exits.get(here);
|
|
1417
|
+
const diggable = diggableFor(rows, state, here);
|
|
1418
|
+
for (const direction of COMPASS) {
|
|
1419
|
+
const slot = el(w + "-dir-" + direction);
|
|
1420
|
+
const open = exits && exits.has(direction);
|
|
1421
|
+
const command = open ? "go " + direction : (diggable.indexOf(direction) !== -1 ? "dig " + direction : null);
|
|
1422
|
+
if (!command) { slot.innerHTML = ""; continue; }
|
|
1423
|
+
const cls = "dir-pill " + (open ? "go" : "dig") + (direction === "up" || direction === "down" ? " vertical" : "");
|
|
1424
|
+
slot.innerHTML = '<button type="button" class="' + cls + '" data-command="' + esc(command) + '" title="' + esc(command)
|
|
1425
|
+
+ '" aria-label="' + esc(command) + '">' + DIR_GLYPH[direction] + "</button>";
|
|
1426
|
+
slot.querySelector("button").addEventListener("click", function () { sendCommand(character, command); });
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// ---- the pill row --------------------------------------------------------
|
|
1431
|
+
// Every action the room actually grants comes from the engine's own
|
|
1432
|
+
// roomAffordances — the SAME list the "You can:" line in a look reply is built
|
|
1433
|
+
// from — so nothing the reply above offers can be missing from the row beneath
|
|
1434
|
+
// it. Exits appear in both the row and the compass ring on purpose: the ring
|
|
1435
|
+
// says WHERE a way out points, the row says what it is called.
|
|
1436
|
+
function affordancesFor(rows, state, here, character) {
|
|
1437
|
+
const fromEngine = window.tmctMud && window.tmctMud.roomAffordances;
|
|
1438
|
+
return fromEngine ? fromEngine(rows, state, here, character) : [];
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function subjectOfAction(action) {
|
|
1442
|
+
return action.indexOf("talk to ") === 0 ? action.slice(8) : action.slice(action.indexOf(" ") + 1);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function commandsBySubject(actions) {
|
|
1446
|
+
const bySubject = {};
|
|
1447
|
+
for (const action of actions) {
|
|
1448
|
+
if (action.indexOf("go ") === 0) continue;
|
|
1449
|
+
bySubject[subjectOfAction(action)] = action;
|
|
1450
|
+
}
|
|
1451
|
+
return bySubject;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function renderChatPills(character, rows, state, here, roomIds) {
|
|
1455
|
+
const w = paneIdFor(character);
|
|
1456
|
+
const pillsEl = el(w + "-chatpills");
|
|
1457
|
+
const pills = [
|
|
1458
|
+
{ command: "look", label: "look", cls: "" },
|
|
1459
|
+
{ command: "what do you know about food", label: "what do you know about food", cls: "" },
|
|
1460
|
+
];
|
|
1461
|
+
for (const action of affordancesFor(rows, state, here, character)) {
|
|
1462
|
+
if (action.indexOf("go ") === 0) { pills.push({ command: action, label: action, cls: " affordance way" }); continue; }
|
|
1463
|
+
const subject = subjectOfAction(action);
|
|
1464
|
+
const label = action.slice(0, action.length - subject.length) + labelForItem(rows, subject, roomIds);
|
|
1465
|
+
pills.push({ command: action, label: label, cls: " affordance" });
|
|
1466
|
+
}
|
|
1467
|
+
pillsEl.innerHTML = pills.map(function (p) {
|
|
1468
|
+
return '<button type="button" class="pill' + p.cls + '" data-command="' + esc(p.command)
|
|
1469
|
+
+ '" title="' + esc(p.command) + '">' + esc(p.label) + "</button>";
|
|
1470
|
+
}).join("");
|
|
1471
|
+
for (const pill of pillsEl.querySelectorAll(".pill")) {
|
|
1472
|
+
pill.addEventListener("click", function () {
|
|
1473
|
+
sendCommand(character, pill.getAttribute("data-command"));
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function renderPouch(character, rows, state, roomIds) {
|
|
1479
|
+
const w = paneIdFor(character);
|
|
1480
|
+
const items = carriedItemsFor(rows, state, character);
|
|
1481
|
+
el(w + "-pouch-list").innerHTML = items.map(function (i) {
|
|
1482
|
+
return '<li title="' + esc(i.subject) + '">' + esc(labelForItem(rows, i.subject, roomIds)) + "</li>";
|
|
1483
|
+
}).join("");
|
|
1484
|
+
const mass = state.masses.get(character);
|
|
1485
|
+
const drain = DATA.mudConfig[speciesOfCharacter(character) + "MassDecrementPerTurn"];
|
|
1486
|
+
el(w + "-stats").textContent = "mass " + (mass ? mass.value : "?") + (drain !== undefined ? " \\u00b7 drain/turn " + drain : "");
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// The world mints a dug object with its own display-name fact, so the engine
|
|
1490
|
+
// can say "carrot" while every verb still resolves the distinct id
|
|
1491
|
+
// ("carrot-1"). itemLabel is the fallback for an id carrying no such fact.
|
|
1492
|
+
function labelForItem(rows, subject, roomIds) {
|
|
1493
|
+
const fromEngine = window.tmctMud && window.tmctMud.displayNameOf;
|
|
1494
|
+
const name = fromEngine ? fromEngine(rows, subject) : null;
|
|
1495
|
+
return name && name !== subject ? name : itemLabel(subject, roomIds);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// ---- the burrow survey ---------------------------------------------------
|
|
1499
|
+
// One routine, drawn twice: the omniscient board in the top row (every room
|
|
1500
|
+
// the world has) and each pane's own known ground (only what that character
|
|
1501
|
+
// has dug or walked, with a fading stub where it knows a way carries on).
|
|
1502
|
+
function burrowSvg(graph, options) {
|
|
1503
|
+
if (!graph.nodes.length) return "";
|
|
1504
|
+
const opts = options || {};
|
|
1505
|
+
const compact = !!opts.compact;
|
|
1506
|
+
const cellX = compact ? 44 : 76, cellY = compact ? 26 : 54;
|
|
1507
|
+
const roomW = compact ? 34 : 62, roomH = compact ? 14 : 26;
|
|
1508
|
+
const maxX = Math.max.apply(null, graph.nodes.map(function (n) { return n.x; }));
|
|
1509
|
+
const maxY = Math.max.apply(null, graph.nodes.map(function (n) { return n.y; }));
|
|
1510
|
+
const w = (maxX + 1) * cellX, h = (maxY + 1) * cellY;
|
|
1511
|
+
const byRoom = {};
|
|
1512
|
+
for (const n of graph.nodes) byRoom[n.id] = n;
|
|
1513
|
+
const cx = function (n) { return (n.x + 0.5) * cellX; };
|
|
1514
|
+
const cy = function (n) { return (n.y + 0.5) * cellY; };
|
|
1515
|
+
|
|
1516
|
+
// The turf band is only drawn when the layout genuinely separates the
|
|
1517
|
+
// surface from the soil — a lateral dig can push a surface room onto a
|
|
1518
|
+
// lower row, and a ground line above it would say something false. The
|
|
1519
|
+
// room fills carry the same information either way, which is all the
|
|
1520
|
+
// thumbnail-sized copy has room for.
|
|
1521
|
+
const surfaceRows = graph.nodes.filter(function (n) { return n.level === 0; }).map(function (n) { return n.y; });
|
|
1522
|
+
const soilRows = graph.nodes.filter(function (n) { return n.level !== 0; }).map(function (n) { return n.y; });
|
|
1523
|
+
let turf = "";
|
|
1524
|
+
if (!compact && surfaceRows.length && soilRows.length && Math.max.apply(null, surfaceRows) < Math.min.apply(null, soilRows)) {
|
|
1525
|
+
const groundY = (Math.max.apply(null, surfaceRows) + 0.5) * cellY + roomH / 2 + (compact ? 3 : 7);
|
|
1526
|
+
turf = '<rect class="turf" x="0" y="0" width="' + w + '" height="' + groundY + '"></rect>'
|
|
1527
|
+
+ '<line class="ground-line" x1="0" y1="' + groundY + '" x2="' + w + '" y2="' + groundY + '"></line>';
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const tunnels = graph.edges.map(function (e) {
|
|
1531
|
+
const a = byRoom[e.from], b = byRoom[e.to];
|
|
1532
|
+
if (!a || !b) return "";
|
|
1533
|
+
const shaft = e.direction === "up" || e.direction === "down" ? " shaft" : "";
|
|
1534
|
+
return '<line class="tunnel' + shaft + '" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '"></line>';
|
|
1535
|
+
}).join("");
|
|
1536
|
+
|
|
1537
|
+
const HINT_DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
|
|
1538
|
+
const hints = graph.hints.map(function (hi) {
|
|
1539
|
+
const from = byRoom[hi.from];
|
|
1540
|
+
if (!from) return "";
|
|
1541
|
+
const d = HINT_DELTA[hi.direction] || [0, 0];
|
|
1542
|
+
return '<circle class="hint" cx="' + (cx(from) + d[0] * cellX * 0.36) + '" cy="' + (cy(from) + d[1] * cellY * 0.36) + '" r="' + (compact ? 2 : 3.2) + '"></circle>';
|
|
1543
|
+
}).join("");
|
|
1544
|
+
|
|
1545
|
+
const occupants = opts.occupants || {};
|
|
1546
|
+
const rooms = graph.nodes.map(function (n) {
|
|
1547
|
+
const here = opts.here === n.id ? " here" : "";
|
|
1548
|
+
const fresh = opts.fresh === n.id ? " freshly-dug" : "";
|
|
1549
|
+
const surface = n.level === 0 ? " surface" : "";
|
|
1550
|
+
const label = esc(n.id);
|
|
1551
|
+
const fit = label.length * (compact ? 3.6 : 4.3) > roomW - 6
|
|
1552
|
+
? ' textLength="' + (roomW - 6) + '" lengthAdjust="spacingAndGlyphs"' : "";
|
|
1553
|
+
const cast = occupants[n.id] || [];
|
|
1554
|
+
// The room's own width is the budget: a dozen animals down one hole must
|
|
1555
|
+
// still read as a dozen dots inside that room rather than a line running
|
|
1556
|
+
// out of it, so the gap closes as the crowd grows.
|
|
1557
|
+
const step = Math.min(compact ? 6 : 9, (roomW - 6) / Math.max(1, cast.length));
|
|
1558
|
+
const dots = cast.map(function (c, i) {
|
|
1559
|
+
const spread = (i - (cast.length - 1) / 2) * step;
|
|
1560
|
+
return '<circle class="occupant" cx="' + (cx(n) + spread) + '" cy="' + (cy(n) + roomH / 2) + '" r="' + (compact ? 2.4 : 3.6)
|
|
1561
|
+
+ '" fill="' + c.color + '"><title>' + esc(c.character) + "</title></circle>";
|
|
1562
|
+
}).join("");
|
|
1563
|
+
return '<g class="room' + surface + here + fresh + '" data-room="' + label + '"><rect x="' + (cx(n) - roomW / 2) + '" y="' + (cy(n) - roomH / 2)
|
|
1564
|
+
+ '" width="' + roomW + '" height="' + roomH + '" rx="3"></rect><text x="' + cx(n) + '" y="' + (cy(n) + (compact ? 2 : 2.5))
|
|
1565
|
+
+ '"' + fit + ">" + label + "</text>" + dots + "</g>";
|
|
1566
|
+
}).join("");
|
|
1567
|
+
|
|
1568
|
+
return '<div class="burrow"><svg viewBox="0 0 ' + w + " " + h + '" preserveAspectRatio="xMidYMid meet" role="img" aria-label="'
|
|
1569
|
+
+ esc(opts.label || "the burrow") + '">' + turf + tunnels + hints + rooms + "</svg></div>";
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function renderMinimap(character, rows, state, here) {
|
|
1573
|
+
const visited = session.windows[character].visitedRoomIds();
|
|
1574
|
+
const graph = burrowGraph(state, visited, DATA.rootRoom);
|
|
1575
|
+
el(paneIdFor(character) + "-minimap").innerHTML = burrowSvg(graph, {
|
|
1576
|
+
compact: true, here: here, label: "the ground " + character + " has covered",
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function renderWorldMap(rows, state, roomIds) {
|
|
1581
|
+
const graph = burrowGraph(state, roomIds, DATA.rootRoom);
|
|
1582
|
+
const occupants = {};
|
|
1583
|
+
for (const room of roomIds) {
|
|
1584
|
+
const here = charactersInRoom(state, room, everyone());
|
|
1585
|
+
if (!here.length) continue;
|
|
1586
|
+
occupants[room] = here.map(function (c) { return { character: c, color: colorFor(c) }; });
|
|
1587
|
+
}
|
|
1588
|
+
el("worldMapBoard").innerHTML = burrowSvg(graph, {
|
|
1589
|
+
occupants: occupants, fresh: freshlyDugRoom, label: "every room in the burrow, and who stands where",
|
|
1590
|
+
});
|
|
1591
|
+
// One key entry per pane, because a pane is what a colour identifies. The
|
|
1592
|
+
// npcs share the last entry: naming ten of them would take more room than
|
|
1593
|
+
// the survey they annotate, and each one's own dot already carries its name.
|
|
1594
|
+
const keys = cast.map(function (c) {
|
|
1595
|
+
const room = state.placements.get(c) ? state.placements.get(c).object : "?";
|
|
1596
|
+
const fate = window.tmctMud.outOfPlayReasonOf(state, c);
|
|
1597
|
+
const where = fate ? fate : "in " + esc(room);
|
|
1598
|
+
return '<span class="key-actor"><span class="key-dot" style="background:' + colorFor(c) + '"></span>'
|
|
1599
|
+
+ esc(speciesOfCharacter(c)) + " " + where + "</span>";
|
|
1600
|
+
});
|
|
1601
|
+
if (npcs.length) {
|
|
1602
|
+
const live = npcs.filter(function (c) { return !finished[c]; }).length;
|
|
1603
|
+
const tally = live === npcs.length ? String(npcs.length) : live + " of " + npcs.length;
|
|
1604
|
+
keys.push('<span class="key-npcs"><span class="key-dot" style="background:' + NPC_COLOR + '"></span>'
|
|
1605
|
+
+ tally + (live === 1 ? " npc" : " npcs") + " digging</span>");
|
|
1606
|
+
}
|
|
1607
|
+
el("worldMapKey").innerHTML = keys.join("");
|
|
1608
|
+
el("worldMapTurn").textContent = "turn " + globalTurn;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// A whole redraw per turn is one thing at two characters and quite another at
|
|
1612
|
+
// fourteen, where the ticks land faster than a snapshot can be read. Requests
|
|
1613
|
+
// arriving mid-render collapse into a single follow-up, so the page always
|
|
1614
|
+
// ends up drawing the newest state and never queues a backlog of stale ones.
|
|
1615
|
+
let rendering = false;
|
|
1616
|
+
let renderAgain = false;
|
|
1617
|
+
async function renderSoon() {
|
|
1618
|
+
if (rendering) { renderAgain = true; return; }
|
|
1619
|
+
rendering = true;
|
|
1620
|
+
try {
|
|
1621
|
+
await renderAll();
|
|
1622
|
+
} finally {
|
|
1623
|
+
rendering = false;
|
|
1624
|
+
}
|
|
1625
|
+
if (renderAgain) { renderAgain = false; renderSoon(); }
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// A character eaten on its OWN turn is out of play the moment that turn
|
|
1629
|
+
// returns, one whole tick before autoplayTick would report it — so every
|
|
1630
|
+
// character's fate is read from the engine every redraw, not only off a tick
|
|
1631
|
+
// result. It reads against the snapshot this redraw already holds: a
|
|
1632
|
+
// per-window call would re-fold the whole world once per animal.
|
|
1633
|
+
async function renderAll() {
|
|
1634
|
+
if (!session) return;
|
|
1635
|
+
el("globalTurnCount").textContent = "turns: " + globalTurn;
|
|
1636
|
+
const snap = await session.snapshot();
|
|
1637
|
+
lastSnapshot = snap;
|
|
1638
|
+
const roomIds = allRoomIds(snap.rows);
|
|
1639
|
+
for (const character of everyone()) {
|
|
1640
|
+
const reason = window.tmctMud.outOfPlayReasonOf(snap.state, character);
|
|
1641
|
+
if (reason) markOutOfPlay(character, reason, null);
|
|
1642
|
+
}
|
|
1643
|
+
renderWorldMap(snap.rows, snap.state, roomIds);
|
|
1644
|
+
for (const character of cast) {
|
|
1645
|
+
el(paneIdFor(character) + "-turn").textContent = "turn " + turnsFor(character);
|
|
1646
|
+
if (finished[character]) continue;
|
|
1647
|
+
const place = snap.state.placements.get(character);
|
|
1648
|
+
const here = place ? place.object : null;
|
|
1649
|
+
if (!here) continue;
|
|
1650
|
+
const roomMates = window.tmctMud.castInRoom(snap.rows, snap.state, here, character);
|
|
1651
|
+
renderRoomView(character, snap.rows, snap.state, here, roomIds, roomMates);
|
|
1652
|
+
renderDirections(character, snap.rows, snap.state, here);
|
|
1653
|
+
renderChatPills(character, snap.rows, snap.state, here, roomIds);
|
|
1654
|
+
renderPouch(character, snap.rows, snap.state, roomIds);
|
|
1655
|
+
renderMinimap(character, snap.rows, snap.state, here);
|
|
1656
|
+
}
|
|
1657
|
+
freshlyDugRoom = null;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// ---- edit mode -----------------------------------------------------------
|
|
1661
|
+
// The textarea is seeded from renderMudEditorText over the world's OWN facts.
|
|
1662
|
+
// Reads are scoped to this world's provenance tag the same way session
|
|
1663
|
+
// .applyEdit scopes its writes: the live store also carries the default
|
|
1664
|
+
// persona's background corpus, and this page must never show, or risk
|
|
1665
|
+
// retracting, a fact that is not part of the burrow.
|
|
1666
|
+
//
|
|
1667
|
+
// Typing debounces two things at two paces. The suggestion pills are cheap
|
|
1668
|
+
// in-memory lookups and can chase the cursor (~180ms). The store sync is the
|
|
1669
|
+
// one that writes, so it waits for the typing to settle (~450ms) — a
|
|
1670
|
+
// half-finished word must never be read as what somebody meant.
|
|
1671
|
+
let editRows = [];
|
|
1672
|
+
let editState = { placements: new Map(), openness: new Map(), masses: new Map(), exits: new Map(), turnCount: 0 };
|
|
1673
|
+
let allStoreRows = [];
|
|
1674
|
+
let selectedRoomId = null;
|
|
1675
|
+
let editing = false;
|
|
1676
|
+
|
|
1677
|
+
function worldOnlyRows(rows) {
|
|
1678
|
+
const prefix = "world:" + DATA.worldPayload.name;
|
|
1679
|
+
return (rows || []).filter(function (r) {
|
|
1680
|
+
return typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0;
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function renderEditMap() {
|
|
1685
|
+
const graph = burrowGraph(editState, allRoomIds(editRows), DATA.rootRoom);
|
|
1686
|
+
el("editMapBoard").innerHTML = graph.nodes.length
|
|
1687
|
+
? burrowSvg(graph, { here: selectedRoomId, label: "every room this world writes" })
|
|
1688
|
+
: '<span class="edit-empty">no rooms written yet</span>';
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// A class card, the legend's and the room detail's shared unit — the sprite
|
|
1692
|
+
// frame the panes already use, shrunk, with the subject's own name under it.
|
|
1693
|
+
function editCardHtml(subject, label, svg) {
|
|
1694
|
+
return '<div class="sprite-card" title="' + esc(subject) + '"><div class="sprite-frame">' + svg
|
|
1695
|
+
+ '</div><div class="sprite-label">' + esc(label) + "</div></div>";
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// Every class this world actually puts in a room, by its own icon. A creature
|
|
1699
|
+
// resolves on its species (the same read every pane's own sprite makes), a
|
|
1700
|
+
// prop on its declared class — so the legend shows what the survey shows.
|
|
1701
|
+
function renderEditLegend() {
|
|
1702
|
+
const classes = {};
|
|
1703
|
+
for (const subject of editState.placements.keys()) {
|
|
1704
|
+
const cls = isCreature(editState, subject) ? speciesOfCharacter(subject) : spriteClassForObject(editRows, subject);
|
|
1705
|
+
if (cls) classes[cls] = true;
|
|
1706
|
+
}
|
|
1707
|
+
const names = Object.keys(classes).sort();
|
|
1708
|
+
el("editLegend").innerHTML = names.length
|
|
1709
|
+
? names.map(function (cls) { return editCardHtml(cls, cls, spriteSvgFor(cls, editRows, "legend-" + cls)); }).join("")
|
|
1710
|
+
: '<span class="edit-empty">nothing placed yet</span>';
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function renderRoomDetail() {
|
|
1714
|
+
const titleEl = el("roomDetailTitle");
|
|
1715
|
+
const castEl = el("roomDetailCast");
|
|
1716
|
+
const captionEl = el("roomDetailCaption");
|
|
1717
|
+
if (!selectedRoomId) {
|
|
1718
|
+
titleEl.textContent = "click a room";
|
|
1719
|
+
castEl.innerHTML = "";
|
|
1720
|
+
captionEl.textContent = "Click a room on the survey to see what stands in it.";
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
titleEl.textContent = selectedRoomId;
|
|
1724
|
+
const roomIds = allRoomIds(editRows);
|
|
1725
|
+
const here = [];
|
|
1726
|
+
for (const subject of editState.placements.keys()) {
|
|
1727
|
+
if (visibleRoomOf(editRows, editState, subject) !== selectedRoomId) continue;
|
|
1728
|
+
const creature = isCreature(editState, subject);
|
|
1729
|
+
here.push({
|
|
1730
|
+
subject: subject,
|
|
1731
|
+
label: creature ? subject : labelForItem(editRows, subject, roomIds),
|
|
1732
|
+
svg: creature ? spriteSvgFor(speciesOfCharacter(subject), editRows, subject) : objectSvgFor(subject, editRows),
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
here.sort(function (a, b) { return a.subject.localeCompare(b.subject); });
|
|
1736
|
+
castEl.innerHTML = here.map(function (t) { return editCardHtml(t.subject, t.label, t.svg); }).join("");
|
|
1737
|
+
const kind = roomKindForRoom(editRows, selectedRoomId);
|
|
1738
|
+
const ways = [...(editState.exits.get(selectedRoomId) || new Map()).keys()].sort();
|
|
1739
|
+
captionEl.textContent = "An " + kind + " room. "
|
|
1740
|
+
+ (ways.length ? "Ways out: " + ways.join(", ") + ". " : "No way out written yet. ")
|
|
1741
|
+
+ (here.length ? here.length + " thing" + (here.length === 1 ? "" : "s") + " placed here." : "Nothing placed here.");
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function refreshEditPanels() {
|
|
1745
|
+
renderEditMap();
|
|
1746
|
+
renderEditLegend();
|
|
1747
|
+
renderRoomDetail();
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
el("editMapBoard").addEventListener("click", function (e) {
|
|
1751
|
+
const g = e.target.closest("[data-room]");
|
|
1752
|
+
if (!g) return;
|
|
1753
|
+
selectedRoomId = g.getAttribute("data-room");
|
|
1754
|
+
renderEditMap();
|
|
1755
|
+
renderRoomDetail();
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
// The lateral SKOS neighbourhood plus the vertical is-a chain for whatever
|
|
1759
|
+
// word the cursor sits behind. Read over the FULL store, not the world's own
|
|
1760
|
+
// rows: a term's synonyms mostly live in the background corpus, not in the
|
|
1761
|
+
// burrow's own vocabulary. Nothing found is nothing shown — an honest miss.
|
|
1762
|
+
function renderSuggestionPills() {
|
|
1763
|
+
const box = el("editorPills");
|
|
1764
|
+
const term = wordBeforeCursor(el("editorText").value, el("editorText").selectionStart);
|
|
1765
|
+
if (!term || !window.tmctMud) { box.innerHTML = ""; return; }
|
|
1766
|
+
const related = window.tmctMud.relatedForTerm ? window.tmctMud.relatedForTerm(allStoreRows, term) : null;
|
|
1767
|
+
const chain = window.tmctMud.classAncestorChain ? window.tmctMud.classAncestorChain(term, allStoreRows) : [];
|
|
1768
|
+
const seen = { };
|
|
1769
|
+
seen[term] = true;
|
|
1770
|
+
const out = [];
|
|
1771
|
+
const push = function (label) { if (label && !seen[label]) { seen[label] = true; out.push(label); } };
|
|
1772
|
+
if (related) {
|
|
1773
|
+
related.synonyms.forEach(push);
|
|
1774
|
+
related.related.forEach(function (r) { push(r.prefLabel); });
|
|
1775
|
+
}
|
|
1776
|
+
chain.slice(1).forEach(push);
|
|
1777
|
+
box.innerHTML = out.slice(0, 8).map(function (s) {
|
|
1778
|
+
return '<button type="button" class="pill" data-insert="' + esc(s) + '">' + esc(s) + "</button>";
|
|
1779
|
+
}).join("");
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
el("editorPills").addEventListener("click", function (e) {
|
|
1783
|
+
const btn = e.target.closest(".pill");
|
|
1784
|
+
if (!btn) return;
|
|
1785
|
+
const area = el("editorText");
|
|
1786
|
+
const pos = area.selectionStart;
|
|
1787
|
+
const word = wordBeforeCursor(area.value, pos);
|
|
1788
|
+
const insert = btn.getAttribute("data-insert");
|
|
1789
|
+
area.value = area.value.slice(0, pos - word.length) + insert + area.value.slice(pos);
|
|
1790
|
+
const next = pos - word.length + insert.length;
|
|
1791
|
+
area.setSelectionRange(next, next);
|
|
1792
|
+
area.focus();
|
|
1793
|
+
onEditorChanged();
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
async function applyEditorText() {
|
|
1797
|
+
if (!session) return;
|
|
1798
|
+
const status = el("editorStatus");
|
|
1799
|
+
status.className = "edit-status pending";
|
|
1800
|
+
status.textContent = "reading the burrow\\u2026";
|
|
1801
|
+
const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
|
|
1802
|
+
const snap = await session.snapshot();
|
|
1803
|
+
allStoreRows = snap.rows;
|
|
1804
|
+
editRows = worldOnlyRows(snap.rows);
|
|
1805
|
+
editState = window.tmctMud.foldWorldState(window.tmctMud.worldActionRows(editRows));
|
|
1806
|
+
if (result.unrecognized.length) {
|
|
1807
|
+
const lines = result.unrecognized.map(function (u) { return u.line; }).join(", ");
|
|
1808
|
+
status.className = "edit-status pending";
|
|
1809
|
+
status.textContent = result.unrecognized.length + " line"
|
|
1810
|
+
+ (result.unrecognized.length === 1 ? "" : "s") + " not understood yet (line " + lines
|
|
1811
|
+
+ ") \\u2014 nothing is retracted until they are.";
|
|
1812
|
+
} else {
|
|
1813
|
+
status.className = "edit-status ok";
|
|
1814
|
+
status.textContent = (result.added || result.removed)
|
|
1815
|
+
? "synced \\u2014 " + result.added + " fact(s) written, " + result.removed + " retracted."
|
|
1816
|
+
: "synced \\u2014 no change.";
|
|
1817
|
+
}
|
|
1818
|
+
refreshEditPanels();
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
let suggestTimer = null;
|
|
1822
|
+
let syncTimer = null;
|
|
1823
|
+
function scheduleSuggestions() { clearTimeout(suggestTimer); suggestTimer = setTimeout(renderSuggestionPills, 180); }
|
|
1824
|
+
function scheduleSync() { clearTimeout(syncTimer); syncTimer = setTimeout(applyEditorText, 450); }
|
|
1825
|
+
function onEditorChanged() { scheduleSuggestions(); scheduleSync(); }
|
|
1826
|
+
el("editorText").addEventListener("input", onEditorChanged);
|
|
1827
|
+
el("editorText").addEventListener("click", scheduleSuggestions);
|
|
1828
|
+
el("editorText").addEventListener("keyup", function (e) {
|
|
1829
|
+
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].indexOf(e.key) !== -1) scheduleSuggestions();
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
// Nothing may tick while the world is being rewritten: a turn and an edit both
|
|
1833
|
+
// write the same store, and a turn landing mid-sync would fold against rows
|
|
1834
|
+
// the textarea no longer describes. So every ticker stops and every control
|
|
1835
|
+
// that could start one is disabled for as long as the editor is open.
|
|
1836
|
+
function setPlayControlsEnabled(on) {
|
|
1837
|
+
el("autoToggle").disabled = !on;
|
|
1838
|
+
el("playerCountSlider").disabled = !on;
|
|
1839
|
+
el("resetBtn").disabled = !on;
|
|
1840
|
+
for (const character of cast) {
|
|
1841
|
+
if (finished[character]) continue;
|
|
1842
|
+
el(paneIdFor(character) + "-play").disabled = !on;
|
|
1843
|
+
el(paneIdFor(character) + "-step").disabled = !on;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
async function enterEditMode() {
|
|
1848
|
+
editing = true;
|
|
1849
|
+
autoOn = false;
|
|
1850
|
+
const playBtn = el("autoToggle");
|
|
1851
|
+
playBtn.setAttribute("aria-pressed", "false");
|
|
1852
|
+
playBtn.textContent = "\\u25B6 play";
|
|
1853
|
+
for (const character of cast) {
|
|
1854
|
+
const ticker = tickers[character];
|
|
1855
|
+
if (ticker) ticker.pause();
|
|
1856
|
+
}
|
|
1857
|
+
setPlayControlsEnabled(false);
|
|
1858
|
+
const snap = await session.snapshot();
|
|
1859
|
+
allStoreRows = snap.rows;
|
|
1860
|
+
editRows = worldOnlyRows(snap.rows);
|
|
1861
|
+
editState = window.tmctMud.foldWorldState(window.tmctMud.worldActionRows(editRows));
|
|
1862
|
+
el("editorText").value = renderMudEditorText(editRows, editState);
|
|
1863
|
+
el("editorStatus").className = "edit-status";
|
|
1864
|
+
el("editorStatus").textContent = "";
|
|
1865
|
+
el("editorPills").innerHTML = "";
|
|
1866
|
+
selectedRoomId = null;
|
|
1867
|
+
document.body.classList.add("editing");
|
|
1868
|
+
el("editModeBtn").textContent = "back to playing";
|
|
1869
|
+
el("editModeBtn").setAttribute("aria-pressed", "true");
|
|
1870
|
+
refreshEditPanels();
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
async function exitEditMode() {
|
|
1874
|
+
editing = false;
|
|
1875
|
+
clearTimeout(syncTimer);
|
|
1876
|
+
// One last sync on the way out, so a change typed in the final half-second
|
|
1877
|
+
// before the button is clicked reaches the world like every other one.
|
|
1878
|
+
await applyEditorText();
|
|
1879
|
+
document.body.classList.remove("editing");
|
|
1880
|
+
el("editModeBtn").textContent = "edit";
|
|
1881
|
+
el("editModeBtn").setAttribute("aria-pressed", "false");
|
|
1882
|
+
setPlayControlsEnabled(true);
|
|
1883
|
+
await renderAll();
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// ---- the control deck ----------------------------------------------------
|
|
1887
|
+
// The players slider runs on detents (1, 2, 4), so its own value is the
|
|
1888
|
+
// index of the count, never the count.
|
|
1889
|
+
function chosenPlayerCount() {
|
|
1890
|
+
const picked = DATA.playerCounts[Number(el("playerCountSlider").value)];
|
|
1891
|
+
return picked || DATA.defaultPlayerCount;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function showPlayerCount(count) {
|
|
1895
|
+
el("playerCountValue").textContent = String(count);
|
|
1896
|
+
el("playerCountSlider").setAttribute("aria-valuetext", count + (count === 1 ? " player" : " players"));
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// The npcs slider carries its own count, so it needs no detent table.
|
|
1900
|
+
function chosenNpcCount() {
|
|
1901
|
+
const picked = Number(el("npcCountSlider").value);
|
|
1902
|
+
return Number.isFinite(picked) ? picked : DATA.defaultNpcCount;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function showNpcCount(count) {
|
|
1906
|
+
el("npcCountValue").textContent = String(count);
|
|
1907
|
+
el("npcCountSlider").setAttribute("aria-valuetext", count + (count === 1 ? " npc" : " npcs"));
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function wireDeck() {
|
|
1911
|
+
const playBtn = el("autoToggle");
|
|
1912
|
+
const delaySlider = el("delaySlider");
|
|
1913
|
+
const maxTurnsSlider = el("maxTurnsSlider");
|
|
1914
|
+
const playerCountSlider = el("playerCountSlider");
|
|
1915
|
+
const npcCountSlider = el("npcCountSlider");
|
|
1916
|
+
playBtn.addEventListener("click", function () {
|
|
1917
|
+
if (!session) return;
|
|
1918
|
+
autoOn = !autoOn;
|
|
1919
|
+
playBtn.setAttribute("aria-pressed", autoOn ? "true" : "false");
|
|
1920
|
+
playBtn.textContent = autoOn ? "\\u23F8 pause" : "\\u25B6 play";
|
|
1921
|
+
// Every animal in the world, pane or none: the deck's play control is the
|
|
1922
|
+
// world running, and an npc has no other control that could start it.
|
|
1923
|
+
for (const character of everyone()) {
|
|
1924
|
+
const ticker = tickers[character];
|
|
1925
|
+
if (!ticker) continue;
|
|
1926
|
+
if (autoOn) ticker.play(); else ticker.pause();
|
|
1927
|
+
}
|
|
1928
|
+
});
|
|
1929
|
+
delaySlider.addEventListener("input", function () {
|
|
1930
|
+
delayMs = Number(delaySlider.value);
|
|
1931
|
+
el("delayValue").textContent = delayMs + "ms";
|
|
1932
|
+
});
|
|
1933
|
+
maxTurnsSlider.addEventListener("input", function () {
|
|
1934
|
+
maxTurns = Number(maxTurnsSlider.value);
|
|
1935
|
+
el("maxTurnsValue").textContent = String(maxTurns);
|
|
1936
|
+
});
|
|
1937
|
+
// The readout follows the thumb, but a new cast waits for the drag to
|
|
1938
|
+
// settle: booting on every intermediate detent would open, and throw
|
|
1939
|
+
// away, a whole shared world per step.
|
|
1940
|
+
playerCountSlider.addEventListener("input", function () { showPlayerCount(chosenPlayerCount()); });
|
|
1941
|
+
playerCountSlider.addEventListener("change", function () { boot(); });
|
|
1942
|
+
npcCountSlider.addEventListener("input", function () { showNpcCount(chosenNpcCount()); });
|
|
1943
|
+
npcCountSlider.addEventListener("change", function () { boot(); });
|
|
1944
|
+
el("resetBtn").addEventListener("click", function () { boot(); });
|
|
1945
|
+
el("editModeBtn").addEventListener("click", function () {
|
|
1946
|
+
if (!session) return;
|
|
1947
|
+
if (editing) exitEditMode(); else enterEditMode();
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
const infoBtn = el("deckInfoBtn");
|
|
1951
|
+
const infoPopup = el("deckInfoPopup");
|
|
1952
|
+
function openDeckInfo() {
|
|
1953
|
+
infoPopup.hidden = false;
|
|
1954
|
+
infoBtn.setAttribute("aria-expanded", "true");
|
|
1955
|
+
}
|
|
1956
|
+
function closeDeckInfo() {
|
|
1957
|
+
infoPopup.hidden = true;
|
|
1958
|
+
infoBtn.setAttribute("aria-expanded", "false");
|
|
1959
|
+
}
|
|
1960
|
+
infoBtn.addEventListener("click", function () { if (infoPopup.hidden) openDeckInfo(); else closeDeckInfo(); });
|
|
1961
|
+
el("deckInfoClose").addEventListener("click", closeDeckInfo);
|
|
1962
|
+
document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !infoPopup.hidden) closeDeckInfo(); });
|
|
1963
|
+
document.addEventListener("click", function (e) {
|
|
1964
|
+
if (infoPopup.hidden) return;
|
|
1965
|
+
if (e.target === infoBtn || infoPopup.contains(e.target)) return;
|
|
1966
|
+
closeDeckInfo();
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// The stage is the cast's, so it is rebuilt for the cast: one paneMarkup
|
|
1971
|
+
// call per animal, the count on the stage itself for the grid to lay out
|
|
1972
|
+
// against, and a fresh set of listeners for the panes that just appeared.
|
|
1973
|
+
function renderStage() {
|
|
1974
|
+
const stage = el("mudStage");
|
|
1975
|
+
stage.setAttribute("data-panes", String(slots.length));
|
|
1976
|
+
stage.innerHTML = slots.map(function (slot) { return paneMarkup(slot); }).join("");
|
|
1977
|
+
for (const slot of slots) wirePane(slot);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function bindPanes() {
|
|
1981
|
+
for (let i = 0; i < slots.length; i += 1) {
|
|
1982
|
+
const w = "window-" + slots[i];
|
|
1983
|
+
const character = cast[i];
|
|
1984
|
+
const pane = el(w);
|
|
1985
|
+
pane.setAttribute("data-character", character);
|
|
1986
|
+
pane.setAttribute("aria-label", speciesOfCharacter(character) + "'s own view of the shared world");
|
|
1987
|
+
el(w + "-name").innerHTML = esc(speciesOfCharacter(character)) + '<span class="mono char-id">' + esc(character) + "</span>";
|
|
1988
|
+
el(w + "-chatq").setAttribute("aria-label", "type a command for " + character);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// Booting builds the shared world and draws the opening state. It never
|
|
1993
|
+
// starts a turn: nothing ticks until the deck's own play control (or a
|
|
1994
|
+
// pane's) is clicked. Opening the world is asynchronous and the controls
|
|
1995
|
+
// that boot are not, so a boot overtaken by a later one drops its own
|
|
1996
|
+
// session on the floor rather than binding it to the stage the newer boot
|
|
1997
|
+
// has already drawn.
|
|
1998
|
+
let bootSeq = 0;
|
|
1999
|
+
async function boot() {
|
|
2000
|
+
const seq = bootSeq += 1;
|
|
2001
|
+
autoOn = false;
|
|
2002
|
+
const playBtn = el("autoToggle");
|
|
2003
|
+
playBtn.setAttribute("aria-pressed", "false");
|
|
2004
|
+
playBtn.textContent = "\\u25B6 play";
|
|
2005
|
+
for (const character of Object.keys(tickers)) tickers[character].pause();
|
|
2006
|
+
tickers = {};
|
|
2007
|
+
globalTurn = 0;
|
|
2008
|
+
freshlyDugRoom = null;
|
|
2009
|
+
lastSnapshot = null;
|
|
2010
|
+
speechBubbles.clear();
|
|
2011
|
+
tickChain = Promise.resolve();
|
|
2012
|
+
// Rebuilt rather than pruned: a character that had a pane last boot can be
|
|
2013
|
+
// an npc in this one, and a stale seat would send its ending to a pane now
|
|
2014
|
+
// showing somebody else.
|
|
2015
|
+
finished = {};
|
|
2016
|
+
turnsTaken = {};
|
|
2017
|
+
slotOf = {};
|
|
2018
|
+
|
|
2019
|
+
// ONE draw, the engine's own, and the same list the session is built from:
|
|
2020
|
+
// a second independent draw here would leave the page showing animals the
|
|
2021
|
+
// shared world was never opened for. The stage follows the draw rather
|
|
2022
|
+
// than the slider, so a roster too small for the chosen count draws every
|
|
2023
|
+
// animal it has and no empty pane. The npcs draw from what the panes left,
|
|
2024
|
+
// out of a roster grown to hold everyone, so no animal is ever both.
|
|
2025
|
+
const npcCount = chosenNpcCount();
|
|
2026
|
+
cast = window.tmctMud.pickMudRoster(roster, { count: chosenPlayerCount() });
|
|
2027
|
+
const pool = window.tmctMud.expandMudRoster(roster, cast.length + npcCount)
|
|
2028
|
+
.filter(function (id) { return cast.indexOf(id) === -1; });
|
|
2029
|
+
npcs = window.tmctMud.pickMudRoster(pool, { count: npcCount });
|
|
2030
|
+
slots = DATA.paneSlots.slice(0, cast.length);
|
|
2031
|
+
showPlayerCount(cast.length);
|
|
2032
|
+
showNpcCount(npcs.length);
|
|
2033
|
+
renderStage();
|
|
2034
|
+
const opened = await window.tmctMud.createMudSession(DATA.worldPayload, { characters: everyone() });
|
|
2035
|
+
if (seq !== bootSeq) return;
|
|
2036
|
+
session = opened;
|
|
2037
|
+
for (let i = 0; i < cast.length; i += 1) slotOf[cast[i]] = slots[i];
|
|
2038
|
+
bindPanes();
|
|
2039
|
+
for (const character of cast) {
|
|
2040
|
+
const w = paneIdFor(character);
|
|
2041
|
+
el(w + "-play").disabled = false;
|
|
2042
|
+
el(w + "-step").disabled = false;
|
|
2043
|
+
el(w + "-chatq").disabled = false;
|
|
2044
|
+
}
|
|
2045
|
+
for (const character of everyone()) {
|
|
2046
|
+
turnsTaken[character] = 0;
|
|
2047
|
+
ensureTicker(character);
|
|
2048
|
+
}
|
|
2049
|
+
renderSoon();
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
wireDeck();
|
|
2053
|
+
boot();
|
|
2054
|
+
})();`;
|
|
2055
|
+
}
|