@moood/render 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ // Selecting any part of the scene. A click (not a drag) picks what is under the cursor —
2
+ // objects and landscape layers nearest first, then the sun / moon / planets, else the sky —
3
+ // pixel-exact: the candidate's own vector shape is re-painted into a tiny canvas around
4
+ // the cursor and we look for ink. The selection glows along its silhouette on an overlay;
5
+ // the page (app.js) tells the chat what is selected.
6
+ // Paths name the selected part inside the scene spec: "objects[3]", "layers[1]",
7
+ // "celestials[0]", "sky" — the same the server understands. Scene elements also carry a
8
+ // stable id (object()), which is what the world's agents know them by.
9
+ const MooodSelect = (() => {
10
+ const I = Moood.internals;
11
+ const NAMES = {
12
+ pine: 'ель', tree: 'дерево', house: 'дом', tower: 'башня', lamp: 'фонарь', lantern: 'фонарик', campfire: 'костёр',
13
+ window: 'окно', boat: 'лодка', rock: 'камень', bench: 'скамейка', figure: 'человек', cat: 'кот', bird: 'птица',
14
+ flower: 'цветок', bridge: 'мостик',
15
+ mountains: 'горы', hills: 'холмы', dunes: 'дюны', forest: 'лес', city: 'город', water: 'вода', ground: 'земля',
16
+ clouds: 'облака', fog: 'туман', sun: 'солнце', moon: 'луна', planet: 'планета',
17
+ };
18
+ const LAND = new Set(['mountains', 'hills', 'dunes', 'forest', 'city', 'water', 'ground']);
19
+ const PROBE = 7, GLOW = 'rgba(255,228,160,0.95)';
20
+
21
+ // the part of the spec a path points to, and a human name for it
22
+ function item(spec, path) {
23
+ if (!spec || !path) return undefined;
24
+ if (path === 'sky') return spec.sky;
25
+ const m = /^(objects|layers|celestials)\[(\d+)\]$/.exec(path);
26
+ return m ? spec[m[1]]?.[Number(m[2])] : undefined;
27
+ }
28
+ function describe(spec, path) {
29
+ if (path === 'sky') return 'небо';
30
+ const it = item(spec, path);
31
+ return it ? it.name || NAMES[it.kind] || it.kind : path; // custom elements carry their own name
32
+ }
33
+
34
+ // hint: what the card suggests doing with the selection (the page decides — it depends on the mode);
35
+ // withCard: false — the page shows the selection its own way (the Android app); only(path) — just
36
+ // these parts can be picked (the others let a click through to what is behind them), each on the
37
+ // first click
38
+ function create({ canvas, stage, onChange, hint, withCard = true, only = null }) {
39
+ let spec = null, sel = null, drawn = '';
40
+ const probe = I.layer(PROBE, PROBE), pg = probe.getContext('2d', { willReadFrequently: true });
41
+ const overlay = document.createElement('canvas');
42
+ overlay.id = 'select-overlay';
43
+ canvas.after(overlay);
44
+ const og = overlay.getContext('2d');
45
+ const card = document.createElement('div');
46
+ card.id = 'selection';
47
+ card.hidden = true;
48
+ document.body.append(card);
49
+
50
+ // Scenes are painted in CSS pixels of the frame here (W × H, the stage SW wide — engine.js),
51
+ // so a plane's view() maps painted coordinates straight to where they are in the frame;
52
+ // the frame lies at (x, y) in the canvas.
53
+ const size = () => { const f = stage.frame(); return { W: f.w, H: f.h, SW: I.STAGE * f.h, x: f.x, y: f.y }; };
54
+ // objects stand where the engine puts them (it may move them to their real distance)
55
+ const placed = (dz, o) => ({ ...o, x: dz.objectX(o), y: dz.objectY(o), size: dz.objectSize(o), _yh: dz.yh });
56
+ // a platformer's hero is drawn moved and turned (engine.js heroMatrix): so is its outline
57
+ function heroed(m, o, cam, SW, H) {
58
+ if (!o || !cam.hero || o.id !== cam.hero.id) return m;
59
+ const dz = I.depths(spec), h = I.heroMatrix(cam.hero, dz.objectX(o) * SW, dz.objectSize(o) * H / (o.height || 2));
60
+ return [m[0] * h[0], 0, 0, m[3], m[0] * h[4] + m[4], m[3] * h[5] + m[5]];
61
+ }
62
+ const layerSeed = i => I.hash(JSON.stringify(spec.layers || []) + (spec.title || '')) + i * 101;
63
+
64
+ // any ink within a few screen pixels of (px, py) when drawn at on-screen scale s?
65
+ function inked(px, py, s, draw) {
66
+ pg.setTransform(1, 0, 0, 1, 0, 0);
67
+ pg.clearRect(0, 0, PROBE, PROBE);
68
+ pg.setTransform(s, 0, 0, s, PROBE / 2 - px * s, PROBE / 2 - py * s);
69
+ draw(pg);
70
+ const d = pg.getImageData(0, 0, PROBE, PROBE).data;
71
+ for (let i = 3; i < d.length; i += 4) if (d[i] > 40) return true;
72
+ return false;
73
+ }
74
+
75
+ function pick(clientX, clientY) {
76
+ const { W, H, SW, x, y } = size(), cam = stage.camera(), dz = I.depths(spec);
77
+ const r = canvas.getBoundingClientRect(), k = r.width / Math.max(1, canvas.clientWidth) || 1;
78
+ const sx = (clientX - r.left) / k - x, sy = (clientY - r.top) / k - y;
79
+ // nearest first, in the renderer's order: it paints far to near, and at equal distance
80
+ // layers in spec order, then objects — so the later one is on top
81
+ // (looking around, the elements standing about — no landscape layers, the sky behind them)
82
+ const round = I.camSetup(spec).round;
83
+ const things = [
84
+ ...(round ? [] : spec.layers || []).map((L, i) => ({ path: `layers[${i}]`, L, i, at: { m: I.view(cam, dz.Z[i], W, H, dz.yh), D: dz.Z[i] - cam.z } })).filter(t => LAND.has(t.L.kind)),
85
+ ...(spec.objects || []).map((o, i) => ({ path: `objects[${i}]`, o: placed(dz, o), at: round && o.sky ? null : I.placeOf(o, cam, W, H, dz, round) })).filter(t => t.o.kind !== 'bird' && !t.o.plane),
86
+ ].map((t, k) => ({ ...t, k })).filter(t => t.at && t.at.D > 0.48 && (!only || only(t.path))).sort((a, b) => a.at.D - b.at.D || b.k - a.k);
87
+ for (const t of things) {
88
+ const m = heroed(t.at.m, t.o, cam, SW, H), s = m[0], px = (sx - m[4]) / s, py = (sy - m[5]) / m[3];
89
+ if (t.o) {
90
+ if (inked(px, py, s, g => I.drawObject(g, t.o, SW, H, 0, []))) return t.path;
91
+ } else if (t.L.kind === 'water') {
92
+ if (py >= t.L.y * H) return t.path;
93
+ } else {
94
+ const k = s, w = PROBE / k;
95
+ I.staticLayer(t.L, SW, H, layerSeed(t.i), { x: px - w / 2, y: py - w / 2, w, h: w, k, canvas: probe });
96
+ const d = pg.getImageData(0, 0, PROBE, PROBE).data;
97
+ for (let j = 3; j < d.length; j += 4) if (d[j] > 40) return t.path;
98
+ }
99
+ }
100
+ const m = I.view(cam, Infinity, W, H, dz.yh), px = (sx - m[4]) / m[0], py = (sy - m[5]) / m[3];
101
+ const ci = (spec.celestials || []).findIndex(c => Math.hypot(px - c.x * SW, py - c.y * H) < c.size * H / 2 + 6);
102
+ const far = ci >= 0 ? `celestials[${ci}]` : 'sky';
103
+ return !only || only(far) ? far : null;
104
+ }
105
+
106
+ // ── the glow ────────────────────────────────────────────────────────
107
+ // The selected thing's silhouette, painted once: { c, x, y, k } — canvas c covers painted
108
+ // coordinates from (x, y) at scale k — and the plane it stands on (Z).
109
+ let sil = null, box = null; // box: where the outlined thing is on the screen now (CSS pixels)
110
+ // the part of a silhouette's canvas that has ink: { x, y, w, h } in its pixels
111
+ function inkBox(c) {
112
+ const k = Math.max(1, Math.ceil(Math.max(c.width, c.height) / 256)), w = Math.ceil(c.width / k), h = Math.ceil(c.height / k);
113
+ const t = I.layer(w, h), tg = t.getContext('2d', { willReadFrequently: true });
114
+ tg.drawImage(c, 0, 0, w, h);
115
+ const d = tg.getImageData(0, 0, w, h).data;
116
+ let x0 = w, y0 = h, x1 = -1, y1 = -1;
117
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (d[(y * w + x) * 4 + 3] > 24) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
118
+ return x1 < 0 ? { x: 0, y: 0, w: c.width, h: c.height } : { x: x0 * k, y: y0 * k, w: (x1 - x0 + 1) * k, h: (y1 - y0 + 1) * k };
119
+ }
120
+ function silhouette() {
121
+ const { SW, H } = size(), dz = I.depths(spec), it = item(spec, sel), i = Number(/\d+/.exec(sel)?.[0]);
122
+ if (sel.startsWith('objects')) {
123
+ const o = placed(dz, it), b = I.objectBox(o, SW, H);
124
+ const k = Math.min(2, 2048 / Math.max(1, b.bw), 2048 / Math.max(1, b.bh));
125
+ const c = I.layer(b.bw * k, b.bh * k), g = c.getContext('2d');
126
+ g.setTransform(k, 0, 0, k, -b.bx * k, -b.by * k);
127
+ I.drawObject(g, o, SW, H, 0, []);
128
+ return { c, x: b.bx, y: b.by, k, Z: dz.objectZ(it), obj: it };
129
+ }
130
+ if (sel.startsWith('layers') && LAND.has(it.kind)) {
131
+ if (it.kind === 'water') {
132
+ const c = I.layer(4, 4); c.getContext('2d').fillRect(0, 0, 4, 4);
133
+ return { c, x: -I.EXT * SW, y: it.y * H, k: 4 / ((1 + 2 * I.EXT) * SW), ky: 4 / ((I.DEEP - it.y) * H), Z: dz.Z[i] };
134
+ }
135
+ const k = 0.5, x = -I.EXT * SW;
136
+ const st = I.staticLayer(it, SW, H, layerSeed(i), { x, y: 0, w: (1 + 2 * I.EXT) * SW, h: I.DEEP * H, k });
137
+ return { c: st.canvas, x, y: 0, k, Z: dz.Z[i] };
138
+ }
139
+ if (sel.startsWith('celestials')) {
140
+ const r = it.size * H / 2, c = I.layer(2 * r + 4, 2 * r + 4), g = c.getContext('2d');
141
+ g.beginPath(); g.arc(r + 2, r + 2, r, 0, 7); g.fill();
142
+ return { c, x: it.x * SW - r - 2, y: it.y * H - r - 2, k: 1, Z: Infinity };
143
+ }
144
+ return null; // the sky: nothing to outline
145
+ }
146
+ function drawGlow() {
147
+ const dpr = window.devicePixelRatio || 1, cam = stage.camera();
148
+ // the scene's state moved or reshaped it (a figure that walks): outline it as it is now
149
+ if (sel && sil && sil.ver !== I.STATE.ver) sil = { ...(silhouette() || sil), ver: I.STATE.ver };
150
+ const key = sel && sil ? `${cam.x.toFixed(4)},${cam.y.toFixed(4)},${cam.z.toFixed(4)},${cam.yaw.toFixed(4)},${cam.h},${overlay.width},${sil.ver}` : '';
151
+ if (key === drawn) return;
152
+ drawn = key;
153
+ og.setTransform(1, 0, 0, 1, 0, 0);
154
+ og.clearRect(0, 0, overlay.width, overlay.height);
155
+ box = null;
156
+ if (!key) return;
157
+ const { W, H, x, y } = size(), dz = I.depths(spec), round = I.camSetup(spec).round;
158
+ const at = sil.obj && !sil.obj.sky ? I.placeOf(sil.obj, cam, W, H, dz, round) : { m: I.view(cam, sil.Z, W, H, dz.yh), D: Infinity };
159
+ if (!at || at.D < 0.3) return;
160
+ const m = heroed(at.m, sil.obj, cam, I.STAGE * H, H);
161
+ const sx = m[0] / sil.k * dpr, sy = m[3] / (sil.ky || sil.k) * dpr;
162
+ const ox = (x + m[0] * sil.x + m[4]) * dpr, oy = (y + m[3] * sil.y + m[5]) * dpr;
163
+ const r = canvas.getBoundingClientRect();
164
+ sil.ink ||= inkBox(sil.c);
165
+ box = { x: r.left + (ox + sil.ink.x * sx) / dpr, y: r.top + (oy + sil.ink.y * sy) / dpr, w: sil.ink.w * sx / dpr, h: sil.ink.h * sy / dpr };
166
+ // the shape itself is drawn far off-canvas; only its blurred shadow lands on screen —
167
+ // then the shape's interior is cut out, leaving a glow around the outline
168
+ const OFF = overlay.width + 4000;
169
+ og.shadowColor = GLOW;
170
+ og.shadowOffsetX = OFF;
171
+ for (const blur of [3, 10, 22]) {
172
+ og.shadowBlur = blur * dpr;
173
+ og.setTransform(sx, 0, 0, sy, ox - OFF, oy);
174
+ og.drawImage(sil.c, 0, 0);
175
+ }
176
+ og.shadowColor = 'transparent';
177
+ og.globalCompositeOperation = 'destination-out';
178
+ og.setTransform(sx, 0, 0, sy, ox, oy);
179
+ og.drawImage(sil.c, 0, 0);
180
+ og.globalCompositeOperation = 'source-over';
181
+ }
182
+ const fit = () => {
183
+ const dpr = window.devicePixelRatio || 1;
184
+ overlay.width = Math.round(overlay.clientWidth * dpr);
185
+ overlay.height = Math.round(overlay.clientHeight * dpr);
186
+ drawn = '';
187
+ };
188
+ addEventListener('resize', () => { fit(); if (sel) sil = silhouette(); });
189
+ fit();
190
+ const loop = () => { requestAnimationFrame(loop); if (spec) drawGlow(); };
191
+ requestAnimationFrame(loop);
192
+
193
+ // ── the card ────────────────────────────────────────────────────────
194
+ const hint_ = () => (hint ? hint() : 'напиши в чат, что с этим сделать');
195
+ function renderCard() {
196
+ card.hidden = !sel || !withCard;
197
+ if (!sel) return;
198
+ const it = item(spec, sel);
199
+ card.innerHTML = '';
200
+ const head = document.createElement('div');
201
+ head.className = 'head';
202
+ const name = document.createElement('b');
203
+ name.textContent = describe(spec, sel);
204
+ const close = document.createElement('button');
205
+ close.type = 'button'; close.textContent = '✕'; close.title = 'снять выделение (Esc)';
206
+ close.onclick = () => select(null);
207
+ head.append(name, close);
208
+ const props = document.createElement('div');
209
+ props.className = 'props';
210
+ const colour = it?.color || it?.top;
211
+ if (colour) { const sw = document.createElement('i'); sw.style.background = colour; props.append(sw); }
212
+ props.append(Object.entries(it || {}).filter(([k, v]) => !['kind', 'code', 'name', 'id'].includes(k) && typeof v !== 'object' && v !== false && String(v).length < 40)
213
+ .map(([k, v]) => `${k} ${typeof v === 'number' ? +v.toFixed(2) : v}`).join(' · '));
214
+ const hint = document.createElement('div');
215
+ hint.className = 'hint';
216
+ hint.textContent = hint_();
217
+ card.append(head, props, hint);
218
+ }
219
+
220
+ function select(path) {
221
+ sel = path && item(spec, path) !== undefined ? path : null;
222
+ sil = sel ? silhouette() : null;
223
+ drawn = 'dirty';
224
+ renderCard();
225
+ onChange?.(sel);
226
+ }
227
+
228
+ // Things (objects, sun, moon) select on the first click. The continuous background —
229
+ // land layers, water, sky — only on a second click on the same part: the first one
230
+ // clears any selection, so stray clicks on the landscape never select it. Clicking
231
+ // what is selected clears it.
232
+ // Background: the old landscape layers and sky, and any element covering more than a
233
+ // quarter of the view from the start (the sky, a lake, far hills) — measured once per
234
+ // scene by painting it small.
235
+ const coverage = new Map();
236
+ function background(path) {
237
+ if (/^(layers\[|sky$)/.test(path)) return true;
238
+ if (!path.startsWith('objects')) return false;
239
+ if (!coverage.has(path)) {
240
+ const { W, H, SW } = size(), dz = I.depths(spec), it = item(spec, path), n = 64, c = I.layer(n, n * H / W), g = c.getContext('2d');
241
+ // from the start — looking around, turned toward it
242
+ const round = I.camSetup(spec).round, p = round && !it.sky ? dz.objectAt(it) : null;
243
+ const at = I.placeOf(it, { x: 0, y: 0, z: 0, h: 1, yaw: p ? Math.atan2(p.X, p.Z) : 0 }, W, H, dz, round && !it.sky), k = n / W;
244
+ const m = at ? at.m : [0, 0, 0, 0, 0, 0];
245
+ g.setTransform(m[0] * k, 0, 0, m[3] * k, m[4] * k, m[5] * k);
246
+ try { I.drawObject(g, placed(dz, it), SW, H, 0, []); } catch {}
247
+ const d = g.getImageData(0, 0, c.width, c.height).data;
248
+ let ink = 0;
249
+ for (let i = 3; i < d.length; i += 4) if (d[i] > 40) ink++;
250
+ coverage.set(path, ink / (d.length / 4));
251
+ }
252
+ return coverage.get(path) > 0.25;
253
+ }
254
+ let armed = null; // a background part clicked once: { path, t }
255
+ function clicked(path, t) {
256
+ const prev = armed;
257
+ armed = null;
258
+ if (path === sel) return select(null);
259
+ if (only) return select(path);
260
+ if (!background(path)) return select(path);
261
+ if (prev && prev.path === path && !sel) return select(path);
262
+ if (sel) select(null);
263
+ armed = { path, t };
264
+ }
265
+
266
+ // a click is a press and release without dragging, with one finger
267
+ let down = null;
268
+ canvas.addEventListener('pointerdown', e => {
269
+ down = down ? { multi: true } : { x: e.clientX, y: e.clientY, t: performance.now() };
270
+ });
271
+ canvas.addEventListener('pointerup', e => {
272
+ const d = down;
273
+ down = null;
274
+ if (!d || d.multi || !spec) return;
275
+ if (Math.hypot(e.clientX - d.x, e.clientY - d.y) > 5 || performance.now() - d.t > 500) return;
276
+ const path = pick(e.clientX, e.clientY);
277
+ if (path) clicked(path, performance.now());
278
+ else if (sel) select(null);
279
+ });
280
+ canvas.addEventListener('pointercancel', () => { down = null; });
281
+ addEventListener('keydown', e => {
282
+ if (e.key === 'Escape' && sel && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) select(null);
283
+ });
284
+
285
+ return {
286
+ // a new scene (another world or version): indices may mean other things now
287
+ setSpec(next) { if (next !== spec) { spec = next; coverage.clear(); select(null); } },
288
+ path: () => sel,
289
+ // the selected scene element itself (with its stable id), or null
290
+ object: () => (sel && sel.startsWith('objects') ? item(spec, sel) : null),
291
+ // where the selection is on the screen now — {x, y, w, h} in CSS pixels — or null
292
+ box: () => (sel ? box : null),
293
+ // what a click there would pick (a path), or null
294
+ at: (clientX, clientY) => (spec ? pick(clientX, clientY) : null),
295
+ refresh: () => renderCard(),
296
+ clear: () => select(null),
297
+ };
298
+ }
299
+
300
+ return { create, describe };
301
+ })();
@@ -0,0 +1,150 @@
1
+ // Lines said in the scene — whatever runs the game has someone say them: a line appears over the
2
+ // one who says it (following it as the viewer walks: stage.screenOf; the narrator's low in the
3
+ // middle), and is heard in its voice (Gemini, /speak — made once, kept for good); words and voice come together.
4
+ // Lines are said one after another. While a line is heard its bubble glimmers; a bubble keeps a ▶
5
+ // to hear it again, and the page is told (the "moood:speech" event: {id, text, on}) so it can light
6
+ // the line up elsewhere too. While someone is about to speak — the one who answers thinking, the
7
+ // voice being made — a "…" glimmers over them (thinking()).
8
+ const MooodSpeech = (() => {
9
+ let stage = null, canvas = null, layer = null, voiceless = null, raf = 0, queue = Promise.resolve();
10
+ const bubbles = new Map(); // element id → { el, text, until, pending }
11
+ const waiting = new Map(); // element id → how many things it is about to say
12
+ let current = null; // the voice being heard right now
13
+ let said = 0; // lines said before the player last spoke up: those not begun yet are dropped
14
+ const NARRATOR = 'narrator';
15
+ MooodVolume.on((kind, v) => { if (kind === 'voice' && current) current.volume = v; });
16
+
17
+ function init(opts) {
18
+ ({ stage, canvas, voiceless } = opts); // voiceless(why): a line couldn't be voiced
19
+ layer = document.createElement('div');
20
+ layer.id = 'speech';
21
+ canvas.after(layer);
22
+ }
23
+
24
+ const told = (id, text, on) => dispatchEvent(new CustomEvent('moood:speech', { detail: { id, text, on } }));
25
+
26
+ // a line's voice, loaded and ready to play (made by /speak once — kept for good); null if it can't
27
+ // be, or not in time — then the line is shown without it, and the lines after it aren't held up
28
+ const MAKING = 45000, LOADING = 15000;
29
+ async function voiced(text, voice, how, who) {
30
+ try {
31
+ const r = await fetch('/speak', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, voice, how, who }),
32
+ signal: AbortSignal.timeout(MAKING) });
33
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || `speech ${r.status}`);
34
+ const a = new Audio((await r.json()).url);
35
+ await new Promise((ok, fail) => {
36
+ const late = setTimeout(() => fail(new Error('the audio took too long to load')), LOADING);
37
+ a.oncanplaythrough = () => { clearTimeout(late); ok(); };
38
+ a.onerror = () => { clearTimeout(late); fail(new Error('the audio would not load')); };
39
+ a.load();
40
+ });
41
+ return a;
42
+ } catch (err) {
43
+ (window.__mooodErrors = window.__mooodErrors || []).push(`speech: ${err.message || err}`);
44
+ voiceless?.(err.name === 'TimeoutError' ? 'голос не успел' : err.message || String(err));
45
+ return null;
46
+ }
47
+ }
48
+
49
+ // plays a line's voice, the bubble glimmering; resolves when it has been heard
50
+ async function hear(id, text, a, bubble) {
51
+ bubble?.el.classList.add('speaking'); told(id, text, true);
52
+ current = a;
53
+ a.volume = MooodVolume.get('voice');
54
+ // (an audio that never says it ended — a stalled stream — is let go after its length)
55
+ try { await new Promise(done => { a.onended = a.onerror = done; a.currentTime = 0; a.play().catch(done); setTimeout(done, (a.duration || 30) * 1000 + 3000); }); }
56
+ finally { if (current === a) current = null; bubble?.el.classList.remove('speaking'); told(id, text, false); }
57
+ }
58
+
59
+ // the player started to speak: the voice being heard stops (its words stay on screen), and what
60
+ // was still to be said is dropped — the world listens to them now
61
+ function hush() {
62
+ said++;
63
+ if (!current) return;
64
+ current.pause();
65
+ current.onended();
66
+ }
67
+
68
+ function bubbleFor(id, text, voice, how, who) {
69
+ const old = bubbles.get(id);
70
+ if (old) old.el.remove();
71
+ const el = document.createElement('div');
72
+ el.className = id === NARRATOR ? 'bubble narrator' : 'bubble';
73
+ const p = document.createElement('p');
74
+ p.textContent = text;
75
+ el.append(p);
76
+ if (voice) {
77
+ const again = document.createElement('button');
78
+ again.type = 'button'; again.textContent = '▶'; again.title = 'ещё раз';
79
+ again.onclick = () => replay(id, text, voice, how, who);
80
+ el.append(again);
81
+ }
82
+ layer.append(el);
83
+ const b = { el, text, until: Infinity, pending: false };
84
+ bubbles.set(id, b);
85
+ if (!raf) raf = requestAnimationFrame(place);
86
+ return b;
87
+ }
88
+
89
+ function place() {
90
+ raf = 0;
91
+ const now = performance.now();
92
+ for (const [id, b] of bubbles) {
93
+ if (now > b.until) { b.el.remove(); bubbles.delete(id); if (waiting.get(id)) pend(id); continue; }
94
+ const at = id === NARRATOR ? [innerWidth / 2, innerHeight * 0.86] : stage.screenOf(id, 0.25, true);
95
+ b.el.style.visibility = at ? '' : 'hidden';
96
+ if (at) b.el.style.transform = `translate(${Math.round(at[0])}px, ${Math.round(at[1])}px) translate(-50%, -100%)`;
97
+ b.el.classList.toggle('fading', now > b.until - 1200);
98
+ }
99
+ if (bubbles.size) raf = requestAnimationFrame(place);
100
+ }
101
+
102
+ // someone is about to speak (on) — or no longer (off): a glimmering "…" over them meanwhile
103
+ function pend(id) {
104
+ const b = bubbleFor(id, '…');
105
+ b.pending = true;
106
+ b.el.classList.add('pending');
107
+ }
108
+ function thinking(id, on) {
109
+ const n = (waiting.get(id) || 0) + (on ? 1 : -1);
110
+ if (n > 0) waiting.set(id, n); else waiting.delete(id);
111
+ const b = bubbles.get(id);
112
+ if (n > 0 && (!b || b.until !== Infinity)) pend(id);
113
+ if (n <= 0 && b?.pending) { b.el.remove(); bubbles.delete(id); }
114
+ }
115
+
116
+ // id: who says it (an element of the scene); voice/how: heard in that voice, that way, played as
117
+ // `who` ("Savely, the old keeper"). A line with
118
+ // a voice appears when it can be heard — its words and its voice together; its voice is made
119
+ // while the lines before it are said.
120
+ function say(id, text, { voice, how, who } = {}) {
121
+ text = String(text || '').trim();
122
+ if (!text) return Promise.resolve();
123
+ const ready = voice ? voiced(text, voice, how, who) : Promise.resolve(null);
124
+ const turn = said;
125
+ const line = queue.then(async () => {
126
+ if (turn !== said) return;
127
+ if (voice) thinking(id, true);
128
+ const a = await ready;
129
+ if (voice) thinking(id, false);
130
+ const b = bubbleFor(id, text, voice, how, who);
131
+ const t0 = performance.now();
132
+ if (a) await hear(id, text, a, b);
133
+ // a line stays a while to be read: longer for a longer line, at least after it was heard
134
+ b.until = Math.max(performance.now() + 2500, t0 + 1500 + text.length * 60);
135
+ });
136
+ queue = line.catch(() => {});
137
+ return line;
138
+ }
139
+
140
+ async function replay(id, text, voice, how, who) {
141
+ const a = await voiced(text, voice, how, who);
142
+ if (!a) return;
143
+ const b = bubbles.get(id);
144
+ if (b) b.until = Infinity;
145
+ await hear(id, text, a, b);
146
+ if (b) b.until = performance.now() + 2500;
147
+ }
148
+
149
+ return { init, say, replay, thinking, hush };
150
+ })();
@@ -0,0 +1,153 @@
1
+ // The artist's work as it goes, and a world's scenes, as little pictures.
2
+ // MooodDrawings.listen(since, handle) — the artist's work from `since` (/api/drawings/live, live.js):
3
+ // handle(event) gets every event.
4
+ // MooodEditing.listen(worlds, handle) — which scenes of these worlds are being edited, as it goes
5
+ // (/api/editing/worlds/<ids>/live): handle(event) gets every event.
6
+ // MooodStrip.create(box, {scenes, drawing, current, world: {id, address}, go}) — a strip of little
7
+ // pictures: first what is being drawn in the world (its sketch as it is now, marked waiting or
8
+ // drawing, live: /d/<id>), then its scenes (<world's address>/scenes/<id>); each opens in the
9
+ // viewer, over the page (viewer.js) — or, go, is gone to (the strip a scene's page switches by). A scene being edited (whoever's session: the artist's, an
10
+ // assistant's through the MCP…) shows what edits it and its draft. Its handle(event) keeps the
11
+ // drawings up to date, its edited(event) the editing.
12
+ const MooodDrawings = (() => {
13
+ const listen = (since, handle) => MooodLive.open('/api/drawings/live', since, handle);
14
+ return { listen };
15
+ })();
16
+
17
+ const MooodEditing = (() => {
18
+ const listen = (worlds, handle) => MooodLive.open(`/api/editing/worlds/${worlds.join(',')}/live`, '', handle);
19
+ return { listen };
20
+ })();
21
+
22
+ const MooodStrip = (() => {
23
+ const AGAIN = 4500, TRIES = 40; // a picture not made yet (it is, seconds after its version) is looked for again: ~3 minutes
24
+ const STATE = { waiting: 'в очереди', drawing: 'рисуется' };
25
+ const DRAFTS = 5000; // a scene's draft is pictured again at most this often
26
+
27
+ // a scene: its picture (the draft's, while it is being edited) — shown once it has come: until
28
+ // then (a version's picture is made seconds after it) the card is empty, and it is asked again
29
+ function sceneCard(world, views) {
30
+ const a = document.createElement('a');
31
+ const img = document.createElement('img');
32
+ img.alt = '';
33
+ img.hidden = true;
34
+ const badge = document.createElement('span');
35
+ badge.className = 'badge';
36
+ a.append(img, badge);
37
+ let src = '', tries = 0, again = null;
38
+ img.onload = () => { img.hidden = false; };
39
+ img.onerror = () => { if (tries++ < TRIES) again = setTimeout(() => { img.src = `${src}${src.includes('?') ? '&' : '?'}again=${tries}`; }, AGAIN); };
40
+ const show = next => {
41
+ if (next === src) return;
42
+ clearTimeout(again);
43
+ src = next; tries = 0;
44
+ img.src = src; // (the picture there stays until the next one has come)
45
+ };
46
+ return {
47
+ el: a,
48
+ update(s, here, editing) {
49
+ a.href = `${world.address}/scenes/${s.id}`;
50
+ if (views) a.dataset.view = s.title || 'без названия';
51
+ // the content its picture shows: the viewer opens that — but a scene being edited shows its
52
+ // draft, so the viewer opens the scene as it is now
53
+ if (editing) delete a.dataset.key; else a.dataset.key = s.look;
54
+ a.className = [s.id === here && 'here', editing && 'sketch'].filter(Boolean).join(' ');
55
+ a.title = editing ? `${s.title || ''}\n${editing.label} правит сцену${editing.stage ? ` — ${editing.stage}` : ''}` : s.title || '';
56
+ badge.hidden = !editing;
57
+ badge.textContent = editing ? `правит: ${editing.label}` : '';
58
+ show(editing?.seq ? `/api/scenes/${s.id}/draft.png?seq=${editing.seq}` : `/pictures/${s.look}.png`);
59
+ },
60
+ };
61
+ }
62
+
63
+ // a request being drawn: its sketch's picture, its state, what was asked and what the artist says
64
+ function jobCard(d, views) {
65
+ const a = document.createElement('a');
66
+ a.href = `/d/${d.id}`;
67
+ if (views) a.dataset.view = `рисуется: ${d.request}`;
68
+ const img = document.createElement('img');
69
+ img.alt = '';
70
+ img.hidden = true;
71
+ img.onload = () => { img.hidden = false; };
72
+ const badge = document.createElement('span');
73
+ badge.className = 'badge';
74
+ a.append(img, badge);
75
+ const card = {
76
+ el: a,
77
+ update(d) {
78
+ a.className = `sketch ${d.state}`;
79
+ badge.textContent = STATE[d.state] || d.state;
80
+ a.title = `${STATE[d.state] || ''}: ${d.request}${d.words?.length ? `\n\n${d.words.slice(-3).join('\n')}` : ''}`;
81
+ if (!d.picture) { img.hidden = true; img.removeAttribute('src'); delete img.dataset.v; } // nothing drawn yet
82
+ else if (img.dataset.v !== String(d.picture)) { img.dataset.v = d.picture; img.src = `/api/drawings/${d.id}/picture.png?v=${d.picture}`; }
83
+ },
84
+ };
85
+ card.update(d);
86
+ return card;
87
+ }
88
+
89
+ function create(box, { scenes = [], drawing = [], current = null, world, go = false }) {
90
+ let here = current;
91
+ const mine = d => d.world === world.id;
92
+ const jobs = new Map(drawing.filter(mine).map(d => [d.id, { d, card: null }]));
93
+ let editing = {}; // scene → its open session {id, label, stage, seq}, as last pictured
94
+ const cards = new Map(); // scene → its card
95
+ const draw = () => {
96
+ box.textContent = '';
97
+ for (const j of jobs.values()) { j.card = j.card || jobCard(j.d, !go); box.append(j.card.el); }
98
+ for (const s of scenes) {
99
+ if (!cards.has(s.id)) cards.set(s.id, sceneCard(world, !go));
100
+ const card = cards.get(s.id);
101
+ card.update(s, here, editing[s.id]);
102
+ box.append(card.el);
103
+ }
104
+ box.hidden = !jobs.size && !scenes.length;
105
+ };
106
+ draw();
107
+
108
+ // the scenes being edited; a draft pictured at most every DRAFTS ms (the latest)
109
+ let later = null;
110
+ const redraw = () => { if (!later) later = setTimeout(() => { later = null; draw(); }, DRAFTS); };
111
+ const ended = async id => { // the scene as it is now (a new version, or as it was)
112
+ const i = scenes.findIndex(x => x.id === id);
113
+ if (i < 0) return;
114
+ const s = await (await fetch(`/api/scenes/${id}`)).json();
115
+ scenes[i] = { ...scenes[i], version: s.current, look: s.look, title: s.spec?.title || '' };
116
+ };
117
+ async function edited(ev) {
118
+ if (ev.type === 'now') {
119
+ const was = editing;
120
+ editing = ev.sessions;
121
+ for (const id of Object.keys(was)) if (!editing[id]) await ended(Number(id));
122
+ draw();
123
+ } else if (!scenes.some(s => s.id === ev.scene)) {
124
+ // (another world's scene)
125
+ } else if (ev.type === 'session') { editing[ev.scene] = ev.session; draw(); }
126
+ else if (ev.type === 'log' && editing[ev.scene]) { editing[ev.scene].stage = ev.text; redraw(); }
127
+ else if (ev.type === 'draft' && editing[ev.scene]) { editing[ev.scene].seq = ev.seq; redraw(); }
128
+ else if (ev.type === 'end' && editing[ev.scene]?.id === ev.session) { delete editing[ev.scene]; await ended(ev.scene); draw(); }
129
+ else if (ev.type === 'content') { await ended(ev.scene); draw(); } // (as main has it now: a module changed, a branch merged)
130
+ }
131
+ function handle(ev) {
132
+ const d = ev.job;
133
+ if (!d || !mine(d)) return;
134
+ const j = jobs.get(d.id);
135
+ if (ev.type === 'asked' || ev.type === 'started') {
136
+ if (j) { Object.assign(j.d, { state: d.state }, ev.type === 'started' ? { picture: 0 } : {}); j.card?.update(j.d); }
137
+ else { jobs.set(d.id, { d: { ...d, picture: 0, words: [] }, card: null }); draw(); }
138
+ } else if (ev.type === 'pictured' && j) {
139
+ j.d.picture = ev.picture; j.d.state = 'drawing'; j.card?.update(j.d);
140
+ } else if (ev.type === 'said' && j) {
141
+ j.d.words = [...(j.d.words || []), ev.text].slice(-3); j.card?.update(j.d);
142
+ } else if (ev.type === 'done') {
143
+ jobs.delete(d.id);
144
+ if (ev.scene && !scenes.some(s => s.id === ev.scene.id)) scenes.unshift(ev.scene);
145
+ draw();
146
+ } else if (ev.type === 'failed' && j) {
147
+ jobs.delete(d.id); draw();
148
+ }
149
+ }
150
+ return { handle, edited, at(id) { here = id; draw(); } };
151
+ }
152
+ return { create };
153
+ })();