@polycode-projects/the-mechanical-code-talker 3.3.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -8
- package/corpus/reference/index.json.gz +0 -0
- package/corpus/reference/manifest.json +8 -8
- package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
- package/corpus/sprites/src/sprite-facts.jsonl +52 -0
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +53 -13
- package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
- package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
- package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
- package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
- package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
- package/corpus/worlds/src/mud-garden.jsonl +34 -1
- package/corpus/worlds/src/mud-hollow.jsonl +82 -0
- package/corpus/worlds/src/mud-warren.jsonl +124 -0
- package/corpus/worlds/src/spider-fly.jsonl +1 -1
- package/data/sprites/animal-icon.toml +11 -5
- package/data/sprites/book-icon.toml +9 -5
- package/data/sprites/cabinet-icon.toml +10 -5
- package/data/sprites/cellar-icon.toml +16 -6
- package/data/sprites/container-icon.toml +7 -3
- package/data/sprites/desk-icon.toml +8 -5
- package/data/sprites/dog-icon.toml +8 -5
- package/data/sprites/dog-with-colour-icon.toml +13 -12
- package/data/sprites/drawing-room-icon.toml +14 -5
- package/data/sprites/egg-icon.toml +6 -3
- package/data/sprites/fly-icon.toml +10 -5
- package/data/sprites/furniture-icon.toml +5 -3
- package/data/sprites/garden-icon.toml +10 -3
- package/data/sprites/key-icon.toml +3 -1
- package/data/sprites/kitchen-icon.toml +15 -6
- package/data/sprites/lamp-icon.toml +9 -4
- package/data/sprites/letter-icon.toml +7 -4
- package/data/sprites/library-icon.toml +14 -3
- package/data/sprites/pan-icon.toml +7 -3
- package/data/sprites/person-icon.toml +6 -2
- package/data/sprites/poodle-icon.toml +1 -1
- package/data/sprites/portable-icon.toml +6 -4
- package/data/sprites/portrait-icon.toml +7 -4
- package/data/sprites/room-icon.toml +11 -2
- package/data/sprites/spider-icon.toml +9 -3
- package/data/sprites/study-icon.toml +10 -2
- package/package.json +5 -4
- package/src/adapters/p2p/webrtc-transport.mjs +146 -0
- package/src/domain/game-config.mjs +26 -5
- package/src/domain/grammar/ace.mjs +40 -4
- package/src/domain/grammar/lexicon-core.json +2 -1
- package/src/domain/grammar/lexicon.mjs +31 -0
- package/src/domain/memory/trust.mjs +6 -4
- 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/reference-pack.mjs +31 -7
- package/src/domain/spider-fly-world.mjs +1 -1
- package/src/domain/sprite-map.mjs +10 -2
- package/src/domain/sprite-templates.mjs +8 -6
- package/src/services/adventure-editor.mjs +10 -2
- package/src/services/adventure-viz.mjs +309 -73
- package/src/services/adventure.mjs +1034 -330
- package/src/services/chat-page-viz.mjs +1097 -16
- package/src/services/chat.mjs +99 -18
- package/src/services/code-explorer-viz.mjs +52 -14
- package/src/services/extract-facts.mjs +4 -7
- package/src/services/ingest-viz.mjs +54 -26
- package/src/services/ledger-viz.mjs +105 -56
- package/src/services/memory-panel-viz.mjs +24 -0
- package/src/services/mud-editor.mjs +313 -0
- package/src/services/mud-turn.mjs +264 -87
- package/src/services/mud-viz.mjs +2606 -384
- package/src/services/p2p-room.mjs +559 -0
- package/src/services/plan-viz.mjs +119 -61
- package/src/services/research-viz.mjs +151 -57
- package/src/services/spider-fly-turn.mjs +1 -1
- package/src/services/spider-fly-viz.mjs +67 -43
- package/src/services/sprite-catalog-viz.mjs +175 -51
- package/src/services/viz-theme.mjs +15 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
- package/src/surfaces/web/mud-browser-entry.mjs +226 -18
- package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
package/src/services/mud-viz.mjs
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
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.
|
|
3
|
-
// burrowing animals
|
|
4
|
-
//
|
|
5
|
-
// (mud-browser-entry.mjs's createMudSession);
|
|
6
|
-
//
|
|
7
|
-
// all, the one deliberate exception
|
|
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.
|
|
8
27
|
//
|
|
9
28
|
// Shaped after adventure-viz.mjs/spider-fly-viz.mjs: one inlined <style>
|
|
10
29
|
// over viz-theme.mjs's shared tokens, behaviour as an inlined IIFE, the
|
|
@@ -15,42 +34,55 @@
|
|
|
15
34
|
// provider and embedded as page data). `createTicker` and a small set of
|
|
16
35
|
// self-contained, `.toString()`-splice-safe room-scene helpers are spliced
|
|
17
36
|
// into the inline script exactly the way spider-fly-viz.mjs splices its own
|
|
18
|
-
// render-glue — `roomSceneObjects`/`spriteClassForObject
|
|
19
|
-
// `roomKindForRoom`
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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).
|
|
25
46
|
//
|
|
26
47
|
// A room's graphic is a rendering of worldDigestRows'/roomAffordances' own
|
|
27
48
|
// text digest, never a replacement for it (PLAN_MUD.md's own "Room view"
|
|
28
|
-
// spec)
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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).
|
|
37
64
|
//
|
|
38
|
-
//
|
|
39
|
-
// each can play/
|
|
40
|
-
//
|
|
41
|
-
// tick, from ANY
|
|
42
|
-
// (serializeTick, spliced below) so two characters' turns
|
|
43
|
-
// interleave their reads/writes of the one shared memoryDir —
|
|
44
|
-
// entry.mjs's own header names this as the caller's
|
|
45
|
-
// is where that responsibility is discharged. The
|
|
46
|
-
// the
|
|
47
|
-
// order turns actually executed, never a race
|
|
48
|
-
|
|
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, scenarioLabel } from "./viz-theme.mjs";
|
|
49
77
|
import { createTicker } from "./viz-ticker.mjs";
|
|
50
78
|
import {
|
|
51
|
-
roomSceneObjects, scenePlacement, spriteClassForObject, spriteAncestryRows,
|
|
52
|
-
visibleRoomOf, roomKindForRoom,
|
|
79
|
+
roomSceneObjects, scenePlacement, spriteClassForObject, spriteAncestryRows, factsForSubject,
|
|
80
|
+
visibleRoomOf, roomKindForRoom, allRoomIds,
|
|
53
81
|
} from "./adventure-viz.mjs";
|
|
82
|
+
import { renderMudEditorText, wordBeforeCursor } from "./mud-editor.mjs";
|
|
83
|
+
import {
|
|
84
|
+
wireStateLabel, nodeRowsFor, nodeInitials, inviteLinkFor, inviteParamsFrom,
|
|
85
|
+
} from "./chat-page-viz.mjs";
|
|
54
86
|
import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
|
|
55
87
|
|
|
56
88
|
const DEFAULT_TITLE = "tmct — the mud";
|
|
@@ -64,13 +96,24 @@ const DISPLAY_STACK = SERIF_STACK;
|
|
|
64
96
|
const SANS_STACK = `"IBM Plex Sans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif`;
|
|
65
97
|
|
|
66
98
|
const ROOT_ROOM = "garden";
|
|
67
|
-
const
|
|
99
|
+
const PANE_SLOTS = ["a", "b", "c", "d"];
|
|
100
|
+
// The players slider's own detents. Every count here divides the two-column
|
|
101
|
+
// grid evenly, so no layout ever carries a half-empty row.
|
|
102
|
+
const PLAYER_COUNTS = [1, 2, 4];
|
|
103
|
+
const DEFAULT_PLAYER_COUNT = 2;
|
|
104
|
+
// The npcs slider is a plain count, not a detent list: nothing lays an NPC
|
|
105
|
+
// out, so every number between its ends is as good as any other.
|
|
106
|
+
const NPC_COUNT_MIN = 1;
|
|
107
|
+
const NPC_COUNT_MAX = 10;
|
|
108
|
+
const NPC_COUNT_LABELLED = [1, 5, 10];
|
|
109
|
+
const DEFAULT_NPC_COUNT = 2;
|
|
68
110
|
const DEFAULT_DELAY_MS = 650;
|
|
69
111
|
const DEFAULT_MAX_TURNS = 400;
|
|
70
112
|
|
|
71
113
|
const MUD_NOTE_LINES = [
|
|
72
|
-
"
|
|
73
|
-
`This is a MUD, short for Multi Underground creature Dig. The name nods to 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.`,
|
|
114
|
+
"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.",
|
|
115
|
+
`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.`,
|
|
116
|
+
"Share the burrow and a second digger joins from another machine, browser to browser, with no server in the middle. Each animal is claimed by one node, one hand on one lemming, and everything either of you digs, eats or learns lands in the same graph.",
|
|
74
117
|
];
|
|
75
118
|
|
|
76
119
|
/** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
|
|
@@ -104,11 +147,10 @@ export function carriedItemsFor(rows, state, character) {
|
|
|
104
147
|
|
|
105
148
|
/** Every room's depth level, `Map<roomId, number>`, BFS from `root` at level
|
|
106
149
|
* 0: an "up"/"down" exit moves the level by ∓ 1, any other direction
|
|
107
|
-
* keeps the level unchanged — the
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* at. Pure, self-contained. */
|
|
150
|
+
* keeps the level unchanged — what tells the survey where the turf line
|
|
151
|
+
* falls. A room unreachable from `root` (should not happen — every dug room
|
|
152
|
+
* writes a two-way exit back) is simply absent from the map rather than
|
|
153
|
+
* guessed at. Pure, self-contained. */
|
|
112
154
|
export function levelsOf(state, root = "garden") {
|
|
113
155
|
const levels = new Map([[root, 0]]);
|
|
114
156
|
const queue = [root];
|
|
@@ -125,65 +167,266 @@ export function levelsOf(state, root = "garden") {
|
|
|
125
167
|
return levels;
|
|
126
168
|
}
|
|
127
169
|
|
|
128
|
-
/**
|
|
129
|
-
*
|
|
130
|
-
* rooms within a level sort by id for a stable, deterministic column order.
|
|
170
|
+
/** Which characters currently stand in `room` — the survey's own per-room
|
|
171
|
+
* roster, both for the omniscient board and a pane's own visited-only one.
|
|
131
172
|
* Pure. */
|
|
132
|
-
export function levelBands(levels) {
|
|
133
|
-
const byLevel = new Map();
|
|
134
|
-
for (const [room, level] of levels) {
|
|
135
|
-
if (!byLevel.has(level)) byLevel.set(level, []);
|
|
136
|
-
byLevel.get(level).push(room);
|
|
137
|
-
}
|
|
138
|
-
return [...byLevel.entries()]
|
|
139
|
-
.sort((a, b) => b[0] - a[0])
|
|
140
|
-
.map(([level, rooms]) => ({ level, rooms: rooms.sort() }));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Which characters currently stand in `room` — the omniscient world map's
|
|
144
|
-
* own per-room roster, and a per-window minimap's own (visited-only)
|
|
145
|
-
* roster. Pure. */
|
|
146
173
|
export function charactersInRoom(state, room, characters) {
|
|
147
174
|
return characters.filter((c) => state.placements.get(c)?.object === room);
|
|
148
175
|
}
|
|
149
176
|
|
|
150
|
-
|
|
151
|
-
|
|
177
|
+
/** The rooms in `roomIds` laid out on an integer grid FROM the world's own
|
|
178
|
+
* has-exit-* directions — never a force-directed guess, so a room north of
|
|
179
|
+
* another sits one row above it and a room dug DOWN sits one row below.
|
|
180
|
+
* Adapted from adventure-viz.mjs's `visitedRoomGraph`, with two differences
|
|
181
|
+
* a burrow needs and a manor does not: a cell already taken nudges right
|
|
182
|
+
* rather than stacking two rooms on one square (dig down and dig south from
|
|
183
|
+
* the same room otherwise collide), and every node carries its own `level`
|
|
184
|
+
* so the renderer can draw the turf line between the surface and the soil.
|
|
185
|
+
* An edge is drawn only between two rooms BOTH in `roomIds`; `hints` names
|
|
186
|
+
* every exit from an included room toward one that is not — the direction
|
|
187
|
+
* only, never the excluded room's own name, so a fog-of-war caller can draw
|
|
188
|
+
* "there's a way on" and nothing more. Pure. */
|
|
189
|
+
export function burrowGraph(state, roomIds, root = "garden") {
|
|
190
|
+
const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
|
|
191
|
+
const known = new Set(roomIds || []);
|
|
192
|
+
const positions = new Map();
|
|
193
|
+
const taken = new Set();
|
|
194
|
+
const edges = [];
|
|
195
|
+
const edgeKeys = new Set();
|
|
196
|
+
const hints = [];
|
|
197
|
+
const place = (room, x, y) => {
|
|
198
|
+
let px = x;
|
|
199
|
+
for (let guard = 0; taken.has(px + "," + y) && guard < 64; guard += 1) px += 1;
|
|
200
|
+
positions.set(room, { x: px, y });
|
|
201
|
+
taken.add(px + "," + y);
|
|
202
|
+
};
|
|
203
|
+
let offsetX = 0;
|
|
204
|
+
for (const start of [root, ...[...known].sort()]) {
|
|
205
|
+
if (!known.has(start) || positions.has(start)) continue;
|
|
206
|
+
place(start, offsetX, 0);
|
|
207
|
+
const queue = [start];
|
|
208
|
+
while (queue.length) {
|
|
209
|
+
const room = queue.shift();
|
|
210
|
+
const pos = positions.get(room);
|
|
211
|
+
const dirs = state.exits.get(room);
|
|
212
|
+
for (const direction of [...(dirs?.keys() ?? [])].sort()) {
|
|
213
|
+
const target = dirs.get(direction);
|
|
214
|
+
if (!known.has(target)) { hints.push({ from: room, direction }); continue; }
|
|
215
|
+
const key = [room, target].sort().join("\0");
|
|
216
|
+
if (!edgeKeys.has(key)) { edgeKeys.add(key); edges.push({ from: room, to: target, direction }); }
|
|
217
|
+
if (positions.has(target)) continue;
|
|
218
|
+
const d = DELTA[direction] ?? [0, 0];
|
|
219
|
+
place(target, pos.x + d[0], pos.y + d[1]);
|
|
220
|
+
queue.push(target);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
offsetX = Math.max(...[...positions.values()].map((p) => p.x)) + 2;
|
|
224
|
+
}
|
|
225
|
+
const levels = levelsOf(state, root);
|
|
226
|
+
const xs = [...positions.values()].map((p) => p.x);
|
|
227
|
+
const ys = [...positions.values()].map((p) => p.y);
|
|
228
|
+
const minX = Math.min(0, ...xs);
|
|
229
|
+
const minY = Math.min(0, ...ys);
|
|
230
|
+
const nodes = [...known].filter((room) => positions.has(room)).sort().map((room) => {
|
|
231
|
+
const p = positions.get(room);
|
|
232
|
+
return { id: room, x: p.x - minX, y: p.y - minY, level: levels.has(room) ? levels.get(room) : null };
|
|
233
|
+
});
|
|
234
|
+
return { nodes, edges, hints };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The directions `here` can still be dug in: every direction the room's kind
|
|
238
|
+
* allows that has no written exit yet. Above ground there is nothing to
|
|
239
|
+
* tunnel sideways THROUGH, so a garden digs straight down and no other way;
|
|
240
|
+
* underground, all six are open soil. The engine is the authority on this —
|
|
241
|
+
* the page prefers `tmctMud.diggableDirections` whenever the bundle exposes
|
|
242
|
+
* one and only falls back to the rule below. Pure, self-contained
|
|
243
|
+
* (roomKindForRoom is spliced alongside it). */
|
|
244
|
+
export function diggableDirections(rows, state, here) {
|
|
245
|
+
const exits = state.exits.get(here);
|
|
246
|
+
const kind = roomKindForRoom(rows, here);
|
|
247
|
+
const allowed = kind === "outdoor" ? ["down"] : ["north", "south", "east", "west", "up", "down"];
|
|
248
|
+
return allowed.filter((direction) => !exits || !exits.has(direction));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A carried or loose object's display name. A dug object is minted as
|
|
252
|
+
* `<kind>-<the full nested room id it was dug from>` (adventure.mjs's
|
|
253
|
+
* freshRoomId), which reads as a path, not a thing — so a subject that ends
|
|
254
|
+
* in "-<a room this world actually has>" shows as its kind alone. Every
|
|
255
|
+
* other id (the world's hand-authored props: "carrot", "worm-1") is already
|
|
256
|
+
* a name and passes straight through. Pure. */
|
|
257
|
+
export function itemLabel(subject, roomIds) {
|
|
258
|
+
const id = String(subject);
|
|
259
|
+
for (const room of roomIds || []) {
|
|
260
|
+
const suffix = "-" + room;
|
|
261
|
+
if (id.length > suffix.length && id.endsWith(suffix)) return id.slice(0, -suffix.length);
|
|
262
|
+
}
|
|
263
|
+
return id;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Whether `subject` is a creature rather than a prop: the world places a
|
|
267
|
+
* character with `mgx:currently-in` and everything else with a containment
|
|
268
|
+
* predicate, the same split mud-turn.mjs's own "what is there to examine"
|
|
269
|
+
* filter reads. Pure — so the room view can put animals on the floor and
|
|
270
|
+
* props on the wall without a roster to check against, and a character the
|
|
271
|
+
* page is not itself driving (an NPC) still reads as an animal. */
|
|
272
|
+
export function isCreature(state, subject) {
|
|
273
|
+
return state.placements.get(subject)?.predicate === "mgx:currently-in";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---- the shared burrow: reading the P2P layer's own facts back ---------------
|
|
277
|
+
// All four readers below work on plain fact rows and nothing else, so the page
|
|
278
|
+
// draws a claim, a wave and a node name identically whether the world is shared
|
|
279
|
+
// with three machines or with none. Pure and `.toString()`-splice safe.
|
|
280
|
+
|
|
281
|
+
/** Every assertion time a provenance string carries, in milliseconds. A tag is
|
|
282
|
+
* `<kind>:<id>@<ISO>`, and a fact asserted more than once holds several joined
|
|
283
|
+
* by " | " — a peer's relabelled copy of a tag keeps the time the fact was
|
|
284
|
+
* actually asserted, so reading them all back is what lets a claim be settled
|
|
285
|
+
* by its oldest and a wave by its newest. Pure, self-contained. */
|
|
286
|
+
export function provenanceStamps(provenance) {
|
|
287
|
+
const stamps = [];
|
|
288
|
+
const text = String(provenance || "");
|
|
289
|
+
const pattern = /@(\d{4}-\d{2}-\d{2}T[0-9:.]+Z)/g;
|
|
290
|
+
let found = pattern.exec(text);
|
|
291
|
+
while (found !== null) {
|
|
292
|
+
const at = Date.parse(found[1]);
|
|
293
|
+
if (!Number.isNaN(at)) stamps.push(at);
|
|
294
|
+
found = pattern.exec(text);
|
|
295
|
+
}
|
|
296
|
+
return stamps;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A peer id as the fact store actually holds it. `appendFacts` normalizes a
|
|
300
|
+
* term by stripping its CURIE prefix and lowercasing, so the `peer:<id>` a
|
|
301
|
+
* claim is written with reads back as the bare id — a comparison against a
|
|
302
|
+
* raw peer id has to normalize the same way or it never matches. Pure,
|
|
303
|
+
* self-contained. */
|
|
304
|
+
export function peerTerm(peerId) {
|
|
305
|
+
return String(peerId || "").replace(/^[a-z][\w.-]*:/i, "").toLowerCase();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Which peer plays `character`, or null when nobody has claimed it. Claims are
|
|
309
|
+
* add-only and never retracted, so two peers claiming the same animal both
|
|
310
|
+
* write and every page settles it the same way: the OLDEST claim wins, with
|
|
311
|
+
* the lower peer term breaking a dead heat so two machines reading the same
|
|
312
|
+
* rows can never disagree. Pure, self-contained (provenanceStamps is spliced
|
|
313
|
+
* alongside it). */
|
|
314
|
+
export function claimantOf(rows, character) {
|
|
315
|
+
let owner = null;
|
|
316
|
+
let ownerAt = Infinity;
|
|
317
|
+
for (const row of rows || []) {
|
|
318
|
+
if (row.subject !== character || row.predicate !== "mgx:playedBy") continue;
|
|
319
|
+
let at = Infinity;
|
|
320
|
+
for (const stamp of provenanceStamps(row.provenance)) if (stamp < at) at = stamp;
|
|
321
|
+
if (at < ownerAt || (at === ownerAt && owner !== null && String(row.object) < owner)) {
|
|
322
|
+
ownerAt = at;
|
|
323
|
+
owner = String(row.object);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return owner;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Which characters are waving right now — a read-time recency question over
|
|
330
|
+
* the newest tag on each `mgx:waved` fact, never stored state and never a
|
|
331
|
+
* retraction. A wave older than the window simply stops being read as one.
|
|
332
|
+
* Pure, self-contained. */
|
|
333
|
+
export function wavingCharacters(rows, nowMs, windowMs = 8000) {
|
|
334
|
+
const waving = [];
|
|
335
|
+
for (const row of rows || []) {
|
|
336
|
+
if (row.predicate !== "mgx:waved") continue;
|
|
337
|
+
let newest = null;
|
|
338
|
+
for (const stamp of provenanceStamps(row.provenance)) if (newest === null || stamp > newest) newest = stamp;
|
|
339
|
+
if (newest === null) continue;
|
|
340
|
+
const age = nowMs - newest;
|
|
341
|
+
if (age < 0 || age > windowMs) continue;
|
|
342
|
+
if (waving.indexOf(row.subject) === -1) waving.push(row.subject);
|
|
343
|
+
}
|
|
344
|
+
return waving;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The offer blob inside whatever somebody actually pasted: the `offer`
|
|
348
|
+
* parameter of a whole invite link, or the bare blob when that is what
|
|
349
|
+
* arrived. Both are what people really paste, and telling them apart by
|
|
350
|
+
* looking is cheaper than asking them to. Pure, self-contained. */
|
|
351
|
+
export function offerBlobIn(pasted) {
|
|
352
|
+
const text = String(pasted || "").trim();
|
|
353
|
+
const marker = text.indexOf("offer=");
|
|
354
|
+
if (marker === -1) return text;
|
|
355
|
+
const rest = text.slice(marker + "offer=".length);
|
|
356
|
+
const end = rest.search(/[&#\s]/);
|
|
357
|
+
return decodeURIComponent(end === -1 ? rest : rest.slice(0, end));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The name a peer chose for its node, from its latest `mgx:nodeName` fact,
|
|
361
|
+
* falling back to a shortened peer id. A label never waits for a name to
|
|
362
|
+
* arrive. Pure, self-contained. */
|
|
363
|
+
export function nodeNameFor(rows, peerId) {
|
|
364
|
+
const want = peerTerm(peerId);
|
|
365
|
+
let name = null;
|
|
366
|
+
let nameAt = -Infinity;
|
|
367
|
+
for (const row of rows || []) {
|
|
368
|
+
if (row.predicate !== "mgx:nodeName" || peerTerm(row.subject) !== want) continue;
|
|
369
|
+
let at = -Infinity;
|
|
370
|
+
for (const stamp of provenanceStamps(row.provenance)) if (stamp > at) at = stamp;
|
|
371
|
+
if (at >= nameAt) { nameAt = at; name = String(row.object); }
|
|
372
|
+
}
|
|
373
|
+
return name || want.slice(0, 8);
|
|
152
374
|
}
|
|
153
375
|
|
|
154
376
|
/** The self-contained mud.html page. Pure — identical output for identical
|
|
155
377
|
* input; every other piece of state (the live world, chat, ticks) is
|
|
156
378
|
* computed in the browser once the sibling bundle loads.
|
|
157
|
-
* `characters` is `[{ id,
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
379
|
+
* `characters` is the ROSTER this page may draw from, `[{ id, species }]`;
|
|
380
|
+
* the page ships the default cast's panes and rebuilds the whole stage at
|
|
381
|
+
* every boot, so how many of the roster play, and which of them, is decided
|
|
382
|
+
* live by the players slider and the engine's own pickMudRoster — the same
|
|
383
|
+
* draw the shared session is then opened for, never a second one. A count
|
|
384
|
+
* the roster cannot fill draws as many panes as it has animals.
|
|
385
|
+
* `worldPayload` is `{ name, facts, rules, opening }`, read once at build
|
|
386
|
+
* time through the real worlds-pack provider (see this module's own
|
|
387
|
+
* header). `scenarios` is the list of burrows the deck's dropdown offers,
|
|
388
|
+
* `[{ label, worldPayload, characters }]` — each one a whole world with its
|
|
389
|
+
* own roster, so picking one recasts the page over that world's animals
|
|
390
|
+
* rather than reskinning this one. `worldPayload`/`characters` name the
|
|
391
|
+
* scenario the page opens with; a page given no `scenarios` is the
|
|
392
|
+
* one-burrow case and ships no dropdown at all. `spriteTemplates` is the
|
|
393
|
+
* large-tier sprite set (data/sprites-large/*.toml) so every species
|
|
394
|
+
* resolves its own art instead of falling back to the flat animal icon.
|
|
163
395
|
* `mudConfig` defaults to game-config.mjs's own DEFAULT_GAME_CONFIG.mud —
|
|
164
|
-
* the per-species mass/speed/dig-reach reference table
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* sibling `<script src>`, mirroring spider-fly-viz.mjs's own standalone-
|
|
169
|
-
* export knob; default empty keeps the site build's sibling-file
|
|
396
|
+
* the per-species mass/speed/dig-reach reference table each pane's stat line
|
|
397
|
+
* reads for flavor. `engineBundleJs` inlines the built mud-browser bundle
|
|
398
|
+
* instead of the sibling `<script src>`, mirroring spider-fly-viz.mjs's own
|
|
399
|
+
* standalone-export knob; default empty keeps the site build's sibling-file
|
|
170
400
|
* arrangement unchanged. */
|
|
171
401
|
export function renderMudHtml({
|
|
172
402
|
title = DEFAULT_TITLE,
|
|
173
403
|
worldPayload,
|
|
174
404
|
characters = [],
|
|
405
|
+
scenarios = [],
|
|
175
406
|
spriteTemplates = [],
|
|
176
407
|
mudConfig = DEFAULT_GAME_CONFIG.mud,
|
|
177
408
|
engineBundleJs = "",
|
|
178
409
|
} = {}) {
|
|
410
|
+
const scenarioList = scenarios.length
|
|
411
|
+
? scenarios
|
|
412
|
+
: [{ label: scenarioLabel(worldPayload?.name), worldPayload, characters }];
|
|
413
|
+
const opening = scenarioList[0];
|
|
414
|
+
const openingCount = Math.max(
|
|
415
|
+
1,
|
|
416
|
+
Math.min(DEFAULT_PLAYER_COUNT, (opening.characters || []).length || DEFAULT_PLAYER_COUNT),
|
|
417
|
+
);
|
|
179
418
|
const pageData = embedJson({
|
|
180
|
-
|
|
419
|
+
scenarios: scenarioList, spriteTemplates, mudConfig,
|
|
420
|
+
paneSlots: PANE_SLOTS,
|
|
421
|
+
playerCounts: PLAYER_COUNTS,
|
|
422
|
+
defaultPlayerCount: DEFAULT_PLAYER_COUNT,
|
|
423
|
+
defaultNpcCount: DEFAULT_NPC_COUNT,
|
|
181
424
|
rootRoom: ROOT_ROOM,
|
|
182
425
|
defaultDelayMs: DEFAULT_DELAY_MS,
|
|
183
426
|
defaultMaxTurns: DEFAULT_MAX_TURNS,
|
|
184
427
|
});
|
|
185
428
|
|
|
186
|
-
const
|
|
429
|
+
const paneHtml = PANE_SLOTS.slice(0, openingCount).map((slot) => paneMarkup(slot)).join("\n");
|
|
187
430
|
|
|
188
431
|
return `<!doctype html>
|
|
189
432
|
<html lang="en">
|
|
@@ -201,36 +444,145 @@ ${MUD_STYLE}
|
|
|
201
444
|
</head>
|
|
202
445
|
<body>
|
|
203
446
|
<main>
|
|
204
|
-
<
|
|
205
|
-
<
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
447
|
+
<h1 class="eyebrow">tmct · mud</h1>
|
|
448
|
+
<div class="deck-row">
|
|
449
|
+
<section class="deck" aria-label="simulation controls">
|
|
450
|
+
<div class="deck-controls">
|
|
451
|
+
<button type="button" class="deck-play" id="autoToggle" aria-pressed="false">▶ play</button>
|
|
452
|
+
<button type="button" id="resetBtn">reset</button>
|
|
453
|
+
${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-select" aria-label="which burrow to play">
|
|
454
|
+
${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
|
|
455
|
+
</select>` : ""}
|
|
456
|
+
<button type="button" id="editModeBtn" aria-pressed="false">edit</button>
|
|
457
|
+
<button type="button" id="shareBtn">share</button>
|
|
458
|
+
<button type="button" id="joinOpenBtn">join</button>
|
|
459
|
+
<button type="button" class="state-pill" id="statePill" aria-expanded="false" aria-controls="netPanel"
|
|
460
|
+
title="whether this burrow is shared with anyone"><i class="state-dot"></i><span id="statePillWord">not shared</span></button>
|
|
461
|
+
<button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
|
|
462
|
+
<span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
|
|
463
|
+
</div>
|
|
464
|
+
<div class="deck-sliders">
|
|
465
|
+
<label class="deck-slider">players
|
|
466
|
+
<input type="range" id="playerCountSlider" min="0" max="${PLAYER_COUNTS.length - 1}" step="1"
|
|
467
|
+
value="${Math.max(0, PLAYER_COUNTS.indexOf(DEFAULT_PLAYER_COUNT))}"
|
|
468
|
+
list="playerCountTicks" aria-valuetext="${DEFAULT_PLAYER_COUNT} players">
|
|
469
|
+
<datalist id="playerCountTicks">${PLAYER_COUNTS.map((n, i) => `<option value="${i}" label="${n}"></option>`).join("")}</datalist>
|
|
470
|
+
<span class="mono" id="playerCountValue">${DEFAULT_PLAYER_COUNT}</span>
|
|
471
|
+
</label>
|
|
472
|
+
<label class="deck-slider">npcs
|
|
473
|
+
<input type="range" id="npcCountSlider" min="${NPC_COUNT_MIN}" max="${NPC_COUNT_MAX}" step="1"
|
|
474
|
+
value="${DEFAULT_NPC_COUNT}"
|
|
475
|
+
list="npcCountTicks" aria-valuetext="${DEFAULT_NPC_COUNT} npcs">
|
|
476
|
+
<datalist id="npcCountTicks">${Array.from({ length: NPC_COUNT_MAX - NPC_COUNT_MIN + 1 }, (_, i) => {
|
|
477
|
+
const n = NPC_COUNT_MIN + i;
|
|
478
|
+
return NPC_COUNT_LABELLED.includes(n) ? `<option value="${n}" label="${n}"></option>` : `<option value="${n}"></option>`;
|
|
479
|
+
}).join("")}</datalist>
|
|
480
|
+
<span class="mono" id="npcCountValue">${DEFAULT_NPC_COUNT}</span>
|
|
481
|
+
</label>
|
|
482
|
+
<label class="deck-slider">delay
|
|
483
|
+
<input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
|
|
484
|
+
<span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
|
|
485
|
+
</label>
|
|
486
|
+
<label class="deck-slider">max turns
|
|
487
|
+
<input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
|
|
488
|
+
<span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
|
|
489
|
+
</label>
|
|
490
|
+
</div>
|
|
491
|
+
<div class="deck-info-popup mud-note" id="deckInfoPopup" role="dialog" aria-label="about this demo" hidden>
|
|
492
|
+
${MUD_NOTE_LINES.map((line) => `<p>${escapeHtml(line)}</p>`).join("\n ")}
|
|
493
|
+
<button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">×</button>
|
|
494
|
+
</div>
|
|
495
|
+
</section>
|
|
496
|
+
<section class="world-map" id="worldMap" aria-label="the whole burrow, every room, every character">
|
|
209
497
|
<div class="world-map-head">
|
|
210
498
|
<span class="world-map-title">the whole burrow</span>
|
|
211
499
|
<span class="mono world-map-turn" id="worldMapTurn">turn 0</span>
|
|
212
500
|
</div>
|
|
213
|
-
<div class="world-map-
|
|
214
|
-
|
|
501
|
+
<div class="world-map-board" id="worldMapBoard"></div>
|
|
502
|
+
<div class="world-map-key" id="worldMapKey"></div>
|
|
503
|
+
</section>
|
|
215
504
|
</div>
|
|
216
|
-
<
|
|
217
|
-
<div class="
|
|
218
|
-
<
|
|
219
|
-
<span class="
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
505
|
+
<section class="net-panel" id="netPanel" aria-label="the shared burrow" hidden>
|
|
506
|
+
<div class="net-head">
|
|
507
|
+
<span class="net-title">the shared burrow</span>
|
|
508
|
+
<span class="net-state" id="wireState" data-tone="idle"><b id="wireStateWord">not shared</b><span id="wireStateNote">this browser holds the only copy of this burrow.</span></span>
|
|
509
|
+
<button type="button" class="net-close" id="netPanelClose" aria-label="close">×</button>
|
|
510
|
+
</div>
|
|
511
|
+
<div class="net-fields">
|
|
512
|
+
<label class="net-field">this node
|
|
513
|
+
<input type="text" id="nodeNameInput" spellcheck="false" aria-label="the name this node goes by">
|
|
223
514
|
</label>
|
|
224
|
-
<label class="
|
|
225
|
-
<input type="
|
|
226
|
-
<span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
|
|
515
|
+
<label class="net-field">this burrow
|
|
516
|
+
<input type="text" id="worldNameInput" spellcheck="false" aria-label="the name of the shared burrow">
|
|
227
517
|
</label>
|
|
228
|
-
<button type="button" id="resetBtn">reset</button>
|
|
229
518
|
</div>
|
|
230
|
-
<div class="
|
|
231
|
-
<p
|
|
232
|
-
<
|
|
519
|
+
<div class="net-block" id="sharePanel" hidden>
|
|
520
|
+
<p class="net-hint">Each link invites one person. Share again for the next.</p>
|
|
521
|
+
<textarea id="shareLink" readonly rows="2" aria-label="the invite link"></textarea>
|
|
522
|
+
</div>
|
|
523
|
+
<div class="net-block" id="invitePanel" hidden>
|
|
524
|
+
<label class="net-label" for="inviteBox">paste the invite you were sent</label>
|
|
525
|
+
<textarea id="inviteBox" rows="2" spellcheck="false"></textarea>
|
|
526
|
+
<button type="button" id="inviteBtn">make my reply</button>
|
|
527
|
+
<p class="net-problem" id="inviteProblem" role="alert" hidden></p>
|
|
233
528
|
</div>
|
|
529
|
+
<div class="net-block" id="replyPanel">
|
|
530
|
+
<label class="net-label" for="replyBox">paste their reply here</label>
|
|
531
|
+
<textarea id="replyBox" rows="2" spellcheck="false"></textarea>
|
|
532
|
+
<button type="button" id="replyBtn">connect</button>
|
|
533
|
+
<p class="net-problem" id="replyProblem" role="alert" hidden></p>
|
|
534
|
+
</div>
|
|
535
|
+
<div class="net-block" id="answerPanel" hidden>
|
|
536
|
+
<label class="net-label" for="replyOut">send this back the same way the invite reached you</label>
|
|
537
|
+
<textarea id="replyOut" readonly rows="2"></textarea>
|
|
538
|
+
<button type="button" id="copyReplyBtn">copy the reply</button>
|
|
539
|
+
</div>
|
|
540
|
+
<div class="net-block">
|
|
541
|
+
<div class="net-label">nodes <span class="mono" id="nodeCount"></span></div>
|
|
542
|
+
<ul class="node-list" id="nodeList"></ul>
|
|
543
|
+
<p class="net-hint" id="nodeEmpty">Nobody else is digging here yet.</p>
|
|
544
|
+
</div>
|
|
545
|
+
</section>
|
|
546
|
+
<div class="join-card" id="joinCard" hidden>
|
|
547
|
+
<div class="join-inner" role="dialog" aria-label="an invitation to a shared burrow">
|
|
548
|
+
<p class="join-eyebrow" id="joinEyebrow">you have been invited to dig in</p>
|
|
549
|
+
<h2 class="join-world" id="joinWorld">a shared burrow</h2>
|
|
550
|
+
<p class="join-body" id="joinBody">Nothing runs until you press the button. It makes one reply for you to send back the way the invite reached you.</p>
|
|
551
|
+
<button type="button" id="joinBtn">create my reply</button>
|
|
552
|
+
<p class="net-problem" id="joinProblem" role="alert" hidden></p>
|
|
553
|
+
<div class="join-reply" id="joinReplyWrap" hidden>
|
|
554
|
+
<textarea id="joinReply" readonly rows="2" aria-label="your reply"></textarea>
|
|
555
|
+
<button type="button" id="joinCopyBtn">copy the reply</button>
|
|
556
|
+
</div>
|
|
557
|
+
<button type="button" class="join-dismiss" id="joinDismiss">dig on your own instead</button>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
<div class="mud-stage" id="mudStage" data-panes="${openingCount}">
|
|
561
|
+
${paneHtml}
|
|
562
|
+
</div>
|
|
563
|
+
<div class="edit-stage" id="mudEditStage" aria-label="the burrow's own facts, in plain sentences">
|
|
564
|
+
<section class="edit-text" aria-label="the world's facts as editable sentences">
|
|
565
|
+
<h2>the burrow, in plain sentences</h2>
|
|
566
|
+
<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>
|
|
567
|
+
<textarea id="editorText" spellcheck="false" aria-label="the world's own facts as plain sentences, one per line"></textarea>
|
|
568
|
+
<div class="chatpills" id="editorPills" aria-label="related words for the term before the cursor"></div>
|
|
569
|
+
<p class="edit-status" id="editorStatus" role="status"></p>
|
|
570
|
+
</section>
|
|
571
|
+
<aside class="edit-side" aria-label="the burrow as written, a clicked room, and the legend">
|
|
572
|
+
<section class="world-map edit-map" aria-label="every room this world writes">
|
|
573
|
+
<div class="world-map-head"><span class="world-map-title">the burrow as written</span></div>
|
|
574
|
+
<div class="world-map-board" id="editMapBoard"></div>
|
|
575
|
+
</section>
|
|
576
|
+
<section class="edit-panel" id="roomDetailPanel">
|
|
577
|
+
<h2 id="roomDetailTitle">click a room</h2>
|
|
578
|
+
<div class="room-detail-cast" id="roomDetailCast"></div>
|
|
579
|
+
<p class="edit-caption" id="roomDetailCaption">Click a room on the survey to see what stands in it.</p>
|
|
580
|
+
</section>
|
|
581
|
+
<section class="edit-panel">
|
|
582
|
+
<h2>legend</h2>
|
|
583
|
+
<div class="edit-legend" id="editLegend"></div>
|
|
584
|
+
</section>
|
|
585
|
+
</aside>
|
|
234
586
|
</div>
|
|
235
587
|
</main>
|
|
236
588
|
<script>
|
|
@@ -245,161 +597,615 @@ ${embedScriptText(pageScript())}
|
|
|
245
597
|
`;
|
|
246
598
|
}
|
|
247
599
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
600
|
+
/** One pane, character-agnostic: which animal it shows is stamped in at boot
|
|
601
|
+
* (`data-character`, the heading, the chat dock's own labels), so the page
|
|
602
|
+
* can draw a different cast out of the roster on every reset without a
|
|
603
|
+
* rebuild. Pure and self-contained (escapeHtml is spliced alongside it) —
|
|
604
|
+
* the page script calls this same function to build the stage in the
|
|
605
|
+
* browser, so the panes a count change adds are the panes this file ships. */
|
|
606
|
+
export function paneMarkup(slot) {
|
|
607
|
+
const w = `window-${slot}`;
|
|
608
|
+
return ` <section class="mud-window pane-${escapeHtml(slot)}" id="${w}" data-slot="${escapeHtml(slot)}" data-character="">
|
|
609
|
+
<div class="pane-head">
|
|
610
|
+
<h2 id="${w}-name">waiting</h2>
|
|
611
|
+
<span class="pane-node" id="${w}-node" hidden></span>
|
|
612
|
+
<span class="mono pane-turn" id="${w}-turn">turn 0</span>
|
|
254
613
|
</div>
|
|
255
|
-
<div class="room-
|
|
256
|
-
<
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
614
|
+
<div class="room-stage">
|
|
615
|
+
<div class="room-view" id="${w}-room">
|
|
616
|
+
<canvas id="${w}-canvas" width="420" height="152" aria-hidden="true"></canvas>
|
|
617
|
+
<div class="wall-band" id="${w}-wall"></div>
|
|
618
|
+
<div class="floor-band">
|
|
619
|
+
<div class="floor-others" id="${w}-others"></div>
|
|
620
|
+
<div class="floor-self" id="${w}-self"></div>
|
|
621
|
+
</div>
|
|
622
|
+
<div class="bubbles" id="${w}-bubbles"></div>
|
|
623
|
+
<div class="dig-flourish" id="${w}-flourish" hidden></div>
|
|
624
|
+
<div class="strike-flash" id="${w}-strike" aria-hidden="true"></div>
|
|
625
|
+
</div>
|
|
626
|
+
<div class="dir-ring" id="${w}-dirs" aria-label="ways out of this room">
|
|
627
|
+
<div class="dir-slot dir-north" id="${w}-dir-north"></div>
|
|
628
|
+
<div class="dir-slot dir-up" id="${w}-dir-up"></div>
|
|
629
|
+
<div class="dir-slot dir-west" id="${w}-dir-west"></div>
|
|
630
|
+
<div class="dir-slot dir-east" id="${w}-dir-east"></div>
|
|
631
|
+
<div class="dir-slot dir-south" id="${w}-dir-south"></div>
|
|
632
|
+
<div class="dir-slot dir-down" id="${w}-dir-down"></div>
|
|
633
|
+
</div>
|
|
260
634
|
</div>
|
|
261
635
|
<p class="room-caption" id="${w}-caption"></p>
|
|
262
|
-
<div class="
|
|
263
|
-
<div class="pouch"
|
|
636
|
+
<div class="pane-columns">
|
|
637
|
+
<div class="pouch" aria-label="pouch">
|
|
264
638
|
<h3>pouch</h3>
|
|
265
639
|
<ul class="pouch-list" id="${w}-pouch-list"></ul>
|
|
266
640
|
<p class="mono stat-line" id="${w}-stats"></p>
|
|
267
641
|
</div>
|
|
268
|
-
<div class="minimap" aria-label="what this character has
|
|
642
|
+
<div class="minimap" aria-label="what this character has dug or walked">
|
|
269
643
|
<h3>known ground</h3>
|
|
270
|
-
<div class="minimap-
|
|
644
|
+
<div class="minimap-board" id="${w}-minimap"></div>
|
|
271
645
|
</div>
|
|
272
646
|
</div>
|
|
273
|
-
<div class="window-controls controls-row">
|
|
274
|
-
<button type="button" id="${w}-play" disabled>▶ play</button>
|
|
275
|
-
<button type="button" id="${w}-step" disabled>step</button>
|
|
276
|
-
<span class="mono" id="${w}-count">0 turns</span>
|
|
277
|
-
</div>
|
|
278
647
|
<div class="chat">
|
|
279
648
|
<div class="chatlog" id="${w}-chatlog" aria-live="polite"></div>
|
|
649
|
+
<div class="log-popup" id="${w}-logpop" role="dialog" aria-label="the whole reply" hidden>
|
|
650
|
+
<p class="log-popup-text" id="${w}-logpop-text"></p>
|
|
651
|
+
<button type="button" class="log-popup-close" id="${w}-logpop-close" aria-label="close the whole reply">×</button>
|
|
652
|
+
</div>
|
|
653
|
+
</div>
|
|
654
|
+
<div class="chat-console">
|
|
655
|
+
<div class="pill-strip">
|
|
656
|
+
<button type="button" class="pill wave-btn" id="${w}-wave" title="wave, so everyone in this room sees it"
|
|
657
|
+
aria-label="wave">👋 wave</button>
|
|
658
|
+
<div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands"></div>
|
|
659
|
+
</div>
|
|
280
660
|
<form class="chatask" id="${w}-chatform">
|
|
281
661
|
<span class="prompt mono">tmct></span>
|
|
282
|
-
<input id="${w}-chatq" type="text" placeholder="
|
|
662
|
+
<input id="${w}-chatq" type="text" placeholder="look" aria-label="type a command" disabled>
|
|
283
663
|
</form>
|
|
284
|
-
<div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands">
|
|
285
|
-
<button type="button" class="pill" data-fill="look" disabled>look</button>
|
|
286
|
-
<button type="button" class="pill" data-fill="what do you know about food" disabled>what do you know about food</button>
|
|
287
|
-
<button type="button" class="pill" data-fill="dig north" disabled>dig north</button>
|
|
288
|
-
<button type="button" class="pill" data-fill="dig down" disabled>dig down</button>
|
|
289
|
-
</div>
|
|
290
664
|
</div>
|
|
291
|
-
|
|
665
|
+
<div class="pane-controls">
|
|
666
|
+
<button type="button" id="${w}-play" disabled>▶ play</button>
|
|
667
|
+
<button type="button" id="${w}-step" disabled>step</button>
|
|
668
|
+
<p class="pane-fate" id="${w}-fate" role="status" hidden></p>
|
|
669
|
+
</div>
|
|
670
|
+
</section>`;
|
|
292
671
|
}
|
|
293
672
|
|
|
294
673
|
const MUD_STYLE = `
|
|
295
674
|
:root {
|
|
296
|
-
--soil-deep: #
|
|
297
|
-
--root-moss: #6B7A4F; --parchment: #EFE6D8; --mud-ink: #2A211A;
|
|
675
|
+
--soil-deep: #241710; --soil-mid: #4A3324; --soil-light: #7A5A3D;
|
|
676
|
+
--root-moss: #6B7A4F; --parchment: #EFE6D8; --mud-ink: #2A211A;
|
|
677
|
+
--burrow-glow: #E8A33D; --chalk: #D9CDB9;
|
|
678
|
+
/* Four hues that stay apart at the size an occupant dot is drawn, and all
|
|
679
|
+
four sit on the dark soil board as well as on parchment. Nothing green
|
|
680
|
+
enough to sink into a turf-filled surface room. */
|
|
681
|
+
--actor-a: #E0912A; --actor-b: #5F97B3; --actor-c: #BFC85F; --actor-d: #AE749E;
|
|
682
|
+
--pane-height: 618px;
|
|
683
|
+
--room-height: 152px;
|
|
298
684
|
}
|
|
299
685
|
html { background: var(--soil-deep); }
|
|
300
|
-
body { margin: 0; background: linear-gradient(180deg, var(--root-moss) 0%, var(--soil-light)
|
|
686
|
+
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; }
|
|
301
687
|
.mono { font-family: ${MONO_STACK}; }
|
|
302
|
-
main { max-width: 1280px; margin: 0 auto; padding: 1.
|
|
303
|
-
.eyebrow { font-family: ${MONO_STACK}; font-size: .
|
|
304
|
-
h1 { font-family: ${DISPLAY_STACK}; font-weight: 600; font-size: 1.7rem; margin: .25rem 0 1.1rem; color: var(--parchment); text-wrap: balance; }
|
|
688
|
+
main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
|
|
689
|
+
.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; }
|
|
305
690
|
h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; text-transform: capitalize; }
|
|
306
|
-
h3 { font-family: ${
|
|
691
|
+
h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--soil-mid); }
|
|
307
692
|
button { font: inherit; color: inherit; background: none; cursor: pointer; }
|
|
308
693
|
button:focus-visible, input:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 2px; }
|
|
309
|
-
button:disabled { opacity: .
|
|
694
|
+
button:disabled { opacity: .4; cursor: default; }
|
|
310
695
|
|
|
311
|
-
|
|
696
|
+
/* ---- top row: the control deck, and the survey beside it ---- */
|
|
697
|
+
.deck-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: stretch; margin-bottom: 1rem; }
|
|
698
|
+
.deck {
|
|
312
699
|
position: relative;
|
|
313
|
-
|
|
700
|
+
background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
|
|
701
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
|
|
702
|
+
padding: .8rem .9rem; display: flex; flex-direction: column; gap: .55rem; min-width: 0;
|
|
703
|
+
}
|
|
704
|
+
.deck-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
|
705
|
+
.deck-info-btn {
|
|
706
|
+
font-family: ${MONO_STACK}; font-size: .78rem; line-height: 1; width: 1.5rem; height: 1.5rem;
|
|
707
|
+
border-radius: 50%; border: 1px solid var(--soil-mid); background: rgba(255,255,255,.5);
|
|
708
|
+
color: var(--soil-mid); padding: 0; flex: 0 0 auto;
|
|
709
|
+
}
|
|
710
|
+
.deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { border-color: var(--burrow-glow); color: var(--mud-ink); }
|
|
711
|
+
.deck button, .pane-controls button {
|
|
712
|
+
font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
|
|
713
|
+
padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px; background: rgba(255,255,255,.5);
|
|
714
|
+
}
|
|
715
|
+
.deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled) { border-color: var(--burrow-glow); }
|
|
716
|
+
.deck-select {
|
|
717
|
+
font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
|
|
718
|
+
padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px;
|
|
719
|
+
background: rgba(255,255,255,.5); color: var(--mud-ink);
|
|
314
720
|
}
|
|
315
|
-
|
|
721
|
+
.deck-select:hover { border-color: var(--burrow-glow); }
|
|
722
|
+
.deck-play { background: var(--mud-ink) !important; color: var(--parchment); border-color: var(--mud-ink) !important; padding: .38rem 1.1rem !important; }
|
|
723
|
+
.deck-play[aria-pressed="true"] { background: var(--burrow-glow) !important; border-color: var(--burrow-glow) !important; color: var(--mud-ink); }
|
|
724
|
+
.deck-turns { margin-left: auto; font-size: .74rem; color: var(--soil-mid); }
|
|
725
|
+
.deck-sliders { display: flex; flex-wrap: wrap; gap: 1rem; }
|
|
726
|
+
.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); }
|
|
727
|
+
.deck-slider input[type="range"] { accent-color: var(--burrow-glow); width: 8rem; max-width: 34vw; }
|
|
728
|
+
.deck-info-popup {
|
|
729
|
+
position: absolute; left: .9rem; right: .9rem; top: calc(100% + 8px); z-index: 8;
|
|
730
|
+
background: var(--soil-deep); color: var(--parchment);
|
|
731
|
+
border: 1px solid var(--burrow-glow); border-radius: 4px;
|
|
732
|
+
padding: .55rem 1.6rem .6rem .65rem; box-shadow: 0 8px 18px rgba(0,0,0,.5);
|
|
733
|
+
animation: popup-rise .16s ease-out;
|
|
734
|
+
}
|
|
735
|
+
.deck-info-popup::before {
|
|
736
|
+
content: ""; position: absolute; left: 1.1rem; top: -6px; width: 0; height: 0;
|
|
737
|
+
border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid var(--burrow-glow);
|
|
738
|
+
}
|
|
739
|
+
.deck-info-popup p { margin: 0 0 .4rem; max-width: 62ch; font-size: .78rem; line-height: 1.4; }
|
|
740
|
+
.deck-info-popup p:last-child { margin-bottom: 0; }
|
|
741
|
+
.deck-info-popup-close { position: absolute; top: .1rem; right: .3rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
|
|
742
|
+
.deck-info-popup-close:hover { color: var(--burrow-glow); }
|
|
743
|
+
@media (prefers-reduced-motion: reduce) { .deck-info-popup { animation: none; } }
|
|
744
|
+
|
|
745
|
+
.world-map {
|
|
746
|
+
background: var(--soil-deep); color: var(--parchment);
|
|
747
|
+
border: 1px solid var(--burrow-glow); border-radius: 4px; padding: .55rem .65rem .6rem;
|
|
748
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.3); display: flex; flex-direction: column; gap: .4rem; min-width: 0;
|
|
749
|
+
}
|
|
750
|
+
.world-map-head { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
|
|
751
|
+
.world-map-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
|
|
752
|
+
.world-map-turn { font-size: .62rem; opacity: .7; }
|
|
753
|
+
.world-map-board { flex: 1; min-height: 168px; display: flex; align-items: center; justify-content: center; }
|
|
754
|
+
.world-map-board svg { width: 100%; height: 100%; max-height: 230px; }
|
|
755
|
+
.world-map-key { display: flex; flex-wrap: wrap; gap: .5rem; font-family: ${MONO_STACK}; font-size: .58rem; opacity: .85; }
|
|
756
|
+
.key-actor, .key-npcs { display: inline-flex; align-items: center; gap: .28rem; }
|
|
757
|
+
.key-npcs { opacity: .75; }
|
|
758
|
+
.key-dot { width: .5rem; height: .5rem; border-radius: 50%; display: inline-block; }
|
|
759
|
+
|
|
760
|
+
/* ---- the survey itself, drawn twice at two sizes ---- */
|
|
761
|
+
.burrow svg { display: block; }
|
|
762
|
+
.burrow .turf { fill: var(--root-moss); opacity: .3; }
|
|
763
|
+
.burrow .ground-line { stroke: var(--root-moss); stroke-width: 1.2; stroke-dasharray: 5 3; opacity: .9; }
|
|
764
|
+
.burrow .tunnel { stroke: var(--soil-light); stroke-width: 5.5; stroke-linecap: round; opacity: .85; }
|
|
765
|
+
.burrow .tunnel.shaft { stroke-dasharray: 3 3.5; stroke-width: 4; }
|
|
766
|
+
.burrow .hint { fill: var(--chalk); opacity: .45; }
|
|
767
|
+
.burrow .room rect { fill: var(--soil-mid); stroke: var(--chalk); stroke-width: .8; }
|
|
768
|
+
.burrow .room.surface rect { fill: var(--root-moss); }
|
|
769
|
+
.burrow .room text { fill: var(--parchment); font-family: ${MONO_STACK}; font-size: 7px; text-anchor: middle; }
|
|
770
|
+
.burrow .room.freshly-dug rect { stroke: var(--burrow-glow); stroke-width: 1.8; animation: dig-pulse 1.2s ease-out; }
|
|
771
|
+
.burrow .occupant { stroke: rgba(0,0,0,.45); stroke-width: .6; }
|
|
772
|
+
@keyframes dig-pulse { 0% { opacity: .25; } 100% { opacity: 1; } }
|
|
773
|
+
@media (prefers-reduced-motion: reduce) { .burrow .room.freshly-dug rect { animation: none; } }
|
|
316
774
|
|
|
775
|
+
/* ---- the panes ----
|
|
776
|
+
Two columns hold every count the players slider offers: four fills them
|
|
777
|
+
twice over, two fills them once. One is the exception, and it earns it —
|
|
778
|
+
a lone animal takes a single centred column at a reading measure and
|
|
779
|
+
spends the width it saves on a taller room and a longer log, so the page
|
|
780
|
+
reads as one portrait rather than one pane marooned in an empty grid. */
|
|
781
|
+
.mud-stage { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; align-items: start; }
|
|
782
|
+
.mud-stage[data-panes="1"] {
|
|
783
|
+
grid-template-columns: minmax(0, 46rem); justify-content: center;
|
|
784
|
+
--pane-height: 700px; --room-height: 196px;
|
|
785
|
+
}
|
|
317
786
|
.mud-window {
|
|
318
787
|
background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
|
|
319
788
|
box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
|
|
320
|
-
padding: .7rem .8rem; display: flex; flex-direction: column; gap: .5rem;
|
|
789
|
+
padding: .7rem .8rem; display: flex; flex-direction: column; gap: .5rem;
|
|
790
|
+
min-width: 0; height: var(--pane-height); box-sizing: border-box; overflow: hidden;
|
|
791
|
+
border-top: 3px solid var(--actor-a);
|
|
321
792
|
}
|
|
322
|
-
.mud-window-
|
|
323
|
-
.
|
|
324
|
-
.window-
|
|
793
|
+
.mud-window.pane-b { border-top-color: var(--actor-b); }
|
|
794
|
+
.mud-window.pane-c { border-top-color: var(--actor-c); }
|
|
795
|
+
.mud-window.pane-d { border-top-color: var(--actor-d); }
|
|
796
|
+
.pane-head { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; }
|
|
797
|
+
.char-id { font-size: .66rem; color: var(--soil-mid); margin-left: .35rem; text-transform: none; }
|
|
798
|
+
.pane-turn { font-size: .68rem; color: var(--soil-mid); }
|
|
325
799
|
|
|
326
|
-
|
|
327
|
-
.room-
|
|
328
|
-
.
|
|
329
|
-
.
|
|
330
|
-
.
|
|
800
|
+
/* ---- room view: one row of soil, the compass around it ---- */
|
|
801
|
+
.room-stage { position: relative; padding: 19px 34px; flex: 0 0 auto; }
|
|
802
|
+
.room-view { position: relative; height: var(--room-height); border-radius: 3px; overflow: hidden; border: 1px solid var(--soil-mid); }
|
|
803
|
+
.room-view canvas { display: block; width: 100%; height: 100%; }
|
|
804
|
+
.wall-band { position: absolute; left: 0; right: 0; top: 4px; height: 62px; display: flex; align-items: flex-start; justify-content: center; gap: .5rem; }
|
|
805
|
+
.wall-item { display: flex; flex-direction: column; align-items: center; min-width: 34px; max-width: 72px; }
|
|
806
|
+
.wall-item .hook { width: 1px; height: 5px; background: rgba(0,0,0,.45); }
|
|
807
|
+
.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); }
|
|
808
|
+
.wall-item.hangs-high { margin-top: 0; }
|
|
809
|
+
.wall-item.hangs-low { margin-top: 14px; }
|
|
810
|
+
.wall-item svg { width: 100%; display: block; }
|
|
811
|
+
.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; }
|
|
812
|
+
/* A room can hold a dozen animals once the npcs slider is up, and the floor
|
|
813
|
+
is one row wide either way — so the crowd shares the row it has instead of
|
|
814
|
+
the surplus running off the edge under the overflow clip. --mate-width is
|
|
815
|
+
set per redraw from the room's own measured width. */
|
|
816
|
+
.floor-others { display: flex; align-items: flex-end; gap: 6px; min-width: 0; }
|
|
817
|
+
.floor-others .sprite-card { max-width: var(--mate-width, 76px); }
|
|
818
|
+
.floor-others .sprite { width: min(40px, var(--mate-width, 40px)); }
|
|
819
|
+
.sprite { width: 40px; }
|
|
331
820
|
.sprite svg { width: 100%; display: block; }
|
|
332
|
-
|
|
821
|
+
.floor-self .sprite { width: 48px; }
|
|
822
|
+
|
|
823
|
+
/* ---- sprite cards: adventure.html's own frame-plus-nameplate treatment,
|
|
824
|
+
shrunk to the one soil row a mud pane has for it. The plate names the
|
|
825
|
+
real subject (mole-1, carrot), never a class word. ---- */
|
|
826
|
+
.sprite-card { display: flex; flex-direction: column; align-items: center; max-width: 76px; }
|
|
827
|
+
.sprite-label {
|
|
828
|
+
margin-top: 2px; max-width: 100%; box-sizing: border-box;
|
|
829
|
+
font-family: ${MONO_STACK}; font-size: .5rem; line-height: 1.25; letter-spacing: .01em; text-align: center;
|
|
830
|
+
color: var(--mud-ink); background: rgba(239,230,216,.92); border: 1px solid var(--soil-mid); border-radius: 2px;
|
|
831
|
+
padding: 0 .22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
832
|
+
}
|
|
833
|
+
.sprite-mass {
|
|
834
|
+
margin-top: 1px; font-family: ${MONO_STACK}; font-size: .46rem; line-height: 1.3; letter-spacing: .02em;
|
|
835
|
+
color: var(--parchment); background: rgba(36,23,16,.72); border-radius: 2px; padding: 0 .2rem; white-space: nowrap;
|
|
836
|
+
}
|
|
837
|
+
.sprite-card.actionable { cursor: pointer; }
|
|
838
|
+
.sprite-card.actionable:hover .sprite-frame, .sprite-card.actionable:focus-visible .sprite-frame { border-color: var(--burrow-glow); }
|
|
839
|
+
.sprite-card.actionable:hover .sprite-label, .sprite-card.actionable:focus-visible .sprite-label { border-color: var(--burrow-glow); background: var(--burrow-glow); }
|
|
840
|
+
.sprite-card.actionable:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 1px; }
|
|
841
|
+
.wall-band .sprite-card.actionable { pointer-events: auto; }
|
|
842
|
+
|
|
843
|
+
/* ---- the compass ring: each way out sits where it points ---- */
|
|
844
|
+
.dir-ring { position: absolute; inset: 0; pointer-events: none; }
|
|
845
|
+
.dir-slot { position: absolute; pointer-events: auto; }
|
|
846
|
+
.dir-north { top: 0; left: 50%; transform: translateX(-102%); }
|
|
847
|
+
.dir-up { top: 0; left: 50%; transform: translateX(6px); }
|
|
848
|
+
.dir-south { bottom: 0; left: 50%; transform: translateX(-102%); }
|
|
849
|
+
.dir-down { bottom: 0; left: 50%; transform: translateX(6px); }
|
|
850
|
+
.dir-west { left: 0; top: 50%; transform: translateY(-50%); }
|
|
851
|
+
.dir-east { right: 0; top: 50%; transform: translateY(-50%); }
|
|
852
|
+
.dir-pill {
|
|
853
|
+
font-family: ${MONO_STACK}; font-size: .58rem; letter-spacing: .06em; line-height: 1;
|
|
854
|
+
padding: .22rem .42rem; border-radius: 2px; border: 1px solid var(--soil-mid);
|
|
855
|
+
background: var(--parchment); color: var(--mud-ink); white-space: nowrap;
|
|
856
|
+
}
|
|
857
|
+
.dir-pill.dig { border-style: dashed; border-color: var(--burrow-glow); color: var(--soil-mid); background: rgba(232,163,61,.14); }
|
|
858
|
+
.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; }
|
|
859
|
+
.dir-pill:hover:not(:disabled) { border-color: var(--burrow-glow); background: var(--burrow-glow); color: var(--mud-ink); }
|
|
333
860
|
|
|
334
|
-
.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
861
|
+
.bubbles { position: absolute; inset: 0; pointer-events: none; }
|
|
862
|
+
.bubble {
|
|
863
|
+
position: absolute; max-width: 46%;
|
|
864
|
+
background: var(--parchment); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 7px;
|
|
865
|
+
padding: .22rem .45rem; font-size: .66rem; line-height: 1.25; box-shadow: 0 2px 4px rgba(0,0,0,.3);
|
|
339
866
|
}
|
|
340
|
-
.
|
|
341
|
-
|
|
867
|
+
.bubble.from-self { right: 4%; bottom: 52%; }
|
|
868
|
+
.bubble.from-other { left: 4%; bottom: 52%; }
|
|
869
|
+
.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); }
|
|
870
|
+
.bubble.from-self::after { right: 12%; }
|
|
871
|
+
.bubble.from-other::after { left: 12%; }
|
|
342
872
|
|
|
343
|
-
.dig-flourish { position: absolute; inset: 0; pointer-events: none; opacity: 0; background: radial-gradient(circle at 50%
|
|
344
|
-
.dig-flourish.shown { opacity: .
|
|
873
|
+
.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; }
|
|
874
|
+
.dig-flourish.shown { opacity: .5; }
|
|
345
875
|
@media (prefers-reduced-motion: reduce) { .dig-flourish { transition: none; opacity: 0 !important; } }
|
|
346
876
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
.
|
|
351
|
-
|
|
352
|
-
.
|
|
353
|
-
.
|
|
354
|
-
.
|
|
355
|
-
.
|
|
356
|
-
.
|
|
357
|
-
.
|
|
358
|
-
|
|
359
|
-
.
|
|
360
|
-
.
|
|
361
|
-
.
|
|
362
|
-
.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
877
|
+
/* Two lines clamped, and a box tall enough to hold both of them: at 1.5
|
|
878
|
+
line-height that is 3em, and anything shorter cuts the second line's
|
|
879
|
+
descenders off. */
|
|
880
|
+
.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; }
|
|
881
|
+
|
|
882
|
+
.pane-columns { flex: 0 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; height: 86px; }
|
|
883
|
+
.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; }
|
|
884
|
+
.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; }
|
|
885
|
+
.pouch-list li { background: var(--soil-light); color: var(--parchment); border-radius: 2px; padding: .06rem .3rem; }
|
|
886
|
+
.pouch-list:empty::after { content: "carrying nothing"; color: var(--soil-mid); font-family: ${SANS_STACK}; font-style: italic; font-size: .68rem; }
|
|
887
|
+
.stat-line { margin: .25rem 0 0; font-size: .58rem; color: var(--soil-mid); }
|
|
888
|
+
.minimap-board { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; }
|
|
889
|
+
.minimap-board svg { width: 100%; height: 100%; max-height: 56px; }
|
|
890
|
+
.minimap-board:empty::after { content: "nothing dug yet"; color: var(--soil-mid); font-style: italic; font-size: .68rem; }
|
|
891
|
+
.minimap .burrow .room text { fill: var(--parchment); font-size: 6px; }
|
|
892
|
+
.minimap .burrow .room.here rect { fill: var(--burrow-glow); }
|
|
893
|
+
.minimap .burrow .room.here text { fill: var(--mud-ink); }
|
|
894
|
+
|
|
895
|
+
/* The log is the ONLY thing in a pane allowed to grow, and it grows inward:
|
|
896
|
+
flex-basis 0 keeps its own content out of the pane's height arithmetic, so
|
|
897
|
+
a hundred turns of narration scroll inside it instead of pushing the
|
|
898
|
+
command console off the bottom of a fixed-height pane. */
|
|
899
|
+
.chat { position: relative; flex: 1 1 0; min-height: 2.6rem; display: flex; flex-direction: column; }
|
|
900
|
+
/* Block flow, deliberately, not a flex column: a scrolled log's own lines
|
|
901
|
+
must keep their natural height, and flex items inside a height-capped
|
|
902
|
+
column shrink toward zero instead. */
|
|
903
|
+
.chatlog {
|
|
904
|
+
flex: 1 1 0; min-height: 0; display: block;
|
|
905
|
+
overflow-y: scroll; overscroll-behavior: contain; padding-right: .25rem;
|
|
906
|
+
}
|
|
907
|
+
.chatlog > * { margin: 0 0 .28rem; }
|
|
908
|
+
.chatlog > *:last-child { margin-bottom: 0; }
|
|
909
|
+
/* The log carries typed exchanges only — a scripted turn narrates through
|
|
910
|
+
the room view — so an untouched pane's log says what would fill it. */
|
|
911
|
+
.chatlog:empty::after { content: "Type a command below and the answer lands here."; color: var(--soil-mid); font-style: italic; font-size: .72rem; }
|
|
912
|
+
/* A styled webkit scrollbar reserves its own gutter, where the overlay one
|
|
913
|
+
Chromium defaults to on macOS shows nothing until you already scroll. The
|
|
914
|
+
standard properties go behind @supports because setting them at all makes
|
|
915
|
+
Blink ignore the pseudo-elements and fall back to that overlay bar. */
|
|
916
|
+
.chatlog::-webkit-scrollbar { width: 9px; }
|
|
917
|
+
.chatlog::-webkit-scrollbar-track { background: rgba(122,90,61,.18); border-radius: 5px; }
|
|
918
|
+
.chatlog::-webkit-scrollbar-thumb { background: var(--soil-light); border-radius: 5px; border: 2px solid var(--parchment); }
|
|
919
|
+
.chatlog::-webkit-scrollbar-thumb:hover { background: var(--soil-mid); }
|
|
920
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
921
|
+
.chatlog { scrollbar-width: thin; scrollbar-color: var(--soil-light) rgba(122,90,61,.18); }
|
|
922
|
+
}
|
|
923
|
+
.chatlog .u { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--soil-mid); overflow-wrap: anywhere; }
|
|
367
924
|
.chatlog .u::before { content: "tmct> "; color: var(--burrow-glow); }
|
|
368
|
-
|
|
925
|
+
/* A reply is capped at four rendered lines. A capped one says so and opens the
|
|
926
|
+
whole text in the popup above, so no single answer can take the log over.
|
|
927
|
+
The unclamped variant exists only to be measured against: a line-clamped box
|
|
928
|
+
reports its clamped height as its scrollHeight. */
|
|
929
|
+
.chatlog .a { font-size: .74rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
930
|
+
.chatlog .a.unclamped { display: block; -webkit-line-clamp: none; overflow: visible; }
|
|
931
|
+
.chatlog .clipped { position: relative; cursor: pointer; padding-right: 2.4rem; }
|
|
932
|
+
.chatlog .clipped::after {
|
|
933
|
+
content: "more"; position: absolute; right: 0; bottom: 0;
|
|
934
|
+
font-family: ${MONO_STACK}; font-size: .5rem; letter-spacing: .06em; text-transform: uppercase;
|
|
935
|
+
color: var(--mud-ink); background: var(--burrow-glow); border-radius: 99px; padding: 0 .32rem;
|
|
936
|
+
}
|
|
937
|
+
.chatlog .clipped:hover::after, .chatlog .clipped:focus-visible::after { background: var(--mud-ink); color: var(--parchment); }
|
|
938
|
+
.chatlog .clipped:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 1px; }
|
|
939
|
+
|
|
940
|
+
.log-popup {
|
|
941
|
+
position: absolute; left: 0; right: 0; bottom: calc(100% - 1.4rem); z-index: 8;
|
|
942
|
+
background: var(--soil-deep); color: var(--parchment);
|
|
943
|
+
border: 1px solid var(--burrow-glow); border-radius: 4px;
|
|
944
|
+
padding: .45rem 1.5rem .5rem .55rem; box-shadow: 0 8px 18px rgba(0,0,0,.5);
|
|
945
|
+
max-height: 13rem; overflow-y: auto; animation: popup-rise .16s ease-out;
|
|
946
|
+
}
|
|
947
|
+
.log-popup::after {
|
|
948
|
+
content: ""; position: absolute; left: 1.4rem; bottom: -6px; width: 0; height: 0;
|
|
949
|
+
border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid var(--burrow-glow);
|
|
950
|
+
}
|
|
951
|
+
.log-popup-text { margin: 0; font-size: .74rem; line-height: 1.4; }
|
|
952
|
+
.log-popup-close { position: absolute; top: .1rem; right: .25rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
|
|
953
|
+
.log-popup-close:hover { color: var(--burrow-glow); }
|
|
954
|
+
@keyframes popup-rise { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
|
|
955
|
+
@media (prefers-reduced-motion: reduce) { .log-popup { animation: none; } }
|
|
956
|
+
|
|
957
|
+
/* The console never moves: pills and prompt sit outside the log's own flex
|
|
958
|
+
track, so no amount of narration can reach them. */
|
|
959
|
+
.chat-console { flex: 0 0 auto; display: flex; flex-direction: column; gap: .3rem; margin-top: .3rem; }
|
|
369
960
|
.chatask { display: flex; align-items: center; gap: .4rem; }
|
|
370
|
-
.chatask .prompt { color: var(--burrow-glow); font-size: .
|
|
371
|
-
.chatask input { flex: 1; min-width: 0; font-family: ${MONO_STACK}; font-size: .
|
|
372
|
-
.chatpills { display: flex; flex-wrap: wrap; gap: .
|
|
373
|
-
.
|
|
374
|
-
.pill:
|
|
961
|
+
.chatask .prompt { color: var(--burrow-glow); font-size: .7rem; }
|
|
962
|
+
.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; }
|
|
963
|
+
.chatpills { display: flex; flex-wrap: wrap; gap: .22rem; max-height: 3.9rem; overflow-y: auto; scrollbar-width: thin; }
|
|
964
|
+
.chatpills:empty { display: none; }
|
|
965
|
+
.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; }
|
|
966
|
+
.pill:hover:not(:disabled) { border-color: var(--burrow-glow); background: var(--burrow-glow); }
|
|
967
|
+
.pill.affordance { border-style: dashed; border-color: var(--soil-light); }
|
|
968
|
+
.pill.way { border-style: solid; border-color: var(--soil-mid); background: rgba(232,163,61,.16); }
|
|
969
|
+
.pane-controls { flex: 0 0 auto; display: flex; align-items: center; gap: .4rem; }
|
|
970
|
+
.pane-fate {
|
|
971
|
+
margin: 0; font-family: ${MONO_STACK}; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em;
|
|
972
|
+
padding: .3rem .6rem; border-radius: 3px; background: var(--soil-deep); color: var(--parchment);
|
|
973
|
+
}
|
|
974
|
+
.mud-window.out-of-play { border-top-color: var(--soil-mid); }
|
|
975
|
+
.mud-window.out-of-play .pane-controls button { display: none; }
|
|
976
|
+
.room-view, .pane-columns { transition: filter .4s ease, opacity .4s ease; }
|
|
977
|
+
.mud-window.out-of-play .room-view, .mud-window.out-of-play .pane-columns { filter: grayscale(1); opacity: .5; }
|
|
978
|
+
.mud-window.out-of-play .dir-ring { display: none; }
|
|
375
979
|
|
|
376
|
-
.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
.
|
|
399
|
-
.
|
|
400
|
-
.
|
|
401
|
-
.
|
|
402
|
-
|
|
980
|
+
/* ---- the pounce: the one violent beat on a page that otherwise only fades.
|
|
981
|
+
Every other motion here eases out; this one accelerates into the prey and
|
|
982
|
+
stops dead. It plays once, on the tick a character is first out of play,
|
|
983
|
+
and the pane grays out underneath it when it lands. ---- */
|
|
984
|
+
.strike-flash {
|
|
985
|
+
position: absolute; inset: 0; pointer-events: none; opacity: 0;
|
|
986
|
+
background: radial-gradient(circle at 76% 72%, rgba(158,32,16,.95) 0%, rgba(36,23,16,.62) 40%, transparent 72%);
|
|
987
|
+
}
|
|
988
|
+
@keyframes strike-flash { 0%, 42% { opacity: 0; } 50% { opacity: .92; } 100% { opacity: 0; } }
|
|
989
|
+
@keyframes fox-pounce {
|
|
990
|
+
0% { transform: translateX(0) scale(1); }
|
|
991
|
+
16% { transform: translateX(-7px) scale(.94); }
|
|
992
|
+
54% { transform: translateX(var(--pounce-x, 180px)) scale(1.48); }
|
|
993
|
+
100% { transform: translateX(var(--pounce-x, 180px)) scale(1.44); }
|
|
994
|
+
}
|
|
995
|
+
@keyframes prey-taken {
|
|
996
|
+
0%, 40% { transform: none; opacity: 1; }
|
|
997
|
+
47% { transform: translateX(-6px) rotate(-8deg); }
|
|
998
|
+
54% { transform: translateX(6px) rotate(7deg); }
|
|
999
|
+
61% { transform: translateX(-4px) rotate(-4deg); }
|
|
1000
|
+
100% { transform: translateX(3px) rotate(4deg) scale(.82); opacity: 0; }
|
|
1001
|
+
}
|
|
1002
|
+
.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; }
|
|
1003
|
+
.room-view.pounce .floor-self { transform-origin: 50% 100%; animation: prey-taken 1.05s cubic-bezier(.4,0,.2,1) forwards; }
|
|
1004
|
+
.room-view.pounce .strike-flash { animation: strike-flash 1.05s ease-out forwards; }
|
|
1005
|
+
.room-view.pounce .bubbles, .room-view.pounce .wall-band { opacity: .35; }
|
|
1006
|
+
@keyframes fate-drop { from { opacity: 0; transform: translateY(-7px); } to { opacity: 1; transform: none; } }
|
|
1007
|
+
.pane-fate { animation: fate-drop .3s ease-out; }
|
|
1008
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1009
|
+
.room-view, .pane-columns { transition: none; }
|
|
1010
|
+
.pane-fate { animation: none; }
|
|
1011
|
+
.room-view.pounce .sprite-card.lunging, .room-view.pounce .floor-self, .room-view.pounce .strike-flash { animation: none; }
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/* ---- edit mode ----
|
|
1015
|
+
One page, two stages. Editing hides the panes AND the deck's own live
|
|
1016
|
+
survey: the survey in here is drawn from the edit buffer, and two boards
|
|
1017
|
+
showing two different burrows side by side would just be a puzzle. The deck
|
|
1018
|
+
itself stays, because the way back out is a button on it. */
|
|
1019
|
+
.edit-stage { display: none; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
|
|
1020
|
+
body.editing .mud-stage, body.editing .deck-row .world-map { display: none; }
|
|
1021
|
+
body.editing .deck-row { grid-template-columns: 1fr; }
|
|
1022
|
+
body.editing .edit-stage { display: grid; }
|
|
1023
|
+
#editModeBtn[aria-pressed="true"] { background: var(--burrow-glow); border-color: var(--burrow-glow); }
|
|
1024
|
+
|
|
1025
|
+
.edit-text, .edit-panel {
|
|
1026
|
+
background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
|
|
1027
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
|
|
1028
|
+
padding: .7rem .8rem; min-width: 0;
|
|
1029
|
+
}
|
|
1030
|
+
.edit-text { display: flex; flex-direction: column; gap: .45rem; }
|
|
1031
|
+
.edit-text h2, .edit-panel h2 {
|
|
1032
|
+
font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em;
|
|
1033
|
+
color: var(--soil-mid); margin: 0;
|
|
1034
|
+
}
|
|
1035
|
+
.edit-lede { margin: 0; font-size: .74rem; color: var(--soil-mid); max-width: 62ch; }
|
|
1036
|
+
#editorText {
|
|
1037
|
+
width: 100%; box-sizing: border-box; min-height: 26rem; flex: 1 1 auto; resize: vertical;
|
|
1038
|
+
font-family: ${MONO_STACK}; font-size: .74rem; line-height: 1.6;
|
|
1039
|
+
color: var(--mud-ink); background: rgba(255,255,255,.6);
|
|
1040
|
+
border: 1px solid var(--soil-mid); border-radius: 3px; padding: .55rem .6rem;
|
|
1041
|
+
}
|
|
1042
|
+
/* Three states, three colours, and the middle one is the important one: a
|
|
1043
|
+
document that does not yet parse is holding its retractions back, and has to
|
|
1044
|
+
look different from one that synced clean. */
|
|
1045
|
+
.edit-status { margin: 0; font-family: ${MONO_STACK}; font-size: .62rem; min-height: 1.2em; color: var(--soil-mid); }
|
|
1046
|
+
.edit-status.pending { color: #9A5B12; }
|
|
1047
|
+
.edit-status.ok { color: #4E6B2E; }
|
|
1048
|
+
|
|
1049
|
+
.edit-side { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
|
1050
|
+
.edit-map .world-map-board { min-height: 190px; }
|
|
1051
|
+
.edit-map .burrow .room { cursor: pointer; }
|
|
1052
|
+
.edit-map .burrow .room.here rect { stroke: var(--burrow-glow); stroke-width: 1.8; }
|
|
1053
|
+
.edit-panel { display: flex; flex-direction: column; gap: .35rem; }
|
|
1054
|
+
.edit-caption { margin: 0; font-size: .74rem; color: var(--soil-mid); }
|
|
1055
|
+
.room-detail-cast, .edit-legend { display: flex; flex-wrap: wrap; gap: .4rem; }
|
|
1056
|
+
.edit-legend .sprite-card, .room-detail-cast .sprite-card { max-width: 58px; }
|
|
1057
|
+
.edit-legend .sprite-frame, .room-detail-cast .sprite-frame {
|
|
1058
|
+
width: 34px; padding: 2px; box-sizing: border-box; background: rgba(255,255,255,.6);
|
|
1059
|
+
border: 1px solid var(--soil-mid); border-radius: 2px;
|
|
1060
|
+
}
|
|
1061
|
+
.edit-legend .sprite-frame svg, .room-detail-cast .sprite-frame svg { width: 100%; display: block; }
|
|
1062
|
+
.edit-empty { font-size: .74rem; font-style: italic; color: var(--soil-mid); }
|
|
1063
|
+
|
|
1064
|
+
/* Narrow, every pane stacks and every count reads the same — including the
|
|
1065
|
+
single-pane one, whose centred column and extra height only buy anything
|
|
1066
|
+
when there is width to spare. */
|
|
1067
|
+
@media (max-width: 900px) {
|
|
1068
|
+
.deck-row { grid-template-columns: 1fr; }
|
|
1069
|
+
.mud-stage, .mud-stage[data-panes="1"] {
|
|
1070
|
+
grid-template-columns: 1fr; justify-content: stretch;
|
|
1071
|
+
--pane-height: 596px; --room-height: 152px;
|
|
1072
|
+
}
|
|
1073
|
+
.world-map-board { min-height: 150px; }
|
|
1074
|
+
.edit-stage { grid-template-columns: 1fr; }
|
|
1075
|
+
#editorText { min-height: 18rem; }
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/* ---- the control panel, in a Lemmings register, on the burrow's own palette ----
|
|
1079
|
+
The 1991 game put everything a player touches in one chunky beveled strip
|
|
1080
|
+
under the level. This deck keeps that shape — one bordered block, a play
|
|
1081
|
+
button that reads as the "go" key, a lit corner readout — but in the same
|
|
1082
|
+
soil/parchment/burrow-glow palette every other panel on the page already
|
|
1083
|
+
uses, so it reads as this world's own control panel rather than a
|
|
1084
|
+
different game's console dropped on top of it. Everything below overrides
|
|
1085
|
+
the parchment deck above it deliberately, so the panel is one block a
|
|
1086
|
+
reader can find rather than a dozen edits. */
|
|
1087
|
+
:root {
|
|
1088
|
+
--lem-face: var(--chalk); --lem-face-hi: #FBF7EF; --lem-face-lo: var(--soil-light);
|
|
1089
|
+
--lem-go: var(--mud-ink); --lem-go-hi: var(--burrow-glow);
|
|
1090
|
+
--lem-alert: #A5432B;
|
|
1091
|
+
--lem-readout: var(--burrow-glow); --lem-readout-bg: var(--soil-deep);
|
|
1092
|
+
--lem-chalk: var(--soil-mid);
|
|
1093
|
+
}
|
|
1094
|
+
.deck-slider, .deck h3 { color: var(--lem-chalk); }
|
|
1095
|
+
.deck-slider input[type="range"] { accent-color: var(--burrow-glow); }
|
|
1096
|
+
.deck button, .pane-controls button, .net-panel button, .join-inner button {
|
|
1097
|
+
background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 3px;
|
|
1098
|
+
padding: .32rem .7rem; box-shadow: 0 1px 0 rgba(0,0,0,.12);
|
|
1099
|
+
}
|
|
1100
|
+
.deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled),
|
|
1101
|
+
.net-panel button:hover:not(:disabled), .join-inner button:hover:not(:disabled) {
|
|
1102
|
+
background: var(--lem-face-hi); border-color: var(--burrow-glow);
|
|
1103
|
+
}
|
|
1104
|
+
.deck button:active:not(:disabled), .pane-controls button:active:not(:disabled),
|
|
1105
|
+
.net-panel button:active:not(:disabled), .join-inner button:active:not(:disabled) {
|
|
1106
|
+
background: var(--lem-face-lo); box-shadow: inset 0 1px 2px rgba(0,0,0,.25);
|
|
1107
|
+
}
|
|
1108
|
+
.deck-play { background: var(--lem-go) !important; color: var(--parchment) !important; border-color: var(--lem-go) !important; }
|
|
1109
|
+
.deck-play[aria-pressed="true"] { background: var(--lem-alert) !important; border-color: var(--lem-alert) !important; color: var(--parchment) !important; }
|
|
1110
|
+
/* The corner readout: a lit panel display, in the burrow's own amber rather than DOS-console green. */
|
|
1111
|
+
.deck-turns { background: var(--lem-readout-bg); color: var(--lem-readout);
|
|
1112
|
+
border: 1px solid var(--soil-mid); border-radius: 2px; padding: .1rem .5rem; }
|
|
1113
|
+
.deck-info-btn { background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); }
|
|
1114
|
+
.deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { background: var(--lem-face-hi); border-color: var(--burrow-glow); color: var(--mud-ink); }
|
|
1115
|
+
#editModeBtn[aria-pressed="true"] { background: var(--burrow-glow); border-color: var(--burrow-glow); color: var(--mud-ink); }
|
|
1116
|
+
|
|
1117
|
+
/* ---- whether the burrow is shared, said in one word on the panel ---- */
|
|
1118
|
+
.state-pill { display: inline-flex; align-items: center; gap: .34rem;
|
|
1119
|
+
font-family: ${MONO_STACK}; font-size: .68rem; text-transform: uppercase; letter-spacing: .08em; }
|
|
1120
|
+
.state-dot { width: .46rem; height: .46rem; border-radius: 50%; background: var(--lem-face-lo); flex: 0 0 auto; }
|
|
1121
|
+
.state-pill[data-tone="waiting"] .state-dot { background: #E0912A; }
|
|
1122
|
+
.state-pill[data-tone="working"] .state-dot { background: #E0912A; }
|
|
1123
|
+
.state-pill[data-tone="live"] .state-dot { background: var(--lem-go); }
|
|
1124
|
+
.state-pill[data-tone="failed"] .state-dot { background: var(--lem-alert); }
|
|
1125
|
+
|
|
1126
|
+
.net-panel {
|
|
1127
|
+
background: var(--soil-deep); color: var(--parchment);
|
|
1128
|
+
border: 1px solid var(--burrow-glow); border-radius: 3px;
|
|
1129
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.3);
|
|
1130
|
+
padding: .7rem .8rem; margin-bottom: 1rem;
|
|
1131
|
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: .6rem .9rem; align-items: start;
|
|
1132
|
+
}
|
|
1133
|
+
.net-head { grid-column: 1 / -1; display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
|
|
1134
|
+
.net-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
|
|
1135
|
+
.net-state { font-size: .74rem; display: flex; gap: .4rem; flex-wrap: wrap; align-items: baseline; }
|
|
1136
|
+
.net-state b { font-family: ${MONO_STACK}; font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; }
|
|
1137
|
+
.net-state[data-tone="live"] b { color: var(--lem-go-hi); }
|
|
1138
|
+
.net-state[data-tone="failed"] b { color: #FF8C70; }
|
|
1139
|
+
.net-state[data-tone="waiting"] b, .net-state[data-tone="working"] b { color: #F0C070; }
|
|
1140
|
+
.net-state span { opacity: .8; }
|
|
1141
|
+
.net-close { margin-left: auto; }
|
|
1142
|
+
.net-fields, .net-block { display: flex; flex-direction: column; gap: .3rem; min-width: 0; }
|
|
1143
|
+
.net-fields { grid-column: 1 / -1; flex-direction: row; flex-wrap: wrap; gap: .8rem; }
|
|
1144
|
+
.net-field, .net-label { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .1em; opacity: .85; }
|
|
1145
|
+
.net-field { display: flex; flex-direction: column; gap: .2rem; }
|
|
1146
|
+
.net-panel input[type="text"], .net-panel textarea {
|
|
1147
|
+
font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout); background: var(--lem-readout-bg);
|
|
1148
|
+
border: 1px solid #000; border-radius: 2px; padding: .26rem .4rem; width: 100%; box-sizing: border-box;
|
|
1149
|
+
resize: vertical; overflow-wrap: anywhere;
|
|
1150
|
+
}
|
|
1151
|
+
.net-panel input[readonly], .net-panel textarea[readonly] { opacity: .85; }
|
|
1152
|
+
.net-panel button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .66rem;
|
|
1153
|
+
text-transform: uppercase; letter-spacing: .08em; }
|
|
1154
|
+
.net-hint { margin: 0; font-size: .7rem; opacity: .75; }
|
|
1155
|
+
.net-problem { margin: 0; font-size: .72rem; color: #FFB4A0; }
|
|
1156
|
+
.node-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .22rem; }
|
|
1157
|
+
.node-row { display: flex; align-items: center; gap: .4rem; font-size: .74rem; }
|
|
1158
|
+
.node-row[data-away="true"] { opacity: .55; }
|
|
1159
|
+
.node-avatar { width: 1.25rem; height: 1.25rem; border-radius: 50%; background: var(--lem-face);
|
|
1160
|
+
color: #16162A; font-family: ${MONO_STACK}; font-size: .52rem; display: inline-flex;
|
|
1161
|
+
align-items: center; justify-content: center; flex: 0 0 auto; }
|
|
1162
|
+
.node-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1163
|
+
.node-self { font-family: ${MONO_STACK}; font-size: .54rem; text-transform: uppercase; letter-spacing: .08em; opacity: .6; }
|
|
1164
|
+
.node-hand { opacity: 0; }
|
|
1165
|
+
.node-row[data-waving="true"] .node-hand { opacity: 1; animation: hand-wave .8s ease-in-out infinite; }
|
|
1166
|
+
.node-when { margin-left: auto; font-family: ${MONO_STACK}; font-size: .58rem; opacity: .6; }
|
|
1167
|
+
|
|
1168
|
+
.join-card { position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center;
|
|
1169
|
+
background: rgba(8,8,42,.86); padding: 1rem; }
|
|
1170
|
+
/* Setting display at all outranks the browser's own [hidden] rule, so every
|
|
1171
|
+
block here that is hidden by attribute has to say so a second time. */
|
|
1172
|
+
.join-card[hidden], .net-panel[hidden], .net-block[hidden], .join-reply[hidden] { display: none; }
|
|
1173
|
+
.join-inner { background: var(--soil-deep);
|
|
1174
|
+
color: var(--parchment); border: 1px solid var(--burrow-glow); border-radius: 3px;
|
|
1175
|
+
box-shadow: 0 10px 26px rgba(0,0,0,.5);
|
|
1176
|
+
padding: 1.1rem 1.2rem; max-width: 32rem; width: 100%; display: flex; flex-direction: column; gap: .55rem; }
|
|
1177
|
+
.join-eyebrow { margin: 0; font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .8; }
|
|
1178
|
+
.join-world { font-family: ${DISPLAY_STACK}; font-size: 1.4rem; margin: 0; }
|
|
1179
|
+
.join-body { margin: 0; font-size: .8rem; opacity: .9; max-width: 52ch; }
|
|
1180
|
+
.join-reply { display: flex; flex-direction: column; gap: .3rem; }
|
|
1181
|
+
.join-inner textarea { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout);
|
|
1182
|
+
background: var(--lem-readout-bg); border: 1px solid #000; border-radius: 2px; padding: .3rem .4rem;
|
|
1183
|
+
width: 100%; box-sizing: border-box; overflow-wrap: anywhere; }
|
|
1184
|
+
.join-inner button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .68rem;
|
|
1185
|
+
text-transform: uppercase; letter-spacing: .08em; }
|
|
1186
|
+
.join-dismiss { background: transparent !important; box-shadow: none !important; color: #C6C6E8 !important;
|
|
1187
|
+
text-decoration: underline; padding-left: 0 !important; }
|
|
1188
|
+
|
|
1189
|
+
/* ---- a wave, and the node a character is played from ---- */
|
|
1190
|
+
.pill-strip { display: flex; align-items: flex-start; gap: .22rem; min-width: 0; }
|
|
1191
|
+
.pill-strip .chatpills { flex: 1 1 auto; min-width: 0; }
|
|
1192
|
+
.wave-btn { flex: 0 0 auto; }
|
|
1193
|
+
.sprite-card { position: relative; }
|
|
1194
|
+
.wave-hand { position: absolute; top: -0.55rem; right: -0.35rem; font-size: 1.1rem; line-height: 1;
|
|
1195
|
+
transform-origin: 70% 80%; animation: hand-wave .8s ease-in-out infinite; pointer-events: none; }
|
|
1196
|
+
@keyframes hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
|
|
1197
|
+
.sprite-node {
|
|
1198
|
+
margin-top: 1px; max-width: 100%; box-sizing: border-box;
|
|
1199
|
+
font-family: ${MONO_STACK}; font-size: .34rem; line-height: 1.3; letter-spacing: .01em; text-align: center;
|
|
1200
|
+
color: var(--soil-mid); background: rgba(239,230,216,.7); border-radius: 2px; padding: 0 .2rem;
|
|
1201
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1202
|
+
}
|
|
1203
|
+
.pane-node { font-family: ${MONO_STACK}; font-size: .56rem; color: var(--soil-mid); opacity: .85; }
|
|
1204
|
+
.mud-window.remote .pane-controls button { display: none; }
|
|
1205
|
+
.mud-window.remote .room-view { box-shadow: inset 0 0 0 2px rgba(60,60,180,.35); }
|
|
1206
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1207
|
+
.wave-hand, .node-row[data-waving="true"] .node-hand { animation: none; }
|
|
1208
|
+
}
|
|
403
1209
|
`;
|
|
404
1210
|
|
|
405
1211
|
/** The inlined page script, as a plain function body handed to
|
|
@@ -411,28 +1217,68 @@ function pageScript() {
|
|
|
411
1217
|
"use strict";
|
|
412
1218
|
const DATA = MUD_PAGE_DATA;
|
|
413
1219
|
const createTicker = ${createTicker.toString()};
|
|
414
|
-
const
|
|
1220
|
+
const escapeHtml = ${escapeHtml.toString()};
|
|
1221
|
+
const esc = escapeHtml;
|
|
1222
|
+
const paneMarkup = ${paneMarkup.toString()};
|
|
415
1223
|
const speciesOfCharacter = ${speciesOfCharacter.toString()};
|
|
416
1224
|
const mudRoomSceneObjects = ${mudRoomSceneObjects.toString()};
|
|
417
1225
|
const carriedItemsFor = ${carriedItemsFor.toString()};
|
|
418
1226
|
const levelsOf = ${levelsOf.toString()};
|
|
419
|
-
const levelBands = ${levelBands.toString()};
|
|
420
1227
|
const charactersInRoom = ${charactersInRoom.toString()};
|
|
1228
|
+
const burrowGraph = ${burrowGraph.toString()};
|
|
1229
|
+
const diggableDirections = ${diggableDirections.toString()};
|
|
1230
|
+
const itemLabel = ${itemLabel.toString()};
|
|
1231
|
+
const isCreature = ${isCreature.toString()};
|
|
421
1232
|
const roomSceneObjects = ${roomSceneObjects.toString()};
|
|
422
1233
|
const scenePlacement = ${scenePlacement.toString()};
|
|
423
1234
|
const spriteClassForObject = ${spriteClassForObject.toString()};
|
|
424
1235
|
const spriteAncestryRows = ${spriteAncestryRows.toString()};
|
|
1236
|
+
const factsForSubject = ${factsForSubject.toString()};
|
|
425
1237
|
const visibleRoomOf = ${visibleRoomOf.toString()};
|
|
426
1238
|
const roomKindForRoom = ${roomKindForRoom.toString()};
|
|
427
|
-
const
|
|
1239
|
+
const allRoomIds = ${allRoomIds.toString()};
|
|
1240
|
+
const renderMudEditorText = ${renderMudEditorText.toString()};
|
|
1241
|
+
const wordBeforeCursor = ${wordBeforeCursor.toString()};
|
|
1242
|
+
const provenanceStamps = ${provenanceStamps.toString()};
|
|
1243
|
+
const peerTerm = ${peerTerm.toString()};
|
|
1244
|
+
const claimantOf = ${claimantOf.toString()};
|
|
1245
|
+
const wavingCharacters = ${wavingCharacters.toString()};
|
|
1246
|
+
const nodeNameFor = ${nodeNameFor.toString()};
|
|
1247
|
+
const offerBlobIn = ${offerBlobIn.toString()};
|
|
1248
|
+
const wireStateLabel = ${wireStateLabel.toString()};
|
|
1249
|
+
const nodeRowsFor = ${nodeRowsFor.toString()};
|
|
1250
|
+
const nodeInitials = ${nodeInitials.toString()};
|
|
1251
|
+
const inviteLinkFor = ${inviteLinkFor.toString()};
|
|
1252
|
+
const inviteParamsFrom = ${inviteParamsFrom.toString()};
|
|
428
1253
|
|
|
429
1254
|
const el = (id) => document.getElementById(id);
|
|
430
|
-
|
|
431
|
-
|
|
1255
|
+
// Which of the shipped burrows is loaded. Every read of the world — the
|
|
1256
|
+
// session it is opened over, the roster it is cast from, the provenance
|
|
1257
|
+
// prefix edit mode filters on — goes through this one index, so switching
|
|
1258
|
+
// scenarios needs no second copy of any of them.
|
|
1259
|
+
let scenarioIndex = 0;
|
|
1260
|
+
const scenario = function () { return DATA.scenarios[scenarioIndex]; };
|
|
1261
|
+
const rosterOf = function (s) { return (s.characters || []).map(function (c) { return c.id; }); };
|
|
1262
|
+
// Where the survey starts laying rooms out: the burrow's own origin, the
|
|
1263
|
+
// same fact the dig mechanic measures its reach from. Falling back to the
|
|
1264
|
+
// shipped default only matters for a world that declares no origin at all,
|
|
1265
|
+
// and burrowGraph already copes by starting from whichever room sorts first.
|
|
1266
|
+
const rootRoomOf = function (s) {
|
|
1267
|
+
const facts = (s.worldPayload && s.worldPayload.facts) || [];
|
|
1268
|
+
const origin = facts.find(function (f) { return f.predicate === "mgx:is-origin" && f.object === "true"; });
|
|
1269
|
+
return origin ? origin.subject : DATA.rootRoom;
|
|
1270
|
+
};
|
|
1271
|
+
let roster = rosterOf(scenario());
|
|
1272
|
+
let rootRoom = rootRoomOf(scenario());
|
|
1273
|
+
let slots = [];
|
|
1274
|
+
const ACTOR_COLORS = ["var(--actor-a)", "var(--actor-b)", "var(--actor-c)", "var(--actor-d)"];
|
|
1275
|
+
const NPC_COLOR = "var(--chalk)";
|
|
1276
|
+
const COMPASS = ["north", "south", "east", "west", "up", "down"];
|
|
1277
|
+
const DIR_GLYPH = { north: "\\u25B2 N", south: "\\u25BC S", west: "\\u25C0 W", east: "E \\u25B6", up: "\\u21E1", down: "\\u21E3" };
|
|
432
1278
|
const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
433
1279
|
|
|
434
1280
|
// ---- the one shared tick queue -----------------------------------------
|
|
435
|
-
// Every
|
|
1281
|
+
// Every pane's ticker calls into this instead of session.windows[...]
|
|
436
1282
|
// directly, so two characters' turns can never interleave their reads and
|
|
437
1283
|
// writes of the one shared memoryDir, and the global turn counter always
|
|
438
1284
|
// increments in real execution order.
|
|
@@ -444,170 +1290,499 @@ function pageScript() {
|
|
|
444
1290
|
}
|
|
445
1291
|
|
|
446
1292
|
let session = null;
|
|
1293
|
+
let cast = [];
|
|
1294
|
+
// The animals with no pane. They act through the same session windows and
|
|
1295
|
+
// the same tick queue as the cast; every list below that says "everyone"
|
|
1296
|
+
// means both, and every list that says "cast" means the panes alone.
|
|
1297
|
+
let npcs = [];
|
|
1298
|
+
let slotOf = {};
|
|
447
1299
|
let globalTurn = 0;
|
|
1300
|
+
let turnsTaken = {};
|
|
1301
|
+
let finished = {};
|
|
448
1302
|
let maxTurns = DATA.defaultMaxTurns;
|
|
449
1303
|
let delayMs = DATA.defaultDelayMs;
|
|
450
1304
|
const wait = function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); };
|
|
451
1305
|
const liveWait = function () { return wait(delayMs); };
|
|
452
1306
|
|
|
453
|
-
const knownRoomIds = new Set();
|
|
454
1307
|
let freshlyDugRoom = null;
|
|
455
|
-
|
|
1308
|
+
// Which animals another node has claimed, character -> that node's name.
|
|
1309
|
+
// Refreshed from the store on every redraw, which is also every turn, so a
|
|
1310
|
+
// claim that arrives from a peer takes effect on the next tick rather than
|
|
1311
|
+
// being cached until something else happens to look.
|
|
1312
|
+
let claimedElsewhere = {};
|
|
1313
|
+
// Whoever is waving right now, and the timer that lets a wave stop being
|
|
1314
|
+
// current. A wave is never retracted — it just stops being recent.
|
|
1315
|
+
let wavingNow = [];
|
|
1316
|
+
let waveRedrawTimer = null;
|
|
1317
|
+
// The last omniscient read, kept so the pounce can draw the predator's own
|
|
1318
|
+
// den after the engine has already moved the character out of every room.
|
|
1319
|
+
let lastSnapshot = null;
|
|
1320
|
+
const speechBubbles = new Map(); // room -> [{ speaker, text, expiresAtTurn }]
|
|
456
1321
|
|
|
457
|
-
|
|
1322
|
+
let tickers = {};
|
|
458
1323
|
let autoOn = false;
|
|
459
1324
|
|
|
460
1325
|
function hasNext() { return globalTurn < maxTurns; }
|
|
1326
|
+
function everyone() { return cast.concat(npcs); }
|
|
1327
|
+
function hasPane(character) { return Boolean(slotOf[character]); }
|
|
1328
|
+
function paneIdFor(character) { return "window-" + slotOf[character]; }
|
|
1329
|
+
function colorFor(character) {
|
|
1330
|
+
const seat = cast.indexOf(character);
|
|
1331
|
+
return seat === -1 ? NPC_COLOR : ACTOR_COLORS[seat % ACTOR_COLORS.length];
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// The engine keeps each character's own tally (its scripted ticks AND its
|
|
1335
|
+
// typed commands), which is the only one that can be right: a typed "dig
|
|
1336
|
+
// north" is a turn the page never drove. The page's own count of the ticks it
|
|
1337
|
+
// fired is the fallback, so a pane never falls back to showing the shared
|
|
1338
|
+
// global figure as if it were its own.
|
|
1339
|
+
function turnsFor(character) {
|
|
1340
|
+
const w = session && session.windows[character];
|
|
1341
|
+
const own = w && typeof w.turnsTaken === "function" ? w.turnsTaken() : null;
|
|
1342
|
+
return typeof own === "number" ? own : (turnsTaken[character] || 0);
|
|
1343
|
+
}
|
|
461
1344
|
|
|
462
1345
|
async function runOneTurn(character) {
|
|
1346
|
+
// A tick already queued when a reset lands belongs to a world that no
|
|
1347
|
+
// longer exists, and a dozen animals make that overlap ordinary rather
|
|
1348
|
+
// than rare.
|
|
1349
|
+
const bootAt = bootSeq;
|
|
463
1350
|
return serializeTick(async function () {
|
|
1351
|
+
if (bootAt !== bootSeq || finished[character]) return { outOfPlay: true };
|
|
1352
|
+
// An animal another node claimed is played there, not here. Its moves
|
|
1353
|
+
// still arrive as facts and it still draws, talks and can be waved at —
|
|
1354
|
+
// this page just never takes its turn for it.
|
|
1355
|
+
if (claimedElsewhere[character]) return { playedElsewhere: true };
|
|
464
1356
|
globalTurn += 1;
|
|
465
1357
|
const result = await session.windows[character].autoplayTick(globalTurn);
|
|
1358
|
+
if (!result.outOfPlay) turnsTaken[character] = (turnsTaken[character] || 0) + 1;
|
|
466
1359
|
afterEngineTurn(character, result);
|
|
1360
|
+
await broadcastLocalChange();
|
|
467
1361
|
return result;
|
|
468
1362
|
});
|
|
469
1363
|
}
|
|
470
1364
|
|
|
1365
|
+
// A character whose run has ended keeps its pane, its final turn count and
|
|
1366
|
+
// its chat log, and loses everything that would advance it — the engine
|
|
1367
|
+
// declines its turns from here on, so offering the controls would promise a
|
|
1368
|
+
// turn that never runs. An NPC has none of that to lose: it stops ticking and
|
|
1369
|
+
// the survey stops drawing it, which is the whole of its ending. WHICH ending
|
|
1370
|
+
// it was is never cached here — every reader asks the engine fresh, so a pane
|
|
1371
|
+
// that says "eaten" over an animal that quietly starved is never possible.
|
|
1372
|
+
function markOutOfPlay(character, reason, note) {
|
|
1373
|
+
if (finished[character]) return;
|
|
1374
|
+
finished[character] = true;
|
|
1375
|
+
const ticker = tickers[character];
|
|
1376
|
+
if (ticker) ticker.pause();
|
|
1377
|
+
if (!hasPane(character)) return;
|
|
1378
|
+
const w = paneIdFor(character);
|
|
1379
|
+
el(w + "-play").disabled = true;
|
|
1380
|
+
el(w + "-step").disabled = true;
|
|
1381
|
+
el(w + "-chatq").disabled = true;
|
|
1382
|
+
el(w + "-chatpills").innerHTML = "";
|
|
1383
|
+
const fate = reason === "starved" ? "starved" : "eaten";
|
|
1384
|
+
// A run that ended on a TYPED command carries no note of its own — the
|
|
1385
|
+
// world's ending sentence comes from the engine either way, never a second
|
|
1386
|
+
// wording of the same event.
|
|
1387
|
+
appendChat(character, "a", note
|
|
1388
|
+
|| (window.tmctMud.outOfPlayPhrase(character, fate) + ". It takes no more turns."));
|
|
1389
|
+
playFateScene(character, fate);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Whichever individual the WORLD marks dangerous, never a species this page
|
|
1393
|
+
// hardcodes — the same fact adventure.mjs's own predator check reads.
|
|
1394
|
+
function predatorInRows(rows) {
|
|
1395
|
+
for (const row of rows) {
|
|
1396
|
+
if (row.predicate === "mgx:is-predator" && row.object === "true") return row.subject;
|
|
1397
|
+
}
|
|
1398
|
+
return null;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// The pane holds on the den for the length of one pounce before it grays out.
|
|
1402
|
+
// Cutting straight to the banner throws away the only moment this demo has to
|
|
1403
|
+
// show what took the character: the character's last drawn room is the one it
|
|
1404
|
+
// walked out OF, so the scene is redrawn against the den first, then the fox
|
|
1405
|
+
// crosses it. An animal that ran out of mass was taken by nothing, so it
|
|
1406
|
+
// settles straight into its banner — as does reduced motion, or a world with
|
|
1407
|
+
// no marked predator.
|
|
1408
|
+
function playFateScene(character, fate) {
|
|
1409
|
+
const w = paneIdFor(character);
|
|
1410
|
+
const settle = function () {
|
|
1411
|
+
el(w).classList.add("out-of-play");
|
|
1412
|
+
const banner = el(w + "-fate");
|
|
1413
|
+
banner.hidden = false;
|
|
1414
|
+
banner.textContent = fate + " \\u00b7 " + turnsFor(character) + " turns";
|
|
1415
|
+
};
|
|
1416
|
+
const predator = lastSnapshot ? predatorInRows(lastSnapshot.rows) : null;
|
|
1417
|
+
const den = predator ? (lastSnapshot.state.placements.get(predator) || {}).object : null;
|
|
1418
|
+
if (fate === "starved" || reduceMotion || !den) { settle(); return; }
|
|
1419
|
+
|
|
1420
|
+
const rows = lastSnapshot.rows;
|
|
1421
|
+
const state = lastSnapshot.state;
|
|
1422
|
+
renderRoomView(character, rows, state, den, allRoomIds(rows),
|
|
1423
|
+
window.tmctMud.castInRoom(rows, state, den, character));
|
|
1424
|
+
|
|
1425
|
+
const room = el(w + "-room");
|
|
1426
|
+
const lunger = room.querySelector('.floor-others .sprite-card[data-subject="' + predator + '"]');
|
|
1427
|
+
const prey = el(w + "-self");
|
|
1428
|
+
if (!lunger || !prey) { settle(); return; }
|
|
1429
|
+
const gap = prey.getBoundingClientRect().left - lunger.getBoundingClientRect().left;
|
|
1430
|
+
lunger.style.setProperty("--pounce-x", Math.max(0, Math.round(gap) - 8) + "px");
|
|
1431
|
+
room.classList.add("pounce");
|
|
1432
|
+
lunger.classList.add("lunging");
|
|
1433
|
+
setTimeout(settle, 1000);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// A bubble carries what was SAID, not the sentence: the answer names a
|
|
1437
|
+
// thing, and the thing's own display label is only resolvable against the
|
|
1438
|
+
// fact rows a redraw has and a finished turn does not.
|
|
1439
|
+
function noteSpeech(room, speaker, bubble) {
|
|
1440
|
+
if (!speechBubbles.has(room)) speechBubbles.set(room, []);
|
|
1441
|
+
speechBubbles.get(room).push(Object.assign({ speaker: speaker, expiresAtTurn: globalTurn + 2 }, bubble));
|
|
1442
|
+
}
|
|
1443
|
+
|
|
471
1444
|
function afterEngineTurn(character, result) {
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
});
|
|
1445
|
+
if (result && result.outOfPlay) {
|
|
1446
|
+
markOutOfPlay(character, result.outOfPlayReason, result.text);
|
|
1447
|
+
renderSoon();
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
if (!result || !result.room) { renderSoon(); return; }
|
|
1451
|
+
if (result.roomAfter && result.roomAfter !== result.room) freshlyDugRoom = result.roomAfter;
|
|
1452
|
+
for (const action of result.actions || []) {
|
|
1453
|
+
if (action.kind !== "ask" && action.kind !== "talk") continue;
|
|
1454
|
+
const other = action.teller || action.target || action.object;
|
|
1455
|
+
if (action.text) noteSpeech(result.room, character, { text: action.text });
|
|
1456
|
+
if (other && action.thing) noteSpeech(result.room, other, { thing: action.thing });
|
|
1457
|
+
else if (other && action.text) noteSpeech(result.room, other, { text: action.text });
|
|
484
1458
|
}
|
|
485
|
-
|
|
1459
|
+
renderSoon();
|
|
486
1460
|
}
|
|
487
1461
|
|
|
488
1462
|
function ensureTicker(character) {
|
|
489
1463
|
if (tickers[character]) return tickers[character];
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
const stepBtn = el(w + "-step");
|
|
493
|
-
const ticker = createTicker({
|
|
1464
|
+
const playBtn = hasPane(character) ? el(paneIdFor(character) + "-play") : null;
|
|
1465
|
+
tickers[character] = createTicker({
|
|
494
1466
|
onTick: function () { return runOneTurn(character); },
|
|
495
1467
|
onRender: function (state) {
|
|
496
|
-
playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
|
|
1468
|
+
if (playBtn) playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
|
|
497
1469
|
},
|
|
498
1470
|
hasNext: hasNext,
|
|
499
1471
|
wait: liveWait,
|
|
500
1472
|
});
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1473
|
+
return tickers[character];
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// ---- chat docks ---------------------------------------------------------
|
|
1477
|
+
// The line's own text is never shortened, only its rendered height — the whole
|
|
1478
|
+
// thing stays readable through the popup, and stays in the DOM for anything
|
|
1479
|
+
// reading the log.
|
|
1480
|
+
function appendChat(character, cls, text) {
|
|
1481
|
+
const log = el(paneIdFor(character) + "-chatlog");
|
|
1482
|
+
const d = document.createElement("div");
|
|
1483
|
+
d.className = cls;
|
|
1484
|
+
d.textContent = text;
|
|
1485
|
+
log.appendChild(d);
|
|
1486
|
+
// A line-clamped box reports scrollHeight EQUAL to its clamped height, so
|
|
1487
|
+
// the overflow has to be read against the same box with the clamp lifted.
|
|
1488
|
+
d.classList.add("unclamped");
|
|
1489
|
+
const wholeHeight = d.scrollHeight;
|
|
1490
|
+
d.classList.remove("unclamped");
|
|
1491
|
+
if (wholeHeight - d.clientHeight > 2) {
|
|
1492
|
+
d.classList.add("clipped");
|
|
1493
|
+
d.setAttribute("role", "button");
|
|
1494
|
+
d.setAttribute("tabindex", "0");
|
|
1495
|
+
d.setAttribute("title", "read the whole line");
|
|
1496
|
+
}
|
|
1497
|
+
log.scrollTop = log.scrollHeight;
|
|
505
1498
|
}
|
|
506
1499
|
|
|
507
|
-
function
|
|
508
|
-
const
|
|
509
|
-
|
|
1500
|
+
function openLogPopup(slot, text) {
|
|
1501
|
+
const w = "window-" + slot;
|
|
1502
|
+
el(w + "-logpop-text").textContent = text;
|
|
1503
|
+
el(w + "-logpop").hidden = false;
|
|
510
1504
|
}
|
|
511
1505
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
1506
|
+
function closeLogPopup(slot) {
|
|
1507
|
+
el("window-" + slot + "-logpop").hidden = true;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function sendCommand(character, line) {
|
|
1511
|
+
appendChat(character, "u", line);
|
|
1512
|
+
const owner = claimedElsewhere[character];
|
|
1513
|
+
if (owner) {
|
|
1514
|
+
const refusal = character + " is played from " + owner + ". You can watch it and wave at it, but its turns are theirs.";
|
|
1515
|
+
appendChat(character, "a", refusal);
|
|
1516
|
+
return Promise.resolve({ answer: refusal, end: false });
|
|
1517
|
+
}
|
|
1518
|
+
// A wave is a gesture, not a world command — the engine has no verb for it
|
|
1519
|
+
// and would answer with an honest miss. Both ways in (this typed line and
|
|
1520
|
+
// the pane's own hand button) write the same fact.
|
|
1521
|
+
if (/^\\/?wave$/i.test(line.trim())) {
|
|
1522
|
+
return waveAs(character).then(function (here) {
|
|
1523
|
+
appendChat(character, "a", here
|
|
1524
|
+
? "you wave. Everyone in the " + here + " sees it."
|
|
1525
|
+
: character + " is standing nowhere, so there is nobody to wave at.");
|
|
1526
|
+
return { answer: "", end: false };
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
return serializeTick(function () { return session.windows[character].turn(line); }).then(async function (res) {
|
|
1530
|
+
appendChat(character, "a", res.answer);
|
|
1531
|
+
await broadcastLocalChange();
|
|
1532
|
+
renderSoon();
|
|
1533
|
+
return res;
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// A wave is an ordinary add-only fact in the shared world, so it needs no
|
|
1538
|
+
// network to work: one browser renders it exactly the way four do, and the
|
|
1539
|
+
// room broadcast below is the only part sharing adds.
|
|
1540
|
+
async function waveAs(character) {
|
|
1541
|
+
if (!session || finished[character]) return null;
|
|
1542
|
+
const here = await serializeTick(function () { return session.wave(character); });
|
|
1543
|
+
if (here) await broadcastLocalChange();
|
|
1544
|
+
renderSoon();
|
|
1545
|
+
return here;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// Wired as the pane is built. The stage is rebuilt on every boot, so a
|
|
1549
|
+
// pane's listeners live and die with it; each one still reads its slot's
|
|
1550
|
+
// character at click time, because the cast is bound after the markup.
|
|
1551
|
+
function wirePane(slot) {
|
|
1552
|
+
const w = "window-" + slot;
|
|
516
1553
|
const input = el(w + "-chatq");
|
|
517
|
-
|
|
518
|
-
const pillsEl = el(w + "-chatpills");
|
|
519
|
-
function append(cls, text) {
|
|
520
|
-
const d = document.createElement("div");
|
|
521
|
-
d.className = cls;
|
|
522
|
-
d.textContent = text;
|
|
523
|
-
log.appendChild(d);
|
|
524
|
-
log.scrollTop = log.scrollHeight;
|
|
525
|
-
}
|
|
526
|
-
form.addEventListener("submit", function (e) {
|
|
1554
|
+
el(w + "-chatform").addEventListener("submit", function (e) {
|
|
527
1555
|
e.preventDefault();
|
|
1556
|
+
const character = characterInSlot(slot);
|
|
528
1557
|
const line = input.value.trim();
|
|
529
|
-
if (!line) return;
|
|
1558
|
+
if (!character || !line) return;
|
|
530
1559
|
input.value = "";
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
1560
|
+
sendCommand(character, line);
|
|
1561
|
+
});
|
|
1562
|
+
el(w + "-play").addEventListener("click", function () {
|
|
1563
|
+
const character = characterInSlot(slot);
|
|
1564
|
+
if (!character) return;
|
|
1565
|
+
const ticker = ensureTicker(character);
|
|
1566
|
+
if (ticker.getState().playing) ticker.pause(); else ticker.play();
|
|
1567
|
+
});
|
|
1568
|
+
el(w + "-step").addEventListener("click", function () {
|
|
1569
|
+
const character = characterInSlot(slot);
|
|
1570
|
+
if (character) ensureTicker(character).stepOnce();
|
|
1571
|
+
});
|
|
1572
|
+
el(w + "-wave").addEventListener("click", function () {
|
|
1573
|
+
const character = characterInSlot(slot);
|
|
1574
|
+
if (character) sendCommand(character, "wave");
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
const log = el(w + "-chatlog");
|
|
1578
|
+
log.addEventListener("click", function (e) {
|
|
1579
|
+
const line = e.target.closest(".clipped");
|
|
1580
|
+
if (line) openLogPopup(slot, line.textContent);
|
|
1581
|
+
});
|
|
1582
|
+
log.addEventListener("keydown", function (e) {
|
|
1583
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
1584
|
+
const line = e.target.closest(".clipped");
|
|
1585
|
+
if (!line) return;
|
|
1586
|
+
e.preventDefault();
|
|
1587
|
+
openLogPopup(slot, line.textContent);
|
|
1588
|
+
});
|
|
1589
|
+
el(w + "-logpop-close").addEventListener("click", function () { closeLogPopup(slot); });
|
|
1590
|
+
el(w).addEventListener("keydown", function (e) {
|
|
1591
|
+
if (e.key === "Escape") closeLogPopup(slot);
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
// Clicking a drawn thing runs the same grounded command its pill would, so
|
|
1595
|
+
// the room view is a second way into one action set rather than its own.
|
|
1596
|
+
// A sprite the room grants no action on carries no command and does nothing.
|
|
1597
|
+
el(w + "-room").addEventListener("click", function (e) {
|
|
1598
|
+
const card = e.target.closest(".sprite-card[data-command]");
|
|
1599
|
+
const character = characterInSlot(slot);
|
|
1600
|
+
if (!card || !character || finished[character]) return;
|
|
1601
|
+
sendCommand(character, card.getAttribute("data-command"));
|
|
1602
|
+
});
|
|
1603
|
+
el(w + "-room").addEventListener("keydown", function (e) {
|
|
1604
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
1605
|
+
const card = e.target.closest(".sprite-card[data-command]");
|
|
1606
|
+
const character = characterInSlot(slot);
|
|
1607
|
+
if (!card || !character || finished[character]) return;
|
|
1608
|
+
e.preventDefault();
|
|
1609
|
+
sendCommand(character, card.getAttribute("data-command"));
|
|
536
1610
|
});
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
});
|
|
542
|
-
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function characterInSlot(slot) {
|
|
1614
|
+
return cast[slots.indexOf(slot)] || null;
|
|
543
1615
|
}
|
|
544
1616
|
|
|
545
1617
|
// ---- room-view rendering -------------------------------------------------
|
|
546
|
-
function
|
|
547
|
-
|
|
548
|
-
|
|
1618
|
+
function hashOf(text) {
|
|
1619
|
+
let h = 0;
|
|
1620
|
+
for (let i = 0; i < String(text).length; i += 1) h = (h * 31 + String(text).charCodeAt(i)) % 100000;
|
|
1621
|
+
return h;
|
|
549
1622
|
}
|
|
550
1623
|
|
|
551
|
-
function spriteSvgFor(species, rows) {
|
|
1624
|
+
function spriteSvgFor(species, rows, instanceKey) {
|
|
552
1625
|
if (window.tmctMud && window.tmctMud.resolveSpriteAsset) {
|
|
553
|
-
return window.tmctMud.resolveSpriteAsset(species, rows, [], DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY);
|
|
1626
|
+
return window.tmctMud.resolveSpriteAsset(species, rows, [], DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY, { instanceKey: instanceKey });
|
|
554
1627
|
}
|
|
555
1628
|
return "";
|
|
556
1629
|
}
|
|
557
1630
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
1631
|
+
// A wall-hung object resolves through the SAME property-aware, large-tier
|
|
1632
|
+
// resolver the characters do, keyed on the object's OWN name (via
|
|
1633
|
+
// spriteAncestryRows' synthetic subClassOf edge to its declared class), so
|
|
1634
|
+
// a carrot draws a carrot and only a class with no template anywhere falls
|
|
1635
|
+
// back. The fallback root is "portable", not the resolver's default
|
|
1636
|
+
// "animal": everything hung on this wall is a thing, and a thing with no
|
|
1637
|
+
// sprite should read as a plain parcel rather than a creature.
|
|
1638
|
+
function objectSvgFor(subject, rows) {
|
|
1639
|
+
if (window.tmctMud && window.tmctMud.resolveSpriteAsset) {
|
|
1640
|
+
return window.tmctMud.resolveSpriteAsset(
|
|
1641
|
+
subject, spriteAncestryRows(rows, subject), factsForSubject(rows, subject),
|
|
1642
|
+
DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY,
|
|
1643
|
+
{ rootFallback: "portable", instanceKey: subject },
|
|
1644
|
+
);
|
|
561
1645
|
}
|
|
562
1646
|
return "";
|
|
563
1647
|
}
|
|
564
1648
|
|
|
565
|
-
|
|
1649
|
+
// The backdrop is a drawing of what the digest already says about the room:
|
|
1650
|
+
// turf and sky above ground, packed strata and pebbles below it. Every
|
|
1651
|
+
// stroke is placed off a hash of the room's own id, so a room looks the
|
|
1652
|
+
// same every redraw and two rooms never look alike.
|
|
1653
|
+
function drawRoomBackdrop(canvas, kind, room) {
|
|
566
1654
|
const ctx = canvas.getContext("2d");
|
|
567
1655
|
const w = canvas.width, h = canvas.height;
|
|
568
1656
|
ctx.clearRect(0, 0, w, h);
|
|
569
1657
|
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
|
570
1658
|
if (kind === "outdoor") {
|
|
571
|
-
grad.addColorStop(0, "#
|
|
1659
|
+
grad.addColorStop(0, "#A9BE83"); grad.addColorStop(0.42, "#7E9159"); grad.addColorStop(1, "#5C6B41");
|
|
572
1660
|
} else {
|
|
573
|
-
grad.addColorStop(0, "#
|
|
1661
|
+
grad.addColorStop(0, "#63482F"); grad.addColorStop(0.55, "#42301F"); grad.addColorStop(1, "#241710");
|
|
574
1662
|
}
|
|
575
1663
|
ctx.fillStyle = grad;
|
|
576
1664
|
ctx.fillRect(0, 0, w, h);
|
|
1665
|
+
|
|
1666
|
+
const seed = hashOf(room);
|
|
1667
|
+
ctx.save();
|
|
1668
|
+
if (kind === "outdoor") {
|
|
1669
|
+
ctx.strokeStyle = "rgba(45,62,30,.5)";
|
|
1670
|
+
ctx.lineWidth = 1.4;
|
|
1671
|
+
for (let i = 0; i < 26; i += 1) {
|
|
1672
|
+
const x = ((seed * (i + 3)) % w);
|
|
1673
|
+
const base = h - 6 - ((seed >> (i % 5)) % 8);
|
|
1674
|
+
ctx.beginPath();
|
|
1675
|
+
ctx.moveTo(x, base);
|
|
1676
|
+
ctx.lineTo(x + (i % 2 ? 3 : -3), base - 9 - (i % 4) * 2);
|
|
1677
|
+
ctx.stroke();
|
|
1678
|
+
}
|
|
1679
|
+
} else {
|
|
1680
|
+
ctx.strokeStyle = "rgba(0,0,0,.22)";
|
|
1681
|
+
ctx.lineWidth = 1;
|
|
1682
|
+
for (let i = 1; i < 4; i += 1) {
|
|
1683
|
+
const y = (h / 4) * i + ((seed >> i) % 6) - 3;
|
|
1684
|
+
ctx.beginPath();
|
|
1685
|
+
ctx.moveTo(0, y);
|
|
1686
|
+
for (let x = 0; x <= w; x += 30) ctx.lineTo(x, y + (((seed + x) % 7) - 3));
|
|
1687
|
+
ctx.stroke();
|
|
1688
|
+
}
|
|
1689
|
+
ctx.fillStyle = "rgba(255,255,255,.07)";
|
|
1690
|
+
for (let i = 0; i < 14; i += 1) {
|
|
1691
|
+
const x = ((seed * (i + 7)) % w);
|
|
1692
|
+
const y = ((seed * (i + 2)) % h);
|
|
1693
|
+
ctx.beginPath();
|
|
1694
|
+
ctx.arc(x, y, 1 + (i % 3), 0, Math.PI * 2);
|
|
1695
|
+
ctx.fill();
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
ctx.restore();
|
|
1699
|
+
const floor = ctx.createLinearGradient(0, h - 26, 0, h);
|
|
1700
|
+
floor.addColorStop(0, "rgba(0,0,0,0)");
|
|
1701
|
+
floor.addColorStop(1, "rgba(0,0,0,.35)");
|
|
1702
|
+
ctx.fillStyle = floor;
|
|
1703
|
+
ctx.fillRect(0, h - 26, w, 26);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// The world's own hasMass fold, the same one the pouch's stat line reads.
|
|
1707
|
+
// Null for anything the world never weighed, which draws no figure at all
|
|
1708
|
+
// rather than a guessed one.
|
|
1709
|
+
function massOf(state, subject) {
|
|
1710
|
+
const mass = state.masses.get(subject);
|
|
1711
|
+
return mass ? mass.value : null;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// A sprite card: adventure.html's own frame-plus-nameplate, carrying the one
|
|
1715
|
+
// command this room currently grants on its subject (none, for a thing the
|
|
1716
|
+
// room offers nothing on) and the subject's own mass under the name. The
|
|
1717
|
+
// last argument holds the two things only a shared burrow has: a hand over a
|
|
1718
|
+
// character waving right now, and the node another peer plays it from — one
|
|
1719
|
+
// more line under the name, in the same font, never a second label system.
|
|
1720
|
+
function spriteCardHtml(subject, label, inner, command, extraClass, mass, extra) {
|
|
1721
|
+
const opts = extra || {};
|
|
1722
|
+
const action = command
|
|
1723
|
+
? ' actionable" data-command="' + esc(command) + '" role="button" tabindex="0" title="' + esc(command) + '"'
|
|
1724
|
+
: '" title="' + esc(label) + '"';
|
|
1725
|
+
return '<div class="sprite-card ' + (extraClass || "") + (opts.waving ? " waving" : "") + action
|
|
1726
|
+
+ ' data-subject="' + esc(subject) + '">'
|
|
1727
|
+
+ (opts.waving ? '<div class="wave-hand" aria-hidden="true">\\u{1F44B}</div>' : "")
|
|
1728
|
+
+ inner + '<div class="sprite-label">' + esc(label) + "</div>"
|
|
1729
|
+
+ (opts.node ? '<div class="sprite-node">via ' + esc(opts.node) + "</div>" : "")
|
|
1730
|
+
+ (mass === null || mass === undefined ? "" : '<div class="sprite-mass">mass ' + esc(mass) + "</div>")
|
|
1731
|
+
+ "</div>";
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// A character another node plays carries that node's name; one this node
|
|
1735
|
+
// plays, or one nobody has claimed, carries nothing extra.
|
|
1736
|
+
function nodeLabelFor(character) {
|
|
1737
|
+
return claimedElsewhere[character] || null;
|
|
1738
|
+
}
|
|
1739
|
+
function isWavingNow(character) {
|
|
1740
|
+
return wavingNow.indexOf(character) !== -1;
|
|
577
1741
|
}
|
|
578
1742
|
|
|
579
|
-
function renderRoomView(character, rows, state, here) {
|
|
580
|
-
const w =
|
|
581
|
-
|
|
582
|
-
const
|
|
583
|
-
drawRoomBackdrop(canvas, kind);
|
|
1743
|
+
function renderRoomView(character, rows, state, here, roomIds, roomMates) {
|
|
1744
|
+
const w = paneIdFor(character);
|
|
1745
|
+
drawRoomBackdrop(el(w + "-canvas"), roomKindForRoom(rows, here), here);
|
|
1746
|
+
const commands = commandsBySubject(affordancesFor(rows, state, here, character));
|
|
584
1747
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
1748
|
+
el(w + "-self").innerHTML = spriteCardHtml(
|
|
1749
|
+
character, character,
|
|
1750
|
+
'<div class="sprite">' + spriteSvgFor(speciesOfCharacter(character), rows, character) + "</div>",
|
|
1751
|
+
null, "self", massOf(state, character),
|
|
1752
|
+
{ waving: isWavingNow(character), node: nodeLabelFor(character) },
|
|
1753
|
+
);
|
|
591
1754
|
|
|
1755
|
+
const others = roomMates.map(function (mate) {
|
|
1756
|
+
return spriteCardHtml(mate, mate,
|
|
1757
|
+
'<div class="sprite">' + spriteSvgFor(speciesOfCharacter(mate), rows, mate) + "</div>",
|
|
1758
|
+
commands[mate], "", massOf(state, mate),
|
|
1759
|
+
{ waving: isWavingNow(mate), node: nodeLabelFor(mate) });
|
|
1760
|
+
});
|
|
1761
|
+
const wall = [];
|
|
592
1762
|
for (const obj of mudRoomSceneObjects(rows, state, here, character)) {
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
const bubble = speechBubbles.get(here);
|
|
603
|
-
if (bubble && bubble.expiresAtTurn >= globalTurn) {
|
|
604
|
-
bubbleEl.textContent = bubble.text;
|
|
605
|
-
bubbleEl.hidden = false;
|
|
606
|
-
bubbleEl.classList.add("shown");
|
|
607
|
-
} else {
|
|
608
|
-
bubbleEl.classList.remove("shown");
|
|
609
|
-
bubbleEl.hidden = true;
|
|
1763
|
+
if (isCreature(state, obj.subject)) continue;
|
|
1764
|
+
const plane = scenePlacement(rows, state, obj.subject).plane;
|
|
1765
|
+
const hang = plane === "ceiling" || plane === "wall" ? "hangs-high" : "hangs-low";
|
|
1766
|
+
// The caption is always the THING'S own name, never the class whose
|
|
1767
|
+
// sprite ended up drawn — an item wearing an ancestor's icon because
|
|
1768
|
+
// its own class has no sprite yet still has to say what it is.
|
|
1769
|
+
wall.push(spriteCardHtml(obj.subject, labelForItem(rows, obj.subject, roomIds),
|
|
1770
|
+
'<div class="hook"></div><div class="sprite-frame">' + objectSvgFor(obj.subject, rows) + "</div>",
|
|
1771
|
+
commands[obj.subject], "wall-item " + hang, massOf(state, obj.subject)));
|
|
610
1772
|
}
|
|
1773
|
+
const othersEl = el(w + "-others");
|
|
1774
|
+
othersEl.innerHTML = others.join("");
|
|
1775
|
+
const floorForMates = el(w + "-room").clientWidth - 68;
|
|
1776
|
+
othersEl.style.setProperty("--mate-width",
|
|
1777
|
+
Math.max(18, Math.min(76, Math.floor(floorForMates / Math.max(1, others.length)) - 6)) + "px");
|
|
1778
|
+
el(w + "-wall").innerHTML = wall.join("");
|
|
1779
|
+
|
|
1780
|
+
const live = (speechBubbles.get(here) || []).filter(function (b) { return b.expiresAtTurn >= globalTurn; });
|
|
1781
|
+
el(w + "-bubbles").innerHTML = live.map(function (b) {
|
|
1782
|
+
const side = b.speaker === character ? "from-self" : "from-other";
|
|
1783
|
+
const said = b.thing ? "there's " + labelForItem(rows, b.thing, roomIds) + ", if you can reach it." : b.text;
|
|
1784
|
+
return '<div class="bubble ' + side + '">' + esc(said) + "</div>";
|
|
1785
|
+
}).join("");
|
|
611
1786
|
|
|
612
1787
|
const flourishEl = el(w + "-flourish");
|
|
613
1788
|
if (freshlyDugRoom && freshlyDugRoom === here && !reduceMotion) {
|
|
@@ -616,86 +1791,1007 @@ function pageScript() {
|
|
|
616
1791
|
setTimeout(function () { flourishEl.classList.remove("shown"); flourishEl.hidden = true; }, 900);
|
|
617
1792
|
}
|
|
618
1793
|
|
|
619
|
-
el(w + "-caption").textContent = roomCaptionFor(rows, state, here, character);
|
|
1794
|
+
el(w + "-caption").textContent = roomCaptionFor(rows, state, here, character, roomMates);
|
|
620
1795
|
}
|
|
621
1796
|
|
|
622
|
-
|
|
623
|
-
|
|
1797
|
+
// The digest's own room sentences say what the ROOM is, and nothing about who
|
|
1798
|
+
// is standing in it — a "Badger-2 is in the sett-1" row is filed under the
|
|
1799
|
+
// badger, not the sett, so filtering the view to this room drops every mate.
|
|
1800
|
+
// The cast sentence comes from the same list the talk pills and the floor
|
|
1801
|
+
// sprites read, so the description can never name a different set of animals
|
|
1802
|
+
// than the pane offers to talk to.
|
|
1803
|
+
function roomCaptionFor(rows, state, here, character, roomMates) {
|
|
1804
|
+
const parts = [];
|
|
624
1805
|
const view = window.tmctMud.worldDigestRows(rows, state, character);
|
|
625
1806
|
const lines = view.filter(function (r) { return r.subject.toLowerCase() === here.toLowerCase(); })
|
|
626
1807
|
.map(function (r) { return r.subject + " " + r.predicate + " " + r.object + "."; });
|
|
627
|
-
|
|
1808
|
+
parts.push(lines.length ? lines.join(" ") : "You are in the " + here + ".");
|
|
1809
|
+
if (roomMates.length) {
|
|
1810
|
+
parts.push("Here with you: " + roomMates.map(function (mate) {
|
|
1811
|
+
const mass = massOf(state, mate);
|
|
1812
|
+
return "the " + mate + (mass === null ? "" : " (mass " + mass + ")");
|
|
1813
|
+
}).join(", ") + ".");
|
|
1814
|
+
}
|
|
1815
|
+
return parts.join(" ");
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// ---- the compass ring ----------------------------------------------------
|
|
1819
|
+
// One button per direction, in the slot that direction points at: the "go"
|
|
1820
|
+
// a written exit already allows, or the "dig" that would open one. A
|
|
1821
|
+
// direction with neither draws nothing, so the ring never offers a command
|
|
1822
|
+
// the world would refuse.
|
|
1823
|
+
function diggableFor(rows, state, here) {
|
|
1824
|
+
const fromEngine = window.tmctMud && window.tmctMud.diggableDirections;
|
|
1825
|
+
return fromEngine ? fromEngine(rows, state, here) : diggableDirections(rows, state, here);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function renderDirections(character, rows, state, here) {
|
|
1829
|
+
const w = paneIdFor(character);
|
|
1830
|
+
const exits = state.exits.get(here);
|
|
1831
|
+
const diggable = diggableFor(rows, state, here);
|
|
1832
|
+
for (const direction of COMPASS) {
|
|
1833
|
+
const slot = el(w + "-dir-" + direction);
|
|
1834
|
+
const open = exits && exits.has(direction);
|
|
1835
|
+
const command = open ? "go " + direction : (diggable.indexOf(direction) !== -1 ? "dig " + direction : null);
|
|
1836
|
+
if (!command) { slot.innerHTML = ""; continue; }
|
|
1837
|
+
const cls = "dir-pill " + (open ? "go" : "dig") + (direction === "up" || direction === "down" ? " vertical" : "");
|
|
1838
|
+
slot.innerHTML = '<button type="button" class="' + cls + '" data-command="' + esc(command) + '" title="' + esc(command)
|
|
1839
|
+
+ '" aria-label="' + esc(command) + '">' + DIR_GLYPH[direction] + "</button>";
|
|
1840
|
+
slot.querySelector("button").addEventListener("click", function () { sendCommand(character, command); });
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// ---- the pill row --------------------------------------------------------
|
|
1845
|
+
// Every action the room actually grants comes from the engine's own
|
|
1846
|
+
// roomAffordances — the SAME list the "You can:" line in a look reply is built
|
|
1847
|
+
// from — so nothing the reply above offers can be missing from the row beneath
|
|
1848
|
+
// it. Exits appear in both the row and the compass ring on purpose: the ring
|
|
1849
|
+
// says WHERE a way out points, the row says what it is called.
|
|
1850
|
+
function affordancesFor(rows, state, here, character) {
|
|
1851
|
+
const fromEngine = window.tmctMud && window.tmctMud.roomAffordances;
|
|
1852
|
+
return fromEngine ? fromEngine(rows, state, here, character) : [];
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function subjectOfAction(action) {
|
|
1856
|
+
return action.indexOf("talk to ") === 0 ? action.slice(8) : action.slice(action.indexOf(" ") + 1);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function commandsBySubject(actions) {
|
|
1860
|
+
const bySubject = {};
|
|
1861
|
+
for (const action of actions) {
|
|
1862
|
+
if (action.indexOf("go ") === 0) continue;
|
|
1863
|
+
bySubject[subjectOfAction(action)] = action;
|
|
1864
|
+
}
|
|
1865
|
+
return bySubject;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function renderChatPills(character, rows, state, here, roomIds) {
|
|
1869
|
+
const w = paneIdFor(character);
|
|
1870
|
+
const pillsEl = el(w + "-chatpills");
|
|
1871
|
+
const pills = [
|
|
1872
|
+
{ command: "look", label: "look", cls: "" },
|
|
1873
|
+
{ command: "what do you know about food", label: "what do you know about food", cls: "" },
|
|
1874
|
+
];
|
|
1875
|
+
for (const action of affordancesFor(rows, state, here, character)) {
|
|
1876
|
+
if (action.indexOf("go ") === 0) { pills.push({ command: action, label: action, cls: " affordance way" }); continue; }
|
|
1877
|
+
const subject = subjectOfAction(action);
|
|
1878
|
+
const label = action.slice(0, action.length - subject.length) + labelForItem(rows, subject, roomIds);
|
|
1879
|
+
pills.push({ command: action, label: label, cls: " affordance" });
|
|
1880
|
+
}
|
|
1881
|
+
pillsEl.innerHTML = pills.map(function (p) {
|
|
1882
|
+
return '<button type="button" class="pill' + p.cls + '" data-command="' + esc(p.command)
|
|
1883
|
+
+ '" title="' + esc(p.command) + '">' + esc(p.label) + "</button>";
|
|
1884
|
+
}).join("");
|
|
1885
|
+
for (const pill of pillsEl.querySelectorAll(".pill")) {
|
|
1886
|
+
pill.addEventListener("click", function () {
|
|
1887
|
+
sendCommand(character, pill.getAttribute("data-command"));
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
628
1890
|
}
|
|
629
1891
|
|
|
630
|
-
function renderPouch(character, rows, state) {
|
|
631
|
-
const w =
|
|
632
|
-
const list = el(w + "-pouch-list");
|
|
1892
|
+
function renderPouch(character, rows, state, roomIds) {
|
|
1893
|
+
const w = paneIdFor(character);
|
|
633
1894
|
const items = carriedItemsFor(rows, state, character);
|
|
634
|
-
list.innerHTML = items.map(function (i) {
|
|
1895
|
+
el(w + "-pouch-list").innerHTML = items.map(function (i) {
|
|
1896
|
+
return '<li title="' + esc(i.subject) + '">' + esc(labelForItem(rows, i.subject, roomIds)) + "</li>";
|
|
1897
|
+
}).join("");
|
|
635
1898
|
const mass = state.masses.get(character);
|
|
636
|
-
const
|
|
637
|
-
const drain = DATA.mudConfig[cfgKey + "MassDecrementPerTurn"];
|
|
1899
|
+
const drain = DATA.mudConfig[speciesOfCharacter(character) + "MassDecrementPerTurn"];
|
|
638
1900
|
el(w + "-stats").textContent = "mass " + (mass ? mass.value : "?") + (drain !== undefined ? " \\u00b7 drain/turn " + drain : "");
|
|
639
1901
|
}
|
|
640
1902
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
mapEl.innerHTML = rooms.map(function (r) {
|
|
649
|
-
return "<span class=\\"minimap-room" + (r === here ? " current" : "") + "\\">" + esc(r) + "</span>";
|
|
650
|
-
}).join("");
|
|
1903
|
+
// The world mints a dug object with its own display-name fact, so the engine
|
|
1904
|
+
// can say "carrot" while every verb still resolves the distinct id
|
|
1905
|
+
// ("carrot-1"). itemLabel is the fallback for an id carrying no such fact.
|
|
1906
|
+
function labelForItem(rows, subject, roomIds) {
|
|
1907
|
+
const fromEngine = window.tmctMud && window.tmctMud.displayNameOf;
|
|
1908
|
+
const name = fromEngine ? fromEngine(rows, subject) : null;
|
|
1909
|
+
return name && name !== subject ? name : itemLabel(subject, roomIds);
|
|
651
1910
|
}
|
|
652
1911
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
1912
|
+
// ---- the burrow survey ---------------------------------------------------
|
|
1913
|
+
// One routine, drawn twice: the omniscient board in the top row (every room
|
|
1914
|
+
// the world has) and each pane's own known ground (only what that character
|
|
1915
|
+
// has dug or walked, with a fading stub where it knows a way carries on).
|
|
1916
|
+
function burrowSvg(graph, options) {
|
|
1917
|
+
if (!graph.nodes.length) return "";
|
|
1918
|
+
const opts = options || {};
|
|
1919
|
+
const compact = !!opts.compact;
|
|
1920
|
+
const cellX = compact ? 44 : 76, cellY = compact ? 26 : 54;
|
|
1921
|
+
const roomW = compact ? 34 : 62, roomH = compact ? 14 : 26;
|
|
1922
|
+
const maxX = Math.max.apply(null, graph.nodes.map(function (n) { return n.x; }));
|
|
1923
|
+
const maxY = Math.max.apply(null, graph.nodes.map(function (n) { return n.y; }));
|
|
1924
|
+
const w = (maxX + 1) * cellX, h = (maxY + 1) * cellY;
|
|
1925
|
+
const byRoom = {};
|
|
1926
|
+
for (const n of graph.nodes) byRoom[n.id] = n;
|
|
1927
|
+
const cx = function (n) { return (n.x + 0.5) * cellX; };
|
|
1928
|
+
const cy = function (n) { return (n.y + 0.5) * cellY; };
|
|
1929
|
+
|
|
1930
|
+
// The turf band is only drawn when the layout genuinely separates the
|
|
1931
|
+
// surface from the soil — a lateral dig can push a surface room onto a
|
|
1932
|
+
// lower row, and a ground line above it would say something false. The
|
|
1933
|
+
// room fills carry the same information either way, which is all the
|
|
1934
|
+
// thumbnail-sized copy has room for.
|
|
1935
|
+
const surfaceRows = graph.nodes.filter(function (n) { return n.level === 0; }).map(function (n) { return n.y; });
|
|
1936
|
+
const soilRows = graph.nodes.filter(function (n) { return n.level !== 0; }).map(function (n) { return n.y; });
|
|
1937
|
+
let turf = "";
|
|
1938
|
+
if (!compact && surfaceRows.length && soilRows.length && Math.max.apply(null, surfaceRows) < Math.min.apply(null, soilRows)) {
|
|
1939
|
+
const groundY = (Math.max.apply(null, surfaceRows) + 0.5) * cellY + roomH / 2 + (compact ? 3 : 7);
|
|
1940
|
+
turf = '<rect class="turf" x="0" y="0" width="' + w + '" height="' + groundY + '"></rect>'
|
|
1941
|
+
+ '<line class="ground-line" x1="0" y1="' + groundY + '" x2="' + w + '" y2="' + groundY + '"></line>';
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
const tunnels = graph.edges.map(function (e) {
|
|
1945
|
+
const a = byRoom[e.from], b = byRoom[e.to];
|
|
1946
|
+
if (!a || !b) return "";
|
|
1947
|
+
const shaft = e.direction === "up" || e.direction === "down" ? " shaft" : "";
|
|
1948
|
+
return '<line class="tunnel' + shaft + '" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '"></line>';
|
|
1949
|
+
}).join("");
|
|
1950
|
+
|
|
1951
|
+
const HINT_DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
|
|
1952
|
+
const hints = graph.hints.map(function (hi) {
|
|
1953
|
+
const from = byRoom[hi.from];
|
|
1954
|
+
if (!from) return "";
|
|
1955
|
+
const d = HINT_DELTA[hi.direction] || [0, 0];
|
|
1956
|
+
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>';
|
|
1957
|
+
}).join("");
|
|
1958
|
+
|
|
1959
|
+
const occupants = opts.occupants || {};
|
|
1960
|
+
const rooms = graph.nodes.map(function (n) {
|
|
1961
|
+
const here = opts.here === n.id ? " here" : "";
|
|
1962
|
+
const fresh = opts.fresh === n.id ? " freshly-dug" : "";
|
|
1963
|
+
const surface = n.level === 0 ? " surface" : "";
|
|
1964
|
+
const label = esc(n.id);
|
|
1965
|
+
const fit = label.length * (compact ? 3.6 : 4.3) > roomW - 6
|
|
1966
|
+
? ' textLength="' + (roomW - 6) + '" lengthAdjust="spacingAndGlyphs"' : "";
|
|
1967
|
+
const cast = occupants[n.id] || [];
|
|
1968
|
+
// The room's own width is the budget: a dozen animals down one hole must
|
|
1969
|
+
// still read as a dozen dots inside that room rather than a line running
|
|
1970
|
+
// out of it, so the gap closes as the crowd grows.
|
|
1971
|
+
const step = Math.min(compact ? 6 : 9, (roomW - 6) / Math.max(1, cast.length));
|
|
1972
|
+
const dots = cast.map(function (c, i) {
|
|
1973
|
+
const spread = (i - (cast.length - 1) / 2) * step;
|
|
1974
|
+
return '<circle class="occupant" cx="' + (cx(n) + spread) + '" cy="' + (cy(n) + roomH / 2) + '" r="' + (compact ? 2.4 : 3.6)
|
|
1975
|
+
+ '" fill="' + c.color + '"><title>' + esc(c.character) + "</title></circle>";
|
|
662
1976
|
}).join("");
|
|
663
|
-
return
|
|
1977
|
+
return '<g class="room' + surface + here + fresh + '" data-room="' + label + '"><rect x="' + (cx(n) - roomW / 2) + '" y="' + (cy(n) - roomH / 2)
|
|
1978
|
+
+ '" width="' + roomW + '" height="' + roomH + '" rx="3"></rect><text x="' + cx(n) + '" y="' + (cy(n) + (compact ? 2 : 2.5))
|
|
1979
|
+
+ '"' + fit + ">" + label + "</text>" + dots + "</g>";
|
|
664
1980
|
}).join("");
|
|
1981
|
+
|
|
1982
|
+
return '<div class="burrow"><svg viewBox="0 0 ' + w + " " + h + '" preserveAspectRatio="xMidYMid meet" role="img" aria-label="'
|
|
1983
|
+
+ esc(opts.label || "the burrow") + '">' + turf + tunnels + hints + rooms + "</svg></div>";
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function renderMinimap(character, rows, state, here) {
|
|
1987
|
+
const visited = session.windows[character].visitedRoomIds();
|
|
1988
|
+
const graph = burrowGraph(state, visited, rootRoom);
|
|
1989
|
+
el(paneIdFor(character) + "-minimap").innerHTML = burrowSvg(graph, {
|
|
1990
|
+
compact: true, here: here, label: "the ground " + character + " has covered",
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
function renderWorldMap(rows, state, roomIds) {
|
|
1995
|
+
const graph = burrowGraph(state, roomIds, rootRoom);
|
|
1996
|
+
const occupants = {};
|
|
1997
|
+
for (const room of roomIds) {
|
|
1998
|
+
const here = charactersInRoom(state, room, everyone());
|
|
1999
|
+
if (!here.length) continue;
|
|
2000
|
+
occupants[room] = here.map(function (c) { return { character: c, color: colorFor(c) }; });
|
|
2001
|
+
}
|
|
2002
|
+
el("worldMapBoard").innerHTML = burrowSvg(graph, {
|
|
2003
|
+
occupants: occupants, fresh: freshlyDugRoom, label: "every room in the burrow, and who stands where",
|
|
2004
|
+
});
|
|
2005
|
+
// One key entry per pane, because a pane is what a colour identifies. The
|
|
2006
|
+
// npcs share the last entry: naming ten of them would take more room than
|
|
2007
|
+
// the survey they annotate, and each one's own dot already carries its name.
|
|
2008
|
+
const keys = cast.map(function (c) {
|
|
2009
|
+
const room = state.placements.get(c) ? state.placements.get(c).object : "?";
|
|
2010
|
+
const fate = window.tmctMud.outOfPlayReasonOf(state, c);
|
|
2011
|
+
const where = fate ? fate : "in " + esc(room);
|
|
2012
|
+
return '<span class="key-actor"><span class="key-dot" style="background:' + colorFor(c) + '"></span>'
|
|
2013
|
+
+ esc(speciesOfCharacter(c)) + " " + where + "</span>";
|
|
2014
|
+
});
|
|
2015
|
+
if (npcs.length) {
|
|
2016
|
+
const live = npcs.filter(function (c) { return !finished[c]; }).length;
|
|
2017
|
+
const tally = live === npcs.length ? String(npcs.length) : live + " of " + npcs.length;
|
|
2018
|
+
keys.push('<span class="key-npcs"><span class="key-dot" style="background:' + NPC_COLOR + '"></span>'
|
|
2019
|
+
+ tally + (live === 1 ? " npc" : " npcs") + " digging</span>");
|
|
2020
|
+
}
|
|
2021
|
+
el("worldMapKey").innerHTML = keys.join("");
|
|
665
2022
|
el("worldMapTurn").textContent = "turn " + globalTurn;
|
|
666
2023
|
}
|
|
667
2024
|
|
|
668
|
-
|
|
2025
|
+
// A whole redraw per turn is one thing at two characters and quite another at
|
|
2026
|
+
// fourteen, where the ticks land faster than a snapshot can be read. Requests
|
|
2027
|
+
// arriving mid-render collapse into a single follow-up, so the page always
|
|
2028
|
+
// ends up drawing the newest state and never queues a backlog of stale ones.
|
|
2029
|
+
let rendering = false;
|
|
2030
|
+
let renderAgain = false;
|
|
2031
|
+
async function renderSoon() {
|
|
2032
|
+
if (rendering) { renderAgain = true; return; }
|
|
2033
|
+
rendering = true;
|
|
2034
|
+
try {
|
|
2035
|
+
await renderAll();
|
|
2036
|
+
} finally {
|
|
2037
|
+
rendering = false;
|
|
2038
|
+
}
|
|
2039
|
+
if (renderAgain) { renderAgain = false; renderSoon(); }
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// A character eaten on its OWN turn is out of play the moment that turn
|
|
2043
|
+
// returns, one whole tick before autoplayTick would report it — so every
|
|
2044
|
+
// character's fate is read from the engine every redraw, not only off a tick
|
|
2045
|
+
// result. It reads against the snapshot this redraw already holds: a
|
|
2046
|
+
// per-window call would re-fold the whole world once per animal.
|
|
2047
|
+
async function renderAll() {
|
|
669
2048
|
if (!session) return;
|
|
670
|
-
|
|
671
|
-
session.snapshot()
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
2049
|
+
el("globalTurnCount").textContent = "turns: " + globalTurn;
|
|
2050
|
+
const snap = await session.snapshot();
|
|
2051
|
+
lastSnapshot = snap;
|
|
2052
|
+
const roomIds = allRoomIds(snap.rows);
|
|
2053
|
+
const nowMs = Date.now();
|
|
2054
|
+
wavingNow = wavingCharacters(snap.rows, nowMs);
|
|
2055
|
+
// Every claimed animal, not just the ones this page cast: a character
|
|
2056
|
+
// another node plays walks into this room as an ordinary room-mate, and
|
|
2057
|
+
// its label has to name where it is played from just the same.
|
|
2058
|
+
claimedElsewhere = {};
|
|
2059
|
+
if (myPeerId) {
|
|
2060
|
+
const claimed = {};
|
|
2061
|
+
for (const row of snap.rows) if (row.predicate === "mgx:playedBy") claimed[row.subject] = true;
|
|
2062
|
+
for (const character of Object.keys(claimed)) {
|
|
2063
|
+
const owner = claimantOf(snap.rows, character);
|
|
2064
|
+
if (owner === null || peerTerm(owner) === peerTerm(myPeerId)) continue;
|
|
2065
|
+
claimedElsewhere[character] = nodeNameFor(snap.rows, owner);
|
|
2066
|
+
const ticker = tickers[character];
|
|
2067
|
+
if (ticker) ticker.pause();
|
|
682
2068
|
}
|
|
683
|
-
|
|
2069
|
+
}
|
|
2070
|
+
for (const character of everyone()) {
|
|
2071
|
+
const reason = window.tmctMud.outOfPlayReasonOf(snap.state, character);
|
|
2072
|
+
if (reason) markOutOfPlay(character, reason, null);
|
|
2073
|
+
}
|
|
2074
|
+
renderWorldMap(snap.rows, snap.state, roomIds);
|
|
2075
|
+
for (const character of cast) {
|
|
2076
|
+
const pane = paneIdFor(character);
|
|
2077
|
+
el(pane + "-turn").textContent = "turn " + turnsFor(character);
|
|
2078
|
+
const owner = claimedElsewhere[character];
|
|
2079
|
+
el(pane).classList.toggle("remote", Boolean(owner));
|
|
2080
|
+
el(pane + "-node").hidden = !owner;
|
|
2081
|
+
el(pane + "-node").textContent = owner ? "played from " + owner : "";
|
|
2082
|
+
if (finished[character]) continue;
|
|
2083
|
+
const place = snap.state.placements.get(character);
|
|
2084
|
+
const here = place ? place.object : null;
|
|
2085
|
+
if (!here) continue;
|
|
2086
|
+
const roomMates = window.tmctMud.castInRoom(snap.rows, snap.state, here, character);
|
|
2087
|
+
renderRoomView(character, snap.rows, snap.state, here, roomIds, roomMates);
|
|
2088
|
+
renderDirections(character, snap.rows, snap.state, here);
|
|
2089
|
+
renderChatPills(character, snap.rows, snap.state, here, roomIds);
|
|
2090
|
+
renderPouch(character, snap.rows, snap.state, roomIds);
|
|
2091
|
+
renderMinimap(character, snap.rows, snap.state, here);
|
|
2092
|
+
}
|
|
2093
|
+
freshlyDugRoom = null;
|
|
2094
|
+
// A wave carries no retraction, so nothing tells the page when one stops
|
|
2095
|
+
// being current — the recency window does, and only a redraw can notice it
|
|
2096
|
+
// has passed. One follow-up per second while any hand is up, and none at
|
|
2097
|
+
// all once they are all down.
|
|
2098
|
+
clearTimeout(waveRedrawTimer);
|
|
2099
|
+
if (wavingNow.length) waveRedrawTimer = setTimeout(renderSoon, 1000);
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// ---- edit mode -----------------------------------------------------------
|
|
2103
|
+
// The textarea is seeded from renderMudEditorText over the world's OWN facts.
|
|
2104
|
+
// Reads are scoped to this world's provenance tag the same way session
|
|
2105
|
+
// .applyEdit scopes its writes: the live store also carries the default
|
|
2106
|
+
// persona's background corpus, and this page must never show, or risk
|
|
2107
|
+
// retracting, a fact that is not part of the burrow.
|
|
2108
|
+
//
|
|
2109
|
+
// Typing debounces two things at two paces. The suggestion pills are cheap
|
|
2110
|
+
// in-memory lookups and can chase the cursor (~180ms). The store sync is the
|
|
2111
|
+
// one that writes, so it waits for the typing to settle (~450ms) — a
|
|
2112
|
+
// half-finished word must never be read as what somebody meant.
|
|
2113
|
+
let editRows = [];
|
|
2114
|
+
let editState = { placements: new Map(), openness: new Map(), masses: new Map(), exits: new Map(), turnCount: 0 };
|
|
2115
|
+
let allStoreRows = [];
|
|
2116
|
+
let selectedRoomId = null;
|
|
2117
|
+
let editing = false;
|
|
2118
|
+
|
|
2119
|
+
function worldOnlyRows(rows) {
|
|
2120
|
+
const prefix = "world:" + scenario().worldPayload.name;
|
|
2121
|
+
return (rows || []).filter(function (r) {
|
|
2122
|
+
return typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0;
|
|
684
2123
|
});
|
|
685
2124
|
}
|
|
686
2125
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
2126
|
+
function renderEditMap() {
|
|
2127
|
+
const graph = burrowGraph(editState, allRoomIds(editRows), rootRoom);
|
|
2128
|
+
el("editMapBoard").innerHTML = graph.nodes.length
|
|
2129
|
+
? burrowSvg(graph, { here: selectedRoomId, label: "every room this world writes" })
|
|
2130
|
+
: '<span class="edit-empty">no rooms written yet</span>';
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
// A class card, the legend's and the room detail's shared unit — the sprite
|
|
2134
|
+
// frame the panes already use, shrunk, with the subject's own name under it.
|
|
2135
|
+
function editCardHtml(subject, label, svg) {
|
|
2136
|
+
return '<div class="sprite-card" title="' + esc(subject) + '"><div class="sprite-frame">' + svg
|
|
2137
|
+
+ '</div><div class="sprite-label">' + esc(label) + "</div></div>";
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// Every class this world actually puts in a room, by its own icon. A creature
|
|
2141
|
+
// resolves on its species (the same read every pane's own sprite makes), a
|
|
2142
|
+
// prop on its declared class — so the legend shows what the survey shows.
|
|
2143
|
+
function renderEditLegend() {
|
|
2144
|
+
const classes = {};
|
|
2145
|
+
for (const subject of editState.placements.keys()) {
|
|
2146
|
+
const cls = isCreature(editState, subject) ? speciesOfCharacter(subject) : spriteClassForObject(editRows, subject);
|
|
2147
|
+
if (cls) classes[cls] = true;
|
|
2148
|
+
}
|
|
2149
|
+
const names = Object.keys(classes).sort();
|
|
2150
|
+
el("editLegend").innerHTML = names.length
|
|
2151
|
+
? names.map(function (cls) { return editCardHtml(cls, cls, spriteSvgFor(cls, editRows, "legend-" + cls)); }).join("")
|
|
2152
|
+
: '<span class="edit-empty">nothing placed yet</span>';
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
function renderRoomDetail() {
|
|
2156
|
+
const titleEl = el("roomDetailTitle");
|
|
2157
|
+
const castEl = el("roomDetailCast");
|
|
2158
|
+
const captionEl = el("roomDetailCaption");
|
|
2159
|
+
if (!selectedRoomId) {
|
|
2160
|
+
titleEl.textContent = "click a room";
|
|
2161
|
+
castEl.innerHTML = "";
|
|
2162
|
+
captionEl.textContent = "Click a room on the survey to see what stands in it.";
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
titleEl.textContent = selectedRoomId;
|
|
2166
|
+
const roomIds = allRoomIds(editRows);
|
|
2167
|
+
const here = [];
|
|
2168
|
+
for (const subject of editState.placements.keys()) {
|
|
2169
|
+
if (visibleRoomOf(editRows, editState, subject) !== selectedRoomId) continue;
|
|
2170
|
+
const creature = isCreature(editState, subject);
|
|
2171
|
+
here.push({
|
|
2172
|
+
subject: subject,
|
|
2173
|
+
label: creature ? subject : labelForItem(editRows, subject, roomIds),
|
|
2174
|
+
svg: creature ? spriteSvgFor(speciesOfCharacter(subject), editRows, subject) : objectSvgFor(subject, editRows),
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
here.sort(function (a, b) { return a.subject.localeCompare(b.subject); });
|
|
2178
|
+
castEl.innerHTML = here.map(function (t) { return editCardHtml(t.subject, t.label, t.svg); }).join("");
|
|
2179
|
+
const kind = roomKindForRoom(editRows, selectedRoomId);
|
|
2180
|
+
const ways = [...(editState.exits.get(selectedRoomId) || new Map()).keys()].sort();
|
|
2181
|
+
captionEl.textContent = "An " + kind + " room. "
|
|
2182
|
+
+ (ways.length ? "Ways out: " + ways.join(", ") + ". " : "No way out written yet. ")
|
|
2183
|
+
+ (here.length ? here.length + " thing" + (here.length === 1 ? "" : "s") + " placed here." : "Nothing placed here.");
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function refreshEditPanels() {
|
|
2187
|
+
renderEditMap();
|
|
2188
|
+
renderEditLegend();
|
|
2189
|
+
renderRoomDetail();
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
el("editMapBoard").addEventListener("click", function (e) {
|
|
2193
|
+
const g = e.target.closest("[data-room]");
|
|
2194
|
+
if (!g) return;
|
|
2195
|
+
selectedRoomId = g.getAttribute("data-room");
|
|
2196
|
+
renderEditMap();
|
|
2197
|
+
renderRoomDetail();
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
// The lateral SKOS neighbourhood plus the vertical is-a chain for whatever
|
|
2201
|
+
// word the cursor sits behind. Read over the FULL store, not the world's own
|
|
2202
|
+
// rows: a term's synonyms mostly live in the background corpus, not in the
|
|
2203
|
+
// burrow's own vocabulary. Nothing found is nothing shown — an honest miss.
|
|
2204
|
+
function renderSuggestionPills() {
|
|
2205
|
+
const box = el("editorPills");
|
|
2206
|
+
const term = wordBeforeCursor(el("editorText").value, el("editorText").selectionStart);
|
|
2207
|
+
if (!term || !window.tmctMud) { box.innerHTML = ""; return; }
|
|
2208
|
+
const related = window.tmctMud.relatedForTerm ? window.tmctMud.relatedForTerm(allStoreRows, term) : null;
|
|
2209
|
+
const chain = window.tmctMud.classAncestorChain ? window.tmctMud.classAncestorChain(term, allStoreRows) : [];
|
|
2210
|
+
const seen = { };
|
|
2211
|
+
seen[term] = true;
|
|
2212
|
+
const out = [];
|
|
2213
|
+
const push = function (label) { if (label && !seen[label]) { seen[label] = true; out.push(label); } };
|
|
2214
|
+
if (related) {
|
|
2215
|
+
related.synonyms.forEach(push);
|
|
2216
|
+
related.related.forEach(function (r) { push(r.prefLabel); });
|
|
2217
|
+
}
|
|
2218
|
+
chain.slice(1).forEach(push);
|
|
2219
|
+
box.innerHTML = out.slice(0, 8).map(function (s) {
|
|
2220
|
+
return '<button type="button" class="pill" data-insert="' + esc(s) + '">' + esc(s) + "</button>";
|
|
2221
|
+
}).join("");
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
el("editorPills").addEventListener("click", function (e) {
|
|
2225
|
+
const btn = e.target.closest(".pill");
|
|
2226
|
+
if (!btn) return;
|
|
2227
|
+
const area = el("editorText");
|
|
2228
|
+
const pos = area.selectionStart;
|
|
2229
|
+
const word = wordBeforeCursor(area.value, pos);
|
|
2230
|
+
const insert = btn.getAttribute("data-insert");
|
|
2231
|
+
area.value = area.value.slice(0, pos - word.length) + insert + area.value.slice(pos);
|
|
2232
|
+
const next = pos - word.length + insert.length;
|
|
2233
|
+
area.setSelectionRange(next, next);
|
|
2234
|
+
area.focus();
|
|
2235
|
+
onEditorChanged();
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
async function applyEditorText() {
|
|
2239
|
+
if (!session) return;
|
|
2240
|
+
const status = el("editorStatus");
|
|
2241
|
+
status.className = "edit-status pending";
|
|
2242
|
+
status.textContent = "reading the burrow\\u2026";
|
|
2243
|
+
const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
|
|
2244
|
+
await broadcastLocalChange();
|
|
2245
|
+
const snap = await session.snapshot();
|
|
2246
|
+
allStoreRows = snap.rows;
|
|
2247
|
+
editRows = worldOnlyRows(snap.rows);
|
|
2248
|
+
editState = window.tmctMud.foldWorldState(window.tmctMud.worldActionRows(editRows));
|
|
2249
|
+
if (result.unrecognized.length) {
|
|
2250
|
+
const lines = result.unrecognized.map(function (u) { return u.line; }).join(", ");
|
|
2251
|
+
status.className = "edit-status pending";
|
|
2252
|
+
status.textContent = result.unrecognized.length + " line"
|
|
2253
|
+
+ (result.unrecognized.length === 1 ? "" : "s") + " not understood yet (line " + lines
|
|
2254
|
+
+ ") \\u2014 nothing is retracted until they are.";
|
|
2255
|
+
} else {
|
|
2256
|
+
status.className = "edit-status ok";
|
|
2257
|
+
status.textContent = (result.added || result.removed)
|
|
2258
|
+
? "synced \\u2014 " + result.added + " fact(s) written, " + result.removed + " retracted."
|
|
2259
|
+
: "synced \\u2014 no change.";
|
|
2260
|
+
}
|
|
2261
|
+
refreshEditPanels();
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
let suggestTimer = null;
|
|
2265
|
+
let syncTimer = null;
|
|
2266
|
+
function scheduleSuggestions() { clearTimeout(suggestTimer); suggestTimer = setTimeout(renderSuggestionPills, 180); }
|
|
2267
|
+
function scheduleSync() { clearTimeout(syncTimer); syncTimer = setTimeout(applyEditorText, 450); }
|
|
2268
|
+
function onEditorChanged() { scheduleSuggestions(); scheduleSync(); }
|
|
2269
|
+
el("editorText").addEventListener("input", onEditorChanged);
|
|
2270
|
+
el("editorText").addEventListener("click", scheduleSuggestions);
|
|
2271
|
+
el("editorText").addEventListener("keyup", function (e) {
|
|
2272
|
+
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].indexOf(e.key) !== -1) scheduleSuggestions();
|
|
2273
|
+
});
|
|
2274
|
+
|
|
2275
|
+
// Nothing may tick while the world is being rewritten: a turn and an edit both
|
|
2276
|
+
// write the same store, and a turn landing mid-sync would fold against rows
|
|
2277
|
+
// the textarea no longer describes. So every ticker stops and every control
|
|
2278
|
+
// that could start one is disabled for as long as the editor is open.
|
|
2279
|
+
function setPlayControlsEnabled(on) {
|
|
2280
|
+
el("autoToggle").disabled = !on;
|
|
2281
|
+
el("playerCountSlider").disabled = !on;
|
|
2282
|
+
el("resetBtn").disabled = !on;
|
|
2283
|
+
for (const character of cast) {
|
|
2284
|
+
if (finished[character]) continue;
|
|
2285
|
+
el(paneIdFor(character) + "-play").disabled = !on;
|
|
2286
|
+
el(paneIdFor(character) + "-step").disabled = !on;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
async function enterEditMode() {
|
|
2291
|
+
editing = true;
|
|
2292
|
+
autoOn = false;
|
|
2293
|
+
const playBtn = el("autoToggle");
|
|
2294
|
+
playBtn.setAttribute("aria-pressed", "false");
|
|
2295
|
+
playBtn.textContent = "\\u25B6 play";
|
|
2296
|
+
for (const character of cast) {
|
|
2297
|
+
const ticker = tickers[character];
|
|
2298
|
+
if (ticker) ticker.pause();
|
|
2299
|
+
}
|
|
2300
|
+
setPlayControlsEnabled(false);
|
|
2301
|
+
const snap = await session.snapshot();
|
|
2302
|
+
allStoreRows = snap.rows;
|
|
2303
|
+
editRows = worldOnlyRows(snap.rows);
|
|
2304
|
+
editState = window.tmctMud.foldWorldState(window.tmctMud.worldActionRows(editRows));
|
|
2305
|
+
el("editorText").value = renderMudEditorText(editRows, editState);
|
|
2306
|
+
el("editorStatus").className = "edit-status";
|
|
2307
|
+
el("editorStatus").textContent = "";
|
|
2308
|
+
el("editorPills").innerHTML = "";
|
|
2309
|
+
selectedRoomId = null;
|
|
2310
|
+
document.body.classList.add("editing");
|
|
2311
|
+
el("editModeBtn").textContent = "back to playing";
|
|
2312
|
+
el("editModeBtn").setAttribute("aria-pressed", "true");
|
|
2313
|
+
refreshEditPanels();
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
async function exitEditMode() {
|
|
2317
|
+
editing = false;
|
|
2318
|
+
clearTimeout(syncTimer);
|
|
2319
|
+
// One last sync on the way out, so a change typed in the final half-second
|
|
2320
|
+
// before the button is clicked reaches the world like every other one.
|
|
2321
|
+
await applyEditorText();
|
|
2322
|
+
document.body.classList.remove("editing");
|
|
2323
|
+
el("editModeBtn").textContent = "edit";
|
|
2324
|
+
el("editModeBtn").setAttribute("aria-pressed", "false");
|
|
2325
|
+
setPlayControlsEnabled(true);
|
|
2326
|
+
await renderAll();
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
// ---- the shared burrow: nodes, the wire, and the two-paste handshake -----
|
|
2330
|
+
// Every piece of networking arrives from ./vendor/p2p.js, the site's own
|
|
2331
|
+
// shared P2P asset, imported the first time somebody actually asks to share
|
|
2332
|
+
// rather than at boot. The room owns signaling, the mesh and the merge; this
|
|
2333
|
+
// block owns what a person sees and clicks, and nothing else.
|
|
2334
|
+
const P2P_ASSET = "./vendor/p2p.js";
|
|
2335
|
+
const NODE_NAME_KEY = "tmct.mud.nodeName";
|
|
2336
|
+
const invite = inviteParamsFrom(window.location.search);
|
|
2337
|
+
|
|
2338
|
+
let p2p = null;
|
|
2339
|
+
let p2pLoad = null;
|
|
2340
|
+
let room = null;
|
|
2341
|
+
let myPeerId = null;
|
|
2342
|
+
let myDisplayName = "";
|
|
2343
|
+
let worldId = "";
|
|
2344
|
+
let worldName = "";
|
|
2345
|
+
let nodeClockTimer = null;
|
|
2346
|
+
|
|
2347
|
+
function loadP2p() {
|
|
2348
|
+
if (!p2pLoad) p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
|
|
2349
|
+
return p2pLoad;
|
|
2350
|
+
}
|
|
2351
|
+
window.tmctP2pLoad = loadP2p;
|
|
2352
|
+
|
|
2353
|
+
function readStoredNodeName() {
|
|
2354
|
+
try { return localStorage.getItem(NODE_NAME_KEY) || ""; } catch { return ""; }
|
|
2355
|
+
}
|
|
2356
|
+
function writeStoredNodeName(name) {
|
|
2357
|
+
try { localStorage.setItem(NODE_NAME_KEY, name); } catch { /* private mode — the name still holds for this visit */ }
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
async function ensureIdentity() {
|
|
2361
|
+
const mod = await loadP2p();
|
|
2362
|
+
if (!myPeerId) myPeerId = mod.generatePeerId();
|
|
2363
|
+
if (!myDisplayName) {
|
|
2364
|
+
myDisplayName = readStoredNodeName() || mod.generateDisplayName();
|
|
2365
|
+
el("nodeNameInput").value = myDisplayName;
|
|
2366
|
+
}
|
|
2367
|
+
if (!worldId) worldId = invite && invite.world ? invite.world : mod.generateWorldId();
|
|
2368
|
+
if (!worldName) {
|
|
2369
|
+
worldName = invite && invite.worldName ? invite.worldName : mod.generateDisplayName();
|
|
2370
|
+
el("worldNameInput").value = worldName;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
async function ensureRoom() {
|
|
2375
|
+
if (room) return room;
|
|
2376
|
+
const mod = await loadP2p();
|
|
2377
|
+
await ensureIdentity();
|
|
2378
|
+
if (!session && bootRun) await bootRun;
|
|
2379
|
+
if (!session) throw new Error("the burrow hasn't finished opening");
|
|
2380
|
+
// The one place this page decides what is worth replicating: whatever the
|
|
2381
|
+
// engine calls live world state, plus the P2P layer's own four predicates.
|
|
2382
|
+
// The check is handed over rather than imported, so the sync filter never
|
|
2383
|
+
// learns the world engine's private predicate names.
|
|
2384
|
+
const extraPredicates = (window.tmctMud.P2P_PREDICATES || []).slice();
|
|
2385
|
+
const isState = window.tmctMud.isMudStatePredicate;
|
|
2386
|
+
room = mod.createP2pRoom({
|
|
2387
|
+
memoryDir: session.memoryDir,
|
|
2388
|
+
myPeerId: myPeerId,
|
|
2389
|
+
myDisplayName: myDisplayName,
|
|
2390
|
+
worldId: worldId,
|
|
2391
|
+
worldName: worldName,
|
|
2392
|
+
transportFactory: function () { return mod.createTransport({ iceServers: [] }); },
|
|
2393
|
+
syncableFacts: function (rows) { return mod.mudSyncableFacts(rows, isState, extraPredicates); },
|
|
2394
|
+
});
|
|
2395
|
+
room.onStateChanged(function (state) {
|
|
2396
|
+
// The join card exists to produce one reply. Once the channel is open it
|
|
2397
|
+
// has done its job, and a full-screen card over a live burrow is just
|
|
2398
|
+
// something in the way.
|
|
2399
|
+
if (state === "connected") el("joinCard").hidden = true;
|
|
2400
|
+
renderWire();
|
|
2401
|
+
});
|
|
2402
|
+
room.onPeersChanged(function () { renderNodes(); renderWire(); });
|
|
2403
|
+
room.onFactsChanged(function () { renderNodes(); renderSoon(); });
|
|
2404
|
+
await room.start();
|
|
2405
|
+
// The burrow's name is written into the graph the moment the room starts,
|
|
2406
|
+
// and this version retracts nothing — so the field stops taking edits.
|
|
2407
|
+
el("worldNameInput").readOnly = true;
|
|
2408
|
+
window.tmctP2pRoom = room;
|
|
2409
|
+
await claimMyAnimals();
|
|
2410
|
+
renderWire();
|
|
2411
|
+
renderNodes();
|
|
2412
|
+
if (!nodeClockTimer) nodeClockTimer = setInterval(renderNodes, 10000);
|
|
2413
|
+
return room;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
// Every animal this page drives is claimed the moment the burrow is shared,
|
|
2417
|
+
// panes and npcs alike: two peers each running scripted turns for one mole
|
|
2418
|
+
// would be two hands on one lemming. The claim is add-only and the oldest
|
|
2419
|
+
// wins, so a page that loses one simply stops driving that animal and
|
|
2420
|
+
// watches somebody else play it instead.
|
|
2421
|
+
async function claimMyAnimals() {
|
|
2422
|
+
if (!session || !room) return;
|
|
2423
|
+
await serializeTick(function () { return session.claimCharacters(everyone(), myPeerId); });
|
|
2424
|
+
await broadcastLocalChange();
|
|
2425
|
+
renderSoon();
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// The room diffs the store against what it last saw and broadcasts whatever
|
|
2429
|
+
// changed. The page calls it after every local turn, wave, claim and edit —
|
|
2430
|
+
// a broadcast that fails is a dead channel, reported by its own close
|
|
2431
|
+
// handler, and must never fail the turn that happened to carry it.
|
|
2432
|
+
function broadcastLocalChange() {
|
|
2433
|
+
if (!room) return Promise.resolve({ broadcast: 0 });
|
|
2434
|
+
return room.afterLocalChange().catch(function () { return { broadcast: 0 }; });
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
function dropRoom(note) {
|
|
2438
|
+
if (!room) return;
|
|
2439
|
+
room.close();
|
|
2440
|
+
room = null;
|
|
2441
|
+
window.tmctP2pRoom = null;
|
|
2442
|
+
clearInterval(nodeClockTimer);
|
|
2443
|
+
nodeClockTimer = null;
|
|
2444
|
+
el("shareLink").value = "";
|
|
2445
|
+
el("replyOut").value = "";
|
|
2446
|
+
el("worldNameInput").readOnly = false;
|
|
2447
|
+
renderWire();
|
|
2448
|
+
renderNodes();
|
|
2449
|
+
// Last, not first: renderWire restates the note for the state it has just
|
|
2450
|
+
// read, which for a room that is gone is the idle one ("this browser holds
|
|
2451
|
+
// the only copy"). WHY the room went is the thing worth saying, so it is
|
|
2452
|
+
// written over the top rather than under it.
|
|
2453
|
+
if (note) el("wireStateNote").textContent = note;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
// The state machine is shared with chat.html and the words mostly are too.
|
|
2457
|
+
// Two of them are not: nothing is taught here and nothing is a node below,
|
|
2458
|
+
// so those two notes are said in this page's own terms rather than forking
|
|
2459
|
+
// the whole table for a burrow.
|
|
2460
|
+
const MUD_WIRE_NOTES = {
|
|
2461
|
+
idle: "this browser holds the only copy of this burrow.",
|
|
2462
|
+
connected: "what you dig from here reaches every node in the burrow, and theirs reaches you.",
|
|
2463
|
+
};
|
|
2464
|
+
|
|
2465
|
+
function renderWire() {
|
|
2466
|
+
const state = room ? room.state : "idle";
|
|
2467
|
+
const label = wireStateLabel(state);
|
|
2468
|
+
const note = MUD_WIRE_NOTES[state] || label.note;
|
|
2469
|
+
const wire = el("wireState");
|
|
2470
|
+
wire.dataset.tone = label.tone;
|
|
2471
|
+
el("wireStateWord").textContent = label.word;
|
|
2472
|
+
el("wireStateNote").textContent = note;
|
|
2473
|
+
const pill = el("statePill");
|
|
2474
|
+
pill.dataset.tone = label.tone;
|
|
2475
|
+
el("statePillWord").textContent = label.pill;
|
|
2476
|
+
pill.title = note;
|
|
2477
|
+
el("sharePanel").hidden = !el("shareLink").value;
|
|
2478
|
+
// Once the channel is open the reply has been used; leaving "send this
|
|
2479
|
+
// back" on screen would be asking for something already done.
|
|
2480
|
+
el("answerPanel").hidden = !el("replyOut").value || (room && room.state === "connected");
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function relativeWhen(at, nowMs) {
|
|
2484
|
+
if (at === null || at === undefined) return "\\u2014";
|
|
2485
|
+
const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
|
|
2486
|
+
if (seconds < 5) return "now";
|
|
2487
|
+
if (seconds < 60) return seconds + "s";
|
|
2488
|
+
if (seconds < 3600) return Math.round(seconds / 60) + "m";
|
|
2489
|
+
if (seconds < 86400) return Math.round(seconds / 3600) + "h";
|
|
2490
|
+
return Math.round(seconds / 86400) + "d";
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
function renderNodes() {
|
|
2494
|
+
const listEl = el("nodeList");
|
|
2495
|
+
if (!room || !p2p) {
|
|
2496
|
+
el("nodeCount").textContent = "";
|
|
2497
|
+
listEl.textContent = "";
|
|
2498
|
+
el("nodeEmpty").hidden = false;
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
const rows = nodeRowsFor({
|
|
2502
|
+
peers: room.peers(),
|
|
2503
|
+
factRows: room.factRows(),
|
|
2504
|
+
myPeerId: myPeerId,
|
|
2505
|
+
myDisplayName: myDisplayName,
|
|
2506
|
+
nameFor: room.displayNameFor,
|
|
2507
|
+
latestTimestampOf: p2p.latestProvenanceTimestamp,
|
|
2508
|
+
});
|
|
2509
|
+
const nowMs = Date.now();
|
|
2510
|
+
const waving = wavingCharacters(room.factRows(), nowMs);
|
|
2511
|
+
el("nodeCount").textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
|
|
2512
|
+
listEl.textContent = "";
|
|
2513
|
+
for (const entry of rows) {
|
|
2514
|
+
const item = document.createElement("li");
|
|
2515
|
+
item.className = "node-row";
|
|
2516
|
+
item.dataset.self = String(entry.isSelf);
|
|
2517
|
+
item.dataset.away = String(!entry.connected);
|
|
2518
|
+
item.dataset.peer = entry.peerId;
|
|
2519
|
+
item.dataset.waving = String(waving.some(function (character) {
|
|
2520
|
+
const owner = claimantOf(room.factRows(), character);
|
|
2521
|
+
return owner !== null && peerTerm(owner) === peerTerm(entry.peerId);
|
|
2522
|
+
}));
|
|
2523
|
+
const avatar = document.createElement("span");
|
|
2524
|
+
avatar.className = "node-avatar";
|
|
2525
|
+
avatar.textContent = nodeInitials(entry.name);
|
|
2526
|
+
avatar.title = entry.connected
|
|
2527
|
+
? "connected"
|
|
2528
|
+
: "away \\u2014 closed the tab or dropped offline; everything it dug stays";
|
|
2529
|
+
const name = document.createElement("span");
|
|
2530
|
+
name.className = "node-name";
|
|
2531
|
+
name.textContent = entry.name;
|
|
2532
|
+
const hand = document.createElement("span");
|
|
2533
|
+
hand.className = "node-hand";
|
|
2534
|
+
hand.textContent = "\\u{1F44B}";
|
|
2535
|
+
const when = document.createElement("span");
|
|
2536
|
+
when.className = "node-when";
|
|
2537
|
+
when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
|
|
2538
|
+
item.appendChild(avatar);
|
|
2539
|
+
item.appendChild(name);
|
|
2540
|
+
if (entry.isSelf) {
|
|
2541
|
+
const self = document.createElement("span");
|
|
2542
|
+
self.className = "node-self";
|
|
2543
|
+
self.textContent = "you";
|
|
2544
|
+
item.appendChild(self);
|
|
2545
|
+
}
|
|
2546
|
+
item.appendChild(hand);
|
|
2547
|
+
item.appendChild(when);
|
|
2548
|
+
listEl.appendChild(item);
|
|
2549
|
+
}
|
|
2550
|
+
el("nodeEmpty").hidden = rows.length > 1;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
async function copyText(text) {
|
|
2554
|
+
try {
|
|
2555
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
2556
|
+
await navigator.clipboard.writeText(text);
|
|
2557
|
+
return true;
|
|
2558
|
+
}
|
|
2559
|
+
} catch { /* fall through — the box on the page still holds the text */ }
|
|
2560
|
+
return false;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
const openNetPanel = function () {
|
|
2564
|
+
el("netPanel").hidden = false;
|
|
2565
|
+
el("statePill").setAttribute("aria-expanded", "true");
|
|
2566
|
+
};
|
|
2567
|
+
const closeNetPanel = function () {
|
|
2568
|
+
el("netPanel").hidden = true;
|
|
2569
|
+
el("statePill").setAttribute("aria-expanded", "false");
|
|
2570
|
+
};
|
|
2571
|
+
|
|
2572
|
+
function wireNetPanel() {
|
|
2573
|
+
el("netPanelClose").addEventListener("click", closeNetPanel);
|
|
2574
|
+
el("statePill").addEventListener("click", function () {
|
|
2575
|
+
if (el("netPanel").hidden) openNetPanel(); else closeNetPanel();
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
el("shareBtn").addEventListener("click", async function () {
|
|
2579
|
+
const shareBtn = el("shareBtn");
|
|
2580
|
+
shareBtn.disabled = true;
|
|
2581
|
+
try {
|
|
2582
|
+
const active = await ensureRoom();
|
|
2583
|
+
const minted = await active.startSharing();
|
|
2584
|
+
el("shareLink").value = inviteLinkFor(window.location.href, {
|
|
2585
|
+
blob: minted.blob, world: worldId, worldName: worldName,
|
|
2586
|
+
});
|
|
2587
|
+
el("replyProblem").hidden = true;
|
|
2588
|
+
openNetPanel();
|
|
2589
|
+
renderWire();
|
|
2590
|
+
await copyText(el("shareLink").value);
|
|
2591
|
+
} catch (err) {
|
|
2592
|
+
el("replyProblem").textContent = "couldn't create an invite (" + (err && err.message ? err.message : err) + ").";
|
|
2593
|
+
el("replyProblem").hidden = false;
|
|
2594
|
+
openNetPanel();
|
|
2595
|
+
} finally {
|
|
2596
|
+
shareBtn.disabled = false;
|
|
2597
|
+
}
|
|
2598
|
+
});
|
|
2599
|
+
|
|
2600
|
+
// The inviter's page has exactly one paste target, so there is no wrong box
|
|
2601
|
+
// to choose. A rejected paste keeps its text so the copy can be fixed.
|
|
2602
|
+
el("replyBtn").addEventListener("click", async function () {
|
|
2603
|
+
let active = room;
|
|
2604
|
+
if (!active) {
|
|
2605
|
+
try { active = await ensureRoom(); } catch { return; }
|
|
2606
|
+
}
|
|
2607
|
+
const outcome = await active.completeInvite(el("replyBox").value);
|
|
2608
|
+
if (outcome && outcome.error) {
|
|
2609
|
+
el("replyProblem").textContent = outcome.message;
|
|
2610
|
+
el("replyProblem").hidden = false;
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
el("replyProblem").hidden = true;
|
|
2614
|
+
el("replyBox").value = "";
|
|
2615
|
+
renderWire();
|
|
2616
|
+
});
|
|
2617
|
+
|
|
2618
|
+
el("copyReplyBtn").addEventListener("click", function () { copyText(el("replyOut").value); });
|
|
2619
|
+
|
|
2620
|
+
// The link flow lands somebody on the join card before they have dug
|
|
2621
|
+
// anything. This is the other case: a burrow already open, an invite that
|
|
2622
|
+
// arrived in a message, and no reason to throw the burrow away to take it.
|
|
2623
|
+
el("joinOpenBtn").addEventListener("click", function () {
|
|
2624
|
+
el("invitePanel").hidden = false;
|
|
2625
|
+
openNetPanel();
|
|
2626
|
+
el("inviteBox").focus();
|
|
2627
|
+
});
|
|
2628
|
+
|
|
2629
|
+
el("inviteBtn").addEventListener("click", async function () {
|
|
2630
|
+
const problem = el("inviteProblem");
|
|
2631
|
+
const pasted = el("inviteBox").value.trim();
|
|
2632
|
+
if (!pasted) {
|
|
2633
|
+
problem.textContent = "paste the invite you were sent, then try again";
|
|
2634
|
+
problem.hidden = false;
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
if (room) {
|
|
2638
|
+
problem.textContent = "this burrow is already shared. Reset it first, then take the invite.";
|
|
2639
|
+
problem.hidden = false;
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
// A whole link and a bare blob are both what people actually paste, so
|
|
2643
|
+
// both are read. The blob's own envelope decides either way.
|
|
2644
|
+
const blob = offerBlobIn(pasted);
|
|
2645
|
+
const mod = await loadP2p();
|
|
2646
|
+
const decoded = mod.decodeInviteBlob(blob);
|
|
2647
|
+
if (decoded.error || decoded.value.kind !== "offer") {
|
|
2648
|
+
problem.textContent = decoded.error
|
|
2649
|
+
? "this invite looks cut short \\u2014 ask for it to be sent again"
|
|
2650
|
+
: "that's a reply, not an invite \\u2014 it belongs on the page that sent the invite";
|
|
2651
|
+
problem.hidden = false;
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
// The world id has to be the invite's before the room is opened: a room
|
|
2655
|
+
// that minted its own would refuse the invite as belonging elsewhere.
|
|
2656
|
+
await ensureIdentity();
|
|
2657
|
+
worldId = decoded.value.world || worldId;
|
|
2658
|
+
if (decoded.value.worldName) {
|
|
2659
|
+
worldName = decoded.value.worldName;
|
|
2660
|
+
el("worldNameInput").value = worldName;
|
|
2661
|
+
}
|
|
2662
|
+
try {
|
|
2663
|
+
const active = await ensureRoom();
|
|
2664
|
+
const outcome = await active.acceptInvite(blob);
|
|
2665
|
+
if (outcome && outcome.error) {
|
|
2666
|
+
problem.textContent = outcome.message;
|
|
2667
|
+
problem.hidden = false;
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
problem.hidden = true;
|
|
2671
|
+
el("inviteBox").value = "";
|
|
2672
|
+
el("replyOut").value = outcome.blob;
|
|
2673
|
+
renderWire();
|
|
2674
|
+
await copyText(outcome.blob);
|
|
2675
|
+
} catch (err) {
|
|
2676
|
+
problem.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
|
|
2677
|
+
problem.hidden = false;
|
|
2678
|
+
}
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
el("nodeNameInput").addEventListener("change", function () {
|
|
2682
|
+
const name = el("nodeNameInput").value.trim();
|
|
2683
|
+
if (!name || name === myDisplayName) { el("nodeNameInput").value = myDisplayName; return; }
|
|
2684
|
+
myDisplayName = name;
|
|
2685
|
+
writeStoredNodeName(name);
|
|
2686
|
+
if (!room) { renderNodes(); return; }
|
|
2687
|
+
room.setMyDisplayName(name).then(renderNodes).catch(function () { /* the next broadcast carries it */ });
|
|
2688
|
+
});
|
|
2689
|
+
el("worldNameInput").addEventListener("change", function () {
|
|
2690
|
+
const input = el("worldNameInput");
|
|
2691
|
+
if (input.readOnly) { input.value = worldName; return; }
|
|
2692
|
+
const name = input.value.trim();
|
|
2693
|
+
if (name) worldName = name;
|
|
2694
|
+
input.value = worldName;
|
|
2695
|
+
});
|
|
2696
|
+
|
|
2697
|
+
el("joinBtn").addEventListener("click", async function () {
|
|
2698
|
+
const joinBtn = el("joinBtn");
|
|
2699
|
+
joinBtn.disabled = true;
|
|
2700
|
+
try {
|
|
2701
|
+
const active = await ensureRoom();
|
|
2702
|
+
const outcome = await active.acceptInvite(invite.offer);
|
|
2703
|
+
if (outcome && outcome.error) {
|
|
2704
|
+
el("joinProblem").textContent = outcome.message;
|
|
2705
|
+
el("joinProblem").hidden = false;
|
|
2706
|
+
joinBtn.disabled = false;
|
|
2707
|
+
joinBtn.textContent = "try again";
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
el("joinProblem").hidden = true;
|
|
2711
|
+
el("joinReply").value = outcome.blob;
|
|
2712
|
+
el("replyOut").value = outcome.blob;
|
|
2713
|
+
el("joinReplyWrap").hidden = false;
|
|
2714
|
+
joinBtn.textContent = "reply created";
|
|
2715
|
+
el("joinDismiss").textContent = "close this and start digging";
|
|
2716
|
+
renderWire();
|
|
2717
|
+
await copyText(outcome.blob);
|
|
2718
|
+
} catch (err) {
|
|
2719
|
+
el("joinProblem").textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
|
|
2720
|
+
el("joinProblem").hidden = false;
|
|
2721
|
+
joinBtn.disabled = false;
|
|
2722
|
+
}
|
|
2723
|
+
});
|
|
2724
|
+
el("joinCopyBtn").addEventListener("click", function () { copyText(el("joinReply").value); });
|
|
2725
|
+
el("joinDismiss").addEventListener("click", function () {
|
|
2726
|
+
el("joinCard").hidden = true;
|
|
2727
|
+
if (el("replyOut").value) openNetPanel();
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// A link that lost part of itself on the way reads as a mangled link, never
|
|
2732
|
+
// as no invite at all.
|
|
2733
|
+
async function prepareJoinCard() {
|
|
2734
|
+
el("joinCard").hidden = false;
|
|
2735
|
+
el("joinWorld").textContent = invite.worldName || "a shared burrow";
|
|
2736
|
+
el("joinBtn").focus();
|
|
2737
|
+
const mod = await loadP2p();
|
|
2738
|
+
const decoded = mod.decodeInviteBlob(invite.offer);
|
|
2739
|
+
if (decoded.error || decoded.value.kind !== "offer") {
|
|
2740
|
+
el("joinBtn").hidden = true;
|
|
2741
|
+
el("joinEyebrow").textContent = "this link didn't arrive in one piece";
|
|
2742
|
+
el("joinWorld").textContent = invite.worldName || "an invitation";
|
|
2743
|
+
el("joinBody").textContent = decoded.error
|
|
2744
|
+
? "Part of the link was lost on the way here. Ask for it to be sent again, and check the whole thing travels."
|
|
2745
|
+
: "That link carries a reply rather than an invite. It belongs in the box on the page that sent the invite.";
|
|
2746
|
+
el("joinDismiss").textContent = "dig on your own instead";
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
if (decoded.value.world) invite.world = decoded.value.world;
|
|
2750
|
+
if (decoded.value.worldName) {
|
|
2751
|
+
invite.worldName = decoded.value.worldName;
|
|
2752
|
+
el("joinWorld").textContent = decoded.value.worldName;
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
// ---- the control deck ----------------------------------------------------
|
|
2757
|
+
// The players slider runs on detents (1, 2, 4), so its own value is the
|
|
2758
|
+
// index of the count, never the count.
|
|
2759
|
+
function chosenPlayerCount() {
|
|
2760
|
+
const picked = DATA.playerCounts[Number(el("playerCountSlider").value)];
|
|
2761
|
+
return picked || DATA.defaultPlayerCount;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
function showPlayerCount(count) {
|
|
2765
|
+
el("playerCountValue").textContent = String(count);
|
|
2766
|
+
el("playerCountSlider").setAttribute("aria-valuetext", count + (count === 1 ? " player" : " players"));
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
// The npcs slider carries its own count, so it needs no detent table.
|
|
2770
|
+
function chosenNpcCount() {
|
|
2771
|
+
const picked = Number(el("npcCountSlider").value);
|
|
2772
|
+
return Number.isFinite(picked) ? picked : DATA.defaultNpcCount;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
function showNpcCount(count) {
|
|
2776
|
+
el("npcCountValue").textContent = String(count);
|
|
2777
|
+
el("npcCountSlider").setAttribute("aria-valuetext", count + (count === 1 ? " npc" : " npcs"));
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
function wireDeck() {
|
|
2781
|
+
const playBtn = el("autoToggle");
|
|
690
2782
|
const delaySlider = el("delaySlider");
|
|
691
2783
|
const maxTurnsSlider = el("maxTurnsSlider");
|
|
692
|
-
const
|
|
693
|
-
|
|
2784
|
+
const playerCountSlider = el("playerCountSlider");
|
|
2785
|
+
const npcCountSlider = el("npcCountSlider");
|
|
2786
|
+
playBtn.addEventListener("click", function () {
|
|
2787
|
+
if (!session) return;
|
|
694
2788
|
autoOn = !autoOn;
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
2789
|
+
playBtn.setAttribute("aria-pressed", autoOn ? "true" : "false");
|
|
2790
|
+
playBtn.textContent = autoOn ? "\\u23F8 pause" : "\\u25B6 play";
|
|
2791
|
+
// Every animal in the world, pane or none: the deck's play control is the
|
|
2792
|
+
// world running, and an npc has no other control that could start it.
|
|
2793
|
+
for (const character of everyone()) {
|
|
2794
|
+
const ticker = tickers[character];
|
|
699
2795
|
if (!ticker) continue;
|
|
700
2796
|
if (autoOn) ticker.play(); else ticker.pause();
|
|
701
2797
|
}
|
|
@@ -708,45 +2804,171 @@ function pageScript() {
|
|
|
708
2804
|
maxTurns = Number(maxTurnsSlider.value);
|
|
709
2805
|
el("maxTurnsValue").textContent = String(maxTurns);
|
|
710
2806
|
});
|
|
711
|
-
|
|
2807
|
+
// The readout follows the thumb, but a new cast waits for the drag to
|
|
2808
|
+
// settle: booting on every intermediate detent would open, and throw
|
|
2809
|
+
// away, a whole shared world per step.
|
|
2810
|
+
playerCountSlider.addEventListener("input", function () { showPlayerCount(chosenPlayerCount()); });
|
|
2811
|
+
playerCountSlider.addEventListener("change", function () { boot(); });
|
|
2812
|
+
npcCountSlider.addEventListener("input", function () { showNpcCount(chosenNpcCount()); });
|
|
2813
|
+
npcCountSlider.addEventListener("change", function () { boot(); });
|
|
2814
|
+
el("resetBtn").addEventListener("click", function () { boot(); });
|
|
2815
|
+
// Picking a burrow is a recast like any other, so it runs the same boot
|
|
2816
|
+
// the sliders and reset run. Edit mode follows the world rather than the
|
|
2817
|
+
// page: whatever is half-typed is flushed into the burrow it was typed
|
|
2818
|
+
// over, and the editor reopens on the one that just loaded.
|
|
2819
|
+
const scenarioSelect = el("scenarioSelect");
|
|
2820
|
+
if (scenarioSelect) {
|
|
2821
|
+
scenarioSelect.addEventListener("change", async function () {
|
|
2822
|
+
const picked = Number(scenarioSelect.value);
|
|
2823
|
+
if (!DATA.scenarios[picked] || picked === scenarioIndex) return;
|
|
2824
|
+
const wasEditing = editing;
|
|
2825
|
+
if (wasEditing) await exitEditMode();
|
|
2826
|
+
scenarioIndex = picked;
|
|
2827
|
+
await boot("a different burrow opened \\u2014 share again to link up.");
|
|
2828
|
+
if (wasEditing) await enterEditMode();
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
el("editModeBtn").addEventListener("click", function () {
|
|
2832
|
+
if (!session) return;
|
|
2833
|
+
if (editing) exitEditMode(); else enterEditMode();
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2836
|
+
const infoBtn = el("deckInfoBtn");
|
|
2837
|
+
const infoPopup = el("deckInfoPopup");
|
|
2838
|
+
function openDeckInfo() {
|
|
2839
|
+
infoPopup.hidden = false;
|
|
2840
|
+
infoBtn.setAttribute("aria-expanded", "true");
|
|
2841
|
+
}
|
|
2842
|
+
function closeDeckInfo() {
|
|
2843
|
+
infoPopup.hidden = true;
|
|
2844
|
+
infoBtn.setAttribute("aria-expanded", "false");
|
|
2845
|
+
}
|
|
2846
|
+
infoBtn.addEventListener("click", function () { if (infoPopup.hidden) openDeckInfo(); else closeDeckInfo(); });
|
|
2847
|
+
el("deckInfoClose").addEventListener("click", closeDeckInfo);
|
|
2848
|
+
document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !infoPopup.hidden) closeDeckInfo(); });
|
|
2849
|
+
document.addEventListener("click", function (e) {
|
|
2850
|
+
if (infoPopup.hidden) return;
|
|
2851
|
+
if (e.target === infoBtn || infoPopup.contains(e.target)) return;
|
|
2852
|
+
closeDeckInfo();
|
|
2853
|
+
});
|
|
712
2854
|
}
|
|
713
2855
|
|
|
714
|
-
|
|
715
|
-
|
|
2856
|
+
// The stage is the cast's, so it is rebuilt for the cast: one paneMarkup
|
|
2857
|
+
// call per animal, the count on the stage itself for the grid to lay out
|
|
2858
|
+
// against, and a fresh set of listeners for the panes that just appeared.
|
|
2859
|
+
function renderStage() {
|
|
2860
|
+
const stage = el("mudStage");
|
|
2861
|
+
stage.setAttribute("data-panes", String(slots.length));
|
|
2862
|
+
stage.innerHTML = slots.map(function (slot) { return paneMarkup(slot); }).join("");
|
|
2863
|
+
for (const slot of slots) wirePane(slot);
|
|
716
2864
|
}
|
|
717
2865
|
|
|
718
|
-
|
|
719
|
-
for (
|
|
720
|
-
const
|
|
721
|
-
|
|
2866
|
+
function bindPanes() {
|
|
2867
|
+
for (let i = 0; i < slots.length; i += 1) {
|
|
2868
|
+
const w = "window-" + slots[i];
|
|
2869
|
+
const character = cast[i];
|
|
2870
|
+
const pane = el(w);
|
|
2871
|
+
pane.setAttribute("data-character", character);
|
|
2872
|
+
pane.setAttribute("aria-label", speciesOfCharacter(character) + "'s own view of the shared world");
|
|
2873
|
+
el(w + "-name").innerHTML = esc(speciesOfCharacter(character)) + '<span class="mono char-id">' + esc(character) + "</span>";
|
|
2874
|
+
el(w + "-chatq").setAttribute("aria-label", "type a command for " + character);
|
|
722
2875
|
}
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// Booting builds the shared world and draws the opening state. It never
|
|
2879
|
+
// starts a turn: nothing ticks until the deck's own play control (or a
|
|
2880
|
+
// pane's) is clicked. Opening the world is asynchronous and the controls
|
|
2881
|
+
// that boot are not, so a boot overtaken by a later one drops its own
|
|
2882
|
+
// session on the floor rather than binding it to the stage the newer boot
|
|
2883
|
+
// has already drawn.
|
|
2884
|
+
let bootSeq = 0;
|
|
2885
|
+
let bootRun = null;
|
|
2886
|
+
function boot(note) {
|
|
2887
|
+
bootRun = openWorld(note);
|
|
2888
|
+
return bootRun;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
async function openWorld(note) {
|
|
2892
|
+
const seq = bootSeq += 1;
|
|
2893
|
+
autoOn = false;
|
|
2894
|
+
// A room is bound to the store it was opened over, and recasting mints a
|
|
2895
|
+
// brand new one — so a burrow that restarts leaves the link behind rather
|
|
2896
|
+
// than quietly syncing a world nobody else is in. Saying so is the whole
|
|
2897
|
+
// handling; sharing again opens a fresh link over the new world. Picking a
|
|
2898
|
+
// different burrow is the same drop for the same reason, and says so in
|
|
2899
|
+
// its own words.
|
|
2900
|
+
dropRoom(note || "the burrow restarted \\u2014 share again to link up.");
|
|
2901
|
+
claimedElsewhere = {};
|
|
2902
|
+
wavingNow = [];
|
|
2903
|
+
const playBtn = el("autoToggle");
|
|
2904
|
+
playBtn.setAttribute("aria-pressed", "false");
|
|
2905
|
+
playBtn.textContent = "\\u25B6 play";
|
|
2906
|
+
for (const character of Object.keys(tickers)) tickers[character].pause();
|
|
2907
|
+
tickers = {};
|
|
723
2908
|
globalTurn = 0;
|
|
724
|
-
knownRoomIds.clear();
|
|
725
2909
|
freshlyDugRoom = null;
|
|
2910
|
+
lastSnapshot = null;
|
|
726
2911
|
speechBubbles.clear();
|
|
727
2912
|
tickChain = Promise.resolve();
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
2913
|
+
// Rebuilt rather than pruned: a character that had a pane last boot can be
|
|
2914
|
+
// an npc in this one, and a stale seat would send its ending to a pane now
|
|
2915
|
+
// showing somebody else.
|
|
2916
|
+
finished = {};
|
|
2917
|
+
turnsTaken = {};
|
|
2918
|
+
slotOf = {};
|
|
2919
|
+
|
|
2920
|
+
// ONE draw, the engine's own, and the same list the session is built from:
|
|
2921
|
+
// a second independent draw here would leave the page showing animals the
|
|
2922
|
+
// shared world was never opened for. The stage follows the draw rather
|
|
2923
|
+
// than the slider, so a roster too small for the chosen count draws every
|
|
2924
|
+
// animal it has and no empty pane. The npcs draw from what the panes left,
|
|
2925
|
+
// out of a roster grown to hold everyone, so no animal is ever both.
|
|
2926
|
+
const npcCount = chosenNpcCount();
|
|
2927
|
+
// Read fresh, never cached at load: each burrow authors its own animals,
|
|
2928
|
+
// so the roster belongs to the scenario rather than to the page.
|
|
2929
|
+
roster = rosterOf(scenario());
|
|
2930
|
+
rootRoom = rootRoomOf(scenario());
|
|
2931
|
+
cast = window.tmctMud.pickMudRoster(roster, { count: chosenPlayerCount() });
|
|
2932
|
+
const pool = window.tmctMud.expandMudRoster(roster, cast.length + npcCount)
|
|
2933
|
+
.filter(function (id) { return cast.indexOf(id) === -1; });
|
|
2934
|
+
npcs = window.tmctMud.pickMudRoster(pool, { count: npcCount });
|
|
2935
|
+
slots = DATA.paneSlots.slice(0, cast.length);
|
|
2936
|
+
showPlayerCount(cast.length);
|
|
2937
|
+
showNpcCount(npcs.length);
|
|
2938
|
+
renderStage();
|
|
2939
|
+
const opened = await window.tmctMud.createMudSession(scenario().worldPayload, { characters: everyone() });
|
|
2940
|
+
if (seq !== bootSeq) return;
|
|
2941
|
+
session = opened;
|
|
2942
|
+
for (let i = 0; i < cast.length; i += 1) slotOf[cast[i]] = slots[i];
|
|
2943
|
+
bindPanes();
|
|
2944
|
+
for (const character of cast) {
|
|
2945
|
+
const w = paneIdFor(character);
|
|
735
2946
|
el(w + "-play").disabled = false;
|
|
736
2947
|
el(w + "-step").disabled = false;
|
|
737
2948
|
el(w + "-chatq").disabled = false;
|
|
738
|
-
for (const pill of el(w + "-chatpills").querySelectorAll(".pill")) pill.disabled = false;
|
|
739
|
-
ensureTicker(c.id);
|
|
740
|
-
wireChat(c.id);
|
|
741
2949
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
2950
|
+
for (const character of everyone()) {
|
|
2951
|
+
turnsTaken[character] = 0;
|
|
2952
|
+
ensureTicker(character);
|
|
2953
|
+
}
|
|
2954
|
+
renderSoon();
|
|
747
2955
|
}
|
|
748
2956
|
|
|
749
|
-
|
|
2957
|
+
wireDeck();
|
|
2958
|
+
wireNetPanel();
|
|
2959
|
+
renderWire();
|
|
2960
|
+
renderNodes();
|
|
750
2961
|
boot();
|
|
2962
|
+
// Off the boot path on purpose: this fetches the shared P2P asset so the
|
|
2963
|
+
// panel can show a real node name and burrow name before anyone clicks
|
|
2964
|
+
// anything. A failure here costs sharing, never the digging.
|
|
2965
|
+
ensureIdentity().catch(function () { /* sharing stays unavailable; the burrow does not */ });
|
|
2966
|
+
if (invite) {
|
|
2967
|
+
prepareJoinCard().catch(function (err) {
|
|
2968
|
+
el("joinProblem").textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";
|
|
2969
|
+
el("joinProblem").hidden = false;
|
|
2970
|
+
el("joinBtn").hidden = true;
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
751
2973
|
})();`;
|
|
752
2974
|
}
|