@7ots/cli 0.1.4 → 0.1.6
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 +14 -20
- package/cli/7ots.mjs +55 -5
- package/cli/lib/account.mjs +89 -0
- package/cli/lib/assistant.mjs +122 -16
- package/cli/lib/brain.mjs +41 -11
- package/cli/lib/card-shot.mjs +84 -0
- package/cli/lib/config.mjs +28 -5
- package/cli/lib/i18n.mjs +57 -0
- package/cli/lib/inferences.mjs +76 -0
- package/cli/lib/integrations.mjs +414 -0
- package/cli/lib/orquesta.mjs +53 -0
- package/cli/lib/pet-core.mjs +7 -10
- package/cli/lib/pet-server.mjs +365 -22
- package/cli/lib/screen.mjs +125 -0
- package/cli/lib/stt-local.py +14 -0
- package/cli/lib/stt.mjs +68 -0
- package/cli/lib/tts-local.mjs +110 -0
- package/cli/lib/tts-local.py +18 -0
- package/cli/lib/vm.mjs +309 -0
- package/cli/lib/watchers.mjs +151 -0
- package/cli/pet/card.html +294 -0
- package/cli/pet/electron/director.cjs +175 -0
- package/cli/pet/electron/main.cjs +515 -59
- package/cli/pet/pet.html +414 -21
- package/cli/pet/settings.html +991 -57
- package/dist/7ots.esm.js +3 -3
- package/dist/7ots.esm.js.map +3 -3
- package/dist/7ots.iife.js +3 -3
- package/dist/7ots.iife.js.map +3 -3
- package/llms.txt +31 -17
- package/package.json +1 -1
- package/server/llm.mjs +2 -0
- package/server/platform/connect.mjs +3 -1
- package/server/platform/db.mjs +77 -0
- package/server/platform/device.mjs +125 -0
- package/server/platform/orquesta.mjs +5 -0
- package/server/platform/routes.mjs +100 -1
- package/server/platform/sites.mjs +146 -0
- package/server/platform/store.mjs +3 -1
- package/server/platform/vm.mjs +386 -0
- package/src/character3d/index.js +2 -1
- package/src/character3d/stage.js +15 -4
- package/src/i18n/messages/character.js +3 -3
- package/src/i18n/messages/platform.js +42 -3
package/cli/pet/pet.html
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
5
6
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
7
|
<title>7ots</title>
|
|
7
8
|
<style>
|
|
@@ -114,21 +115,41 @@
|
|
|
114
115
|
.ask input:focus { border-color: var(--accent); }
|
|
115
116
|
/* ── desktop: transparent frameless window walking along the bottom of the screen ── */
|
|
116
117
|
html:has(body.desktop:not(.opaque)) { background: transparent; } /* html also paints --bg: without this the window is a white box */
|
|
117
|
-
body.desktop { background: transparent; padding: 0; place-items: end center; height: 100vh; overflow: hidden;
|
|
118
|
+
body.desktop { background: transparent; padding: 0; place-items: end center; height: 100vh; overflow: hidden; user-select: none; } /* dragged by hand (see 'drag' below): a drag region would swallow clicks and double clicks */
|
|
118
119
|
body.desktop .app { width: 100%; background: transparent; border: 0; box-shadow: none; border-radius: 0; overflow: visible; }
|
|
119
120
|
body.desktop .top, body.desktop .who, body.desktop .level, body.desktop .stats, body.desktop .feed, body.desktop .foot { display: none; }
|
|
120
121
|
body.desktop .hero { background: none; padding: 0 0 6px; }
|
|
121
122
|
body.desktop .stage { width: 140px; height: 148px; }
|
|
122
123
|
body.desktop .stage.flip .face { transform: scaleX(-1); }
|
|
124
|
+
/* scenes with other ots (electron/director.cjs): the acts move .act; props (fireballs, beams, sabers, bullets) */
|
|
125
|
+
.actor { position: absolute; inset: 0; z-index: 1; transform-origin: 50% 92%; }
|
|
126
|
+
.props { position: absolute; inset: 0; z-index: 3; pointer-events: none; overflow: visible; }
|
|
127
|
+
.props > * { position: absolute; }
|
|
128
|
+
.props .ball { left: 50%; top: 42%; width: 34px; height: 34px; margin: -17px; border-radius: 50%; background: radial-gradient(circle at 40% 40%, #fff 0 18%, #8fe3ff 30%, #2a7bff 62%, transparent 72%); box-shadow: 0 0 22px 8px #4cb8ff; }
|
|
129
|
+
.props .beam { left: 50%; top: 40%; height: 26px; margin-top: -13px; border-radius: 13px; background: linear-gradient(180deg, #bfefff, #fff 45%, #fff 55%, #7fd6ff); box-shadow: 0 0 24px 10px #49b6ff, 0 0 60px 18px #1f6bff88; }
|
|
130
|
+
.props .bullet { top: 34%; width: 14px; height: 6px; margin-top: -3px; border-radius: 3px; background: linear-gradient(90deg, #6b5a2b, #e9cf7a); }
|
|
131
|
+
.props .ring { top: 34%; width: 18px; height: 18px; margin: -9px; border-radius: 50%; border: 2px solid rgb(180 220 255 / .8); }
|
|
132
|
+
.props .saber { left: 50%; top: 50%; width: 6px; height: 92px; margin-left: -3px; transform-origin: 50% 100%; border-radius: 4px; background: #fff; }
|
|
133
|
+
.props .flash { top: 44%; width: 30px; height: 30px; margin: -15px; border-radius: 50%; background: radial-gradient(#fff, #ffd35a 40%, #ff7a1a 60%, transparent 70%); }
|
|
134
|
+
.props .pow { left: 50%; top: 6%; font: 900 22px/1 system-ui, sans-serif; color: #ffd400; -webkit-text-stroke: 2px #1a1020; transform: translateX(-50%); white-space: nowrap; }
|
|
135
|
+
/* a visiting ot: its own little menu (play, say goodbye, settings, move); feeding, talking and sleep are the host's */
|
|
136
|
+
body.guest .ask, body.guest #talkBtn, body.guest #micBtn, body.guest #sleepBtn { display: none !important; }
|
|
137
|
+
.bye { display: none; }
|
|
138
|
+
body.desktop.guest .bye { display: flex; }
|
|
123
139
|
body.desktop .bubble { max-width: 220px; font-size: 12.5px; padding: 8px 12px; }
|
|
124
|
-
body.desktop .actions { position: absolute; right:
|
|
140
|
+
body.desktop .actions { position: absolute; right: auto; left: calc(50% + 66px); bottom: 14px; transform: translateX(6px); display: flex; flex-direction: column; gap: 4px; padding: 4px; border-radius: 99px;
|
|
125
141
|
background: color-mix(in srgb, var(--panel) 86%, transparent); border: 1px solid var(--line); box-shadow: var(--shadow); backdrop-filter: blur(10px);
|
|
126
|
-
opacity: 0; transition:
|
|
127
|
-
body.desktop:hover .actions { opacity: 1; transform: none; }
|
|
142
|
+
opacity: 0; transition: none; /* a transition here never finished painting in the transparent window: the menu stayed invisible */ -webkit-app-region: no-drag; z-index: 5; } /* above the 3D stage canvas (z 3) */
|
|
143
|
+
body.desktop:hover .actions, body.desktop.hover .actions, body.desktop.menu .actions { opacity: 1; transform: none; } /* .hover: set by the shell, which sees the cursor even when no mouse event reaches the page */
|
|
128
144
|
body.desktop .act { width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 0; background: transparent; }
|
|
129
145
|
body.desktop .act span { display: none; }
|
|
146
|
+
.grip { display: none; }
|
|
147
|
+
body.desktop .grip { display: flex; cursor: grab; touch-action: none; }
|
|
148
|
+
body.desktop .grip:active { cursor: grabbing; }
|
|
130
149
|
body.desktop .act.primary { background: var(--accent); }
|
|
131
150
|
body.desktop .badge { display: flex; }
|
|
151
|
+
body.listening #micBtn { background: var(--err); color: #fff; animation: rec 1s ease-in-out infinite; }
|
|
152
|
+
@keyframes rec { 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--err) 30%, transparent); } }
|
|
132
153
|
body.desktop .ask { display: none; position: absolute; left: 6px; right: 6px; top: 6px; margin: 0; z-index: 4; -webkit-app-region: no-drag; }
|
|
133
154
|
body.desktop.talking .ask { display: flex; }
|
|
134
155
|
.badge { display: none; position: absolute; z-index: 2; left: -6px; bottom: 18px; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 99px; align-items: center; justify-content: center;
|
|
@@ -152,7 +173,8 @@
|
|
|
152
173
|
<div class="stage" id="stage">
|
|
153
174
|
<div class="bubble" id="bubble" role="status" aria-live="polite"></div>
|
|
154
175
|
<span class="zz" aria-hidden="true">z</span>
|
|
155
|
-
<div class="face" id="face"></div>
|
|
176
|
+
<div class="actor" id="act"><div class="face" id="face"></div></div>
|
|
177
|
+
<div class="props" id="props" aria-hidden="true"></div>
|
|
156
178
|
<div class="floor" aria-hidden="true"></div>
|
|
157
179
|
<span class="badge" id="badge"></span>
|
|
158
180
|
</div>
|
|
@@ -173,9 +195,11 @@
|
|
|
173
195
|
<nav class="actions">
|
|
174
196
|
<button class="act primary" data-act="feed"><svg viewBox="0 0 24 24"><path d="M12 7c-1.5-2.5-6-2.5-7 1.5S7 20 9.5 20c1.2 0 1.6-.6 2.5-.6s1.3.6 2.5.6C17 20 20 12.5 19 8.5S13.5 4.5 12 7z"/><path d="M12 7c0-2 1-3.5 3-4"/></svg><span data-t="feed"></span></button>
|
|
175
197
|
<button class="act" id="talkBtn" type="button"><svg viewBox="0 0 24 24"><path d="M5 6h14v9H9l-4 3z"/></svg><span data-t="talk"></span></button>
|
|
176
|
-
<button class="act"
|
|
198
|
+
<button class="act" id="micBtn" type="button"><svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/></svg><span data-t="listen"></span></button>
|
|
177
199
|
<button class="act" data-act="sleep" id="sleepBtn"><svg viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></svg><span data-t="sleep"></span></button>
|
|
178
200
|
<a class="act" id="setBtn" target="_blank" rel="noopener"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1"/></svg><span data-t="settings"></span></a>
|
|
201
|
+
<button class="act bye" id="byeBtn" type="button"><svg viewBox="0 0 24 24"><path d="M15 4h4v16h-4M10 8l-4 4 4 4M6 12h10"/></svg></button>
|
|
202
|
+
<button class="act grip" id="moveBtn" type="button" title="⇔"><svg viewBox="0 0 24 24"><path d="M12 3v18M3 12h18M12 3l-3 3M12 3l3 3M12 21l-3-3M12 21l3-3M3 12l3-3M3 12l3 3M21 12l-3-3M21 12l-3 3"/></svg></button>
|
|
179
203
|
</nav>
|
|
180
204
|
|
|
181
205
|
<section class="feed" aria-labelledby="feed-h">
|
|
@@ -189,17 +213,17 @@
|
|
|
189
213
|
<script type="module">
|
|
190
214
|
const BOOT = __BOOT__;
|
|
191
215
|
const T = {
|
|
192
|
-
en: { settings: 'Settings', talk: 'Talk', askPh: 'Remind me to check email in 10 min…', feed: '
|
|
216
|
+
en: { settings: 'Settings', dismiss: 'Send home', listen: 'Listen', listening: 'Listening… 🎙️', noStt: 'Voice control needs an OpenAI or ElevenLabs key (Settings).', noMic: 'I can’t reach the microphone.', sttErr: 'I didn’t catch that.', talk: 'Talk', askPh: 'Remind me to check email in 10 min…', feed: 'Treat', play: 'Play', sleep: 'Sleep', wake: 'Wake', food: 'Food', energy: 'Energy', fun: 'Fun', level: 'Level {l}', xp: '{x} / {n} xp', live: 'Live', off: 'Reconnecting…',
|
|
193
217
|
activity: 'Activity', empty: 'Nothing yet. Run a command or ask your coding agent something.', tally: '{ok} ok · {fail} failed · turns {turns}',
|
|
194
218
|
brain: 'Brain: {b}', age: 'Born {d}', lines: 'built-in phrases', now: 'now', m: '{n}m', h: '{n}h',
|
|
195
219
|
moods: { happy: 'Happy', neutral: 'Calm', sad: 'Sad', disgust: 'Bored', sleep: 'Asleep', thinking: 'Thinking' },
|
|
196
220
|
kind: { tool: 'Tool', shell: 'Shell', prompt: 'You asked', stop: 'Turn finished', notify: 'Needs you', start: 'Session started' } },
|
|
197
|
-
es: { settings: 'Ajustes', talk: 'Hablar', askPh: 'Recuérdame ver el correo en 10 min…', feed: '
|
|
221
|
+
es: { settings: 'Ajustes', dismiss: 'Despedir', listen: 'Escuchar', listening: 'Te escucho… 🎙️', noStt: 'Para hablarme por voz falta una key de OpenAI o ElevenLabs (Ajustes).', noMic: 'No puedo usar el micrófono.', sttErr: 'No te entendí.', talk: 'Hablar', askPh: 'Recuérdame ver el correo en 10 min…', feed: 'Mimar', play: 'Jugar', sleep: 'Dormir', wake: 'Despertar', food: 'Comida', energy: 'Energía', fun: 'Diversión', level: 'Nivel {l}', xp: '{x} / {n} xp', live: 'En vivo', off: 'Reconectando…',
|
|
198
222
|
activity: 'Actividad', empty: 'Nada todavía. Ejecuta un comando o pregúntale algo a tu agente.', tally: '{ok} ok · {fail} fallos · turnos {turns}',
|
|
199
223
|
brain: 'Cerebro: {b}', age: 'Nació {d}', lines: 'frases propias', now: 'ahora', m: '{n} min', h: '{n} h',
|
|
200
224
|
moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Aburrido', sleep: 'Dormido', thinking: 'Pensando' },
|
|
201
225
|
kind: { tool: 'Herramienta', shell: 'Terminal', prompt: 'Preguntaste', stop: 'Turno terminado', notify: 'Te necesita', start: 'Sesión iniciada' } },
|
|
202
|
-
pt: { settings: 'Ajustes', talk: 'Falar', askPh: 'Me lembre de ver o e-mail em 10 min…', feed: '
|
|
226
|
+
pt: { settings: 'Ajustes', dismiss: 'Despedir', listen: 'Ouvir', listening: 'Estou ouvindo… 🎙️', noStt: 'Para falar por voz falta uma key da OpenAI ou ElevenLabs (Ajustes).', noMic: 'Não consigo usar o microfone.', sttErr: 'Não entendi.', talk: 'Falar', askPh: 'Me lembre de ver o e-mail em 10 min…', feed: 'Mimar', play: 'Brincar', sleep: 'Dormir', wake: 'Acordar', food: 'Comida', energy: 'Energia', fun: 'Diversão', level: 'Nível {l}', xp: '{x} / {n} xp', live: 'Ao vivo', off: 'Reconectando…',
|
|
203
227
|
activity: 'Atividade', empty: 'Nada ainda. Rode um comando ou pergunte algo ao seu agente.', tally: '{ok} ok · {fail} falhas · turnos {turns}',
|
|
204
228
|
brain: 'Cérebro: {b}', age: 'Nasceu {d}', lines: 'frases próprias', now: 'agora', m: '{n} min', h: '{n} h',
|
|
205
229
|
moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Entediado', sleep: 'Dormindo', thinking: 'Pensando' },
|
|
@@ -211,6 +235,8 @@
|
|
|
211
235
|
const $ = (s) => document.querySelector(s);
|
|
212
236
|
const id = BOOT.identity;
|
|
213
237
|
const desktop = new URLSearchParams(location.search).has('desktop');
|
|
238
|
+
const guest = BOOT.guest || ''; // a visiting ot: no state, no voice, only the scenes
|
|
239
|
+
document.body.classList.toggle('guest', !!guest);
|
|
214
240
|
|
|
215
241
|
document.documentElement.lang = lang;
|
|
216
242
|
document.body.classList.toggle('desktop', desktop);
|
|
@@ -237,7 +263,7 @@
|
|
|
237
263
|
// three comes from the CDN; without WebGL or network the 2D ot takes over
|
|
238
264
|
try {
|
|
239
265
|
const m = await loadCharacter3D();
|
|
240
|
-
ch = m.createCharacter3D($('#face'), spec, { material: 'plush', backdrop: !desktop, orbit: !desktop, stage: { key: 'pet', zIndex: 3, maxDpr: 2 } });
|
|
266
|
+
ch = m.createCharacter3D($('#face'), spec, { material: 'plush', backdrop: !desktop, orbit: !desktop, bleed: desktop ? 'window' : 0, stage: { key: 'pet', zIndex: 3, maxDpr: 2, minDpr: 1.25, supersample: desktop } });
|
|
241
267
|
if (ch.fallback) console.warn('[7ots pet] no WebGL: 2D ot');
|
|
242
268
|
} catch (err) {
|
|
243
269
|
console.warn('[7ots pet] 3D ot unavailable, using 2D:', err?.message || err);
|
|
@@ -246,13 +272,106 @@
|
|
|
246
272
|
}
|
|
247
273
|
ch ||= createCharacter($('#face'), spec, { locale: lang, title: id.name });
|
|
248
274
|
const is3d = !!ch.character;
|
|
249
|
-
|
|
275
|
+
// While a gesture/effect/morph plays the ot needs room around it (wings, hearts, confetti): the desktop
|
|
276
|
+
// shell reads window.__need() and sizes the window to what is really on screen.
|
|
277
|
+
let fxUntil = 0;
|
|
278
|
+
let actUntil = 0; // a scene act is playing: it needs actRoom px on each side (a beam, a saber swing)
|
|
279
|
+
let actRoom = 0;
|
|
280
|
+
for (const k of ['gesture', 'effect', 'morph']) {
|
|
281
|
+
const f = ch[k]?.bind(ch);
|
|
282
|
+
if (!f) continue;
|
|
283
|
+
try {
|
|
284
|
+
ch[k] = (n, o) => ((fxUntil = Math.max(fxUntil, Date.now() + Math.max(2600, o?.ms || 0) + 400)), tellNeed(), f(n, o));
|
|
285
|
+
} catch {}
|
|
286
|
+
}
|
|
287
|
+
// Effects the character starts on its own (a mood's steam, a gesture's sparkles) skip the wrapper: they show
|
|
288
|
+
// up as children of its fx layers, so those count as "playing" too.
|
|
289
|
+
const fxLayers = () => $('#face').querySelectorAll('[data-p="fx"], [data-p="fxback"], [data-p="fxface"]');
|
|
290
|
+
const fxOn = () => Date.now() < fxUntil || [...fxLayers()].some((g) => g.childElementCount);
|
|
291
|
+
// What the figure really covers (arms out, a raised hand, a hop): the union of the drawn parts, which can
|
|
292
|
+
// stick out of the stage. The window is centred on the ot, so the width is twice the widest side.
|
|
293
|
+
const drawn = () => {
|
|
294
|
+
const parts = $('#face').querySelectorAll('svg > g, svg > use, svg > path');
|
|
295
|
+
let l = Infinity, t = Infinity, r = -Infinity;
|
|
296
|
+
for (const e of parts) {
|
|
297
|
+
const b = e.getBoundingClientRect();
|
|
298
|
+
if (!b.width && !b.height) continue;
|
|
299
|
+
l = Math.min(l, b.left), t = Math.min(t, b.top), r = Math.max(r, b.right);
|
|
300
|
+
}
|
|
301
|
+
return l === Infinity ? null : { l, t, r };
|
|
302
|
+
};
|
|
303
|
+
window.__need = () => {
|
|
304
|
+
const st = $('#stage').getBoundingClientRect();
|
|
305
|
+
// playing: room up front (hearts rise, confetti flies), not grown pixel by pixel as they move
|
|
306
|
+
const on = fxOn();
|
|
307
|
+
let w = st.width + (on ? 72 : 12) * 2;
|
|
308
|
+
let h = st.height + 6 + (on ? 96 : 12);
|
|
309
|
+
const d = drawn();
|
|
310
|
+
if (d) {
|
|
311
|
+
const cx = st.left + st.width / 2;
|
|
312
|
+
w = Math.max(w, 2 * Math.max(cx - d.l, d.r - cx) + 24);
|
|
313
|
+
h = Math.max(h, innerHeight - d.t + 12);
|
|
314
|
+
}
|
|
315
|
+
const b = $('#bubble');
|
|
316
|
+
if (b.classList.contains('on')) {
|
|
317
|
+
w = Math.max(w, b.offsetWidth + 24);
|
|
318
|
+
h = Math.max(h, st.height + 6 + b.offsetHeight + 16);
|
|
319
|
+
}
|
|
320
|
+
if (document.body.classList.contains('talking')) (w = Math.max(w, 240)), (h = Math.max(h, st.height + 60));
|
|
321
|
+
if (Date.now() < actUntil) (w = Math.max(w, actRoom * 2)), (h = Math.max(h, st.height + 70));
|
|
322
|
+
return { w: Math.ceil(w), h: Math.ceil(h) };
|
|
323
|
+
};
|
|
324
|
+
// The shell polls __need every 250 ms; when an effect starts it's told at once (a console line it listens
|
|
325
|
+
// for), so the window is big before the first frame that needs it instead of cutting it off.
|
|
326
|
+
let toldNeed = '';
|
|
327
|
+
function tellNeed() {
|
|
328
|
+
if (!desktop) return;
|
|
329
|
+
requestAnimationFrame(() => {
|
|
330
|
+
const n = JSON.stringify(window.__need());
|
|
331
|
+
if (n !== toldNeed) (toldNeed = n), console.debug('\u00a77need' + n);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
if (!is3d) {
|
|
335
|
+
const mo = new MutationObserver(tellNeed);
|
|
336
|
+
for (const g of fxLayers()) mo.observe(g, { childList: true });
|
|
337
|
+
}
|
|
338
|
+
// Drag: the page tells the shell when the button goes down and up on the ot; the shell moves the window
|
|
339
|
+
// with the cursor in between. A click that doesn't move stays a click.
|
|
340
|
+
const onOt = (e) => {
|
|
341
|
+
const r = $('#stage').getBoundingClientRect();
|
|
342
|
+
return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
|
|
343
|
+
};
|
|
344
|
+
if (desktop) {
|
|
345
|
+
let down = false;
|
|
346
|
+
const up = () => down && ((down = false), console.debug('\u00a77drag' + JSON.stringify({ on: false })));
|
|
347
|
+
// on the document: in 3D the ot is drawn on a canvas above the page, so events don't target #stage
|
|
348
|
+
addEventListener('pointerdown', (e) => {
|
|
349
|
+
// on the ot itself, or on the move handle in the menu (a sure grip while the ot is mid-animation)
|
|
350
|
+
const grip = e.target.closest?.('#moveBtn');
|
|
351
|
+
if (e.button !== 0 || (!grip && (e.target.closest?.('.actions, .ask, a, button') || !onOt(e)))) return;
|
|
352
|
+
down = true;
|
|
353
|
+
document.documentElement.setPointerCapture?.(e.pointerId);
|
|
354
|
+
console.debug('\u00a77drag' + JSON.stringify({ on: true }));
|
|
355
|
+
});
|
|
356
|
+
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) addEventListener(ev, up);
|
|
357
|
+
addEventListener('blur', up);
|
|
358
|
+
}
|
|
359
|
+
let lookFree = 0; // while > now, the eyes wander on their own (a glance) instead of following the cursor
|
|
360
|
+
addEventListener('pointermove', (e) => Date.now() > lookFree && ch.lookAtPoint(e.clientX, e.clientY));
|
|
250
361
|
$('#face').addEventListener('click', () => ch.gesture('bounce'));
|
|
251
362
|
|
|
252
363
|
const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': BOOT.token }, body: JSON.stringify(body) });
|
|
253
364
|
let asleep = false;
|
|
365
|
+
// something urgent while asleep: it wakes up for a moment (says it, walks over), then dozes off again
|
|
366
|
+
let stirUntil = 0, stirT = 0;
|
|
367
|
+
function stir() {
|
|
368
|
+
stirUntil = Date.now() + 30000;
|
|
369
|
+
document.body.classList.remove('asleep');
|
|
370
|
+
clearTimeout(stirT);
|
|
371
|
+
stirT = setTimeout(() => last && render(last), 30500);
|
|
372
|
+
}
|
|
254
373
|
document.querySelectorAll('[data-act]').forEach((b) =>
|
|
255
|
-
b.addEventListener('click', () => post('/event', { type: b.dataset.act === 'sleep' && asleep ? 'wake' : b.dataset.act })),
|
|
374
|
+
b.addEventListener('click', () => guest ? (b.dataset.act === 'feed' && postMessage({ gesture: 'celebrate' })) : post('/event', { type: b.dataset.act === 'sleep' && asleep ? 'wake' : b.dataset.act })),
|
|
256
375
|
);
|
|
257
376
|
|
|
258
377
|
// ── state ──
|
|
@@ -276,8 +395,9 @@
|
|
|
276
395
|
function render(s) {
|
|
277
396
|
last = s;
|
|
278
397
|
asleep = s.asleep;
|
|
279
|
-
|
|
280
|
-
|
|
398
|
+
const stirred = asleep && Date.now() < stirUntil;
|
|
399
|
+
document.body.classList.toggle('asleep', asleep && !stirred);
|
|
400
|
+
ch.mood(stirred ? 'neutral' : s.mood);
|
|
281
401
|
paintMood();
|
|
282
402
|
$('#sleepBtn [data-t]').textContent = t(asleep ? 'wake' : 'sleep');
|
|
283
403
|
$('#sleepBtn').setAttribute('aria-label', t(asleep ? 'wake' : 'sleep'));
|
|
@@ -386,8 +506,35 @@
|
|
|
386
506
|
clearTimeout(hideT);
|
|
387
507
|
if (ms) hideT = setTimeout(() => bubble.classList.remove('on'), ms);
|
|
388
508
|
}
|
|
389
|
-
|
|
509
|
+
// What it says shows on its face too: a gesture/effect picked from the words (when the server sent none).
|
|
510
|
+
const REACT = [
|
|
511
|
+
[/(ja){2,}|(ha){2,}|jeje|lol|kkk|😂|🤣/i, 'laugh', null],
|
|
512
|
+
[/gracias|thank|obrigad|te quiero|love you|❤|💜|lindo|cute/i, 'bow', 'hearts'],
|
|
513
|
+
[/error|fail|fall[óo]|rompi|broke|oops|ups|ay no|uh.?oh/i, 'shake', 'sweat'],
|
|
514
|
+
[/listo|done|logrado|genial|great|bravo|yay|bien hecho|nice|perfect|✓|✅|🎉/i, 'celebrate', 'confetti'],
|
|
515
|
+
[/hambre|hungry|fome|comida|food|🍎/i, 'point', 'thought'],
|
|
516
|
+
[/sueño|tired|cansad|dormir|sleep|nap|zz/i, 'yawn', null],
|
|
517
|
+
[/miedo|scary|assust|fear|😱/i, 'shiver', 'sweat'],
|
|
518
|
+
[/enojad|angry|bravo con|grr|😠/i, 'stomp', 'anger'],
|
|
519
|
+
[/hola|hello|hi\b|hey|oi\b|buen(os|as) d[ií]as|good morning/i, 'wave', null],
|
|
520
|
+
[/[?¿]\s*$/, 'think', 'question'],
|
|
521
|
+
[/!\s*$/, 'bounce', 'exclaim'],
|
|
522
|
+
];
|
|
523
|
+
function react(text) {
|
|
524
|
+
for (const [re, g, fx] of REACT) {
|
|
525
|
+
if (!re.test(text)) continue;
|
|
526
|
+
if (g) ch.gesture(g);
|
|
527
|
+
if (fx) ch.effect?.(fx);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
ch.gesture(Math.random() < 0.5 ? 'nod' : 'wiggle');
|
|
531
|
+
}
|
|
532
|
+
function say(text, withFace = false) {
|
|
390
533
|
showBubble(document.createTextNode(text), Math.max(4500, text.length * 90));
|
|
534
|
+
talk(true, text); // the mouth moves with the words even without a voice; a real voice re-syncs it
|
|
535
|
+
clearTimeout(say.t);
|
|
536
|
+
say.t = setTimeout(() => talk(false), 500 + text.length * 60);
|
|
537
|
+
if (withFace) react(text);
|
|
391
538
|
speak(text);
|
|
392
539
|
}
|
|
393
540
|
function setThinking(on) {
|
|
@@ -404,19 +551,44 @@
|
|
|
404
551
|
|
|
405
552
|
// ── live updates ──
|
|
406
553
|
const live = $('#live');
|
|
554
|
+
// The daemon restarts (another ot): wait for it, then reload with the new identity.
|
|
555
|
+
async function waitAndReload() {
|
|
556
|
+
for (let i = 0; i < 60; i++) {
|
|
557
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
558
|
+
// the new daemon, not the one still letting go of the port (it would serve the old ot again)
|
|
559
|
+
const h = await fetch('/health').then((r) => (r.ok ? r.json() : null), () => null);
|
|
560
|
+
if (h && h.pid !== BOOT.pid) return location.reload();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// The view is in the URL (?3d): on the desktop the shell reloads it (and relaunches when the 3D ot needs
|
|
564
|
+
// the GPU it started without); in a browser tab the page reloads itself with the new URL.
|
|
565
|
+
function setView(on) {
|
|
566
|
+
const q = new URLSearchParams(location.search);
|
|
567
|
+
if (q.has('3d') === on) return false;
|
|
568
|
+
if (desktop) return console.debug('§7view' + (on ? '3d' : '2d')), true;
|
|
569
|
+
on ? q.set('3d', '1') : q.delete('3d');
|
|
570
|
+
location.replace(`${location.pathname}?${q}`);
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
407
573
|
function connect() {
|
|
408
574
|
const es = new EventSource(`/events?token=${encodeURIComponent(BOOT.token)}`);
|
|
409
575
|
es.onopen = () => { live.classList.add('on'); live.textContent = t('live'); };
|
|
410
576
|
es.onerror = () => { live.classList.remove('on'); live.textContent = t('off'); };
|
|
411
577
|
es.onmessage = (e) => {
|
|
412
578
|
const m = JSON.parse(e.data);
|
|
579
|
+
if (m.reload) return location.reload(); // e.g. the voice changed in the settings
|
|
580
|
+
if (m.restart) return waitAndReload(); // it became another ot: a new daemon is starting
|
|
581
|
+
if (m.view && setView(m.view === '3d')) return; // 2D ↔ 3D changed in the settings
|
|
582
|
+
if (guest) return; // the host's events are the host's
|
|
583
|
+
if (m.stir || (m.say && asleep && Date.now() < stirUntil)) stir(); // awake until it has said it
|
|
413
584
|
if (m.state) render(m.state);
|
|
414
585
|
if ('thinking' in m) setThinking(!!m.thinking);
|
|
415
586
|
if (m.activity) addActivity(m.activity);
|
|
416
587
|
if (m.gesture) ch.gesture(m.gesture);
|
|
417
588
|
if (m.morph) ch.morph?.(m.morph);
|
|
418
589
|
if (m.effect) ch.effect?.(m.effect);
|
|
419
|
-
if (m.say) say(m.say);
|
|
590
|
+
if (m.say) say(m.say, !m.gesture);
|
|
591
|
+
if (m.point && desktop) window.__goal = m.point; // the desktop shell walks it over there
|
|
420
592
|
};
|
|
421
593
|
}
|
|
422
594
|
live.textContent = t('off');
|
|
@@ -428,12 +600,23 @@
|
|
|
428
600
|
askIn.placeholder = t('askPh');
|
|
429
601
|
$('#setBtn').href = `/settings?token=${encodeURIComponent(BOOT.token)}`;
|
|
430
602
|
$('#setBtn').title = t('settings');
|
|
603
|
+
$('#byeBtn').title = t('dismiss');
|
|
604
|
+
$('#byeBtn').addEventListener('click', () => console.debug('\u00a77bye')); // the shell sends the visitor home
|
|
431
605
|
const talking = (on) => {
|
|
432
606
|
document.body.classList.toggle('talking', on);
|
|
433
607
|
if (on) askIn.focus();
|
|
434
608
|
};
|
|
435
609
|
$('#talkBtn').addEventListener('click', () => talking(!document.body.classList.contains('talking') || !desktop));
|
|
436
|
-
|
|
610
|
+
// double click: the menu stays out (for a while, or until another double click); talking is its 💬 button
|
|
611
|
+
let menuT = 0;
|
|
612
|
+
addEventListener('dblclick', (e) => {
|
|
613
|
+
if (!onOt(e) || e.target.closest?.('.actions, .ask')) return;
|
|
614
|
+
console.log('[7ots] menu');
|
|
615
|
+
const on = !document.body.classList.contains('menu');
|
|
616
|
+
document.body.classList.toggle('menu', on);
|
|
617
|
+
clearTimeout(menuT);
|
|
618
|
+
if (on) menuT = setTimeout(() => document.body.classList.remove('menu'), 8000);
|
|
619
|
+
});
|
|
437
620
|
askIn.addEventListener('keydown', (e) => e.key === 'Escape' && talking(false));
|
|
438
621
|
ask.addEventListener('submit', async (e) => {
|
|
439
622
|
e.preventDefault();
|
|
@@ -446,12 +629,222 @@
|
|
|
446
629
|
});
|
|
447
630
|
addEventListener('message', (e) => e.data?.talk && talking(true));
|
|
448
631
|
|
|
449
|
-
//
|
|
632
|
+
// ── voice: record until you stop talking, transcribe (/stt), then it's like typing it (/ask) ──
|
|
633
|
+
let rec = null;
|
|
634
|
+
async function listen() {
|
|
635
|
+
if (rec) return rec.state === 'recording' && rec.stop();
|
|
636
|
+
if (!BOOT.stt) return say(t('noStt'));
|
|
637
|
+
let stream;
|
|
638
|
+
try {
|
|
639
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
|
|
640
|
+
} catch {
|
|
641
|
+
return say(t('noMic'));
|
|
642
|
+
}
|
|
643
|
+
const type = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : '';
|
|
644
|
+
const chunks = [];
|
|
645
|
+
rec = new MediaRecorder(stream, type ? { mimeType: type } : {});
|
|
646
|
+
rec.ondataavailable = (e) => e.data.size && chunks.push(e.data);
|
|
647
|
+
const ac = new AudioContext();
|
|
648
|
+
const an = ac.createAnalyser();
|
|
649
|
+
an.fftSize = 1024;
|
|
650
|
+
ac.createMediaStreamSource(stream).connect(an);
|
|
651
|
+
const buf = new Float32Array(an.fftSize);
|
|
652
|
+
const t0 = Date.now();
|
|
653
|
+
let heard = false;
|
|
654
|
+
let quiet = t0;
|
|
655
|
+
let peak = 0;
|
|
656
|
+
const iv = setInterval(() => {
|
|
657
|
+
an.getFloatTimeDomainData(buf);
|
|
658
|
+
let sum = 0;
|
|
659
|
+
for (const x of buf) sum += x * x;
|
|
660
|
+
const now = Date.now();
|
|
661
|
+
const level = Math.sqrt(sum / buf.length);
|
|
662
|
+
if (level > 0.015) (heard = true), (quiet = now);
|
|
663
|
+
peak = Math.max(peak, level);
|
|
664
|
+
// stops after 1.3 s of silence, 6 s without a word, or 15 s in all
|
|
665
|
+
if ((heard && now - quiet > 1300) || (!heard && now - t0 > 6000) || now - t0 > 15000) rec?.state === 'recording' && rec.stop();
|
|
666
|
+
}, 80);
|
|
667
|
+
rec.onstop = async () => {
|
|
668
|
+
clearInterval(iv);
|
|
669
|
+
stream.getTracks().forEach((x) => x.stop());
|
|
670
|
+
ac.close().catch(() => {});
|
|
671
|
+
rec = null;
|
|
672
|
+
document.body.classList.remove('listening');
|
|
673
|
+
console.log('[7ots voice] stopped', { heard, peak: peak.toFixed(3), chunks: chunks.length, ms: Date.now() - t0 });
|
|
674
|
+
if (!chunks.length) return bubble.classList.remove('on');
|
|
675
|
+
if (!heard && peak < 0.004) return say(t('noMic')); // silence all along: wrong or muted microphone
|
|
676
|
+
setThinking(true);
|
|
677
|
+
const blob = new Blob(chunks, { type: chunks[0].type || 'audio/webm' });
|
|
678
|
+
const r = await fetch('/stt', { method: 'POST', headers: { 'Content-Type': blob.type, 'X-7ots-Token': BOOT.token }, body: blob })
|
|
679
|
+
.then((x) => x.json())
|
|
680
|
+
.catch(() => null);
|
|
681
|
+
setThinking(false);
|
|
682
|
+
const text = r?.text?.trim();
|
|
683
|
+
if (!text) return say(r?.error && !/stt/.test(r.error) ? t('noStt') : t('sttErr'));
|
|
684
|
+
showBubble(document.createTextNode(`🎙️ “${text}”`), 2500);
|
|
685
|
+
post('/ask', { text });
|
|
686
|
+
};
|
|
687
|
+
rec.onerror = (e) => console.warn('[7ots voice] recorder', e.error?.message || e);
|
|
688
|
+
const hard = setTimeout(() => rec?.state === 'recording' && rec.stop(), 15000); // whatever the level meter says
|
|
689
|
+
rec.addEventListener('stop', () => clearTimeout(hard));
|
|
690
|
+
ac.resume().catch(() => {});
|
|
691
|
+
rec.start(250);
|
|
692
|
+
console.log('[7ots voice] listening', rec.mimeType, ac.state);
|
|
693
|
+
document.body.classList.add('listening');
|
|
694
|
+
showBubble(document.createTextNode(t('listening')), 0);
|
|
695
|
+
ch.gesture('nod');
|
|
696
|
+
lookFree = Date.now() + 2000;
|
|
697
|
+
ch.lookAt(0, 0);
|
|
698
|
+
}
|
|
699
|
+
$('#micBtn').addEventListener('click', listen);
|
|
700
|
+
|
|
701
|
+
// The desktop shell tells us how we move: facing (walk -1..1), hops, being dropped, following, the cursor.
|
|
702
|
+
addEventListener('message', (e) => {
|
|
703
|
+
const d = e.data || {};
|
|
704
|
+
if (d.walk != null) {
|
|
705
|
+
if (is3d) ch.character.yaw = -d.walk * 0.9; // turns toward where it walks, smoothly
|
|
706
|
+
else $('#stage').classList.toggle('flip', d.walk < 0);
|
|
707
|
+
}
|
|
708
|
+
if (d.hop) ch.gesture('bounce');
|
|
709
|
+
if (d.errand) ch.effect?.('exclaim');
|
|
710
|
+
if (d.point) {
|
|
711
|
+
lookFree = Date.now() + 3500;
|
|
712
|
+
ch.lookAtPoint(d.at[0], d.at[1]);
|
|
713
|
+
ch.gesture('point', { ms: 2200 });
|
|
714
|
+
}
|
|
715
|
+
if (d.listen) listen();
|
|
716
|
+
if (d.dropped) (ch.gesture('dizzy'), ch.effect?.('dizzy'));
|
|
717
|
+
if (d.follow) ch.effect?.(Math.random() < 0.6 ? 'hearts' : 'question');
|
|
718
|
+
if (d.look && Date.now() > lookFree) ch.lookAtPoint(d.look[0], d.look[1]); // the cursor anywhere on screen
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// ── scenes with other ots: the shell's director sends lines, gestures and acts to each one ──
|
|
722
|
+
const act = $('#act');
|
|
723
|
+
const props = $('#props');
|
|
724
|
+
const prop = (cls, css = {}, text = '') => {
|
|
725
|
+
const e = Object.assign(document.createElement('div'), { className: cls, textContent: text });
|
|
726
|
+
Object.assign(e.style, css);
|
|
727
|
+
props.append(e);
|
|
728
|
+
return e;
|
|
729
|
+
};
|
|
730
|
+
const gone = (e, a) => a.finished.then(() => e.remove(), () => e.remove());
|
|
731
|
+
const ACTS = {
|
|
732
|
+
guard: () => act.animate([{ transform: 'none' }, { transform: 'translateY(-5px) rotate(-3deg)' }, { transform: 'none' }, { transform: 'translateY(-5px) rotate(3deg)' }, { transform: 'none' }], { duration: 700, iterations: 3 }),
|
|
733
|
+
jab: (d) => act.animate([{ transform: 'none' }, { transform: `translateX(${d * 30}px) rotate(${d * 7}deg) scaleX(1.06)`, offset: 0.35 }, { transform: 'none' }], { duration: 280, easing: 'ease-out' }),
|
|
734
|
+
uppercut: (d) => act.animate([{ transform: 'none' }, { transform: `translate(${-d * 6}px, 8px) rotate(${-d * 8}deg)`, offset: 0.3 }, { transform: `translate(${d * 22}px, -30px) rotate(${d * 16}deg)`, offset: 0.6 }, { transform: 'none' }], { duration: 650, easing: 'ease-out' }),
|
|
735
|
+
hit: (d) => act.animate([{ transform: 'none' }, { transform: `translateX(${-d * 16}px) rotate(${-d * 12}deg)`, offset: 0.25 }, { transform: 'none' }], { duration: 420 }),
|
|
736
|
+
pow: (d, o) => {
|
|
737
|
+
const e = prop('pow', {}, o?.text || 'POW!');
|
|
738
|
+
return gone(e, e.animate([{ opacity: 0, transform: 'translateX(-50%) scale(.3)' }, { opacity: 1, transform: 'translateX(-50%) scale(1.25) rotate(-8deg)', offset: 0.3 }, { opacity: 0, transform: 'translate(-50%, -16px) scale(1)' }], { duration: 900 }));
|
|
739
|
+
},
|
|
740
|
+
// Matrix: bending back in slow motion while the bullets go by
|
|
741
|
+
lean: (d) => {
|
|
742
|
+
const a = act.animate([{ transform: 'none' }, { transform: `rotate(${-d * 74}deg) translateX(${-d * 8}px)`, offset: 0.22 }, { transform: `rotate(${-d * 80}deg) translateX(${-d * 10}px)`, offset: 0.78 }, { transform: 'none' }], { duration: 3200, easing: 'cubic-bezier(.3,.7,.3,1)' });
|
|
743
|
+
for (let i = 0; i < 3; i++) {
|
|
744
|
+
const y = `${26 + i * 7}%`;
|
|
745
|
+
const from = d > 0 ? `calc(100% + ${actRoom}px)` : `-${actRoom}px`;
|
|
746
|
+
const to = d > 0 ? `-${actRoom}px` : `calc(100% + ${actRoom}px)`;
|
|
747
|
+
const b = prop('bullet', { top: y, left: from });
|
|
748
|
+
gone(b, b.animate([{ left: from }, { left: to }], { duration: 2400, delay: 500 + i * 260, easing: 'linear', fill: 'both' }));
|
|
749
|
+
const r = prop('ring', { top: y, left: from, opacity: 0 });
|
|
750
|
+
gone(r, r.animate([{ left: from, opacity: 0 }, { left: '50%', opacity: 1, transform: 'scale(1)' }, { left: to, opacity: 0, transform: 'scale(2.6)' }], { duration: 2400, delay: 500 + i * 260, fill: 'both' }));
|
|
751
|
+
}
|
|
752
|
+
return a;
|
|
753
|
+
},
|
|
754
|
+
fall: (d) => act.animate([{ transform: 'none' }, { transform: `rotate(${-d * 90}deg) translateY(10px)`, offset: 0.2 }, { transform: `rotate(${-d * 90}deg) translateY(10px)`, offset: 0.8 }, { transform: 'none' }], { duration: 2600, easing: 'ease-in-out' }),
|
|
755
|
+
charge: () => act.animate([{ transform: 'none', filter: 'none' }, { transform: 'translate(-2px,1px)', filter: 'drop-shadow(0 0 10px #59c4ff)' }, { transform: 'translate(2px,-1px)', filter: 'drop-shadow(0 0 18px #59c4ff)' }, { transform: 'none', filter: 'none' }], { duration: 160, iterations: 9 }),
|
|
756
|
+
hadouken: (d) => {
|
|
757
|
+
const b = prop('ball');
|
|
758
|
+
gone(b, b.animate([{ transform: 'translateX(0) scale(.4)' }, { transform: `translateX(${d * (actRoom - 10)}px) scale(1.15)` }], { duration: 650, easing: 'ease-in' }));
|
|
759
|
+
return act.animate([{ transform: 'none' }, { transform: `translateX(${d * 12}px)`, offset: 0.2 }, { transform: 'none' }], { duration: 700 });
|
|
760
|
+
},
|
|
761
|
+
beam: (d) => {
|
|
762
|
+
const b = prop('beam', d > 0 ? { left: '50%' } : { right: '50%', left: 'auto' });
|
|
763
|
+
gone(b, b.animate([{ width: '0px', opacity: 1 }, { width: `${actRoom}px`, opacity: 1, offset: 0.25 }, { width: `${actRoom}px`, opacity: 1, offset: 0.8 }, { width: `${actRoom}px`, opacity: 0 }], { duration: 1800 }));
|
|
764
|
+
return act.animate([{ transform: 'none' }, { transform: `translateX(${-d * 6}px)` }], { duration: 120, iterations: 14, direction: 'alternate' });
|
|
765
|
+
},
|
|
766
|
+
saber: (d, o) => {
|
|
767
|
+
const c = o?.color || '#ff3b3b';
|
|
768
|
+
const s = prop('saber', { background: `linear-gradient(90deg, ${c}, #fff 40%, #fff 60%, ${c})`, boxShadow: `0 0 12px 4px ${c}`, left: `calc(50% + ${d * 34}px)` });
|
|
769
|
+
return gone(s, s.animate([{ transform: `rotate(${-d * 40}deg)` }, { transform: `rotate(${d * 50}deg)` }, { transform: `rotate(${-d * 20}deg)` }, { transform: `rotate(${d * 70}deg)` }, { transform: `rotate(${-d * 40}deg)` }], { duration: 2400, easing: 'ease-in-out' }));
|
|
770
|
+
},
|
|
771
|
+
bang: (d) => {
|
|
772
|
+
const f = prop('flash', { left: `calc(50% + ${d * 46}px)` });
|
|
773
|
+
return gone(f, f.animate([{ opacity: 1, transform: 'scale(.4)' }, { opacity: 0, transform: 'scale(1.6)' }], { duration: 260 }));
|
|
774
|
+
},
|
|
775
|
+
twist: () => act.animate([{ transform: 'rotate(-12deg) translateX(-6px)' }, { transform: 'rotate(12deg) translateX(6px)' }], { duration: 330, iterations: 10, direction: 'alternate', easing: 'ease-in-out' }),
|
|
776
|
+
shove: (d) => act.animate([{ transform: 'none' }, { transform: `translateX(${d * 24}px) scaleX(1.1)`, offset: 0.4 }, { transform: 'none' }], { duration: 380 }),
|
|
777
|
+
};
|
|
450
778
|
addEventListener('message', (e) => {
|
|
451
|
-
|
|
452
|
-
if (
|
|
453
|
-
|
|
779
|
+
const d = e.data || {};
|
|
780
|
+
if (d.hush) (bubble.classList.remove('on'), clearTimeout(say.t), talk(false)); // the other ot's turn to talk
|
|
781
|
+
if (d.line) {
|
|
782
|
+
showBubble(document.createTextNode(d.line), d.ms || Math.max(2200, d.line.length * 70));
|
|
783
|
+
talk(true, d.line);
|
|
784
|
+
clearTimeout(say.t);
|
|
785
|
+
say.t = setTimeout(() => talk(false), 400 + d.line.length * 55);
|
|
786
|
+
}
|
|
787
|
+
if (d.gesture) ch.gesture(d.gesture, d.gms ? { ms: d.gms } : undefined);
|
|
788
|
+
if (d.effect) ch.effect?.(d.effect);
|
|
789
|
+
if (d.act && ACTS[d.act]) {
|
|
790
|
+
actRoom = d.room || 150;
|
|
791
|
+
actUntil = Date.now() + (d.ms || 3600);
|
|
792
|
+
tellNeed();
|
|
793
|
+
requestAnimationFrame(() => ACTS[d.act](d.dir || 1, d));
|
|
794
|
+
}
|
|
795
|
+
if (d.face != null) {
|
|
796
|
+
if (is3d) ch.character.yaw = -d.face * 0.9;
|
|
797
|
+
else $('#stage').classList.toggle('flip', d.face < 0);
|
|
798
|
+
lookFree = Date.now() + 1500;
|
|
799
|
+
ch.lookAt?.(d.face * 0.8, 0);
|
|
800
|
+
}
|
|
454
801
|
});
|
|
802
|
+
|
|
803
|
+
// ── personality: small life between events (glances, fidgets, effects, shape changes) ──
|
|
804
|
+
const pick = (a) => a[Math.floor(Math.random() * a.length)];
|
|
805
|
+
const SHAPES = ['round', 'egg', 'pear', 'bean', 'squircle', 'drop', 'ghost', 'cat', 'cloud', 'blob', 'heart', 'mushroom', 'tall', 'wide'].filter((x) => x !== spec.shape);
|
|
806
|
+
const weighted = (list) => {
|
|
807
|
+
let r = Math.random() * list.reduce((a, x) => a + x[0], 0);
|
|
808
|
+
for (const x of list) if ((r -= x[0]) <= 0) return x[1];
|
|
809
|
+
};
|
|
810
|
+
function glance() {
|
|
811
|
+
lookFree = Date.now() + 1400 + Math.random() * 1200;
|
|
812
|
+
ch.lookAt((Math.random() * 2 - 1) * 0.8, (Math.random() * 2 - 1) * 0.5);
|
|
813
|
+
if (Math.random() < 0.4) ch.blink?.();
|
|
814
|
+
}
|
|
815
|
+
function fidget() {
|
|
816
|
+
const s = last || {};
|
|
817
|
+
const tired = s.energy < 35;
|
|
818
|
+
const bored = s.fun < 35;
|
|
819
|
+
const hungry = s.food < 35;
|
|
820
|
+
const happy = s.mood === 'happy';
|
|
821
|
+
weighted([
|
|
822
|
+
[5, glance],
|
|
823
|
+
[2, () => ch.gesture('wiggle')],
|
|
824
|
+
[1.5, () => (ch.gesture('think'), ch.effect?.('question'))],
|
|
825
|
+
[1, () => ch.gesture('shrug')],
|
|
826
|
+
[0.6, () => ch.gesture('hiccup')],
|
|
827
|
+
[0.5, () => (ch.gesture('sneeze'), ch.effect?.('sneeze'))],
|
|
828
|
+
[happy ? 2.5 : 0.6, () => (ch.gesture('dance'), ch.effect?.('notes'))],
|
|
829
|
+
[happy ? 0.8 : 0.2, () => (ch.gesture('celebrate'), ch.effect?.('sparkles'))],
|
|
830
|
+
[0.6, () => ch.gesture('spin')],
|
|
831
|
+
[0.5, () => ch.gesture('clap')],
|
|
832
|
+
[tired ? 3 : 0.4, () => ch.gesture('yawn')],
|
|
833
|
+
[bored ? 1.5 : 0.2, () => (ch.gesture('stomp'), ch.effect?.('steam'))],
|
|
834
|
+
[hungry ? 1.2 : 0, () => (ch.gesture('point'), ch.effect?.('thought'))],
|
|
835
|
+
[0.6, () => ch.effect?.('blush')],
|
|
836
|
+
[0.3, () => ch.gesture('shiver')],
|
|
837
|
+
[1.2, () => ch.morph?.(pick(['puff', 'squish', 'stretch', 'jelly']), { ms: 1600 + Math.random() * 1600 })],
|
|
838
|
+
[0.7, () => ch.morph?.(pick(['wings', 'horns', 'spikes', 'hands']), { ms: 2500 + Math.random() * 2500 })],
|
|
839
|
+
[0.8, () => ch.morph?.(`shape:${pick(SHAPES)}`, { ms: 2500 + Math.random() * 3000 })],
|
|
840
|
+
])?.();
|
|
841
|
+
}
|
|
842
|
+
(function life() {
|
|
843
|
+
setTimeout(() => {
|
|
844
|
+
if (!asleep && !thinking && !rec && !bubble.classList.contains('on') && !document.hidden) fidget();
|
|
845
|
+
life();
|
|
846
|
+
}, 4000 + Math.random() * 8000);
|
|
847
|
+
})();
|
|
455
848
|
</script>
|
|
456
849
|
</body>
|
|
457
850
|
</html>
|