@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.
- package/README.md +29 -3
- package/cli.js +34 -0
- package/engine/actions.js +69 -0
- package/engine/captions.js +20 -0
- package/engine/directions.js +44 -0
- package/engine/engine.js +1456 -0
- package/engine/engine.json +1 -0
- package/engine/gl.js +1027 -0
- package/engine/kit.js +142 -0
- package/engine/live.js +23 -0
- package/engine/mic.js +76 -0
- package/engine/music.css +28 -0
- package/engine/music.js +279 -0
- package/engine/nav.css +66 -0
- package/engine/render.html +50 -0
- package/engine/scene.css +124 -0
- package/engine/scenestate.js +220 -0
- package/engine/select.js +301 -0
- package/engine/speech.js +150 -0
- package/engine/strip.js +153 -0
- package/engine/viewer.js +216 -0
- package/engine/volume.js +56 -0
- package/engine/widgets.js +88 -0
- package/index.js +70 -0
- package/package.json +13 -6
- package/scene.js +43 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="ru">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>moood · render</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<link rel="stylesheet" href="scene.css">
|
|
8
|
+
<style>html, body { margin: 0; height: 100%; overflow: hidden; background: #000; } #scene { position: fixed; inset: 0; width: 100%; height: 100%; } .titles { right: 0; }</style>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<!-- A scene from its starting viewpoint, no UI — what the render service (services/render)
|
|
12
|
+
screenshots for the artist's look at its own work, the pictures of scenes and assistants (MCP).
|
|
13
|
+
The service hands the
|
|
14
|
+
spec in as window.__mooodSpec; errors the scene's code threw end up in
|
|
15
|
+
window.__mooodRenderErrors; the title turns "ready" when it is time to take the picture. -->
|
|
16
|
+
<canvas id="scene"></canvas>
|
|
17
|
+
<header class="titles"><h1 id="title"></h1><p id="caption"></p></header>
|
|
18
|
+
<script src="kit.js"></script>
|
|
19
|
+
<script src="engine.js"></script>
|
|
20
|
+
<script src="gl.js"></script>
|
|
21
|
+
<script src="captions.js"></script>
|
|
22
|
+
<script src="widgets.js"></script>
|
|
23
|
+
<script src="scenestate.js"></script>
|
|
24
|
+
<script>
|
|
25
|
+
(async () => {
|
|
26
|
+
const errors = [];
|
|
27
|
+
addEventListener('error', e => errors.push(String(e.message)));
|
|
28
|
+
try {
|
|
29
|
+
const spec = window.__mooodSpec;
|
|
30
|
+
const bare = new URLSearchParams(location.search).has('bare'); // a picture for the list: no text
|
|
31
|
+
if (!bare) {
|
|
32
|
+
document.getElementById('title').textContent = spec.title || '';
|
|
33
|
+
document.getElementById('caption').textContent = spec.caption || '';
|
|
34
|
+
}
|
|
35
|
+
const modules = { texts: window.__mooodModules || {} }; // the modules the scene lists, given (kit.js)
|
|
36
|
+
const canvas = document.getElementById('scene'), stage = Moood.mount(canvas);
|
|
37
|
+
stage.show(spec, modules);
|
|
38
|
+
MooodState.start(spec, {}, { stage, modules }); // the starting state, scripts running (their errors are reported)
|
|
39
|
+
if (!bare) MooodWidgets.create({ canvas, stage }).show({ ...spec, widgets: (spec.widgets || []).map(w => ({ ...w, at: 0, for: 0 })) });
|
|
40
|
+
// show every caption at once, so the reviewer reads them all
|
|
41
|
+
if (!bare) MooodCaptions.show((spec.captions || []).map(c => ({ ...c, at: 0, for: 0 })));
|
|
42
|
+
} catch (e) { errors.push(String(e.message || e)); }
|
|
43
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
44
|
+
errors.push(...(window.__mooodErrors || []));
|
|
45
|
+
window.__mooodRenderErrors = errors;
|
|
46
|
+
document.title = 'ready';
|
|
47
|
+
})();
|
|
48
|
+
</script>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
package/engine/scene.css
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/* Any page with a scene (web/scene, shared by every game): the living scene full-screen, its title,
|
|
2
|
+
timed captions, widgets, the lines said over whoever says them, the selection's glow, what can be
|
|
3
|
+
done with a thing picked, a strip of
|
|
4
|
+
the theme's scenes. Each page adds its own (its panels, the microphone). */
|
|
5
|
+
html, body { margin: 0; height: 100%; overflow: hidden; background: #07080c; color: #e6e8ee; font-family: Georgia, "Times New Roman", serif;
|
|
6
|
+
-webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
|
7
|
+
#scene { position: fixed; inset: 0; width: 100%; height: 100%; display: block; transition: opacity 1.4s ease; }
|
|
8
|
+
#scene.away { opacity: 0; }
|
|
9
|
+
a { color: inherit; }
|
|
10
|
+
|
|
11
|
+
.titles { position: fixed; top: 5vh; left: 0; right: 0; padding: 0 16px; text-align: center; pointer-events: none; text-shadow: 0 2px 20px rgba(0, 0, 0, 0.8);
|
|
12
|
+
transition: opacity 3s ease; }
|
|
13
|
+
.titles.gone { opacity: 0; }
|
|
14
|
+
/* in the Android app the app's own bar is over the top (--app-top) */
|
|
15
|
+
body.app .titles { top: calc(var(--app-top, 0px) + 2vh); }
|
|
16
|
+
.titles h1 { margin: 0; font-weight: normal; font-style: italic; font-size: clamp(1.6rem, 4vw, 2.8rem); letter-spacing: 0.04em; }
|
|
17
|
+
.titles p { margin: 0.6em 0 0; font-style: italic; opacity: 0.75; font-size: clamp(0.95rem, 1.6vw, 1.15rem); }
|
|
18
|
+
|
|
19
|
+
/* the selection's glow (select.js) */
|
|
20
|
+
#select-overlay { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
|
21
|
+
|
|
22
|
+
/* what can be done with the thing picked (actions.js): beside it */
|
|
23
|
+
#actions { position: fixed; left: 0; top: 0; z-index: 4; display: flex; flex-direction: column; gap: 3px; min-width: 150px; max-width: 280px;
|
|
24
|
+
padding: 8px; border-radius: 12px; background: rgba(10, 12, 18, 0.86); font: 15px/1.35 Georgia, 'Times New Roman', serif; color: #eee7da;
|
|
25
|
+
box-shadow: 0 6px 28px rgba(0, 0, 0, 0.45); }
|
|
26
|
+
#actions[hidden] { display: none; }
|
|
27
|
+
#actions b { padding: 2px 8px 4px; font-weight: normal; font-style: italic; font-size: 13px; opacity: 0.6; }
|
|
28
|
+
#actions button { border: 0; border-radius: 8px; padding: 6px 10px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
29
|
+
#actions button:hover, #actions button:focus-visible { background: rgba(255, 228, 170, 0.14); outline: none; }
|
|
30
|
+
|
|
31
|
+
/* timed captions (spec.captions) */
|
|
32
|
+
#captions { position: fixed; left: 0; right: 0; bottom: 14vh; z-index: 2; pointer-events: none; text-align: center; }
|
|
33
|
+
#captions p { margin: 0.4em auto; max-width: 42em; padding: 0 16px; font: italic 400 clamp(17px, 2.1vw, 26px)/1.4 Georgia, 'Times New Roman', serif;
|
|
34
|
+
color: rgba(240, 236, 228, 0.92); text-shadow: 0 1px 14px rgba(0, 0, 0, 0.55); opacity: 0; transition: opacity 2.4s ease; }
|
|
35
|
+
#captions p:not(.on) { position: absolute; left: 0; right: 0; }
|
|
36
|
+
#captions p.on { opacity: 1; }
|
|
37
|
+
|
|
38
|
+
/* widgets (spec.widgets) */
|
|
39
|
+
#widgets { position: fixed; inset: 0; z-index: 1; pointer-events: none; overflow: hidden; }
|
|
40
|
+
#widgets .widget { position: absolute; left: 0; top: 0; transform-origin: 0 0; opacity: 0; transition: opacity 1.2s ease; }
|
|
41
|
+
#widgets .widget.on { opacity: 1; }
|
|
42
|
+
|
|
43
|
+
/* lines said (speech.js): over the one who says it, glimmering while heard; the narrator's low in
|
|
44
|
+
the middle */
|
|
45
|
+
#speech { position: fixed; inset: 0; z-index: 2; pointer-events: none; overflow: hidden; }
|
|
46
|
+
#speech .bubble { position: absolute; left: 0; top: 0; max-width: min(340px, 40vw); display: flex; gap: 6px; align-items: flex-start;
|
|
47
|
+
padding: 8px 12px; border-radius: 14px; background: rgba(12, 14, 22, 0.78); border: 1px solid rgba(255, 255, 255, 0.14);
|
|
48
|
+
color: rgba(244, 240, 232, 0.95); font: 15px/1.4 Georgia, 'Times New Roman', serif; transition: opacity 1.2s ease, box-shadow 0.3s; }
|
|
49
|
+
#speech .bubble.narrator { max-width: min(640px, 80vw); background: rgba(12, 14, 22, 0.55); border-color: transparent; font-style: italic; font-size: 17px; }
|
|
50
|
+
#speech .bubble p { margin: 0; }
|
|
51
|
+
#speech .bubble button { pointer-events: auto; flex: none; border: 0; background: none; color: inherit; opacity: 0.6; cursor: pointer; font-size: 12px; padding: 2px; }
|
|
52
|
+
#speech .bubble button:hover { opacity: 1; }
|
|
53
|
+
#speech .bubble.speaking { animation: speaking 1.4s ease-in-out infinite; }
|
|
54
|
+
#speech .bubble.fading { opacity: 0; }
|
|
55
|
+
#speech .bubble.pending { padding: 4px 12px; letter-spacing: 0.2em; animation: working 1.2s ease-in-out infinite; }
|
|
56
|
+
@keyframes speaking { 50% { box-shadow: 0 0 18px rgba(255, 220, 160, 0.45); border-color: rgba(255, 220, 160, 0.6); } }
|
|
57
|
+
@keyframes working { 50% { opacity: 0.55; } }
|
|
58
|
+
|
|
59
|
+
/* the microphone over what the player pressed on: hold to speak */
|
|
60
|
+
#mic { position: fixed; left: 0; top: 0; z-index: 3; width: 64px; height: 64px; margin: -32px 0 0 -32px; border-radius: 50%;
|
|
61
|
+
border: 1px solid rgba(255, 228, 160, 0.6); background: rgba(12, 14, 22, 0.72); color: rgba(255, 236, 200, 0.95);
|
|
62
|
+
display: grid; place-items: center; cursor: pointer; touch-action: none; transition: transform 0.2s, background 0.2s, opacity 0.3s; }
|
|
63
|
+
#mic svg { width: 26px; height: 26px; }
|
|
64
|
+
#mic[hidden] { display: none; }
|
|
65
|
+
#mic.on { transform: scale(1.18); background: rgba(150, 40, 30, 0.75); border-color: rgba(255, 170, 140, 0.9); animation: listening 1.1s ease-in-out infinite; }
|
|
66
|
+
#mic.busy { opacity: 0.5; }
|
|
67
|
+
@keyframes listening { 50% { box-shadow: 0 0 0 12px rgba(255, 140, 110, 0.15); } }
|
|
68
|
+
|
|
69
|
+
/* what the player said, as it is heard */
|
|
70
|
+
#heard { position: fixed; left: 50%; bottom: 6vh; z-index: 3; transform: translateX(-50%); max-width: min(640px, 86vw); padding: 8px 16px;
|
|
71
|
+
border-radius: 14px; background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.92); font: 16px/1.4 Georgia, serif;
|
|
72
|
+
pointer-events: none; opacity: 0; transition: opacity 0.4s; }
|
|
73
|
+
#heard.on { opacity: 1; }
|
|
74
|
+
#heard.partial { color: rgba(255, 255, 255, 0.6); font-style: italic; }
|
|
75
|
+
|
|
76
|
+
/* the theme's scenes and what is being drawn (strip.js), low on the right */
|
|
77
|
+
#strip { position: fixed; right: 12px; bottom: 12px; z-index: 3; display: flex; gap: 6px; max-width: 46vw; overflow-x: auto; padding: 4px; }
|
|
78
|
+
#strip[hidden] { display: none; }
|
|
79
|
+
#strip a { flex: none; display: block; width: 80px; aspect-ratio: 16 / 9; border-radius: 6px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.18);
|
|
80
|
+
background: rgba(12, 14, 22, 0.6); opacity: 0.75; transition: opacity 0.2s, border-color 0.2s; }
|
|
81
|
+
#strip a:hover { opacity: 1; }
|
|
82
|
+
#strip a.here { opacity: 1; border-color: rgba(255, 228, 170, 0.85); }
|
|
83
|
+
#strip img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
84
|
+
#strip img[hidden] { display: none; }
|
|
85
|
+
/* a scene being drawn: its sketch as it is now, marked; waiting ones dashed */
|
|
86
|
+
#strip a.sketch { position: relative; opacity: 1; border-color: rgba(255, 196, 120, 0.85); animation: drawing 1.6s ease-in-out infinite; }
|
|
87
|
+
#strip a.sketch.waiting { border-style: dashed; animation: none; opacity: 0.75; }
|
|
88
|
+
#strip .badge { position: absolute; left: 0; right: 0; bottom: 0; padding: 1px 4px; font: italic 10px/1.3 Georgia, serif; color: #ffe3c0;
|
|
89
|
+
background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); }
|
|
90
|
+
@keyframes drawing { 50% { box-shadow: 0 0 0 2px rgba(255, 176, 100, 0.35); } }
|
|
91
|
+
|
|
92
|
+
/* around a scene on a page (the crumbs top-left: nav.css): the game stumbling, what a scene was
|
|
93
|
+
asked for, a drawing's work */
|
|
94
|
+
|
|
95
|
+
/* the master stumbled (an error in its turn) */
|
|
96
|
+
#trouble { position: fixed; left: 14px; bottom: 12px; z-index: 3; max-width: 60vw; font: italic 13px/1.4 Georgia, serif; color: rgba(255, 170, 150, 0.85);
|
|
97
|
+
pointer-events: none; opacity: 0; transition: opacity 0.6s; }
|
|
98
|
+
#trouble.on { opacity: 1; }
|
|
99
|
+
|
|
100
|
+
#about { position: fixed; left: 14px; top: 12px; z-index: 4; max-width: min(520px, 80vw); max-height: 70vh; overflow: auto; padding: 8px 12px;
|
|
101
|
+
border-radius: 12px; background: rgba(12, 14, 22, 0.78); font: 13px/1.45 Georgia, serif; color: rgba(240, 236, 228, 0.9); }
|
|
102
|
+
#about summary { cursor: pointer; font-style: italic; opacity: 0.8; }
|
|
103
|
+
#about .note { white-space: pre-wrap; opacity: 0.75; font-size: 12px; }
|
|
104
|
+
#crumbs ~ #about, #crumbs ~ #work { top: 48px; }
|
|
105
|
+
|
|
106
|
+
/* a scene being drawn (drawing.html): what was asked, its state, what the artist says as it works */
|
|
107
|
+
#work { position: fixed; left: 14px; top: 12px; z-index: 4; width: min(420px, 80vw); max-height: 60vh; display: flex; flex-direction: column; gap: 6px;
|
|
108
|
+
padding: 10px 14px; border-radius: 12px; background: rgba(12, 14, 22, 0.8); font: 13px/1.45 Georgia, serif; color: rgba(240, 236, 228, 0.92); }
|
|
109
|
+
#work .state { margin: 0; font-style: italic; color: #ffcf9a; }
|
|
110
|
+
#work.drawing .state::before { content: '● '; animation: working 1.2s ease-in-out infinite; }
|
|
111
|
+
#work .asked { margin: 0; opacity: 0.8; max-height: 9em; overflow: auto; }
|
|
112
|
+
#work ol { margin: 0; padding: 0; list-style: none; overflow: auto; display: flex; flex-direction: column; gap: 3px; font-size: 12px; }
|
|
113
|
+
#work li { opacity: 0.85; }
|
|
114
|
+
#work li.tool { opacity: 0.45; font-family: ui-monospace, Consolas, monospace; font-size: 11px; }
|
|
115
|
+
|
|
116
|
+
/* the ways the scene can change (directions.js): a panel to try them */
|
|
117
|
+
#directions { position: fixed; left: 16px; bottom: 16px; z-index: 3; display: flex; flex-direction: column; gap: 6px; padding: 10px 12px;
|
|
118
|
+
border-radius: 12px; background: rgba(8, 10, 16, 0.78); font-size: 13px; -webkit-user-select: none; user-select: none; }
|
|
119
|
+
#directions[hidden] { display: none; }
|
|
120
|
+
#directions .direction { white-space: nowrap; display: grid; grid-template-columns: 15em auto 10em auto; align-items: center; gap: 6px; }
|
|
121
|
+
#directions .direction.blocked { opacity: 0.4; }
|
|
122
|
+
#directions button { border: 0; background: none; color: inherit; cursor: pointer; font-size: 12px; opacity: 0.75; }
|
|
123
|
+
#directions button:hover:not(:disabled) { opacity: 1; }
|
|
124
|
+
#directions input { width: 100%; accent-color: #e8b86a; }
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// The scene's state and its scripts — the scene's own mechanics, knowing nothing of the game
|
|
2
|
+
// played on it. The state is a set of values (spec.state holds where they start: "time": "20:00",
|
|
3
|
+
// "dawn": 0, "girl": "waiting" …) — the scene's parameters. Whatever changes them, the scene reacts:
|
|
4
|
+
// scripts — spec.script and any element's `script`: JS run in the page (a clock that ticks, a
|
|
5
|
+
// ship that passes at 00:50, someone walking to where the state says);
|
|
6
|
+
// the game — whatever runs on the scene sets them (its agents, its master): the page passes them on;
|
|
7
|
+
// widgets — MooodState is global: get / set / on.
|
|
8
|
+
// The painting code reads the current values as e.state and is repainted when they change. A
|
|
9
|
+
// number may be set to move there over some seconds, along an easing curve (kit.js: smooth by
|
|
10
|
+
// default — it starts and stops gently; linear, in, out, back, spring, anticipate…). What a value
|
|
11
|
+
// was a moment ago stays known for a while (was): what lags behind — a coat's tail, hair, a
|
|
12
|
+
// head that follows — reads it. A script may tell that something happened (scene.tell): the
|
|
13
|
+
// game decides what it means.
|
|
14
|
+
const MooodState = (() => {
|
|
15
|
+
let stage = null, tell = null, save = null, t0 = performance.now();
|
|
16
|
+
let values = {}, cur = {}, tweens = new Map(), listeners = [], timers = [], raf = 0, pending = {}, flushTimer = 0;
|
|
17
|
+
// each number's recent moves, to say what it was a moment ago: key → [{start, end, from, to, curve}]
|
|
18
|
+
// (a jump: start = end); kept for PAST seconds
|
|
19
|
+
const PAST = 10;
|
|
20
|
+
// the engine's own values (engine.js, gl.js: the camera a film moves, its fade): 0 until set
|
|
21
|
+
const ENGINE = { camera_x: 0, camera_h: 0, camera_z: 0, camera_look: 0, fade: 0 };
|
|
22
|
+
let past = new Map();
|
|
23
|
+
const report = (who, err) => (window.__mooodErrors = window.__mooodErrors || []).push(`script ${who}: ${err && err.message || err}`);
|
|
24
|
+
|
|
25
|
+
// where a move is at `now` (a move along a path: where its path is, `way` 0…1 of it)
|
|
26
|
+
const along = (m, now) => {
|
|
27
|
+
if (now >= m.end) return m.to;
|
|
28
|
+
if (now <= m.start) return m.from;
|
|
29
|
+
const u = m.curve((now - m.start) / (m.end - m.start));
|
|
30
|
+
return m.way ? m.way(u) : m.from + (m.to - m.from) * u;
|
|
31
|
+
};
|
|
32
|
+
// what a number was `ago` seconds ago (as it is now if it hasn't moved since)
|
|
33
|
+
function was(key, ago = 0) {
|
|
34
|
+
const now = performance.now(), at = now - ago * 1000, moves = past.get(key);
|
|
35
|
+
if (!moves || !moves.length) return cur[key];
|
|
36
|
+
for (let i = moves.length - 1; i >= 0; i--) if (moves[i].start <= at) return along(moves[i], at);
|
|
37
|
+
return moves[0].from;
|
|
38
|
+
}
|
|
39
|
+
function remember(key, move) {
|
|
40
|
+
const moves = past.get(key) || [], gone = move.start - PAST * 1000;
|
|
41
|
+
while (moves.length > 1 && moves[1].end < gone) moves.shift();
|
|
42
|
+
moves.push(move);
|
|
43
|
+
past.set(key, moves);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function publish() { cur = { ...values }; for (const [k, tw] of tweens) cur[k] = along(tw, performance.now()); stage?.setState(cur, was, sample); }
|
|
47
|
+
// the moving values, where they are now (the stage asks as it paints a frame)
|
|
48
|
+
function sample() {
|
|
49
|
+
if (!tweens.size) return;
|
|
50
|
+
const now = performance.now();
|
|
51
|
+
for (const [k, tw] of tweens) if (now >= tw.end) tweens.delete(k);
|
|
52
|
+
publish();
|
|
53
|
+
}
|
|
54
|
+
function tick() {
|
|
55
|
+
raf = 0;
|
|
56
|
+
sample();
|
|
57
|
+
if (tweens.size) raf = requestAnimationFrame(tick);
|
|
58
|
+
}
|
|
59
|
+
function emit(ev, key, ...args) {
|
|
60
|
+
for (const l of listeners.slice()) {
|
|
61
|
+
if (l.ev !== ev || (l.key != null && l.key !== key)) continue;
|
|
62
|
+
try { l.fn(...args); } catch (err) { report(l.who, err); }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// changes go to the game's keeping (if it keeps them), a few at a time
|
|
66
|
+
function keep(changes) {
|
|
67
|
+
if (!save) return;
|
|
68
|
+
Object.assign(pending, changes);
|
|
69
|
+
clearTimeout(flushTimer);
|
|
70
|
+
flushTimer = setTimeout(() => { const body = pending; pending = {}; save(body); }, 500);
|
|
71
|
+
}
|
|
72
|
+
// set a value; a number with `over` seconds moves there along `ease` (a curve's name, or u → u);
|
|
73
|
+
// null removes it. kept: false — the game set it and keeps it itself
|
|
74
|
+
function set(key, value, over = 0, ease = 'smooth', { kept = true } = {}) {
|
|
75
|
+
key = String(key);
|
|
76
|
+
const old = key in cur ? cur[key] : key in values ? values[key] : ENGINE[key];
|
|
77
|
+
if (value === undefined) value = null;
|
|
78
|
+
const now = performance.now();
|
|
79
|
+
if (typeof value === 'number' && typeof old === 'number' && over > 0 && value !== old) {
|
|
80
|
+
const move = { start: now, end: now + over * 1000, from: old, to: value, curve: MooodKit.anim.easeOf(ease) };
|
|
81
|
+
tweens.set(key, move);
|
|
82
|
+
remember(key, move);
|
|
83
|
+
if (!raf) raf = requestAnimationFrame(tick);
|
|
84
|
+
} else {
|
|
85
|
+
tweens.delete(key);
|
|
86
|
+
if (typeof value === 'number') remember(key, { start: now, end: now, from: typeof old === 'number' ? old : value, to: value, curve: MooodKit.anim.ease.linear });
|
|
87
|
+
}
|
|
88
|
+
if (value === null) delete values[key]; else values[key] = value;
|
|
89
|
+
publish();
|
|
90
|
+
if (kept) keep({ [key]: value });
|
|
91
|
+
if (JSON.stringify(old) !== JSON.stringify(value)) emit('change', key, value, old, key);
|
|
92
|
+
}
|
|
93
|
+
// several numbers moved together through points, as one movement: keys ['cart_a', 'cart_d'], points
|
|
94
|
+
// [[a, d], …] (from where they are now); over `over` seconds or at `speed` (their units a second,
|
|
95
|
+
// along the way), along `ease` — so it slows nowhere but where the curve says (a path cut into
|
|
96
|
+
// set()s stops at every corner). Returns its seconds.
|
|
97
|
+
function path(keys, points, { over = 0, speed = 0, ease = 'smooth' } = {}) {
|
|
98
|
+
keys = keys.map(String);
|
|
99
|
+
const from = keys.map(k => Number(k in cur ? cur[k] : k in values ? values[k] : ENGINE[k]) || 0);
|
|
100
|
+
const pts = [from, ...points.map(p => keys.map((_, i) => Number(p[i]) || 0))], to = pts[pts.length - 1];
|
|
101
|
+
const ends = [0];
|
|
102
|
+
for (let i = 1; i < pts.length; i++) ends.push(ends[i - 1] + Math.hypot(...pts[i].map((v, j) => v - pts[i - 1][j])));
|
|
103
|
+
const length = ends[ends.length - 1], seconds = over > 0 ? over : speed > 0 ? length / speed : 0;
|
|
104
|
+
if (!(seconds > 0 && length > 0)) { keys.forEach((k, i) => set(k, to[i])); return 0; }
|
|
105
|
+
const at = (u, j) => {
|
|
106
|
+
const d = u * length;
|
|
107
|
+
let i = 1;
|
|
108
|
+
while (i < ends.length - 1 && ends[i] < d) i++;
|
|
109
|
+
const f = (d - ends[i - 1]) / ((ends[i] - ends[i - 1]) || 1);
|
|
110
|
+
return pts[i - 1][j] + (pts[i][j] - pts[i - 1][j]) * f;
|
|
111
|
+
};
|
|
112
|
+
const now = performance.now(), curve = MooodKit.anim.easeOf(ease), old = keys.map(k => cur[k]);
|
|
113
|
+
keys.forEach((k, j) => {
|
|
114
|
+
const move = { start: now, end: now + seconds * 1000, from: from[j], to: to[j], curve, way: u => at(u, j) };
|
|
115
|
+
tweens.set(k, move);
|
|
116
|
+
remember(k, move);
|
|
117
|
+
values[k] = to[j];
|
|
118
|
+
});
|
|
119
|
+
if (!raf) raf = requestAnimationFrame(tick);
|
|
120
|
+
publish();
|
|
121
|
+
keep(Object.fromEntries(keys.map((k, j) => [k, to[j]])));
|
|
122
|
+
keys.forEach((k, j) => { if (old[j] !== to[j]) emit('change', k, to[j], old[j], k); });
|
|
123
|
+
return seconds;
|
|
124
|
+
}
|
|
125
|
+
// an angle in degrees, turned the short way round (from 170 to −170 is 20°, not 340°)
|
|
126
|
+
function turn(key, degrees, over = 0, ease = 'smooth') {
|
|
127
|
+
const old = Number(key in cur ? cur[key] : values[key]) || 0;
|
|
128
|
+
set(key, old + ((((degrees - old) % 360) + 540) % 360 - 180), over, ease);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// the API a script gets: for the scene (id null) or for one element
|
|
132
|
+
function api(who, id, element, shared) {
|
|
133
|
+
const own = fn => (...a) => { try { fn(...a); } catch (err) { report(who, err); } };
|
|
134
|
+
const after = (seconds, fn) => { const h = setTimeout(own(fn), Math.max(0, seconds) * 1000); timers.push(() => clearTimeout(h)); };
|
|
135
|
+
return {
|
|
136
|
+
id, element,
|
|
137
|
+
anim: MooodKit.anim, kit: shared.kit, lib: shared.lib,
|
|
138
|
+
get state() { return cur; },
|
|
139
|
+
get time() { return (performance.now() - t0) / 1000; }, // seconds since the scene opened
|
|
140
|
+
get: key => cur[key],
|
|
141
|
+
set: (key, value, over, ease) => set(key, value, over, ease),
|
|
142
|
+
turn: (key, degrees, over, ease) => turn(key, degrees, over, ease),
|
|
143
|
+
path: (keys, points, opts) => path(keys, points, opts),
|
|
144
|
+
was: (key, ago) => was(key, ago),
|
|
145
|
+
// a platformer's hero now: { across, elevation (metres), ground } — or null
|
|
146
|
+
hero: () => stage?.hero?.() ?? null,
|
|
147
|
+
// something happened in the scene (about one of its elements, if `about`): the game hears it
|
|
148
|
+
tell: (text, about = null) => tell?.(String(text), about),
|
|
149
|
+
every(seconds, fn) { const h = setInterval(own(fn), Math.max(0.05, seconds) * 1000); timers.push(() => clearInterval(h)); },
|
|
150
|
+
after,
|
|
151
|
+
// a timeline: rows [at, key, value, over?, ease?] — or [at, fn] — where `at` is the second it
|
|
152
|
+
// starts (from now), or '+s': s seconds after the row before it ends ('+0': as it ends); a
|
|
153
|
+
// key in degrees turned the short way: [at, key, {turn: degrees}, over?, ease?]; numbers
|
|
154
|
+
// moved together through points (path, above): [at, [keys], {path: points}, over, ease?].
|
|
155
|
+
// Returns when the last row ends (seconds from now).
|
|
156
|
+
play(rows) {
|
|
157
|
+
let end = 0, last = 0;
|
|
158
|
+
for (const row of rows) {
|
|
159
|
+
const [at, a, b, over = 0, ease] = row;
|
|
160
|
+
const start = typeof at === 'string' && at.startsWith('+') ? last + Number(at.slice(1)) : Number(at);
|
|
161
|
+
if (typeof a === 'function') after(start, a);
|
|
162
|
+
else if (Array.isArray(a) && b && typeof b === 'object' && Array.isArray(b.path)) after(start, () => path(a, b.path, { over, ease }));
|
|
163
|
+
else if (b && typeof b === 'object' && 'turn' in b) after(start, () => turn(a, b.turn, over, ease));
|
|
164
|
+
else after(start, () => set(a, b, over, ease));
|
|
165
|
+
last = start + (typeof a === 'function' ? 0 : over);
|
|
166
|
+
end = Math.max(end, last);
|
|
167
|
+
}
|
|
168
|
+
return end;
|
|
169
|
+
},
|
|
170
|
+
// on('change', fn(value, old, key)) · on('change', key, fn) · on('load', fn(state))
|
|
171
|
+
on(ev, a, b) {
|
|
172
|
+
const fn = typeof a === 'function' ? a : b, key = typeof a === 'function' ? null : String(a);
|
|
173
|
+
listeners.push({ ev, key, fn, who });
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// (re)starts the scene: its starting values, `state` on top (what the game kept), then the
|
|
179
|
+
// scripts — once what they share (the world's modules, the scene's lib) is here. opts: stage;
|
|
180
|
+
// modules — where the spec's modules come from (kit.js: {world, refs} or {texts});
|
|
181
|
+
// tell(text, about) — what the scripts tell; save(changes) — the game keeps the values the scene
|
|
182
|
+
// changes (none: they live as long as the page)
|
|
183
|
+
let run = 0;
|
|
184
|
+
function start(spec, state = {}, opts = {}) {
|
|
185
|
+
stop();
|
|
186
|
+
stage = opts.stage || stage;
|
|
187
|
+
tell = opts.tell || null;
|
|
188
|
+
save = opts.save || null;
|
|
189
|
+
values = { ...(spec?.state && typeof spec.state === 'object' ? spec.state : {}), ...state };
|
|
190
|
+
past = new Map();
|
|
191
|
+
t0 = performance.now();
|
|
192
|
+
publish();
|
|
193
|
+
emit('load', null, cur);
|
|
194
|
+
const mine = ++run;
|
|
195
|
+
const go = shared => {
|
|
196
|
+
if (mine !== run) return; // started again meanwhile
|
|
197
|
+
const exec = (code, who, id, element) => {
|
|
198
|
+
if (typeof code !== 'string' || !code.trim()) return;
|
|
199
|
+
try { new Function('scene', code)(api(who, id, element, shared)); } catch (err) { report(who, err); }
|
|
200
|
+
};
|
|
201
|
+
exec(spec?.script, 'scene', null, null);
|
|
202
|
+
for (const o of spec?.objects || []) exec(o.script, o.name || o.id, o.id || null, o);
|
|
203
|
+
};
|
|
204
|
+
const shared = MooodKit.shared(spec, opts.modules);
|
|
205
|
+
if (shared instanceof Promise) shared.then(go); else go(shared);
|
|
206
|
+
}
|
|
207
|
+
function stop() {
|
|
208
|
+
for (const off of timers) off();
|
|
209
|
+
// the scripts' listeners go; widgets' stay (a widget lives on while its html is the same)
|
|
210
|
+
timers = []; listeners = listeners.filter(l => l.who === 'widget'); tweens.clear();
|
|
211
|
+
cancelAnimationFrame(raf); raf = 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
start, stop, set, turn, path, was,
|
|
216
|
+
get: key => cur[key],
|
|
217
|
+
all: () => cur,
|
|
218
|
+
on: (ev, a, b) => api('widget', null, null, { kit: {}, lib: {} }).on(ev, a, b),
|
|
219
|
+
};
|
|
220
|
+
})();
|