@moood/render 0.0.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -29
- package/cli.js +34 -34
- package/engine/actions.js +69 -69
- package/engine/captions.js +20 -20
- package/engine/directions.js +44 -44
- package/engine/engine.js +1456 -1456
- package/engine/engine.json +1 -1
- package/engine/gl.js +1027 -1027
- package/engine/kit.js +142 -142
- package/engine/live.js +23 -23
- package/engine/mic.js +76 -76
- package/engine/music.css +28 -28
- package/engine/music.js +279 -279
- package/engine/nav.css +66 -66
- package/engine/render.html +50 -50
- package/engine/scene.css +124 -124
- package/engine/scenestate.js +220 -220
- package/engine/select.js +301 -301
- package/engine/speech.js +150 -150
- package/engine/volume.js +56 -56
- package/engine/widgets.js +88 -88
- package/index.js +70 -70
- package/package.json +27 -13
- package/scene.js +43 -43
package/engine/speech.js
CHANGED
|
@@ -1,150 +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
|
-
})();
|
|
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
|
+
})();
|
package/engine/volume.js
CHANGED
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
// How loud the site is for whoever listens here: one setting for all of it, every scene — the
|
|
2
|
-
// music and the voices, each 0..1, kept in this browser (and followed by its other tabs). The 🔊 in
|
|
3
|
-
// the crumbs (any [data-volume] button) opens two sliders; the players ask get() and listen on().
|
|
4
|
-
const MooodVolume = (() => {
|
|
5
|
-
const DEFAULT = { music: 0.4, voice: 1 };
|
|
6
|
-
const NAMES = { music: 'музыка', voice: 'голоса' };
|
|
7
|
-
const key = k => `moood:volume:${k}`;
|
|
8
|
-
const listeners = new Set();
|
|
9
|
-
|
|
10
|
-
function get(k) {
|
|
11
|
-
try { const v = parseFloat(localStorage.getItem(key(k))); if (v >= 0 && v <= 1) return v; } catch {}
|
|
12
|
-
return DEFAULT[k];
|
|
13
|
-
}
|
|
14
|
-
function set(k, v) {
|
|
15
|
-
try { localStorage.setItem(key(k), String(v)); } catch {}
|
|
16
|
-
tell(k);
|
|
17
|
-
}
|
|
18
|
-
function tell(k) { for (const f of listeners) f(k, get(k)); }
|
|
19
|
-
// f(kind, value): kind 'music' or 'voice' changed
|
|
20
|
-
function on(f) { listeners.add(f); }
|
|
21
|
-
addEventListener('storage', e => { for (const k in DEFAULT) if (e.key === key(k)) tell(k); });
|
|
22
|
-
|
|
23
|
-
// ── the sliders ──────────────────────────────────────────────────────────
|
|
24
|
-
let panel = null;
|
|
25
|
-
function build() {
|
|
26
|
-
panel = document.createElement('div');
|
|
27
|
-
panel.id = 'volume';
|
|
28
|
-
panel.hidden = true;
|
|
29
|
-
for (const k in DEFAULT) {
|
|
30
|
-
const row = document.createElement('label');
|
|
31
|
-
const name = document.createElement('span');
|
|
32
|
-
name.textContent = NAMES[k];
|
|
33
|
-
const r = document.createElement('input');
|
|
34
|
-
r.type = 'range'; r.min = 0; r.max = 1; r.step = 0.01; r.value = get(k); r.dataset.kind = k;
|
|
35
|
-
r.oninput = () => set(k, Number(r.value));
|
|
36
|
-
row.append(name, r);
|
|
37
|
-
panel.append(row);
|
|
38
|
-
}
|
|
39
|
-
document.body.append(panel);
|
|
40
|
-
}
|
|
41
|
-
function open(button) {
|
|
42
|
-
if (!panel) build();
|
|
43
|
-
for (const r of panel.querySelectorAll('input')) r.value = get(r.dataset.kind);
|
|
44
|
-
const at = button.getBoundingClientRect();
|
|
45
|
-
panel.style.left = `${Math.max(8, Math.min(at.left, innerWidth - 228))}px`;
|
|
46
|
-
panel.style.top = `${at.bottom + 8}px`;
|
|
47
|
-
panel.hidden = false;
|
|
48
|
-
}
|
|
49
|
-
document.addEventListener('click', e => {
|
|
50
|
-
const b = e.target.closest?.('[data-volume]');
|
|
51
|
-
if (b) { panel && !panel.hidden ? (panel.hidden = true) : open(b); return; }
|
|
52
|
-
if (panel && !panel.hidden && !panel.contains(e.target)) panel.hidden = true;
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
return { get, set, on };
|
|
56
|
-
})();
|
|
1
|
+
// How loud the site is for whoever listens here: one setting for all of it, every scene — the
|
|
2
|
+
// music and the voices, each 0..1, kept in this browser (and followed by its other tabs). The 🔊 in
|
|
3
|
+
// the crumbs (any [data-volume] button) opens two sliders; the players ask get() and listen on().
|
|
4
|
+
const MooodVolume = (() => {
|
|
5
|
+
const DEFAULT = { music: 0.4, voice: 1 };
|
|
6
|
+
const NAMES = { music: 'музыка', voice: 'голоса' };
|
|
7
|
+
const key = k => `moood:volume:${k}`;
|
|
8
|
+
const listeners = new Set();
|
|
9
|
+
|
|
10
|
+
function get(k) {
|
|
11
|
+
try { const v = parseFloat(localStorage.getItem(key(k))); if (v >= 0 && v <= 1) return v; } catch {}
|
|
12
|
+
return DEFAULT[k];
|
|
13
|
+
}
|
|
14
|
+
function set(k, v) {
|
|
15
|
+
try { localStorage.setItem(key(k), String(v)); } catch {}
|
|
16
|
+
tell(k);
|
|
17
|
+
}
|
|
18
|
+
function tell(k) { for (const f of listeners) f(k, get(k)); }
|
|
19
|
+
// f(kind, value): kind 'music' or 'voice' changed
|
|
20
|
+
function on(f) { listeners.add(f); }
|
|
21
|
+
addEventListener('storage', e => { for (const k in DEFAULT) if (e.key === key(k)) tell(k); });
|
|
22
|
+
|
|
23
|
+
// ── the sliders ──────────────────────────────────────────────────────────
|
|
24
|
+
let panel = null;
|
|
25
|
+
function build() {
|
|
26
|
+
panel = document.createElement('div');
|
|
27
|
+
panel.id = 'volume';
|
|
28
|
+
panel.hidden = true;
|
|
29
|
+
for (const k in DEFAULT) {
|
|
30
|
+
const row = document.createElement('label');
|
|
31
|
+
const name = document.createElement('span');
|
|
32
|
+
name.textContent = NAMES[k];
|
|
33
|
+
const r = document.createElement('input');
|
|
34
|
+
r.type = 'range'; r.min = 0; r.max = 1; r.step = 0.01; r.value = get(k); r.dataset.kind = k;
|
|
35
|
+
r.oninput = () => set(k, Number(r.value));
|
|
36
|
+
row.append(name, r);
|
|
37
|
+
panel.append(row);
|
|
38
|
+
}
|
|
39
|
+
document.body.append(panel);
|
|
40
|
+
}
|
|
41
|
+
function open(button) {
|
|
42
|
+
if (!panel) build();
|
|
43
|
+
for (const r of panel.querySelectorAll('input')) r.value = get(r.dataset.kind);
|
|
44
|
+
const at = button.getBoundingClientRect();
|
|
45
|
+
panel.style.left = `${Math.max(8, Math.min(at.left, innerWidth - 228))}px`;
|
|
46
|
+
panel.style.top = `${at.bottom + 8}px`;
|
|
47
|
+
panel.hidden = false;
|
|
48
|
+
}
|
|
49
|
+
document.addEventListener('click', e => {
|
|
50
|
+
const b = e.target.closest?.('[data-volume]');
|
|
51
|
+
if (b) { panel && !panel.hidden ? (panel.hidden = true) : open(b); return; }
|
|
52
|
+
if (panel && !panel.hidden && !panel.contains(e.target)) panel.hidden = true;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return { get, set, on };
|
|
56
|
+
})();
|
package/engine/widgets.js
CHANGED
|
@@ -1,88 +1,88 @@
|
|
|
1
|
-
// Widgets — the scene's own bits of interface over the picture: a sign over someone's head,
|
|
2
|
-
// a clock, a letter, a scoreboard, a panel with buttons (spec.widgets). Each is the artist's
|
|
3
|
-
// HTML + CSS + JS, living right in the page: its markup and styles in a shadow root (so its CSS
|
|
4
|
-
// and the page's don't mix), its scripts run as the page's own, given `root` (that shadow root:
|
|
5
|
-
// root.getElementById, root.querySelector) and `widget` (its element). A widget is laid out in px
|
|
6
|
-
// for a 1080-px-tall screen and scaled with the screen, so it looks the same everywhere. It
|
|
7
|
-
// stands at a point of the frame (interface: a corner, the middle), or follows an element of the scene (hovering `lift` metres
|
|
8
|
-
// over its anchor) as the viewer walks. Errors its scripts throw land in window.__mooodErrors.
|
|
9
|
-
const MooodWidgets = (() => {
|
|
10
|
-
const REF = 1080;
|
|
11
|
-
const ANCHORS = { center: [0.5, 0.5], top: [0.5, 0], bottom: [0.5, 1], left: [0, 0.5], right: [1, 0.5],
|
|
12
|
-
'top-left': [0, 0], 'top-right': [1, 0], 'bottom-left': [0, 1], 'bottom-right': [1, 1] };
|
|
13
|
-
const report = (key, err) => (window.__mooodErrors = window.__mooodErrors || []).push(`widget ${key}: ${err && err.message || err}`);
|
|
14
|
-
|
|
15
|
-
// builds a widget's element: markup into its shadow root, then its scripts, in order
|
|
16
|
-
function make(w, key) {
|
|
17
|
-
const el = document.createElement('div');
|
|
18
|
-
el.className = 'widget';
|
|
19
|
-
el.dataset.id = key;
|
|
20
|
-
const root = el.attachShadow({ mode: 'open' });
|
|
21
|
-
root.innerHTML = `<style>:host{display:block;overflow:hidden}</style>${w.html}`;
|
|
22
|
-
// markup set as HTML doesn't run its scripts: run them ourselves (after the element is in the page)
|
|
23
|
-
const scripts = [...root.querySelectorAll('script')];
|
|
24
|
-
for (const s of scripts) s.remove();
|
|
25
|
-
el.start = () => {
|
|
26
|
-
for (const s of scripts) {
|
|
27
|
-
if (s.src) { const n = document.createElement('script'); n.src = s.src; n.onerror = () => report(key, `can't load ${s.src}`); document.head.append(n); continue; }
|
|
28
|
-
try { new Function('root', 'widget', s.textContent)(root, el); } catch (err) { report(key, err); }
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
return el;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function create({ canvas, stage }) {
|
|
35
|
-
let spec = null, items = [], timers = [], raf = 0, gen = 0;
|
|
36
|
-
const box = document.createElement('div');
|
|
37
|
-
box.id = 'widgets';
|
|
38
|
-
canvas.after(box);
|
|
39
|
-
|
|
40
|
-
function show(next) {
|
|
41
|
-
spec = next;
|
|
42
|
-
for (const t of timers) clearTimeout(t);
|
|
43
|
-
timers = [];
|
|
44
|
-
const list = (Array.isArray(spec?.widgets) ? spec.widgets : []).filter(w => w && typeof w.html === 'string');
|
|
45
|
-
// keep a widget (and its state) when only its placement changed
|
|
46
|
-
const old = new Map(items.map(it => [it.src, it]));
|
|
47
|
-
const fresh = [];
|
|
48
|
-
items = list.map((w, i) => {
|
|
49
|
-
const key = w.id || `widget${i}`, src = `${key}\n${w.html}`;
|
|
50
|
-
let it = old.get(src);
|
|
51
|
-
if (it) old.delete(src);
|
|
52
|
-
else { it = { src, el: make(w, key) }; fresh.push(it); }
|
|
53
|
-
it.w = w;
|
|
54
|
-
const el = it.el;
|
|
55
|
-
el.style.width = `${Math.max(1, w.width || 320)}px`;
|
|
56
|
-
el.style.height = `${Math.max(1, w.height || 120)}px`;
|
|
57
|
-
el.style.pointerEvents = w.interactive ? 'auto' : 'none';
|
|
58
|
-
el.classList.toggle('on', !(w.at > 0));
|
|
59
|
-
if (w.at > 0) timers.push(setTimeout(() => el.classList.add('on'), w.at * 1000));
|
|
60
|
-
if (w.for > 0) timers.push(setTimeout(() => el.classList.remove('on'), (Math.max(0, w.at || 0) + w.for) * 1000));
|
|
61
|
-
box.append(el); // in spec order: later ones on top
|
|
62
|
-
return it;
|
|
63
|
-
});
|
|
64
|
-
for (const it of old.values()) it.el.remove();
|
|
65
|
-
for (const it of fresh) it.el.start();
|
|
66
|
-
cancelAnimationFrame(raf);
|
|
67
|
-
const g = ++gen;
|
|
68
|
-
const loop = () => { if (g !== gen) return; place(); raf = requestAnimationFrame(loop); };
|
|
69
|
-
if (items.length) loop();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function place() {
|
|
73
|
-
// in the frame (engine.js): bars around it on screens too narrow or wide for it
|
|
74
|
-
const f = stage.frame(), k = f.h / REF;
|
|
75
|
-
for (const { w, el } of items) {
|
|
76
|
-
const [ax, ay] = ANCHORS[w.anchor] || (w.follow ? ANCHORS.bottom : ANCHORS.center);
|
|
77
|
-
const at = w.follow ? stage.screenOf(w.follow, w.lift ?? 0) : [f.x + (w.x ?? 0.5) * f.w, f.y + (w.y ?? 0.5) * f.h];
|
|
78
|
-
el.style.visibility = at ? '' : 'hidden';
|
|
79
|
-
if (!at) continue;
|
|
80
|
-
const fw = Math.max(1, w.width || 320), fh = Math.max(1, w.height || 120);
|
|
81
|
-
el.style.transform = `translate(${at[0] - ax * fw * k}px, ${at[1] - ay * fh * k}px) scale(${k})`;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return { show };
|
|
86
|
-
}
|
|
87
|
-
return { create };
|
|
88
|
-
})();
|
|
1
|
+
// Widgets — the scene's own bits of interface over the picture: a sign over someone's head,
|
|
2
|
+
// a clock, a letter, a scoreboard, a panel with buttons (spec.widgets). Each is the artist's
|
|
3
|
+
// HTML + CSS + JS, living right in the page: its markup and styles in a shadow root (so its CSS
|
|
4
|
+
// and the page's don't mix), its scripts run as the page's own, given `root` (that shadow root:
|
|
5
|
+
// root.getElementById, root.querySelector) and `widget` (its element). A widget is laid out in px
|
|
6
|
+
// for a 1080-px-tall screen and scaled with the screen, so it looks the same everywhere. It
|
|
7
|
+
// stands at a point of the frame (interface: a corner, the middle), or follows an element of the scene (hovering `lift` metres
|
|
8
|
+
// over its anchor) as the viewer walks. Errors its scripts throw land in window.__mooodErrors.
|
|
9
|
+
const MooodWidgets = (() => {
|
|
10
|
+
const REF = 1080;
|
|
11
|
+
const ANCHORS = { center: [0.5, 0.5], top: [0.5, 0], bottom: [0.5, 1], left: [0, 0.5], right: [1, 0.5],
|
|
12
|
+
'top-left': [0, 0], 'top-right': [1, 0], 'bottom-left': [0, 1], 'bottom-right': [1, 1] };
|
|
13
|
+
const report = (key, err) => (window.__mooodErrors = window.__mooodErrors || []).push(`widget ${key}: ${err && err.message || err}`);
|
|
14
|
+
|
|
15
|
+
// builds a widget's element: markup into its shadow root, then its scripts, in order
|
|
16
|
+
function make(w, key) {
|
|
17
|
+
const el = document.createElement('div');
|
|
18
|
+
el.className = 'widget';
|
|
19
|
+
el.dataset.id = key;
|
|
20
|
+
const root = el.attachShadow({ mode: 'open' });
|
|
21
|
+
root.innerHTML = `<style>:host{display:block;overflow:hidden}</style>${w.html}`;
|
|
22
|
+
// markup set as HTML doesn't run its scripts: run them ourselves (after the element is in the page)
|
|
23
|
+
const scripts = [...root.querySelectorAll('script')];
|
|
24
|
+
for (const s of scripts) s.remove();
|
|
25
|
+
el.start = () => {
|
|
26
|
+
for (const s of scripts) {
|
|
27
|
+
if (s.src) { const n = document.createElement('script'); n.src = s.src; n.onerror = () => report(key, `can't load ${s.src}`); document.head.append(n); continue; }
|
|
28
|
+
try { new Function('root', 'widget', s.textContent)(root, el); } catch (err) { report(key, err); }
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
return el;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function create({ canvas, stage }) {
|
|
35
|
+
let spec = null, items = [], timers = [], raf = 0, gen = 0;
|
|
36
|
+
const box = document.createElement('div');
|
|
37
|
+
box.id = 'widgets';
|
|
38
|
+
canvas.after(box);
|
|
39
|
+
|
|
40
|
+
function show(next) {
|
|
41
|
+
spec = next;
|
|
42
|
+
for (const t of timers) clearTimeout(t);
|
|
43
|
+
timers = [];
|
|
44
|
+
const list = (Array.isArray(spec?.widgets) ? spec.widgets : []).filter(w => w && typeof w.html === 'string');
|
|
45
|
+
// keep a widget (and its state) when only its placement changed
|
|
46
|
+
const old = new Map(items.map(it => [it.src, it]));
|
|
47
|
+
const fresh = [];
|
|
48
|
+
items = list.map((w, i) => {
|
|
49
|
+
const key = w.id || `widget${i}`, src = `${key}\n${w.html}`;
|
|
50
|
+
let it = old.get(src);
|
|
51
|
+
if (it) old.delete(src);
|
|
52
|
+
else { it = { src, el: make(w, key) }; fresh.push(it); }
|
|
53
|
+
it.w = w;
|
|
54
|
+
const el = it.el;
|
|
55
|
+
el.style.width = `${Math.max(1, w.width || 320)}px`;
|
|
56
|
+
el.style.height = `${Math.max(1, w.height || 120)}px`;
|
|
57
|
+
el.style.pointerEvents = w.interactive ? 'auto' : 'none';
|
|
58
|
+
el.classList.toggle('on', !(w.at > 0));
|
|
59
|
+
if (w.at > 0) timers.push(setTimeout(() => el.classList.add('on'), w.at * 1000));
|
|
60
|
+
if (w.for > 0) timers.push(setTimeout(() => el.classList.remove('on'), (Math.max(0, w.at || 0) + w.for) * 1000));
|
|
61
|
+
box.append(el); // in spec order: later ones on top
|
|
62
|
+
return it;
|
|
63
|
+
});
|
|
64
|
+
for (const it of old.values()) it.el.remove();
|
|
65
|
+
for (const it of fresh) it.el.start();
|
|
66
|
+
cancelAnimationFrame(raf);
|
|
67
|
+
const g = ++gen;
|
|
68
|
+
const loop = () => { if (g !== gen) return; place(); raf = requestAnimationFrame(loop); };
|
|
69
|
+
if (items.length) loop();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function place() {
|
|
73
|
+
// in the frame (engine.js): bars around it on screens too narrow or wide for it
|
|
74
|
+
const f = stage.frame(), k = f.h / REF;
|
|
75
|
+
for (const { w, el } of items) {
|
|
76
|
+
const [ax, ay] = ANCHORS[w.anchor] || (w.follow ? ANCHORS.bottom : ANCHORS.center);
|
|
77
|
+
const at = w.follow ? stage.screenOf(w.follow, w.lift ?? 0) : [f.x + (w.x ?? 0.5) * f.w, f.y + (w.y ?? 0.5) * f.h];
|
|
78
|
+
el.style.visibility = at ? '' : 'hidden';
|
|
79
|
+
if (!at) continue;
|
|
80
|
+
const fw = Math.max(1, w.width || 320), fh = Math.max(1, w.height || 120);
|
|
81
|
+
el.style.transform = `translate(${at[0] - ax * fw * k}px, ${at[1] - ay * fh * k}px) scale(${k})`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { show };
|
|
86
|
+
}
|
|
87
|
+
return { create };
|
|
88
|
+
})();
|