@moood/render 0.0.0 → 0.1.3
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 +1479 -1456
- package/engine/engine.json +1 -1
- package/engine/gl.js +1023 -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 +225 -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/kit.js
CHANGED
|
@@ -1,142 +1,142 @@
|
|
|
1
|
-
// What a scene's code shares, and the tools for moving things well.
|
|
2
|
-
// anim — the animator's toolkit, for every scene: easing curves (a movement starts and stops as
|
|
3
|
-
// a body does), angles the short way round, smooth noise and steady random (life that doesn't
|
|
4
|
-
// repeat, the same every time), a two-bone joint (an elbow, a knee: the hand or the foot set
|
|
5
|
-
// where it must be), poses mixed by weight.
|
|
6
|
-
// the world's modules — code the world's scenes share (its people, its horses, its things):
|
|
7
|
-
// modules/<name>.js in the world's repository, the body of function (anim, data) that returns
|
|
8
|
-
// what it offers (data: modules/<name>.data.json, if there is one). A scene lists the ones it
|
|
9
|
-
// uses (spec.modules) and gets them as kit.<name>, as they are at the commit it is shown at.
|
|
10
|
-
// the scene's lib — code its own elements and scripts share: spec.lib, the body of
|
|
11
|
-
// function (anim, kit) that returns what it offers.
|
|
12
|
-
// Painting code sees them as e.anim, e.kit, e.lib; scripts as scene.anim, scene.kit, scene.lib.
|
|
13
|
-
// Where a spec's modules come from (the page says, with the spec):
|
|
14
|
-
// {world, refs} — the world's id and the modules by their content's sha ({name: {code, data}},
|
|
15
|
-
// as the scene's payload has them: the commit it shows); a module the refs don't name (a draft
|
|
16
|
-
// that has just listed it) is the world's main's now (/api/worlds/<world>/modules). The files
|
|
17
|
-
// are read by /api/worlds/<world>/blobs/<sha> — the same for good, fetched once.
|
|
18
|
-
// {texts} — the modules themselves, {name: {code, data}} (the renderer is given them).
|
|
19
|
-
const MooodKit = (() => {
|
|
20
|
-
const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
|
|
21
|
-
const mix = (a, b, u) => a + (b - a) * u;
|
|
22
|
-
// u (0…1) of the way between a and b that v is, clamped
|
|
23
|
-
const span = (v, a, b) => clamp((v - a) / (b - a));
|
|
24
|
-
// easing: how the 0…1 of a movement's time becomes the 0…1 of its way
|
|
25
|
-
const ease = {
|
|
26
|
-
linear: u => u,
|
|
27
|
-
smooth: u => (1 - Math.cos(Math.PI * u)) / 2, // starts and stops gently (the default)
|
|
28
|
-
in: u => u * u * u, // gathers speed, ends at full speed
|
|
29
|
-
out: u => 1 - Math.pow(1 - u, 3), // sets off at once, settles
|
|
30
|
-
inOut: u => (u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2),
|
|
31
|
-
back: u => { const c = 1.70158; return 1 + (c + 1) * Math.pow(u - 1, 3) + c * Math.pow(u - 1, 2); }, // a little past, and back
|
|
32
|
-
spring: u => (u >= 1 ? 1 : 1 - Math.exp(-6 * u) * Math.cos(9 * u)), // overshoots, wobbles, rests
|
|
33
|
-
anticipate: u => { const c = 1.4; return u * u * ((c + 1) * u - c); }, // back a little first, then go
|
|
34
|
-
};
|
|
35
|
-
const easeOf = e => (typeof e === 'function' ? e : ease[e] || ease.smooth);
|
|
36
|
-
// angles (radians): the short way round from a to b
|
|
37
|
-
const turn = (a, b, u) => { let d = ((b - a) % (2 * Math.PI) + 3 * Math.PI) % (2 * Math.PI) - Math.PI; return a + d * u; };
|
|
38
|
-
// steady random: the same n gives the same 0…1, however many other calls came before
|
|
39
|
-
const hash = (n, seed = 0) => { let h = Math.imul((n * 374761393 + seed * 668265263) | 0, 1274126177); h = (h ^ (h >>> 13)) >>> 0; h = Math.imul(h, 1103515245) >>> 0; return ((h ^ (h >>> 16)) >>> 0) / 4294967296; };
|
|
40
|
-
// smooth noise along x (−1…1): for breathing, swaying, drifting that never repeats
|
|
41
|
-
const noise = (x, seed = 0) => { const i = Math.floor(x), f = x - i, s = f * f * (3 - 2 * f); return mix(hash(i, seed) * 2 - 1, hash(i + 1, seed) * 2 - 1, s); };
|
|
42
|
-
// a few octaves of it, slower ones stronger
|
|
43
|
-
const drift = (x, seed = 0) => noise(x, seed) * 0.6 + noise(x * 2.1 + 7, seed + 1) * 0.3 + noise(x * 4.3 + 13, seed + 2) * 0.1;
|
|
44
|
-
// a two-bone joint: from the root (x0, y0) to the end (x1, y1) with bones a and b; bend 1 or −1 —
|
|
45
|
-
// which side the joint goes. Returns the joint [x, y] (the end out of reach: the limb straight toward it)
|
|
46
|
-
const joint = (x0, y0, x1, y1, a, b, bend = 1) => {
|
|
47
|
-
const dx = x1 - x0, dy = y1 - y0, d = Math.min(Math.hypot(dx, dy), a + b - 1e-6), ang = Math.atan2(dy, dx);
|
|
48
|
-
const k = clamp((a * a + d * d - b * b) / (2 * a * d || 1), -1, 1), off = Math.acos(k) * bend;
|
|
49
|
-
return [x0 + Math.cos(ang + off) * a, y0 + Math.sin(ang + off) * a];
|
|
50
|
-
};
|
|
51
|
-
// poses mixed: pose objects (numbers, or nested objects/arrays of them) weighted [[pose, weight], …]
|
|
52
|
-
const blend = list => {
|
|
53
|
-
const total = list.reduce((s, [, w]) => s + w, 0) || 1, [first] = list[0];
|
|
54
|
-
const go = (sel) => {
|
|
55
|
-
const v0 = sel(first);
|
|
56
|
-
if (typeof v0 === 'number') return list.reduce((s, [p, w]) => s + (sel(p) ?? v0) * w, 0) / total;
|
|
57
|
-
if (Array.isArray(v0)) return v0.map((_, i) => go(p => sel(p)?.[i]));
|
|
58
|
-
if (v0 && typeof v0 === 'object') return Object.fromEntries(Object.keys(v0).map(k => [k, go(p => sel(p)?.[k])]));
|
|
59
|
-
return v0;
|
|
60
|
-
};
|
|
61
|
-
return go(p => p);
|
|
62
|
-
};
|
|
63
|
-
const anim = Object.freeze({ clamp, mix, span, ease, easeOf, turn, hash, noise, drift, joint, blend });
|
|
64
|
-
|
|
65
|
-
const report = (who, err) => (window.__mooodErrors = window.__mooodErrors || []).push(`${who}: ${err && err.message || err}`);
|
|
66
|
-
// files by their content's sha (the same for good), modules made from them, libs made: once each
|
|
67
|
-
const files = new Map(), made = new Map(), libs = new Map(), mains = new Map();
|
|
68
|
-
const fileOf = (world, sha) => {
|
|
69
|
-
if (!files.has(sha)) files.set(sha, fetch(`/api/worlds/${world}/blobs/${sha}`).then(r => (r.ok ? r.text() : Promise.reject(new Error(`${sha}: ${r.status}`)))));
|
|
70
|
-
return files.get(sha);
|
|
71
|
-
};
|
|
72
|
-
const mainOf = world => {
|
|
73
|
-
if (!mains.has(world)) mains.set(world, fetch(`/api/worlds/${world}/modules`).then(r => (r.ok ? r.json() : {})).catch(() => ({})));
|
|
74
|
-
return mains.get(world);
|
|
75
|
-
};
|
|
76
|
-
function make(name, key, code, data) {
|
|
77
|
-
if (!made.has(key)) {
|
|
78
|
-
let value = {};
|
|
79
|
-
try { value = new Function('anim', 'data', String(code))(anim, data) || {}; } catch (err) { report(`module ${name}`, err); }
|
|
80
|
-
made.set(key, value);
|
|
81
|
-
}
|
|
82
|
-
return made.get(key);
|
|
83
|
-
}
|
|
84
|
-
// a spec's modules, as `source` says: {mods: {name: what it offers}, key: which they are} — or a
|
|
85
|
-
// promise of it
|
|
86
|
-
function modulesOf(spec, source) {
|
|
87
|
-
const names = [...new Set((Array.isArray(spec?.modules) ? spec.modules : []).filter(n => typeof n === 'string'))].sort();
|
|
88
|
-
const out = (mods, keys) => ({ mods, key: keys.join('\n') });
|
|
89
|
-
if (!names.length) return out({}, []);
|
|
90
|
-
if (source?.texts) {
|
|
91
|
-
const mods = {}, keys = [];
|
|
92
|
-
for (const n of names) {
|
|
93
|
-
const m = source.texts[n];
|
|
94
|
-
if (m?.code == null) { report(`module ${n}`, 'not in this world'); continue; }
|
|
95
|
-
const key = `${n} ${m.code} ${JSON.stringify(m.data ?? null)}`;
|
|
96
|
-
mods[n] = make(n, key, m.code, m.data ?? null); keys.push(key);
|
|
97
|
-
}
|
|
98
|
-
return out(mods, keys);
|
|
99
|
-
}
|
|
100
|
-
const world = source?.world;
|
|
101
|
-
if (world == null) { report('modules', 'the page did not say whose they are'); return out({}, []); }
|
|
102
|
-
const keyOf = (n, r) => `${n} ${r.code} ${r.data}`;
|
|
103
|
-
const built = refs => {
|
|
104
|
-
const mods = {}, keys = [];
|
|
105
|
-
for (const n of names) if (refs[n]?.code && made.has(keyOf(n, refs[n]))) { mods[n] = made.get(keyOf(n, refs[n])); keys.push(keyOf(n, refs[n])); }
|
|
106
|
-
return out(mods, keys);
|
|
107
|
-
};
|
|
108
|
-
const refs0 = source.refs || {};
|
|
109
|
-
if (names.every(n => refs0[n]?.code && made.has(keyOf(n, refs0[n])))) return built(refs0);
|
|
110
|
-
return (names.every(n => refs0[n]?.code) ? Promise.resolve(refs0) : mainOf(world).then(now => ({ ...now, ...refs0 }))).then(async refs => {
|
|
111
|
-
await Promise.all(names.map(async n => {
|
|
112
|
-
const r = refs[n];
|
|
113
|
-
if (!r?.code) { report(`module ${n}`, 'not in this world'); return; }
|
|
114
|
-
if (made.has(keyOf(n, r))) return;
|
|
115
|
-
try {
|
|
116
|
-
const [code, data] = await Promise.all([fileOf(world, r.code), r.data ? fileOf(world, r.data).then(JSON.parse) : null]);
|
|
117
|
-
make(n, keyOf(n, r), code, data);
|
|
118
|
-
} catch (err) { report(`module ${n}`, err); }
|
|
119
|
-
}));
|
|
120
|
-
return built(refs);
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
// what a spec's code shares, once its modules are here: { kit, lib } — or a promise of it (they
|
|
124
|
-
// are still on their way); `source`: where its modules come from (above). The same modules are
|
|
125
|
-
// the same kit, and a lib on them is made once.
|
|
126
|
-
const kits = new Map();
|
|
127
|
-
function shared(spec, source) {
|
|
128
|
-
const done = ({ mods, key }) => {
|
|
129
|
-
if (!kits.has(key)) kits.set(key, Object.freeze(mods));
|
|
130
|
-
const kit = kits.get(key), lib = typeof spec?.lib === 'string' && spec.lib.trim() ? spec.lib : null;
|
|
131
|
-
if (lib && !libs.has(`${key}\n\n${lib}`)) {
|
|
132
|
-
let value = {};
|
|
133
|
-
try { value = new Function('anim', 'kit', lib)(anim, kit) || {}; } catch (err) { report('lib', err); }
|
|
134
|
-
libs.set(`${key}\n\n${lib}`, value);
|
|
135
|
-
}
|
|
136
|
-
return { kit, lib: lib ? libs.get(`${key}\n\n${lib}`) : {} };
|
|
137
|
-
};
|
|
138
|
-
const m = modulesOf(spec, source);
|
|
139
|
-
return m instanceof Promise ? m.then(done) : done(m);
|
|
140
|
-
}
|
|
141
|
-
return { anim, shared };
|
|
142
|
-
})();
|
|
1
|
+
// What a scene's code shares, and the tools for moving things well.
|
|
2
|
+
// anim — the animator's toolkit, for every scene: easing curves (a movement starts and stops as
|
|
3
|
+
// a body does), angles the short way round, smooth noise and steady random (life that doesn't
|
|
4
|
+
// repeat, the same every time), a two-bone joint (an elbow, a knee: the hand or the foot set
|
|
5
|
+
// where it must be), poses mixed by weight.
|
|
6
|
+
// the world's modules — code the world's scenes share (its people, its horses, its things):
|
|
7
|
+
// modules/<name>.js in the world's repository, the body of function (anim, data) that returns
|
|
8
|
+
// what it offers (data: modules/<name>.data.json, if there is one). A scene lists the ones it
|
|
9
|
+
// uses (spec.modules) and gets them as kit.<name>, as they are at the commit it is shown at.
|
|
10
|
+
// the scene's lib — code its own elements and scripts share: spec.lib, the body of
|
|
11
|
+
// function (anim, kit) that returns what it offers.
|
|
12
|
+
// Painting code sees them as e.anim, e.kit, e.lib; scripts as scene.anim, scene.kit, scene.lib.
|
|
13
|
+
// Where a spec's modules come from (the page says, with the spec):
|
|
14
|
+
// {world, refs} — the world's id and the modules by their content's sha ({name: {code, data}},
|
|
15
|
+
// as the scene's payload has them: the commit it shows); a module the refs don't name (a draft
|
|
16
|
+
// that has just listed it) is the world's main's now (/api/worlds/<world>/modules). The files
|
|
17
|
+
// are read by /api/worlds/<world>/blobs/<sha> — the same for good, fetched once.
|
|
18
|
+
// {texts} — the modules themselves, {name: {code, data}} (the renderer is given them).
|
|
19
|
+
const MooodKit = (() => {
|
|
20
|
+
const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
|
|
21
|
+
const mix = (a, b, u) => a + (b - a) * u;
|
|
22
|
+
// u (0…1) of the way between a and b that v is, clamped
|
|
23
|
+
const span = (v, a, b) => clamp((v - a) / (b - a));
|
|
24
|
+
// easing: how the 0…1 of a movement's time becomes the 0…1 of its way
|
|
25
|
+
const ease = {
|
|
26
|
+
linear: u => u,
|
|
27
|
+
smooth: u => (1 - Math.cos(Math.PI * u)) / 2, // starts and stops gently (the default)
|
|
28
|
+
in: u => u * u * u, // gathers speed, ends at full speed
|
|
29
|
+
out: u => 1 - Math.pow(1 - u, 3), // sets off at once, settles
|
|
30
|
+
inOut: u => (u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2),
|
|
31
|
+
back: u => { const c = 1.70158; return 1 + (c + 1) * Math.pow(u - 1, 3) + c * Math.pow(u - 1, 2); }, // a little past, and back
|
|
32
|
+
spring: u => (u >= 1 ? 1 : 1 - Math.exp(-6 * u) * Math.cos(9 * u)), // overshoots, wobbles, rests
|
|
33
|
+
anticipate: u => { const c = 1.4; return u * u * ((c + 1) * u - c); }, // back a little first, then go
|
|
34
|
+
};
|
|
35
|
+
const easeOf = e => (typeof e === 'function' ? e : ease[e] || ease.smooth);
|
|
36
|
+
// angles (radians): the short way round from a to b
|
|
37
|
+
const turn = (a, b, u) => { let d = ((b - a) % (2 * Math.PI) + 3 * Math.PI) % (2 * Math.PI) - Math.PI; return a + d * u; };
|
|
38
|
+
// steady random: the same n gives the same 0…1, however many other calls came before
|
|
39
|
+
const hash = (n, seed = 0) => { let h = Math.imul((n * 374761393 + seed * 668265263) | 0, 1274126177); h = (h ^ (h >>> 13)) >>> 0; h = Math.imul(h, 1103515245) >>> 0; return ((h ^ (h >>> 16)) >>> 0) / 4294967296; };
|
|
40
|
+
// smooth noise along x (−1…1): for breathing, swaying, drifting that never repeats
|
|
41
|
+
const noise = (x, seed = 0) => { const i = Math.floor(x), f = x - i, s = f * f * (3 - 2 * f); return mix(hash(i, seed) * 2 - 1, hash(i + 1, seed) * 2 - 1, s); };
|
|
42
|
+
// a few octaves of it, slower ones stronger
|
|
43
|
+
const drift = (x, seed = 0) => noise(x, seed) * 0.6 + noise(x * 2.1 + 7, seed + 1) * 0.3 + noise(x * 4.3 + 13, seed + 2) * 0.1;
|
|
44
|
+
// a two-bone joint: from the root (x0, y0) to the end (x1, y1) with bones a and b; bend 1 or −1 —
|
|
45
|
+
// which side the joint goes. Returns the joint [x, y] (the end out of reach: the limb straight toward it)
|
|
46
|
+
const joint = (x0, y0, x1, y1, a, b, bend = 1) => {
|
|
47
|
+
const dx = x1 - x0, dy = y1 - y0, d = Math.min(Math.hypot(dx, dy), a + b - 1e-6), ang = Math.atan2(dy, dx);
|
|
48
|
+
const k = clamp((a * a + d * d - b * b) / (2 * a * d || 1), -1, 1), off = Math.acos(k) * bend;
|
|
49
|
+
return [x0 + Math.cos(ang + off) * a, y0 + Math.sin(ang + off) * a];
|
|
50
|
+
};
|
|
51
|
+
// poses mixed: pose objects (numbers, or nested objects/arrays of them) weighted [[pose, weight], …]
|
|
52
|
+
const blend = list => {
|
|
53
|
+
const total = list.reduce((s, [, w]) => s + w, 0) || 1, [first] = list[0];
|
|
54
|
+
const go = (sel) => {
|
|
55
|
+
const v0 = sel(first);
|
|
56
|
+
if (typeof v0 === 'number') return list.reduce((s, [p, w]) => s + (sel(p) ?? v0) * w, 0) / total;
|
|
57
|
+
if (Array.isArray(v0)) return v0.map((_, i) => go(p => sel(p)?.[i]));
|
|
58
|
+
if (v0 && typeof v0 === 'object') return Object.fromEntries(Object.keys(v0).map(k => [k, go(p => sel(p)?.[k])]));
|
|
59
|
+
return v0;
|
|
60
|
+
};
|
|
61
|
+
return go(p => p);
|
|
62
|
+
};
|
|
63
|
+
const anim = Object.freeze({ clamp, mix, span, ease, easeOf, turn, hash, noise, drift, joint, blend });
|
|
64
|
+
|
|
65
|
+
const report = (who, err) => (window.__mooodErrors = window.__mooodErrors || []).push(`${who}: ${err && err.message || err}`);
|
|
66
|
+
// files by their content's sha (the same for good), modules made from them, libs made: once each
|
|
67
|
+
const files = new Map(), made = new Map(), libs = new Map(), mains = new Map();
|
|
68
|
+
const fileOf = (world, sha) => {
|
|
69
|
+
if (!files.has(sha)) files.set(sha, fetch(`/api/worlds/${world}/blobs/${sha}`).then(r => (r.ok ? r.text() : Promise.reject(new Error(`${sha}: ${r.status}`)))));
|
|
70
|
+
return files.get(sha);
|
|
71
|
+
};
|
|
72
|
+
const mainOf = world => {
|
|
73
|
+
if (!mains.has(world)) mains.set(world, fetch(`/api/worlds/${world}/modules`).then(r => (r.ok ? r.json() : {})).catch(() => ({})));
|
|
74
|
+
return mains.get(world);
|
|
75
|
+
};
|
|
76
|
+
function make(name, key, code, data) {
|
|
77
|
+
if (!made.has(key)) {
|
|
78
|
+
let value = {};
|
|
79
|
+
try { value = new Function('anim', 'data', String(code))(anim, data) || {}; } catch (err) { report(`module ${name}`, err); }
|
|
80
|
+
made.set(key, value);
|
|
81
|
+
}
|
|
82
|
+
return made.get(key);
|
|
83
|
+
}
|
|
84
|
+
// a spec's modules, as `source` says: {mods: {name: what it offers}, key: which they are} — or a
|
|
85
|
+
// promise of it
|
|
86
|
+
function modulesOf(spec, source) {
|
|
87
|
+
const names = [...new Set((Array.isArray(spec?.modules) ? spec.modules : []).filter(n => typeof n === 'string'))].sort();
|
|
88
|
+
const out = (mods, keys) => ({ mods, key: keys.join('\n') });
|
|
89
|
+
if (!names.length) return out({}, []);
|
|
90
|
+
if (source?.texts) {
|
|
91
|
+
const mods = {}, keys = [];
|
|
92
|
+
for (const n of names) {
|
|
93
|
+
const m = source.texts[n];
|
|
94
|
+
if (m?.code == null) { report(`module ${n}`, 'not in this world'); continue; }
|
|
95
|
+
const key = `${n} ${m.code} ${JSON.stringify(m.data ?? null)}`;
|
|
96
|
+
mods[n] = make(n, key, m.code, m.data ?? null); keys.push(key);
|
|
97
|
+
}
|
|
98
|
+
return out(mods, keys);
|
|
99
|
+
}
|
|
100
|
+
const world = source?.world;
|
|
101
|
+
if (world == null) { report('modules', 'the page did not say whose they are'); return out({}, []); }
|
|
102
|
+
const keyOf = (n, r) => `${n} ${r.code} ${r.data}`;
|
|
103
|
+
const built = refs => {
|
|
104
|
+
const mods = {}, keys = [];
|
|
105
|
+
for (const n of names) if (refs[n]?.code && made.has(keyOf(n, refs[n]))) { mods[n] = made.get(keyOf(n, refs[n])); keys.push(keyOf(n, refs[n])); }
|
|
106
|
+
return out(mods, keys);
|
|
107
|
+
};
|
|
108
|
+
const refs0 = source.refs || {};
|
|
109
|
+
if (names.every(n => refs0[n]?.code && made.has(keyOf(n, refs0[n])))) return built(refs0);
|
|
110
|
+
return (names.every(n => refs0[n]?.code) ? Promise.resolve(refs0) : mainOf(world).then(now => ({ ...now, ...refs0 }))).then(async refs => {
|
|
111
|
+
await Promise.all(names.map(async n => {
|
|
112
|
+
const r = refs[n];
|
|
113
|
+
if (!r?.code) { report(`module ${n}`, 'not in this world'); return; }
|
|
114
|
+
if (made.has(keyOf(n, r))) return;
|
|
115
|
+
try {
|
|
116
|
+
const [code, data] = await Promise.all([fileOf(world, r.code), r.data ? fileOf(world, r.data).then(JSON.parse) : null]);
|
|
117
|
+
make(n, keyOf(n, r), code, data);
|
|
118
|
+
} catch (err) { report(`module ${n}`, err); }
|
|
119
|
+
}));
|
|
120
|
+
return built(refs);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// what a spec's code shares, once its modules are here: { kit, lib } — or a promise of it (they
|
|
124
|
+
// are still on their way); `source`: where its modules come from (above). The same modules are
|
|
125
|
+
// the same kit, and a lib on them is made once.
|
|
126
|
+
const kits = new Map();
|
|
127
|
+
function shared(spec, source) {
|
|
128
|
+
const done = ({ mods, key }) => {
|
|
129
|
+
if (!kits.has(key)) kits.set(key, Object.freeze(mods));
|
|
130
|
+
const kit = kits.get(key), lib = typeof spec?.lib === 'string' && spec.lib.trim() ? spec.lib : null;
|
|
131
|
+
if (lib && !libs.has(`${key}\n\n${lib}`)) {
|
|
132
|
+
let value = {};
|
|
133
|
+
try { value = new Function('anim', 'kit', lib)(anim, kit) || {}; } catch (err) { report('lib', err); }
|
|
134
|
+
libs.set(`${key}\n\n${lib}`, value);
|
|
135
|
+
}
|
|
136
|
+
return { kit, lib: lib ? libs.get(`${key}\n\n${lib}`) : {} };
|
|
137
|
+
};
|
|
138
|
+
const m = modulesOf(spec, source);
|
|
139
|
+
return m instanceof Promise ? m.then(done) : done(m);
|
|
140
|
+
}
|
|
141
|
+
return { anim, shared };
|
|
142
|
+
})();
|
package/engine/live.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
// A live channel from the server (a game's, or the artist's work): a WebSocket — unlike a
|
|
2
|
-
// long-lived HTTP request (SSE), it doesn't count against the browser's six connections to a site,
|
|
3
|
-
// which a few open tabs would use up and leave every next page waiting. Each message is an event,
|
|
4
|
-
// most marked (_at) with where it is in the channel; a closed socket is opened again from the last
|
|
5
|
-
// one, so nothing is missed.
|
|
6
|
-
// MooodLive.open(path, since, handle) — handle(event) for every event but pings
|
|
7
|
-
const MooodLive = (() => {
|
|
8
|
-
function open(path, since, handle) {
|
|
9
|
-
let leaving = false;
|
|
10
|
-
addEventListener('pagehide', () => { leaving = true; });
|
|
11
|
-
const connect = () => {
|
|
12
|
-
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${path}?since=${encodeURIComponent(since)}`);
|
|
13
|
-
ws.onmessage = m => {
|
|
14
|
-
const ev = JSON.parse(m.data);
|
|
15
|
-
if (ev._at) since = ev._at;
|
|
16
|
-
if (ev.type !== 'ping') handle(ev);
|
|
17
|
-
};
|
|
18
|
-
ws.onclose = () => { if (!leaving) setTimeout(connect, 2000); };
|
|
19
|
-
};
|
|
20
|
-
connect();
|
|
21
|
-
}
|
|
22
|
-
return { open };
|
|
23
|
-
})();
|
|
1
|
+
// A live channel from the server (a game's, or the artist's work): a WebSocket — unlike a
|
|
2
|
+
// long-lived HTTP request (SSE), it doesn't count against the browser's six connections to a site,
|
|
3
|
+
// which a few open tabs would use up and leave every next page waiting. Each message is an event,
|
|
4
|
+
// most marked (_at) with where it is in the channel; a closed socket is opened again from the last
|
|
5
|
+
// one, so nothing is missed.
|
|
6
|
+
// MooodLive.open(path, since, handle) — handle(event) for every event but pings
|
|
7
|
+
const MooodLive = (() => {
|
|
8
|
+
function open(path, since, handle) {
|
|
9
|
+
let leaving = false;
|
|
10
|
+
addEventListener('pagehide', () => { leaving = true; });
|
|
11
|
+
const connect = () => {
|
|
12
|
+
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${path}?since=${encodeURIComponent(since)}`);
|
|
13
|
+
ws.onmessage = m => {
|
|
14
|
+
const ev = JSON.parse(m.data);
|
|
15
|
+
if (ev._at) since = ev._at;
|
|
16
|
+
if (ev.type !== 'ping') handle(ev);
|
|
17
|
+
};
|
|
18
|
+
ws.onclose = () => { if (!leaving) setTimeout(connect, 2000); };
|
|
19
|
+
};
|
|
20
|
+
connect();
|
|
21
|
+
}
|
|
22
|
+
return { open };
|
|
23
|
+
})();
|
package/engine/mic.js
CHANGED
|
@@ -1,76 +1,76 @@
|
|
|
1
|
-
// Speaking. While the player holds the microphone, the page streams it to /listen (the sound
|
|
2
|
-
// service: PCM16, 24 kHz, mono, binary frames), telling what scene it is — its words help hearing
|
|
3
|
-
// names right — and the words come back as they are heard; letting go sends {"type": "done"} and
|
|
4
|
-
// the final text follows.
|
|
5
|
-
const MooodMic = (() => {
|
|
6
|
-
// a tap on the audio graph: the microphone at the context's own rate, handed on at 24 kHz
|
|
7
|
-
// (linear interpolation across blocks), whatever rate the browser runs audio at
|
|
8
|
-
const TAP = `registerProcessor('tap', class extends AudioWorkletProcessor {
|
|
9
|
-
constructor() { super(); this.step = sampleRate / 24000; this.pos = 0; this.prev = 0; }
|
|
10
|
-
process(inputs) {
|
|
11
|
-
const ch = inputs[0][0];
|
|
12
|
-
if (!ch) return true;
|
|
13
|
-
const out = [];
|
|
14
|
-
let pos = this.pos; // where the next sample falls, in this block's samples (-1..0: just before it)
|
|
15
|
-
while (pos < ch.length - 1) {
|
|
16
|
-
const i = Math.floor(pos), f = pos - i, a = i < 0 ? this.prev : ch[i];
|
|
17
|
-
out.push(a + (ch[i + 1] - a) * f);
|
|
18
|
-
pos += this.step;
|
|
19
|
-
}
|
|
20
|
-
this.pos = pos - ch.length; this.prev = ch[ch.length - 1];
|
|
21
|
-
if (out.length) this.port.postMessage(Float32Array.from(out));
|
|
22
|
-
return true;
|
|
23
|
-
}
|
|
24
|
-
});`;
|
|
25
|
-
let tapUrl = null;
|
|
26
|
-
|
|
27
|
-
// starts an utterance in the scene `spec`; on: { partial(text), final(text), error(text) }.
|
|
28
|
-
// Returns { done(), cancel() }.
|
|
29
|
-
async function listen(spec, on) {
|
|
30
|
-
const q = new URLSearchParams({ title: spec?.title || '', caption: spec?.caption || '' });
|
|
31
|
-
for (const o of spec?.objects || []) if (o.name) q.append('name', o.name);
|
|
32
|
-
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/listen?${q}`);
|
|
33
|
-
ws.binaryType = 'arraybuffer';
|
|
34
|
-
const early = []; // what was said before the socket opened
|
|
35
|
-
ws.onopen = () => { for (const b of early.splice(0)) ws.send(b); };
|
|
36
|
-
let over = false; // the final text or an error came
|
|
37
|
-
ws.onmessage = e => {
|
|
38
|
-
const m = JSON.parse(e.data);
|
|
39
|
-
if (m.type === 'partial') on.partial(m.text);
|
|
40
|
-
else if (m.type === 'final') { over = true; on.final(m.text); }
|
|
41
|
-
else if (m.type === 'error') { over = true; on.error(m.text); }
|
|
42
|
-
};
|
|
43
|
-
ws.onclose = () => { if (!over) on.error(''); };
|
|
44
|
-
let media, ctx;
|
|
45
|
-
try {
|
|
46
|
-
media = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
|
47
|
-
ctx = new AudioContext();
|
|
48
|
-
tapUrl = tapUrl || URL.createObjectURL(new Blob([TAP], { type: 'text/javascript' }));
|
|
49
|
-
await ctx.audioWorklet.addModule(tapUrl);
|
|
50
|
-
} catch (err) {
|
|
51
|
-
ws.close();
|
|
52
|
-
media?.getTracks().forEach(t => t.stop());
|
|
53
|
-
throw err;
|
|
54
|
-
}
|
|
55
|
-
const tap = new AudioWorkletNode(ctx, 'tap'), mute = ctx.createGain();
|
|
56
|
-
mute.gain.value = 0;
|
|
57
|
-
tap.port.onmessage = e => {
|
|
58
|
-
const f = e.data, pcm = new Int16Array(f.length);
|
|
59
|
-
for (let i = 0; i < f.length; i++) pcm[i] = Math.max(-1, Math.min(1, f[i])) * 0x7fff;
|
|
60
|
-
if (ws.readyState === WebSocket.OPEN) ws.send(pcm.buffer); else if (ws.readyState === WebSocket.CONNECTING) early.push(pcm.buffer);
|
|
61
|
-
};
|
|
62
|
-
ctx.createMediaStreamSource(media).connect(tap);
|
|
63
|
-
tap.connect(mute).connect(ctx.destination); // pulled by the graph, heard by no one
|
|
64
|
-
const close = () => { media.getTracks().forEach(t => t.stop()); ctx.close(); };
|
|
65
|
-
return {
|
|
66
|
-
done() {
|
|
67
|
-
close();
|
|
68
|
-
const send = () => ws.send(JSON.stringify({ type: 'done' }));
|
|
69
|
-
if (ws.readyState === WebSocket.OPEN) send(); else ws.addEventListener('open', send);
|
|
70
|
-
},
|
|
71
|
-
cancel() { close(); ws.close(); },
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return { listen };
|
|
76
|
-
})();
|
|
1
|
+
// Speaking. While the player holds the microphone, the page streams it to /listen (the sound
|
|
2
|
+
// service: PCM16, 24 kHz, mono, binary frames), telling what scene it is — its words help hearing
|
|
3
|
+
// names right — and the words come back as they are heard; letting go sends {"type": "done"} and
|
|
4
|
+
// the final text follows.
|
|
5
|
+
const MooodMic = (() => {
|
|
6
|
+
// a tap on the audio graph: the microphone at the context's own rate, handed on at 24 kHz
|
|
7
|
+
// (linear interpolation across blocks), whatever rate the browser runs audio at
|
|
8
|
+
const TAP = `registerProcessor('tap', class extends AudioWorkletProcessor {
|
|
9
|
+
constructor() { super(); this.step = sampleRate / 24000; this.pos = 0; this.prev = 0; }
|
|
10
|
+
process(inputs) {
|
|
11
|
+
const ch = inputs[0][0];
|
|
12
|
+
if (!ch) return true;
|
|
13
|
+
const out = [];
|
|
14
|
+
let pos = this.pos; // where the next sample falls, in this block's samples (-1..0: just before it)
|
|
15
|
+
while (pos < ch.length - 1) {
|
|
16
|
+
const i = Math.floor(pos), f = pos - i, a = i < 0 ? this.prev : ch[i];
|
|
17
|
+
out.push(a + (ch[i + 1] - a) * f);
|
|
18
|
+
pos += this.step;
|
|
19
|
+
}
|
|
20
|
+
this.pos = pos - ch.length; this.prev = ch[ch.length - 1];
|
|
21
|
+
if (out.length) this.port.postMessage(Float32Array.from(out));
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
});`;
|
|
25
|
+
let tapUrl = null;
|
|
26
|
+
|
|
27
|
+
// starts an utterance in the scene `spec`; on: { partial(text), final(text), error(text) }.
|
|
28
|
+
// Returns { done(), cancel() }.
|
|
29
|
+
async function listen(spec, on) {
|
|
30
|
+
const q = new URLSearchParams({ title: spec?.title || '', caption: spec?.caption || '' });
|
|
31
|
+
for (const o of spec?.objects || []) if (o.name) q.append('name', o.name);
|
|
32
|
+
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/listen?${q}`);
|
|
33
|
+
ws.binaryType = 'arraybuffer';
|
|
34
|
+
const early = []; // what was said before the socket opened
|
|
35
|
+
ws.onopen = () => { for (const b of early.splice(0)) ws.send(b); };
|
|
36
|
+
let over = false; // the final text or an error came
|
|
37
|
+
ws.onmessage = e => {
|
|
38
|
+
const m = JSON.parse(e.data);
|
|
39
|
+
if (m.type === 'partial') on.partial(m.text);
|
|
40
|
+
else if (m.type === 'final') { over = true; on.final(m.text); }
|
|
41
|
+
else if (m.type === 'error') { over = true; on.error(m.text); }
|
|
42
|
+
};
|
|
43
|
+
ws.onclose = () => { if (!over) on.error(''); };
|
|
44
|
+
let media, ctx;
|
|
45
|
+
try {
|
|
46
|
+
media = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
|
47
|
+
ctx = new AudioContext();
|
|
48
|
+
tapUrl = tapUrl || URL.createObjectURL(new Blob([TAP], { type: 'text/javascript' }));
|
|
49
|
+
await ctx.audioWorklet.addModule(tapUrl);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
ws.close();
|
|
52
|
+
media?.getTracks().forEach(t => t.stop());
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
const tap = new AudioWorkletNode(ctx, 'tap'), mute = ctx.createGain();
|
|
56
|
+
mute.gain.value = 0;
|
|
57
|
+
tap.port.onmessage = e => {
|
|
58
|
+
const f = e.data, pcm = new Int16Array(f.length);
|
|
59
|
+
for (let i = 0; i < f.length; i++) pcm[i] = Math.max(-1, Math.min(1, f[i])) * 0x7fff;
|
|
60
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(pcm.buffer); else if (ws.readyState === WebSocket.CONNECTING) early.push(pcm.buffer);
|
|
61
|
+
};
|
|
62
|
+
ctx.createMediaStreamSource(media).connect(tap);
|
|
63
|
+
tap.connect(mute).connect(ctx.destination); // pulled by the graph, heard by no one
|
|
64
|
+
const close = () => { media.getTracks().forEach(t => t.stop()); ctx.close(); };
|
|
65
|
+
return {
|
|
66
|
+
done() {
|
|
67
|
+
close();
|
|
68
|
+
const send = () => ws.send(JSON.stringify({ type: 'done' }));
|
|
69
|
+
if (ws.readyState === WebSocket.OPEN) send(); else ws.addEventListener('open', send);
|
|
70
|
+
},
|
|
71
|
+
cancel() { close(); ws.close(); },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { listen };
|
|
76
|
+
})();
|
package/engine/music.css
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
/* The world's music panel (music.js): descriptions with weights, settings, save — on the scene
|
|
2
|
-
page (listening) and the author's page (tuning). */
|
|
3
|
-
#music { position: fixed; top: 16px; left: 16px; bottom: 16px; width: 360px; display: flex; flex-direction: column; border-radius: 16px; background: rgba(8, 10, 16, 0.86); overflow: hidden; z-index: 3; font-size: 13px; color: #e6e8ee; font-family: Georgia, "Times New Roman", serif; }
|
|
4
|
-
/* the panel brings its own buttons: it sits on the dark scene page and the light author's page alike */
|
|
5
|
-
#music button { border: 1px solid rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.06); color: inherit; font: inherit; font-size: 14px; padding: 4px 12px; border-radius: 999px; cursor: pointer; }
|
|
6
|
-
#music button:hover { background: rgba(255, 255, 255, 0.14); }
|
|
7
|
-
#music[hidden] { display: none; }
|
|
8
|
-
#music .music-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); }
|
|
9
|
-
#music .music-head .title { flex: 1; font-size: 15px; }
|
|
10
|
-
#music .play { min-width: 38px; }
|
|
11
|
-
#music .status { padding: 6px 12px 0; font-size: 12px; font-style: italic; opacity: 0.6; min-height: 1.4em; }
|
|
12
|
-
#music .prompts { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: 8px 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
13
|
-
#music .prompts li { padding: 6px 8px; border-radius: 10px; background: rgba(255, 255, 255, 0.03); opacity: 0.6; }
|
|
14
|
-
#music .prompts li.on { opacity: 1; background: rgba(255, 214, 150, 0.08); }
|
|
15
|
-
#music textarea, #music .add input { width: 100%; box-sizing: border-box; resize: none; field-sizing: content; border: 0; outline: none; background: transparent; color: inherit; font: inherit; line-height: 1.35; }
|
|
16
|
-
#music .row, #music .knobs label { display: flex; align-items: center; gap: 8px; }
|
|
17
|
-
#music input[type=range] { flex: 1; accent-color: #e8c48a; }
|
|
18
|
-
#music .val { width: 3.2em; text-align: right; font-variant-numeric: tabular-nums; opacity: 0.75; font-size: 12px; }
|
|
19
|
-
#music .del { padding: 0 7px; font-size: 12px; }
|
|
20
|
-
#music .add { display: flex; gap: 6px; padding: 8px 12px; border-top: 1px solid rgba(255, 255, 255, 0.08); }
|
|
21
|
-
#music .add input { border-bottom: 1px solid rgba(255, 255, 255, 0.15); }
|
|
22
|
-
#music .knobs { padding: 6px 12px; display: flex; flex-direction: column; gap: 4px; border-top: 1px solid rgba(255, 255, 255, 0.08); }
|
|
23
|
-
#music .knobs .name { width: 5.5em; opacity: 0.8; }
|
|
24
|
-
#music .knobs label.auto input[type=range] { opacity: 0.35; }
|
|
25
|
-
#music .foot { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 12px 12px; }
|
|
26
|
-
#music.readonly .add, #music.readonly .foot, #music.readonly .del { display: none; }
|
|
27
|
-
#music.readonly textarea, #music.readonly input[type=range] { pointer-events: none; }
|
|
28
|
-
@media (max-width: 700px) { #music { left: 8px; right: 8px; width: auto; top: 8px; bottom: auto; max-height: 50vh; } }
|
|
1
|
+
/* The world's music panel (music.js): descriptions with weights, settings, save — on the scene
|
|
2
|
+
page (listening) and the author's page (tuning). */
|
|
3
|
+
#music { position: fixed; top: 16px; left: 16px; bottom: 16px; width: 360px; display: flex; flex-direction: column; border-radius: 16px; background: rgba(8, 10, 16, 0.86); overflow: hidden; z-index: 3; font-size: 13px; color: #e6e8ee; font-family: Georgia, "Times New Roman", serif; }
|
|
4
|
+
/* the panel brings its own buttons: it sits on the dark scene page and the light author's page alike */
|
|
5
|
+
#music button { border: 1px solid rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.06); color: inherit; font: inherit; font-size: 14px; padding: 4px 12px; border-radius: 999px; cursor: pointer; }
|
|
6
|
+
#music button:hover { background: rgba(255, 255, 255, 0.14); }
|
|
7
|
+
#music[hidden] { display: none; }
|
|
8
|
+
#music .music-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); }
|
|
9
|
+
#music .music-head .title { flex: 1; font-size: 15px; }
|
|
10
|
+
#music .play { min-width: 38px; }
|
|
11
|
+
#music .status { padding: 6px 12px 0; font-size: 12px; font-style: italic; opacity: 0.6; min-height: 1.4em; }
|
|
12
|
+
#music .prompts { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: 8px 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
13
|
+
#music .prompts li { padding: 6px 8px; border-radius: 10px; background: rgba(255, 255, 255, 0.03); opacity: 0.6; }
|
|
14
|
+
#music .prompts li.on { opacity: 1; background: rgba(255, 214, 150, 0.08); }
|
|
15
|
+
#music textarea, #music .add input { width: 100%; box-sizing: border-box; resize: none; field-sizing: content; border: 0; outline: none; background: transparent; color: inherit; font: inherit; line-height: 1.35; }
|
|
16
|
+
#music .row, #music .knobs label { display: flex; align-items: center; gap: 8px; }
|
|
17
|
+
#music input[type=range] { flex: 1; accent-color: #e8c48a; }
|
|
18
|
+
#music .val { width: 3.2em; text-align: right; font-variant-numeric: tabular-nums; opacity: 0.75; font-size: 12px; }
|
|
19
|
+
#music .del { padding: 0 7px; font-size: 12px; }
|
|
20
|
+
#music .add { display: flex; gap: 6px; padding: 8px 12px; border-top: 1px solid rgba(255, 255, 255, 0.08); }
|
|
21
|
+
#music .add input { border-bottom: 1px solid rgba(255, 255, 255, 0.15); }
|
|
22
|
+
#music .knobs { padding: 6px 12px; display: flex; flex-direction: column; gap: 4px; border-top: 1px solid rgba(255, 255, 255, 0.08); }
|
|
23
|
+
#music .knobs .name { width: 5.5em; opacity: 0.8; }
|
|
24
|
+
#music .knobs label.auto input[type=range] { opacity: 0.35; }
|
|
25
|
+
#music .foot { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 12px 12px; }
|
|
26
|
+
#music.readonly .add, #music.readonly .foot, #music.readonly .del { display: none; }
|
|
27
|
+
#music.readonly textarea, #music.readonly input[type=range] { pointer-events: none; }
|
|
28
|
+
@media (max-width: 700px) { #music { left: 8px; right: 8px; width: auto; top: 8px; bottom: auto; max-height: 50vh; } }
|