@7ots/cli 0.1.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/.env.example +140 -0
- package/LICENSE +21 -0
- package/README.md +655 -0
- package/brand/apple-touch-icon.png +0 -0
- package/brand/favicon.svg +4 -0
- package/brand/icon-512.png +0 -0
- package/brand/mark-mono.svg +3 -0
- package/brand/mark.svg +4 -0
- package/brand/og-es.png +0 -0
- package/brand/og-pt.png +0 -0
- package/brand/og.png +0 -0
- package/brand/ots.json +11 -0
- package/brand/tokens.css +59 -0
- package/brand/wordmark.svg +5 -0
- package/cli/7ots.mjs +400 -0
- package/cli/lib/brain.mjs +143 -0
- package/cli/lib/config.mjs +70 -0
- package/cli/lib/hooks.mjs +151 -0
- package/cli/lib/i18n.mjs +480 -0
- package/cli/lib/identity.mjs +51 -0
- package/cli/lib/install.mjs +79 -0
- package/cli/lib/lines.mjs +73 -0
- package/cli/lib/meet.mjs +166 -0
- package/cli/lib/open.mjs +19 -0
- package/cli/lib/orquesta.mjs +84 -0
- package/cli/lib/paths.mjs +49 -0
- package/cli/lib/pet-core.mjs +184 -0
- package/cli/lib/pet-server.mjs +296 -0
- package/cli/lib/prompts.mjs +75 -0
- package/cli/lib/terminal.mjs +145 -0
- package/cli/lib/ui.mjs +234 -0
- package/cli/lib/wizard.mjs +279 -0
- package/cli/pet/electron/main.cjs +86 -0
- package/cli/pet/pet.html +404 -0
- package/dist/7ots.esm.js +627 -0
- package/dist/7ots.esm.js.map +7 -0
- package/dist/7ots.iife.js +627 -0
- package/dist/7ots.iife.js.map +7 -0
- package/llms.txt +57 -0
- package/package.json +73 -0
- package/server/admin.mjs +213 -0
- package/server/apuchat-relay.mjs +170 -0
- package/server/channels/agent.mjs +136 -0
- package/server/channels/apuchat.mjs +364 -0
- package/server/channels/apumail.mjs +211 -0
- package/server/channels/index.mjs +69 -0
- package/server/contact.mjs +151 -0
- package/server/demo.mjs +154 -0
- package/server/i18n.mjs +54 -0
- package/server/identity.mjs +108 -0
- package/server/llm.mjs +413 -0
- package/server/platform/auth.mjs +290 -0
- package/server/platform/crypto.mjs +80 -0
- package/server/platform/db.mjs +130 -0
- package/server/platform/routes.mjs +388 -0
- package/server/platform/runtime.mjs +180 -0
- package/server/platform/store.mjs +331 -0
- package/server/sdk.mjs +125 -0
- package/server/server.mjs +456 -0
- package/server/settings.mjs +177 -0
- package/server/tts.mjs +186 -0
- package/skills/7ots/SKILL.md +59 -0
- package/src/actions/ActionRegistry.js +254 -0
- package/src/actions/PageTools.js +270 -0
- package/src/actions/builtins.js +565 -0
- package/src/auth/AuthManager.js +128 -0
- package/src/avatar/AvatarStage.js +473 -0
- package/src/character/AvatarEditor.js +829 -0
- package/src/character/Character.js +1191 -0
- package/src/character/motion.js +794 -0
- package/src/character/parts.js +559 -0
- package/src/context/ContextManager.js +472 -0
- package/src/core/AgentBrain.js +341 -0
- package/src/core/AgentWidget.js +1059 -0
- package/src/core/EventBus.js +61 -0
- package/src/core/Proactivity.js +363 -0
- package/src/core/storage.js +35 -0
- package/src/i18n/index.js +193 -0
- package/src/i18n/messages/character.js +543 -0
- package/src/i18n/messages/identity.js +141 -0
- package/src/i18n/messages/platform.js +114 -0
- package/src/i18n/messages/server.js +630 -0
- package/src/i18n/messages/widget.js +362 -0
- package/src/identity/ContactCard.js +351 -0
- package/src/identity/Face.js +148 -0
- package/src/identity/random.js +112 -0
- package/src/identity/schema.js +262 -0
- package/src/index.js +117 -0
- package/src/integrations/apuchat.js +212 -0
- package/src/integrations/apumail.js +88 -0
- package/src/llm/ProxyLLM.js +87 -0
- package/src/mcp/McpClient.js +153 -0
- package/src/ui/Companion.js +498 -0
- package/src/ui/UIManager.js +425 -0
- package/src/ui/VirtualPointer.js +746 -0
- package/src/ui/markdown.js +94 -0
- package/src/voice/VoiceEngine.js +227 -0
|
@@ -0,0 +1,1191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personaje 2D paramétrico de 7ots (estilo Pou): una silueta de 16 puntos que se deforma
|
|
3
|
+
* arrastrando, más ojos, cejas, boca, mejillas, patrón, acabado y accesorios de catálogo.
|
|
4
|
+
*
|
|
5
|
+
* const spec = normalizeCharacter({ preset: 'pirate' });
|
|
6
|
+
* el.innerHTML = renderCharacter(spec); // SVG estático (también en Node)
|
|
7
|
+
* const ch = createCharacter(el, spec); // vivo: respira, parpadea, mira, habla
|
|
8
|
+
* ch.mood('happy'); // el ánimo se interpola (y lanza su efecto)
|
|
9
|
+
* ch.gesture('celebrate'); // CHARACTER_GESTURES (+ alias de TalkingHead)
|
|
10
|
+
* ch.morph('wings', { ms: 3000 }); // CHARACTER_MORPHS: alas, manos, pinchos, 'shape:heart'…
|
|
11
|
+
* ch.effect('hearts'); // CHARACTER_EFFECTS: brillos, sudor, zzz, «!»…
|
|
12
|
+
* ch.speak('Hola, ¿qué tal?'); // lip-sync por visemas a partir del texto
|
|
13
|
+
* const stop = ch.lipsync(analyserNode); // …o del audio real (Web Audio)
|
|
14
|
+
* ch.mouth(0.7); ch.lookAt(0.5, -0.2); ch.update(otroSpec);
|
|
15
|
+
*
|
|
16
|
+
* Gestos, metamorfosis, efectos y visemas están en motion.js. Todo es temporal salvo que se
|
|
17
|
+
* pida { hold: true }, y con prefers-reduced-motion el cuerpo no se mueve.
|
|
18
|
+
*
|
|
19
|
+
* Todo el SVG sale de catálogos propios; lo que llega del usuario se reduce a ids de catálogo,
|
|
20
|
+
* colores #rrggbb y números acotados (normalizeCharacter), así que es seguro inyectarlo.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
BODY_POINTS, SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, ACCESSORIES, PRESETS, shade, luminance,
|
|
25
|
+
} from './parts.js';
|
|
26
|
+
import { translator } from '../i18n/index.js';
|
|
27
|
+
import {
|
|
28
|
+
GESTURES, GESTURE_ALIAS, MORPHS, EFFECTS, VISEMES, POSE0, EASE, clamp, lerp, approach, seeded,
|
|
29
|
+
shapePoints, ptsBox, textToVisemes, babbleVisemes,
|
|
30
|
+
} from './motion.js';
|
|
31
|
+
|
|
32
|
+
export const CHARACTER_MOODS = ['neutral', 'happy', 'angry', 'sad', 'fear', 'disgust', 'love', 'sleep', 'surprised'];
|
|
33
|
+
export const CHARACTER_GESTURES = Object.keys(GESTURES);
|
|
34
|
+
/** Metamorfosis temporales del cuerpo (más 'shape:<id>' con cualquier forma de SHAPES). */
|
|
35
|
+
export const CHARACTER_MORPHS = Object.keys(MORPHS);
|
|
36
|
+
/** Efectos (partículas y símbolos) que acompañan a gestos y ánimos. */
|
|
37
|
+
export const CHARACTER_EFFECTS = Object.keys(EFFECTS);
|
|
38
|
+
export { VISEMES, textToVisemes, GESTURE_ALIAS as CHARACTER_GESTURE_ALIAS };
|
|
39
|
+
|
|
40
|
+
const MOOD = {
|
|
41
|
+
neutral: { curve: 0.15, tilt: 0 },
|
|
42
|
+
happy: { curve: 0.85, tilt: -0.25, squash: 0.85 },
|
|
43
|
+
angry: { curve: -0.45, tilt: 1 },
|
|
44
|
+
sad: { curve: -0.85, tilt: -1 },
|
|
45
|
+
fear: { curve: -0.35, tilt: -0.8, eye: 1.15, open: 0.15 },
|
|
46
|
+
disgust: { curve: -0.55, tilt: 0.6, squash: 0.8 },
|
|
47
|
+
love: { curve: 0.75, tilt: -0.3 },
|
|
48
|
+
sleep: { curve: 0, tilt: 0, closed: true },
|
|
49
|
+
surprised: { curve: 0, tilt: -0.4, eye: 1.2, open: 0.45, lift: 4 },
|
|
50
|
+
};
|
|
51
|
+
const MOOD_BASE = { curve: 0, tilt: 0, squash: 1, eye: 1, open: 0, lift: 0 };
|
|
52
|
+
const MOOD_KEYS = Object.keys(MOOD_BASE);
|
|
53
|
+
/** Valores completos de un ánimo (para interpolar entre ánimos). */
|
|
54
|
+
const moodVals = (name) => ({ ...MOOD_BASE, ...(MOOD[name] || MOOD.neutral) });
|
|
55
|
+
|
|
56
|
+
// ───────────────────────────── spec ─────────────────────────────
|
|
57
|
+
|
|
58
|
+
const byId = (list) => Object.assign(Object.create(null), Object.fromEntries(list.map((p) => [p.id, p])));
|
|
59
|
+
const SHAPE = byId(SHAPES);
|
|
60
|
+
const EYE = byId(EYES);
|
|
61
|
+
const BROW = byId(BROWS);
|
|
62
|
+
const MOUTH = byId(MOUTHS);
|
|
63
|
+
const CHEEK = byId(CHEEKS);
|
|
64
|
+
const PATTERN = byId(PATTERNS);
|
|
65
|
+
const FINISH = byId(FINISHES);
|
|
66
|
+
const ACC = byId(ACCESSORIES);
|
|
67
|
+
const PRESET = byId(PRESETS);
|
|
68
|
+
|
|
69
|
+
export const DEFAULT_CHARACTER = Object.freeze({
|
|
70
|
+
v: 1,
|
|
71
|
+
body: { shape: 'pou', points: null, color: '#a0714f', pattern: 'belly', patternColor: '#c9a27e' },
|
|
72
|
+
finish: 'glossy',
|
|
73
|
+
outline: 'auto',
|
|
74
|
+
eyes: { type: 'round', size: 1, spacing: 1, y: 0, iris: '#3b82f6', white: '#ffffff', ink: '#1a1a24' },
|
|
75
|
+
brows: { type: 'none', color: '' },
|
|
76
|
+
mouth: { type: 'smile', size: 1, y: 0, color: '', lipColor: '#e11d48' },
|
|
77
|
+
cheeks: { type: 'blush', color: '#ff6b8a' },
|
|
78
|
+
accessories: [],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const hex = (v, def = '') => {
|
|
82
|
+
const s = String(v ?? '').trim();
|
|
83
|
+
if (/^#[0-9a-f]{6}$/i.test(s)) return s.toLowerCase();
|
|
84
|
+
if (/^#[0-9a-f]{3}$/i.test(s)) return `#${s.slice(1).split('').map((c) => c + c).join('')}`.toLowerCase();
|
|
85
|
+
return def;
|
|
86
|
+
};
|
|
87
|
+
const num = (v, def, min, max) => {
|
|
88
|
+
const x = Number(v);
|
|
89
|
+
return Number.isFinite(x) ? Math.min(max, Math.max(min, x)) : def;
|
|
90
|
+
};
|
|
91
|
+
const pickId = (map, v, def) => (typeof v === 'string' && Object.hasOwn(map, v) ? v : def);
|
|
92
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Normaliza (y sanea) una especificación de personaje. Nunca lanza.
|
|
96
|
+
* `preset: 'pirate'` parte de ese estilo; el resto de campos lo sobreescribe.
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeCharacter(input = {}) {
|
|
99
|
+
const i = isObj(input) ? input : {};
|
|
100
|
+
const preset = typeof i.preset === 'string' && Object.hasOwn(PRESET, i.preset) ? PRESET[i.preset].spec : null;
|
|
101
|
+
const d = DEFAULT_CHARACTER;
|
|
102
|
+
const base = preset
|
|
103
|
+
? {
|
|
104
|
+
...d,
|
|
105
|
+
...preset,
|
|
106
|
+
body: { ...d.body, pattern: 'none', ...preset.body },
|
|
107
|
+
eyes: { ...d.eyes, ...preset.eyes },
|
|
108
|
+
brows: { ...d.brows, ...preset.brows },
|
|
109
|
+
mouth: { ...d.mouth, ...preset.mouth },
|
|
110
|
+
cheeks: { ...d.cheeks, type: 'none', ...preset.cheeks },
|
|
111
|
+
accessories: preset.accessories || [],
|
|
112
|
+
}
|
|
113
|
+
: d;
|
|
114
|
+
const g = (k) => ({ ...base[k], ...(isObj(i[k]) ? i[k] : {}) });
|
|
115
|
+
const body = g('body');
|
|
116
|
+
const eyes = g('eyes');
|
|
117
|
+
const brows = g('brows');
|
|
118
|
+
const mouth = g('mouth');
|
|
119
|
+
const cheeks = g('cheeks');
|
|
120
|
+
|
|
121
|
+
let points = null;
|
|
122
|
+
if (Array.isArray(body.points) && body.points.length === BODY_POINTS) {
|
|
123
|
+
const p = body.points.map((pt) => (Array.isArray(pt) ? [num(pt[0], NaN, -40, 240), num(pt[1], NaN, -40, 240)] : [NaN, NaN]));
|
|
124
|
+
if (p.every(([x, y]) => Number.isFinite(x) && Number.isFinite(y))) points = p.map(([x, y]) => [Math.round(x * 10) / 10, Math.round(y * 10) / 10]);
|
|
125
|
+
}
|
|
126
|
+
const bodyColor = hex(body.color, d.body.color);
|
|
127
|
+
const accIn = Array.isArray(i.accessories) ? i.accessories : base.accessories;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
v: 1,
|
|
131
|
+
...(preset ? { preset: i.preset } : typeof i.preset === 'string' && /^[\w-]{1,40}$/.test(i.preset) ? { preset: i.preset } : {}),
|
|
132
|
+
body: {
|
|
133
|
+
shape: pickId(SHAPE, body.shape, 'pou'),
|
|
134
|
+
points,
|
|
135
|
+
color: bodyColor,
|
|
136
|
+
pattern: pickId(PATTERN, body.pattern, 'none'),
|
|
137
|
+
patternColor: hex(body.patternColor, shade(bodyColor, 0.3)),
|
|
138
|
+
},
|
|
139
|
+
finish: pickId(FINISH, i.finish ?? base.finish, 'glossy'),
|
|
140
|
+
outline: ['auto', 'none', 'thin', 'bold'].includes(i.outline) ? i.outline : 'auto',
|
|
141
|
+
eyes: {
|
|
142
|
+
type: pickId(EYE, eyes.type, 'round'),
|
|
143
|
+
size: num(eyes.size, 1, 0.4, 2.2),
|
|
144
|
+
spacing: num(eyes.spacing, 1, 0.3, 2),
|
|
145
|
+
y: num(eyes.y, 0, -50, 50),
|
|
146
|
+
iris: hex(eyes.iris, d.eyes.iris),
|
|
147
|
+
white: hex(eyes.white, d.eyes.white),
|
|
148
|
+
ink: hex(eyes.ink, d.eyes.ink),
|
|
149
|
+
},
|
|
150
|
+
brows: { type: pickId(BROW, brows.type, 'none'), color: hex(brows.color, '') },
|
|
151
|
+
mouth: {
|
|
152
|
+
type: pickId(MOUTH, mouth.type, 'smile'),
|
|
153
|
+
size: num(mouth.size, 1, 0.4, 2.2),
|
|
154
|
+
y: num(mouth.y, 0, -50, 50),
|
|
155
|
+
color: hex(mouth.color, ''),
|
|
156
|
+
lipColor: hex(mouth.lipColor, d.mouth.lipColor),
|
|
157
|
+
},
|
|
158
|
+
cheeks: { type: pickId(CHEEK, cheeks.type, 'none'), color: hex(cheeks.color, d.cheeks.color) },
|
|
159
|
+
accessories: accIn
|
|
160
|
+
.filter((a) => isObj(a) && typeof a.id === 'string' && Object.hasOwn(ACC, a.id))
|
|
161
|
+
.slice(0, 16)
|
|
162
|
+
.map((a) => ({
|
|
163
|
+
id: a.id,
|
|
164
|
+
x: num(a.x, 0, -120, 120),
|
|
165
|
+
y: num(a.y, 0, -120, 120),
|
|
166
|
+
scale: num(a.scale, 1, 0.2, 3),
|
|
167
|
+
rot: num(a.rot, 0, -180, 180),
|
|
168
|
+
flip: !!a.flip,
|
|
169
|
+
color: hex(a.color, ACC[a.id].color || '#1f2937'),
|
|
170
|
+
color2: hex(a.color2, ACC[a.id].color2 || '#ffffff'),
|
|
171
|
+
})),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Aplica un estilo (preset) conservando lo que se indique en `keep` (p. ej. ['body']). */
|
|
176
|
+
export function applyPreset(spec, id, keep = []) {
|
|
177
|
+
const next = normalizeCharacter({ preset: id });
|
|
178
|
+
for (const k of keep) if (spec[k]) next[k] = structuredClone(spec[k]);
|
|
179
|
+
return next;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Personaje aleatorio (determinista con `seed`). */
|
|
183
|
+
export function randomCharacter(seed = Math.random() * 1e9) {
|
|
184
|
+
let s = Math.floor(seed) >>> 0 || 1;
|
|
185
|
+
const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32);
|
|
186
|
+
const pick = (list) => list[Math.floor(rnd() * list.length)];
|
|
187
|
+
const hue = Math.floor(rnd() * 360);
|
|
188
|
+
const hsl = (h, sat, l) => {
|
|
189
|
+
const a = (sat * Math.min(l, 1 - l)) / 1;
|
|
190
|
+
const f = (k) => {
|
|
191
|
+
const t = (k + h / 30) % 12;
|
|
192
|
+
return Math.round(255 * (l - a * Math.max(-1, Math.min(t - 3, 9 - t, 1))));
|
|
193
|
+
};
|
|
194
|
+
return `#${[f(0), f(8), f(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
195
|
+
};
|
|
196
|
+
const groups = new Map();
|
|
197
|
+
for (const a of ACCESSORIES) if (a.group !== 'extra') groups.set(a.group, [...(groups.get(a.group) || []), a]);
|
|
198
|
+
const acc = [];
|
|
199
|
+
for (const [, list] of groups) if (rnd() < 0.35) acc.push({ id: pick(list).id });
|
|
200
|
+
return normalizeCharacter({
|
|
201
|
+
body: { shape: pick(SHAPES).id, color: hsl(hue, 0.55, 0.62), pattern: rnd() < 0.5 ? 'none' : pick(PATTERNS).id, patternColor: hsl((hue + 30) % 360, 0.5, 0.75) },
|
|
202
|
+
finish: pick(FINISHES.filter((f) => f.id !== 'sketch')).id,
|
|
203
|
+
eyes: { type: pick(EYES).id, iris: hsl(Math.floor(rnd() * 360), 0.7, 0.5), size: 0.85 + rnd() * 0.4, spacing: 0.85 + rnd() * 0.3 },
|
|
204
|
+
brows: { type: rnd() < 0.5 ? 'none' : pick(BROWS).id },
|
|
205
|
+
mouth: { type: pick(MOUTHS).id },
|
|
206
|
+
cheeks: { type: rnd() < 0.5 ? 'blush' : pick(CHEEKS).id },
|
|
207
|
+
accessories: acc,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ───────────────────────────── geometría ─────────────────────────────
|
|
212
|
+
|
|
213
|
+
export function bodyPoints(spec) {
|
|
214
|
+
return spec.body.points || SHAPE[spec.body.shape]?.points || SHAPES[0].points;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Contorno cerrado Catmull-Rom → Bézier cúbicas. */
|
|
218
|
+
export function splinePath(pts, t = 1) {
|
|
219
|
+
const N = pts.length;
|
|
220
|
+
const P = (i) => pts[(i + N) % N];
|
|
221
|
+
const r = (v) => Math.round(v * 100) / 100;
|
|
222
|
+
let d = `M${r(P(0)[0])} ${r(P(0)[1])}`;
|
|
223
|
+
for (let i = 0; i < N; i++) {
|
|
224
|
+
const [p0, p1, p2, p3] = [P(i - 1), P(i), P(i + 1), P(i + 2)];
|
|
225
|
+
const c1 = [p1[0] + ((p2[0] - p0[0]) / 6) * t, p1[1] + ((p2[1] - p0[1]) / 6) * t];
|
|
226
|
+
const c2 = [p2[0] - ((p3[0] - p1[0]) / 6) * t, p2[1] - ((p3[1] - p1[1]) / 6) * t];
|
|
227
|
+
d += `C${r(c1[0])} ${r(c1[1])} ${r(c2[0])} ${r(c2[1])} ${r(p2[0])} ${r(p2[1])}`;
|
|
228
|
+
}
|
|
229
|
+
return `${d}Z`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Posiciones de la cara y anclas de accesorios para una spec (o para unos puntos deformados). */
|
|
233
|
+
export function layout(spec, points = null) {
|
|
234
|
+
const pts = points || bodyPoints(spec);
|
|
235
|
+
const xs = pts.map((p) => p[0]);
|
|
236
|
+
const ys = pts.map((p) => p[1]);
|
|
237
|
+
const box = { x: Math.min(...xs), y: Math.min(...ys) };
|
|
238
|
+
box.w = Math.max(...xs) - box.x;
|
|
239
|
+
box.h = Math.max(...ys) - box.y;
|
|
240
|
+
const cx = box.x + box.w / 2;
|
|
241
|
+
const unit = Math.sqrt(box.w * box.h) / 125;
|
|
242
|
+
const r = 12 * spec.eyes.size * unit;
|
|
243
|
+
const sp = box.w * 0.2 * spec.eyes.spacing;
|
|
244
|
+
const eyeY = box.y + box.h * 0.42 + spec.eyes.y;
|
|
245
|
+
const mw = 16 * spec.mouth.size * unit;
|
|
246
|
+
const mouthY = Math.max(eyeY + r * 1.2, box.y + box.h * 0.66 + spec.mouth.y);
|
|
247
|
+
const top = pts[0];
|
|
248
|
+
const bottom = pts[Math.round(BODY_POINTS / 2)];
|
|
249
|
+
return {
|
|
250
|
+
pts, box, cx, unit, r, sp, eyeY, mw, mouthY,
|
|
251
|
+
anchors: {
|
|
252
|
+
top: { x: top[0], y: top[1] + 4 },
|
|
253
|
+
eyes: { x: cx, y: eyeY },
|
|
254
|
+
nose: { x: cx, y: (eyeY + mouthY) / 2 + r * 0.25 },
|
|
255
|
+
mouth: { x: cx, y: mouthY },
|
|
256
|
+
neck: { x: cx, y: mouthY + (bottom[1] - mouthY) * 0.55 },
|
|
257
|
+
center: { x: cx, y: box.y + box.h / 2 },
|
|
258
|
+
bottom: { x: bottom[0], y: bottom[1] },
|
|
259
|
+
shoulder: { x: cx + box.w * 0.4, y: box.y + box.h * 0.3 },
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ───────────────────────────── render ─────────────────────────────
|
|
265
|
+
|
|
266
|
+
let uidSeq = 0;
|
|
267
|
+
const R = (v) => Math.round(v * 100) / 100;
|
|
268
|
+
|
|
269
|
+
function palette(spec) {
|
|
270
|
+
const body = spec.body.color;
|
|
271
|
+
const dark = luminance(body) < 0.28;
|
|
272
|
+
return {
|
|
273
|
+
body,
|
|
274
|
+
dark,
|
|
275
|
+
line: dark ? shade(body, 0.55) : shade(body, -0.45),
|
|
276
|
+
lip: spec.mouth.color || (dark ? shade(body, 0.6) : shade(body, -0.5)),
|
|
277
|
+
brow: spec.brows.color || (dark ? shade(body, 0.6) : shade(body, -0.55)),
|
|
278
|
+
lid: shade(body, -0.06),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function outlineOf(spec, pal) {
|
|
283
|
+
const o = spec.outline;
|
|
284
|
+
if (o === 'none') return null;
|
|
285
|
+
if (o === 'bold' || spec.finish === 'toon') return { color: '#1f2937', w: 4.5 };
|
|
286
|
+
if (spec.finish === 'sketch') return { color: '#1f2937', w: 2.2 };
|
|
287
|
+
if (spec.finish === 'neon') return { color: spec.body.color, w: 3.5 };
|
|
288
|
+
if (o === 'thin' || (o === 'auto' && spec.finish !== 'flat')) return { color: pal.line, w: 2.2, op: 0.55 };
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Ojos (con parpadeo/mirada/ánimo) centrados en el ancla de ojos. */
|
|
293
|
+
function eyesSVG(spec, L, st, pal) {
|
|
294
|
+
const e = spec.eyes;
|
|
295
|
+
const closed = st.closed ?? st.mood === 'sleep';
|
|
296
|
+
const part = EYE[closed && !EYE[e.type].noBlink ? 'closed' : e.type];
|
|
297
|
+
const m = st.m || moodVals(st.mood);
|
|
298
|
+
const blink = part.noBlink ? 1 : Math.max(0.08, 1 - st.blink);
|
|
299
|
+
const sy = blink * (m.squash || 1);
|
|
300
|
+
const sc = m.eye || 1;
|
|
301
|
+
const base = { r: L.r, lx: st.lx, ly: st.ly, iris: e.iris, ink: e.ink, white: e.white, lid: pal.lid, mood: st.mood };
|
|
302
|
+
const one = (x, side) =>
|
|
303
|
+
`<g transform="translate(${R(x)} ${R(L.eyeY)}) scale(${R(sc)} ${R(sc * sy)})">${part.draw({ ...base, side })}</g>`;
|
|
304
|
+
return part.single ? one(L.cx, 1) : one(L.cx - L.sp, -1) + one(L.cx + L.sp, 1);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function browsSVG(spec, L, st, pal) {
|
|
308
|
+
const part = BROW[spec.brows.type];
|
|
309
|
+
if (!part || spec.brows.type === 'none') return '';
|
|
310
|
+
const m = st.m || moodVals(st.mood);
|
|
311
|
+
const y = L.eyeY - L.r * 1.65 * (m.eye || 1) - (m.lift || 0) - st.browLift;
|
|
312
|
+
const p = { r: L.r, color: pal.brow, sp: L.sp };
|
|
313
|
+
if (part.uni) return `<g transform="translate(${R(L.cx)} ${R(y)})">${part.draw({ ...p, side: 1 })}</g>`;
|
|
314
|
+
return [-1, 1]
|
|
315
|
+
.map((side) => `<g transform="translate(${R(L.cx + side * L.sp)} ${R(y)}) rotate(${R(side * m.tilt * 14)})">${part.draw({ ...p, side })}</g>`)
|
|
316
|
+
.join('');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function mouthSVG(spec, L, st, pal) {
|
|
320
|
+
const m = st.m || moodVals(st.mood);
|
|
321
|
+
const open = Math.min(1, Math.max(0, st.open, m.open || 0));
|
|
322
|
+
const msx = st.msx ?? 1;
|
|
323
|
+
const p = {
|
|
324
|
+
w: L.mw,
|
|
325
|
+
open,
|
|
326
|
+
curve: m.curve,
|
|
327
|
+
ink: '#5b1a22',
|
|
328
|
+
lip: pal.lip,
|
|
329
|
+
tongue: '#ff7a8a',
|
|
330
|
+
teeth: '#ffffff',
|
|
331
|
+
lipColor: spec.mouth.lipColor,
|
|
332
|
+
};
|
|
333
|
+
const sc = Math.abs(msx - 1) > 0.01 ? ` scale(${R(msx)} 1)` : '';
|
|
334
|
+
return `<g transform="translate(${R(L.cx)} ${R(L.mouthY)})${sc}">${MOUTH[spec.mouth.type].draw(p)}</g>`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function accessorySVG(a, spec, L, pal, clipUrl) {
|
|
338
|
+
const part = ACC[a.id];
|
|
339
|
+
const at = L.anchors[part.anchor] || L.anchors.center;
|
|
340
|
+
const c = {
|
|
341
|
+
k: L.box.w / 120,
|
|
342
|
+
m: L.mw / 16,
|
|
343
|
+
mw: L.mw,
|
|
344
|
+
w: L.box.w,
|
|
345
|
+
h: L.box.h,
|
|
346
|
+
sp: L.sp,
|
|
347
|
+
r: L.r,
|
|
348
|
+
eyeDy: L.eyeY - L.box.y,
|
|
349
|
+
mouthDy: L.mouthY - L.eyeY,
|
|
350
|
+
color: a.color,
|
|
351
|
+
color2: a.color2,
|
|
352
|
+
body: pal.body,
|
|
353
|
+
above: at.y - L.box.y,
|
|
354
|
+
below: L.anchors.bottom.y - at.y,
|
|
355
|
+
};
|
|
356
|
+
const tf = `translate(${R(at.x + a.x)} ${R(at.y + a.y)}) rotate(${R(a.rot)}) scale(${R(a.flip ? -a.scale : a.scale)} ${R(a.scale)})`;
|
|
357
|
+
const main = `<g transform="${tf}">${part.draw(c)}</g>`;
|
|
358
|
+
const extra = part.extra ? `<g transform="${tf}">${part.extra(c)}</g>` : '';
|
|
359
|
+
const clip = (s) => (s ? `<g clip-path="${clipUrl}">${s}</g>` : '');
|
|
360
|
+
if (part.clip) return clip(main) + extra;
|
|
361
|
+
if (part.clipExtra) return main + clip(extra);
|
|
362
|
+
return main + extra;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Piezas del SVG por capas (lo usa createCharacter para animar sin re-renderizar todo).
|
|
367
|
+
* opts.pts: contorno deformado (metamorfosis) en lugar del de la spec.
|
|
368
|
+
* @returns {{ viewBox:string, defs:string, back:string, body:string, cheeks:string, eyes:string, brows:string, mouth:string, front:string, L:object }}
|
|
369
|
+
*/
|
|
370
|
+
export function renderLayers(spec, state = {}, opts = {}) {
|
|
371
|
+
const st = { open: 0, blink: 0, lx: 0, ly: 0, mood: 'neutral', browLift: 0, ...state };
|
|
372
|
+
const L = layout(spec, opts.pts);
|
|
373
|
+
const pal = palette(spec);
|
|
374
|
+
const id = opts.uid || `otsc${(++uidSeq).toString(36)}`;
|
|
375
|
+
const d = splinePath(L.pts);
|
|
376
|
+
const { box } = L;
|
|
377
|
+
const f = spec.finish;
|
|
378
|
+
const col = spec.body.color;
|
|
379
|
+
const ol = outlineOf(spec, pal);
|
|
380
|
+
const clipUrl = `url(#${id}-clip)`;
|
|
381
|
+
|
|
382
|
+
let defs = `<clipPath id="${id}-clip"><path d="${d}"/></clipPath>`;
|
|
383
|
+
let fill = col;
|
|
384
|
+
if (f === 'glossy' || f === 'soft3d' || f === 'clay') {
|
|
385
|
+
const [a, b] = f === 'glossy' ? [0.38, -0.28] : f === 'soft3d' ? [0.22, -0.2] : [0.14, -0.22];
|
|
386
|
+
defs += `<radialGradient id="${id}-g" cx="36%" cy="28%" r="85%"><stop offset="0" stop-color="${shade(col, a)}"/><stop offset=".55" stop-color="${col}"/><stop offset="1" stop-color="${shade(col, b)}"/></radialGradient>`;
|
|
387
|
+
fill = `url(#${id}-g)`;
|
|
388
|
+
}
|
|
389
|
+
if (f === 'soft3d') defs += `<filter id="${id}-soft" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="${R(box.w * 0.06)}"/></filter>`;
|
|
390
|
+
if (f === 'clay') defs += `<filter id="${id}-noise" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" seed="7"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .9 0"/></filter>`;
|
|
391
|
+
if (f === 'neon') defs += `<filter id="${id}-glow" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`;
|
|
392
|
+
if (f === 'sketch') {
|
|
393
|
+
defs += `<filter id="${id}-rough"><feTurbulence type="turbulence" baseFrequency=".035" numOctaves="2" seed="3"/><feDisplacementMap in="SourceGraphic" scale="3.5"/></filter>`;
|
|
394
|
+
defs += `<pattern id="${id}-hatch" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(40)"><path d="M0 0V7" stroke="${shade(col, -0.35)}" stroke-width="1.4" opacity=".45"/></pattern>`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Capa del cuerpo
|
|
398
|
+
let body = '';
|
|
399
|
+
if (f !== 'flat' && f !== 'neon') body += `<ellipse cx="${R(L.anchors.bottom.x)}" cy="${R(L.anchors.bottom.y + 5)}" rx="${R(box.w * 0.36)}" ry="${R(Math.max(4, box.h * 0.045))}" fill="#000" opacity=".13"/>`;
|
|
400
|
+
body += `<path d="${d}" fill="${f === 'neon' ? shade(col, -0.72) : fill}"${f === 'neon' ? ` filter="url(#${id}-glow)"` : ''}/>`;
|
|
401
|
+
let inner = '';
|
|
402
|
+
if (f === 'toon') inner += `<path d="${d}" fill="${shade(col, -0.2)}"/><path d="${d}" fill="${col}" transform="translate(${R(-box.w * 0.06)} ${R(-box.h * 0.07)})"/>`;
|
|
403
|
+
if (spec.body.pattern !== 'none') inner += PATTERN[spec.body.pattern].draw({ box, color: spec.body.patternColor, eyeY: L.eyeY, r: L.r });
|
|
404
|
+
if (f === 'glossy') {
|
|
405
|
+
inner += `<ellipse cx="${R(box.x + box.w * 0.3)}" cy="${R(box.y + box.h * 0.2)}" rx="${R(box.w * 0.15)}" ry="${R(box.h * 0.08)}" transform="rotate(-32 ${R(box.x + box.w * 0.3)} ${R(box.y + box.h * 0.2)})" fill="#fff" opacity=".5"/>`;
|
|
406
|
+
inner += `<circle cx="${R(box.x + box.w * 0.44)}" cy="${R(box.y + box.h * 0.12)}" r="${R(box.w * 0.025)}" fill="#fff" opacity=".6"/>`;
|
|
407
|
+
inner += `<path d="${d}" fill="none" stroke="${shade(col, -0.35)}" stroke-width="${R(box.w * 0.08)}" opacity=".18"/>`;
|
|
408
|
+
}
|
|
409
|
+
if (f === 'soft3d') {
|
|
410
|
+
inner += `<ellipse cx="${R(L.cx + box.w * 0.1)}" cy="${R(box.y + box.h * 1.02)}" rx="${R(box.w * 0.6)}" ry="${R(box.h * 0.28)}" fill="${shade(col, -0.45)}" opacity=".35" filter="url(#${id}-soft)"/>`;
|
|
411
|
+
inner += `<ellipse cx="${R(box.x + box.w * 0.34)}" cy="${R(box.y + box.h * 0.2)}" rx="${R(box.w * 0.2)}" ry="${R(box.h * 0.1)}" fill="#fff" opacity=".45" filter="url(#${id}-soft)"/>`;
|
|
412
|
+
}
|
|
413
|
+
if (f === 'clay') inner += `<rect x="${R(box.x - 5)}" y="${R(box.y - 5)}" width="${R(box.w + 10)}" height="${R(box.h + 10)}" filter="url(#${id}-noise)" opacity=".13"/>`;
|
|
414
|
+
if (f === 'sketch') inner += `<path d="${d}" fill="url(#${id}-hatch)" transform="translate(${R(box.w * 0.05)} ${R(box.h * 0.06)})"/>`;
|
|
415
|
+
if (inner) body += `<g clip-path="${clipUrl}">${inner}</g>`;
|
|
416
|
+
if (ol) body += `<path d="${d}" fill="none" stroke="${ol.color}" stroke-width="${ol.w}" stroke-linejoin="round"${ol.op ? ` stroke-opacity="${ol.op}"` : ''}${f === 'sketch' ? ` filter="url(#${id}-rough)"` : ''}${f === 'neon' ? ` filter="url(#${id}-glow)"` : ''}/>`;
|
|
417
|
+
|
|
418
|
+
const cheeks = spec.cheeks.type === 'none' ? '' : `<g transform="translate(${R(L.cx)} ${R(L.eyeY)})">${CHEEK[spec.cheeks.type].draw({ sp: L.sp, r: L.r, color: spec.cheeks.color })}</g>`;
|
|
419
|
+
|
|
420
|
+
const back = spec.accessories.filter((a) => ACC[a.id].layer === 'back').map((a) => accessorySVG(a, spec, L, pal, clipUrl)).join('');
|
|
421
|
+
const front = spec.accessories.filter((a) => ACC[a.id].layer !== 'back').map((a) => accessorySVG(a, spec, L, pal, clipUrl)).join('');
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
L,
|
|
425
|
+
uid: id,
|
|
426
|
+
viewBox: opts.viewBox || '-20 -50 240 270',
|
|
427
|
+
defs,
|
|
428
|
+
back,
|
|
429
|
+
body,
|
|
430
|
+
cheeks,
|
|
431
|
+
eyes: eyesSVG(spec, L, st, pal),
|
|
432
|
+
brows: browsSVG(spec, L, st, pal),
|
|
433
|
+
mouth: mouthSVG(spec, L, st, pal),
|
|
434
|
+
front,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* SVG completo como string (sirve en Node, para miniaturas o para <img src="data:…">).
|
|
440
|
+
* @param {object} spec (se normaliza)
|
|
441
|
+
* @param {{open?:number, blink?:number, lx?:number, ly?:number, mood?:string}} [state]
|
|
442
|
+
* @param {{viewBox?:string, title?:string}} [opts]
|
|
443
|
+
*/
|
|
444
|
+
export function renderCharacter(spec, state = {}, opts = {}) {
|
|
445
|
+
const s = normalizeCharacter(spec);
|
|
446
|
+
const x = renderLayers(s, state, opts);
|
|
447
|
+
const title = opts.title ? `<title>${String(opts.title).replace(/[<>&"]/g, '')}</title>` : '';
|
|
448
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${x.viewBox}" role="img"${opts.title ? '' : ' aria-hidden="true"'}>${title}<defs>${x.defs}</defs>${x.back}${x.body}${x.cheeks}${x.eyes}${x.brows}${x.mouth}${x.front}</svg>`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ───────────────────────────── vivo ─────────────────────────────
|
|
452
|
+
|
|
453
|
+
// Respiración y balanceo en CSS (no gastan JS en reposo); gestos, boca y metamorfosis en un
|
|
454
|
+
// bucle rAF que solo corre mientras hay algo animándose.
|
|
455
|
+
const STYLE = `
|
|
456
|
+
.otsc-sway{animation:otsc-sway 5.3s ease-in-out infinite;transform-box:fill-box;transform-origin:50% 100%}
|
|
457
|
+
.otsc-breathe{animation:otsc-breathe 3.6s ease-in-out infinite;transform-box:fill-box;transform-origin:50% 100%}
|
|
458
|
+
.otsc-talk .otsc-breathe{animation-duration:2.2s}
|
|
459
|
+
@keyframes otsc-breathe{0%,100%{transform:scale(1,1)}50%{transform:scale(1.014,.982)}}
|
|
460
|
+
@keyframes otsc-sway{0%,100%{transform:rotate(-.6deg)}50%{transform:rotate(.6deg)}}
|
|
461
|
+
@media (prefers-reduced-motion:reduce){.otsc-breathe,.otsc-sway{animation:none!important}}
|
|
462
|
+
`;
|
|
463
|
+
|
|
464
|
+
/** Efectos y metamorfosis que acompañan a cada ánimo (solo al cambiar de ánimo). */
|
|
465
|
+
const MOOD_FX = {
|
|
466
|
+
angry: [['spikes', { ms: 900 }], ['anger'], ['steam']],
|
|
467
|
+
surprised: [['exclaim']],
|
|
468
|
+
love: [['hearts'], ['blush']],
|
|
469
|
+
happy: [['sparkles']],
|
|
470
|
+
sad: [['tears']],
|
|
471
|
+
fear: [['sweat']],
|
|
472
|
+
disgust: [['squish', { ms: 700 }]],
|
|
473
|
+
sleep: [['zzz', { hold: true, tag: 'mood' }]],
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const MORPH_MS = 1800;
|
|
477
|
+
const MORPH_IN = 380;
|
|
478
|
+
const MORPH_OUT = 300;
|
|
479
|
+
const FX_OUT = 300;
|
|
480
|
+
const POSE_KEYS = Object.keys(POSE0);
|
|
481
|
+
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
482
|
+
const mixPts = (a, b, k) => a.map((p, i) => [lerp(p[0], b[i][0], k), lerp(p[1], b[i][1], k)]);
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Personaje animado dentro de `container`.
|
|
486
|
+
* @param {HTMLElement} container
|
|
487
|
+
* @param {object} spec
|
|
488
|
+
* @param {{ idle?: boolean, fit?: 'all'|'body'|string, title?: string, locale?: string, moodFx?: boolean, zoom?: boolean }} [opts]
|
|
489
|
+
* title: nombre accesible (aria-label); sin él, «Avatar» en el idioma `locale` (defecto: el global).
|
|
490
|
+
* idle: respirar, parpadear y mirar alrededor solo (defecto true).
|
|
491
|
+
* moodFx: efectos al cambiar de ánimo (pinchos al enfadarse, zzz al dormir…; defecto true).
|
|
492
|
+
* zoom: alejar el encuadre cuando una metamorfosis no cabe (defecto true con fit 'all'/'body').
|
|
493
|
+
*/
|
|
494
|
+
export function createCharacter(container, spec, opts = {}) {
|
|
495
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
496
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
497
|
+
svg.setAttribute('class', 'otsc');
|
|
498
|
+
svg.setAttribute('role', 'img');
|
|
499
|
+
svg.setAttribute('aria-label', opts.title || translator(opts.locale)('character.avatar'));
|
|
500
|
+
svg.style.cssText = 'width:100%;height:100%;display:block;overflow:visible';
|
|
501
|
+
container.appendChild(svg);
|
|
502
|
+
|
|
503
|
+
const idle = opts.idle !== false;
|
|
504
|
+
const moodFx = opts.moodFx !== false;
|
|
505
|
+
const fitMode = opts.fit || 'all';
|
|
506
|
+
const canZoom = opts.zoom !== false && (fitMode === 'all' || fitMode === 'body');
|
|
507
|
+
const reduced = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
508
|
+
|
|
509
|
+
let s = normalizeCharacter(spec);
|
|
510
|
+
let uid = `otsc${(++uidSeq).toString(36)}`;
|
|
511
|
+
let g = {};
|
|
512
|
+
let L = layout(s);
|
|
513
|
+
let L0 = L;
|
|
514
|
+
let pal = palette(s);
|
|
515
|
+
let alive = true;
|
|
516
|
+
let raf = 0;
|
|
517
|
+
let last = 0;
|
|
518
|
+
let blinkT = 0;
|
|
519
|
+
let fxSeq = 0;
|
|
520
|
+
|
|
521
|
+
// estado animado
|
|
522
|
+
let moodName = 'neutral';
|
|
523
|
+
const mv = moodVals('neutral');
|
|
524
|
+
let sleepK = 0;
|
|
525
|
+
const look = { x: 0, y: 0, tx: 0, ty: 0, at: -1e9 };
|
|
526
|
+
const mo = { open: 0, target: 0, msx: 1, tmsx: 1 };
|
|
527
|
+
let blinkAt = -1e9;
|
|
528
|
+
let gest = null;
|
|
529
|
+
let pose = { ...POSE0 };
|
|
530
|
+
const morphs = new Map();
|
|
531
|
+
let fxs = [];
|
|
532
|
+
let speech = null;
|
|
533
|
+
let zoom = 1;
|
|
534
|
+
let vb = null;
|
|
535
|
+
const keys = { body: '', eyes: '', brows: '', mouth: '', rig: '', mback: '', mfront: '', fx: '', fxface: '' };
|
|
536
|
+
|
|
537
|
+
function build() {
|
|
538
|
+
pal = palette(s);
|
|
539
|
+
svg.innerHTML = `<style>${STYLE}</style><defs data-p="defs"></defs><g class="otsc-rig"><g data-p="fxback"></g><g class="otsc-sway"><g class="otsc-breathe"><g data-p="mback"></g><g data-p="back"></g><g data-p="body"></g><g data-p="cheeks"></g><g data-p="fxface"></g><g data-p="eyes"></g><g data-p="brows"></g><g data-p="mouth"></g><g data-p="front"></g><g data-p="mfront"></g></g></g><g data-p="fx"></g></g>`;
|
|
540
|
+
g = { rig: svg.querySelector('.otsc-rig') };
|
|
541
|
+
for (const el of svg.querySelectorAll('[data-p]')) g[el.dataset.p] = el;
|
|
542
|
+
if (!idle) {
|
|
543
|
+
svg.querySelector('.otsc-breathe').setAttribute('class', '');
|
|
544
|
+
svg.querySelector('.otsc-sway').setAttribute('class', '');
|
|
545
|
+
}
|
|
546
|
+
for (const k in keys) keys[k] = '';
|
|
547
|
+
L0 = layout(s);
|
|
548
|
+
paintBody(bodyPoints(s), 'base');
|
|
549
|
+
paintFace(faceState());
|
|
550
|
+
fit();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function paintBody(pts, key) {
|
|
554
|
+
const x = renderLayers(s, faceState(), { uid, pts });
|
|
555
|
+
L = x.L;
|
|
556
|
+
g.defs.innerHTML = x.defs;
|
|
557
|
+
g.back.innerHTML = x.back;
|
|
558
|
+
g.body.innerHTML = x.body;
|
|
559
|
+
g.cheeks.innerHTML = x.cheeks;
|
|
560
|
+
g.front.innerHTML = x.front;
|
|
561
|
+
keys.body = key;
|
|
562
|
+
keys.eyes = keys.brows = keys.mouth = '';
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Estado de cara de este frame: ánimo interpolado + retoques del gesto + boca + mirada. */
|
|
566
|
+
function faceState() {
|
|
567
|
+
const m = { ...mv };
|
|
568
|
+
m.curve = clamp(m.curve + pose.curve, -1, 1);
|
|
569
|
+
m.eye *= pose.eye;
|
|
570
|
+
m.lift += pose.lift;
|
|
571
|
+
const blink = Math.max(blinkValue(nowMs()), pose.blink, sleepK > 0.02 ? Math.min(1, sleepK * 1.1) : 0);
|
|
572
|
+
return {
|
|
573
|
+
m,
|
|
574
|
+
mood: moodName,
|
|
575
|
+
closed: sleepK > 0.85,
|
|
576
|
+
blink: clamp(blink),
|
|
577
|
+
open: clamp(mo.open + pose.open),
|
|
578
|
+
msx: mo.msx * pose.msx,
|
|
579
|
+
lx: clamp(look.x + pose.lx, -1, 1),
|
|
580
|
+
ly: clamp(look.y + pose.ly, -1, 1),
|
|
581
|
+
browLift: 0,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function paintFace(st) {
|
|
586
|
+
const r2 = (v) => Math.round(v * 100);
|
|
587
|
+
const kE = `${keys.body}|${r2(st.lx)},${r2(st.ly)},${r2(st.blink)},${r2(st.m.squash)},${r2(st.m.eye)},${st.closed}`;
|
|
588
|
+
if (kE !== keys.eyes) { g.eyes.innerHTML = eyesSVG(s, L, st, pal); keys.eyes = kE; }
|
|
589
|
+
const kB = `${keys.body}|${r2(st.m.tilt)},${r2(st.m.eye)},${r2(st.m.lift)}`;
|
|
590
|
+
if (kB !== keys.brows) { g.brows.innerHTML = browsSVG(s, L, st, pal); keys.brows = kB; }
|
|
591
|
+
const kM = `${keys.body}|${Math.round(st.open * 200)},${r2(st.msx)},${r2(st.m.curve)},${r2(st.m.open)}`;
|
|
592
|
+
if (kM !== keys.mouth) { g.mouth.innerHTML = mouthSVG(s, L, st, pal); keys.mouth = kM; }
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ── encuadre ──
|
|
596
|
+
function fit() {
|
|
597
|
+
if (fitMode !== 'all' && fitMode !== 'body') {
|
|
598
|
+
vb = null;
|
|
599
|
+
svg.setAttribute('viewBox', fitMode);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const pad = 8;
|
|
603
|
+
let b = { x: L0.box.x, y: L0.box.y, width: L0.box.w, height: L0.box.h + 10 };
|
|
604
|
+
if (fitMode === 'all') {
|
|
605
|
+
// Solo cuerpo y accesorios: las metamorfosis y efectos se encuadran con el zoom.
|
|
606
|
+
try {
|
|
607
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
608
|
+
for (const el of [g.back, g.body, g.front]) {
|
|
609
|
+
const bb = el.getBBox();
|
|
610
|
+
if (!bb.width && !bb.height) continue;
|
|
611
|
+
x0 = Math.min(x0, bb.x);
|
|
612
|
+
y0 = Math.min(y0, bb.y);
|
|
613
|
+
x1 = Math.max(x1, bb.x + bb.width);
|
|
614
|
+
y1 = Math.max(y1, bb.y + bb.height);
|
|
615
|
+
}
|
|
616
|
+
if (x1 > x0) b = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
617
|
+
} catch { /* sin layout (oculto): encuadre del cuerpo */ }
|
|
618
|
+
}
|
|
619
|
+
const size = Math.max(b.width, b.height) + pad * 2;
|
|
620
|
+
vb = { cx: b.x + b.width / 2, cy: b.y + b.height / 2, size };
|
|
621
|
+
applyViewBox();
|
|
622
|
+
}
|
|
623
|
+
function applyViewBox() {
|
|
624
|
+
if (!vb) return;
|
|
625
|
+
const size = vb.size * zoom;
|
|
626
|
+
// se aleja anclado cerca de los pies: el personaje no «sube» al crecerle alas
|
|
627
|
+
const pivot = vb.cy + vb.size * 0.3;
|
|
628
|
+
svg.setAttribute('viewBox', `${R(vb.cx - size / 2)} ${R(pivot - size * 0.8)} ${R(size)} ${R(size)}`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ── bucle ──
|
|
632
|
+
function wake() {
|
|
633
|
+
if (raf || !alive) return;
|
|
634
|
+
last = nowMs();
|
|
635
|
+
raf = requestAnimationFrame(frame);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function blinkValue(t) {
|
|
639
|
+
const bt = t - blinkAt;
|
|
640
|
+
if (bt < 0 || bt >= 180) return 0;
|
|
641
|
+
return bt < 70 ? EASE.in(bt / 70) ** 0.5 : 1 - EASE.out((bt - 70) / 110);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function frame(t) {
|
|
645
|
+
raf = 0;
|
|
646
|
+
if (!alive) return;
|
|
647
|
+
const dt = Math.min(64, Math.max(0, t - last));
|
|
648
|
+
last = t;
|
|
649
|
+
const rm = reduced();
|
|
650
|
+
let busy = false;
|
|
651
|
+
const settle = (cur, tgt, eps = 0.004) => Math.abs(cur - tgt) > eps;
|
|
652
|
+
|
|
653
|
+
// 1) voz → objetivo de boca
|
|
654
|
+
if (speech) {
|
|
655
|
+
busy = true;
|
|
656
|
+
if (!speech.tick(t)) endSpeech();
|
|
657
|
+
}
|
|
658
|
+
// 2) boca con ataque rápido y relajación algo más lenta
|
|
659
|
+
mo.open = approach(mo.open, mo.target, dt, mo.target > mo.open ? 35 : 70);
|
|
660
|
+
mo.msx = approach(mo.msx, mo.tmsx, dt, 60);
|
|
661
|
+
if (settle(mo.open, mo.target) || settle(mo.msx, mo.tmsx)) busy = true;
|
|
662
|
+
else { mo.open = mo.target; mo.msx = mo.tmsx; }
|
|
663
|
+
svg.classList.toggle('otsc-talk', mo.open > 0.08);
|
|
664
|
+
|
|
665
|
+
// 3) mirada suavizada
|
|
666
|
+
look.x = rm ? look.tx : approach(look.x, look.tx, dt, 70);
|
|
667
|
+
look.y = rm ? look.ty : approach(look.y, look.ty, dt, 70);
|
|
668
|
+
if (settle(look.x, look.tx) || settle(look.y, look.ty)) busy = true;
|
|
669
|
+
|
|
670
|
+
// 4) ánimo interpolado
|
|
671
|
+
const tgt = moodVals(moodName);
|
|
672
|
+
for (const k of MOOD_KEYS) {
|
|
673
|
+
mv[k] = rm ? tgt[k] : approach(mv[k], tgt[k], dt, 90);
|
|
674
|
+
if (settle(mv[k], tgt[k], 0.003)) busy = true;
|
|
675
|
+
else mv[k] = tgt[k];
|
|
676
|
+
}
|
|
677
|
+
const sk = moodName === 'sleep' ? 1 : 0;
|
|
678
|
+
sleepK = rm ? sk : approach(sleepK, sk, dt, 260);
|
|
679
|
+
if (settle(sleepK, sk, 0.002)) busy = true;
|
|
680
|
+
else sleepK = sk;
|
|
681
|
+
|
|
682
|
+
// 5) parpadeo
|
|
683
|
+
if (blinkValue(t) > 0 || t - blinkAt < 180) busy = true;
|
|
684
|
+
|
|
685
|
+
// 6) gesto
|
|
686
|
+
pose = { ...POSE0 };
|
|
687
|
+
if (gest) {
|
|
688
|
+
busy = true;
|
|
689
|
+
const age = t - gest.start;
|
|
690
|
+
const tt = clamp(age / gest.ms);
|
|
691
|
+
const p = gest.def.pose(tt, rm ? 0 : age, gest);
|
|
692
|
+
for (const k in p) pose[k] = p[k];
|
|
693
|
+
if (gest.dir < 0) { pose.x = -pose.x; pose.r = -pose.r; pose.lx = -pose.lx; }
|
|
694
|
+
if (gest.from) {
|
|
695
|
+
const w = EASE.out(clamp(age / 160));
|
|
696
|
+
for (const k of POSE_KEYS) pose[k] = lerp(gest.from[k], pose[k], w);
|
|
697
|
+
}
|
|
698
|
+
for (const f of gest.def.fx || []) {
|
|
699
|
+
if (tt >= f[0] && !gest.fired.has(f)) {
|
|
700
|
+
gest.fired.add(f);
|
|
701
|
+
effect(f[1], { dir: gest.dir, ...(f[2] || {}) });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (tt >= 1) endGesture();
|
|
705
|
+
}
|
|
706
|
+
if (rm) Object.assign(pose, { x: 0, y: 0, r: 0, sx: 1, sy: 1 });
|
|
707
|
+
|
|
708
|
+
// 7) metamorfosis: envolventes, contorno y capas extra
|
|
709
|
+
const base = bodyPoints(s);
|
|
710
|
+
let pts = base;
|
|
711
|
+
let room = 1;
|
|
712
|
+
const drawn = [];
|
|
713
|
+
for (const [slot, m] of morphs) {
|
|
714
|
+
busy = true;
|
|
715
|
+
if (m.phase !== 'out' && t >= m.until) { m.phase = 'out'; m.outAt = t; m.k0 = m.k; }
|
|
716
|
+
if (m.phase === 'in') {
|
|
717
|
+
const q = clamp((t - m.start) / MORPH_IN);
|
|
718
|
+
m.k = rm ? 1 : (m.def.shape ? EASE.inOut(q) : EASE.backOut(q));
|
|
719
|
+
if (q >= 1) m.phase = 'on';
|
|
720
|
+
} else if (m.phase === 'on') m.k = 1;
|
|
721
|
+
else {
|
|
722
|
+
const q = clamp((t - m.outAt) / MORPH_OUT);
|
|
723
|
+
m.k = rm ? 0 : m.k0 * (1 - EASE.inOut(q));
|
|
724
|
+
if (q >= 1) { morphs.delete(slot); continue; }
|
|
725
|
+
}
|
|
726
|
+
if (m.phase !== 'out') room = Math.max(room, m.def.room || 1);
|
|
727
|
+
const age = rm ? 0 : t - m.start;
|
|
728
|
+
if (m.def.shape) {
|
|
729
|
+
const b = m.prev ? EASE.inOut(clamp((t - m.bAt) / 420)) : 1;
|
|
730
|
+
const target = b < 1 ? mixPts(m.prev, m.target, b) : m.target;
|
|
731
|
+
pts = mixPts(pts, target, m.k);
|
|
732
|
+
} else if (m.def.deform) pts = m.def.deform(pts, m.k, age, ptsBox(pts), rm);
|
|
733
|
+
else {
|
|
734
|
+
if (m.o.pose !== undefined) m.o.blend = clamp((t - (m.poseAt || 0)) / 220);
|
|
735
|
+
drawn.push([m, age]);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const bodyKey = pts === base ? 'base' : pts.map((p) => `${Math.round(p[0] * 10)},${Math.round(p[1] * 10)}`).join(' ');
|
|
739
|
+
if (bodyKey !== keys.body) paintBody(pts, bodyKey);
|
|
740
|
+
|
|
741
|
+
let mback = '';
|
|
742
|
+
let mfront = '';
|
|
743
|
+
if (drawn.length) {
|
|
744
|
+
const ol = outlineOf(s, pal);
|
|
745
|
+
const c = {
|
|
746
|
+
pts: L.pts, cx: L.cx, top: L.box.y, W: L.box.w, H: L.box.h, mid: L.box.y + L.box.h * 0.55,
|
|
747
|
+
eyeY: L.eyeY, mouthY: L.mouthY, sp: L.sp, r: L.r, mw: L.mw, pal,
|
|
748
|
+
line: ol ? ol.color : pal.line, lw: ol ? R(Math.min(ol.w, 3.2)) : 1.8, fill: s.body.color,
|
|
749
|
+
hr: Math.max(6, L.box.w * 0.1),
|
|
750
|
+
};
|
|
751
|
+
c.aw = c.hr * 0.55;
|
|
752
|
+
// orden: alas detrás de todo, luego pinchos, cuernos y brazos
|
|
753
|
+
const order = ['wings', 'spikes', 'horns', 'hands'];
|
|
754
|
+
drawn.sort((a, b) => order.indexOf(a[0].name) - order.indexOf(b[0].name));
|
|
755
|
+
for (const [m, age] of drawn) {
|
|
756
|
+
const [bk, fr] = m.def.draw(c, m.k, age, { ...m.o, still: rm });
|
|
757
|
+
mback += bk;
|
|
758
|
+
mfront += fr;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (mback !== keys.mback) { g.mback.innerHTML = mback; keys.mback = mback; }
|
|
762
|
+
if (mfront !== keys.mfront) { g.mfront.innerHTML = mfront; keys.mfront = mfront; }
|
|
763
|
+
|
|
764
|
+
// 8) efectos
|
|
765
|
+
let fxFront = '';
|
|
766
|
+
let fxFace = '';
|
|
767
|
+
if (fxs.length) {
|
|
768
|
+
busy = true;
|
|
769
|
+
const c = {
|
|
770
|
+
cx: L.cx, top: L.box.y, W: L.box.w, H: L.box.h, eyeY: L.eyeY, mouthY: L.mouthY, sp: L.sp, r: L.r, mw: L.mw, pal,
|
|
771
|
+
};
|
|
772
|
+
fxs = fxs.filter((f) => {
|
|
773
|
+
const age = t - f.start;
|
|
774
|
+
let alpha = 1;
|
|
775
|
+
let p;
|
|
776
|
+
if (f.loop) {
|
|
777
|
+
if (!f.hold && age >= f.ms && f.end == null) f.end = t;
|
|
778
|
+
if (f.end != null) {
|
|
779
|
+
alpha = 1 - clamp((t - f.end) / FX_OUT);
|
|
780
|
+
if (alpha <= 0) return false;
|
|
781
|
+
}
|
|
782
|
+
p = rm ? 0.3 : (age % f.cycle) / f.cycle;
|
|
783
|
+
} else {
|
|
784
|
+
if (age >= f.ms) return false;
|
|
785
|
+
if (f.end != null) {
|
|
786
|
+
alpha = 1 - clamp((t - f.end) / FX_OUT);
|
|
787
|
+
if (alpha <= 0) return false;
|
|
788
|
+
}
|
|
789
|
+
p = rm ? 0.5 : age / f.ms;
|
|
790
|
+
}
|
|
791
|
+
const out = f.def.draw({ ...c, dir: f.dir }, p, rm ? 0 : age, f.o, seeded(f.seed));
|
|
792
|
+
const svgS = alpha < 1 ? `<g opacity="${R(alpha)}">${out}</g>` : out;
|
|
793
|
+
if (f.def.layer === 'face') fxFace += svgS;
|
|
794
|
+
else fxFront += svgS;
|
|
795
|
+
return true;
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
if (fxFront !== keys.fx) { g.fx.innerHTML = fxFront; keys.fx = fxFront; }
|
|
799
|
+
if (fxFace !== keys.fxface) { g.fxface.innerHTML = fxFace; keys.fxface = fxFace; }
|
|
800
|
+
|
|
801
|
+
// 9) zoom del encuadre para que quepan alas, manos, pinchos…
|
|
802
|
+
const zt = canZoom ? room : 1;
|
|
803
|
+
if (zoom !== zt) {
|
|
804
|
+
zoom = rm ? zt : approach(zoom, zt, dt, 180);
|
|
805
|
+
if (!settle(zoom, zt, 0.002)) zoom = zt;
|
|
806
|
+
else busy = true;
|
|
807
|
+
applyViewBox();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// 10) cara y cuerpo
|
|
811
|
+
paintFace(faceState());
|
|
812
|
+
const talk = mo.open;
|
|
813
|
+
const sx = pose.sx * (1 - 0.015 * talk);
|
|
814
|
+
const sy = pose.sy * (1 + 0.03 * talk);
|
|
815
|
+
const r = pose.r + (rm ? 0 : look.x * 2);
|
|
816
|
+
const ox = L0.cx;
|
|
817
|
+
const oy = L0.box.y + L0.box.h;
|
|
818
|
+
const tf = `translate(${R(ox + pose.x * L0.box.w)} ${R(oy + pose.y * L0.box.h)}) rotate(${R(r)}) scale(${R(sx * 1000) / 1000} ${R(sy * 1000) / 1000}) translate(${R(-ox)} ${R(-oy)})`;
|
|
819
|
+
if (tf !== keys.rig) { g.rig.setAttribute('transform', tf); keys.rig = tf; }
|
|
820
|
+
|
|
821
|
+
if (busy) raf = requestAnimationFrame(frame);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ── parpadeo y miradas espontáneas ──
|
|
825
|
+
function blink() {
|
|
826
|
+
if (!alive) return;
|
|
827
|
+
blinkAt = nowMs();
|
|
828
|
+
wake();
|
|
829
|
+
}
|
|
830
|
+
function idleTick() {
|
|
831
|
+
clearTimeout(blinkT);
|
|
832
|
+
if (!alive || !idle) return;
|
|
833
|
+
blinkT = setTimeout(() => {
|
|
834
|
+
if (moodName !== 'sleep' && !reduced()) {
|
|
835
|
+
blink();
|
|
836
|
+
if (Math.random() < 0.2) setTimeout(blink, 240);
|
|
837
|
+
// si nadie le dice dónde mirar, mira alrededor de vez en cuando
|
|
838
|
+
if (nowMs() - look.at > 3000 && Math.random() < 0.45) {
|
|
839
|
+
const back = Math.abs(look.tx) + Math.abs(look.ty) > 0.1 && Math.random() < 0.6;
|
|
840
|
+
look.tx = back ? 0 : (Math.random() - 0.5) * 0.9;
|
|
841
|
+
look.ty = back ? 0 : (Math.random() - 0.5) * 0.5;
|
|
842
|
+
wake();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
idleTick();
|
|
846
|
+
}, 1800 + Math.random() * 3600);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ── gestos ──
|
|
850
|
+
function endGesture() {
|
|
851
|
+
if (!gest) return;
|
|
852
|
+
const old = gest;
|
|
853
|
+
gest = null;
|
|
854
|
+
g.rig?.setAttribute('class', 'otsc-rig');
|
|
855
|
+
const hands = morphs.get('hands');
|
|
856
|
+
if (old.def.hands && hands && !hands.byGesture && hands.userPose !== undefined) setPose(hands, hands.userPose);
|
|
857
|
+
const wings = morphs.get('wings');
|
|
858
|
+
if (old.def.wings && wings && !wings.byGesture) wings.o.flap = wings.userFlap;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function gesture(name, o = {}) {
|
|
862
|
+
const gname = Object.hasOwn(GESTURES, name) ? name : GESTURE_ALIAS[name];
|
|
863
|
+
if (!gname || !g.rig || !alive) return false;
|
|
864
|
+
if (typeof o === 'number') o = { ms: o };
|
|
865
|
+
const def = GESTURES[gname];
|
|
866
|
+
const t = nowMs();
|
|
867
|
+
const ms = clamp(Number(o.ms) || def.ms, 200, 60000);
|
|
868
|
+
const dir = o.dir === -1 || o.mirror === true ? -1 : 1;
|
|
869
|
+
const from = gest ? { ...pose } : null;
|
|
870
|
+
if (gest) endGesture();
|
|
871
|
+
gest = { name: gname, def, start: t, ms, dir, from, fired: new Set() };
|
|
872
|
+
g.rig.setAttribute('class', `otsc-rig otsc-g-${gname}`);
|
|
873
|
+
// manos y alas que necesita el gesto (si ya estaban, solo cambian de postura)
|
|
874
|
+
const hands = morphs.get('hands');
|
|
875
|
+
if (def.hands) {
|
|
876
|
+
if (hands && hands.phase !== 'out') {
|
|
877
|
+
setPose(hands, def.hands);
|
|
878
|
+
hands.o.dir = dir;
|
|
879
|
+
if (hands.byGesture) hands.until = t + ms - MORPH_OUT;
|
|
880
|
+
} else morph('hands', { pose: def.hands, dir, ms: ms - MORPH_OUT, byGesture: true });
|
|
881
|
+
} else if (hands?.byGesture && hands.phase !== 'out') hands.until = t;
|
|
882
|
+
const wings = morphs.get('wings');
|
|
883
|
+
if (def.wings) {
|
|
884
|
+
if (wings && wings.phase !== 'out') {
|
|
885
|
+
wings.o.flap = def.wings;
|
|
886
|
+
if (wings.byGesture) wings.until = t + ms - MORPH_OUT;
|
|
887
|
+
} else morph('wings', { flap: def.wings, ms: ms - MORPH_OUT, byGesture: true });
|
|
888
|
+
} else if (wings?.byGesture && wings.phase !== 'out') wings.until = t;
|
|
889
|
+
wake();
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ── metamorfosis ──
|
|
894
|
+
function setPose(m, p) {
|
|
895
|
+
if (m.o.pose === p) return;
|
|
896
|
+
m.o.from = m.o.pose;
|
|
897
|
+
m.o.pose = p;
|
|
898
|
+
m.o.blend = 0;
|
|
899
|
+
m.poseAt = nowMs();
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function morph(name, o = {}) {
|
|
903
|
+
if (typeof name !== 'string' || !alive) return false;
|
|
904
|
+
if (o === false) return unmorph(name);
|
|
905
|
+
if (typeof o === 'number') o = { ms: o };
|
|
906
|
+
if (!Object.hasOwn(MORPHS, name) && Object.hasOwn(EFFECTS, name)) return effect(name, o);
|
|
907
|
+
let def;
|
|
908
|
+
let slot = name;
|
|
909
|
+
let target = null;
|
|
910
|
+
if (name.startsWith('shape:')) {
|
|
911
|
+
target = shapePoints(name.slice(6));
|
|
912
|
+
if (!target) return false;
|
|
913
|
+
def = { room: 1.12, shape: true };
|
|
914
|
+
slot = 'shape';
|
|
915
|
+
} else if (Object.hasOwn(MORPHS, name)) def = MORPHS[name];
|
|
916
|
+
else return false;
|
|
917
|
+
const t = nowMs();
|
|
918
|
+
const hold = o.hold === true || o.ms === Infinity;
|
|
919
|
+
const ms = hold ? Infinity : clamp(Number(o.ms) || MORPH_MS, 200, 600000);
|
|
920
|
+
const user = !o.byGesture;
|
|
921
|
+
const ex = morphs.get(slot);
|
|
922
|
+
if (ex) {
|
|
923
|
+
if (ex.phase === 'out') { ex.phase = 'in'; ex.start = t - MORPH_IN * clamp(ex.k); }
|
|
924
|
+
ex.until = Math.max(user ? 0 : ex.until, t + ms);
|
|
925
|
+
if (user) ex.byGesture = false;
|
|
926
|
+
if (slot === 'shape' && ex.target !== target) {
|
|
927
|
+
const b = ex.prev ? EASE.inOut(clamp((t - ex.bAt) / 420)) : 1;
|
|
928
|
+
ex.prev = b < 1 ? mixPts(ex.prev, ex.target, b) : ex.target;
|
|
929
|
+
ex.target = target;
|
|
930
|
+
ex.bAt = t;
|
|
931
|
+
}
|
|
932
|
+
if (o.pose) setPose(ex, o.pose);
|
|
933
|
+
if (o.dir) ex.o.dir = o.dir;
|
|
934
|
+
if (o.flap) ex.o.flap = o.flap;
|
|
935
|
+
if (o.color) ex.o.color = o.color;
|
|
936
|
+
if (user) { ex.userPose = ex.o.pose; ex.userFlap = ex.o.flap; }
|
|
937
|
+
} else {
|
|
938
|
+
const mo2 = { pose: name === 'hands' ? o.pose || 'rest' : undefined, dir: o.dir === -1 ? -1 : 1, flap: o.flap, color: /^#[0-9a-f]{6}$/i.test(o.color || '') ? o.color : undefined };
|
|
939
|
+
morphs.set(slot, {
|
|
940
|
+
name: slot, def, start: t, until: t + ms, phase: 'in', k: 0, o: mo2, target, prev: null, bAt: t,
|
|
941
|
+
byGesture: !user, userPose: user ? mo2.pose : undefined, userFlap: user ? mo2.flap : undefined,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
wake();
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function unmorph(name) {
|
|
949
|
+
const t = nowMs();
|
|
950
|
+
const slot = typeof name === 'string' && name.startsWith('shape') ? 'shape' : name;
|
|
951
|
+
for (const [k, m] of morphs) {
|
|
952
|
+
if (slot && k !== slot) continue;
|
|
953
|
+
if (m.phase !== 'out') { m.phase = 'out'; m.outAt = t; m.k0 = m.k; }
|
|
954
|
+
}
|
|
955
|
+
wake();
|
|
956
|
+
return true;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ── efectos ──
|
|
960
|
+
function effect(name, o = {}) {
|
|
961
|
+
if (typeof name !== 'string' || !alive) return false;
|
|
962
|
+
if (typeof o === 'number') o = { ms: o };
|
|
963
|
+
if (Object.hasOwn(MORPHS, name) || name.startsWith('shape:')) return morph(name, o);
|
|
964
|
+
if (!Object.hasOwn(EFFECTS, name)) return false;
|
|
965
|
+
const def = EFFECTS[name];
|
|
966
|
+
const ms = clamp(Number(o.ms) || (def.loop ? def.ms * 1.5 : def.ms), 100, 600000);
|
|
967
|
+
fxs.push({
|
|
968
|
+
name, def, start: nowMs(), ms, cycle: def.ms, loop: !!def.loop || o.hold === true, hold: o.hold === true,
|
|
969
|
+
dir: o.dir === -1 ? -1 : 1, o, tag: o.tag, seed: (++fxSeq * 7919) ^ uidSeq, end: null,
|
|
970
|
+
});
|
|
971
|
+
if (fxs.length > 24) fxs.shift();
|
|
972
|
+
wake();
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
function clearEffects(name) {
|
|
976
|
+
const t = nowMs();
|
|
977
|
+
for (const f of fxs) if (!name || f.name === name || f.tag === name) f.end ??= t;
|
|
978
|
+
wake();
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// ── voz / lip-sync ──
|
|
982
|
+
function endSpeech() {
|
|
983
|
+
const sp = speech;
|
|
984
|
+
speech = null;
|
|
985
|
+
mo.target = 0;
|
|
986
|
+
mo.tmsx = 1;
|
|
987
|
+
sp?.resolve?.();
|
|
988
|
+
wake();
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** Recorre una secuencia de visemas en `dur` ms; devuelve la «voz» con tick/sync. */
|
|
992
|
+
function visemeTrack(seq, dur) {
|
|
993
|
+
let W = 0;
|
|
994
|
+
for (const it of seq) { it.t0 = W; W += it.w; }
|
|
995
|
+
const tr = {
|
|
996
|
+
start: nowMs(),
|
|
997
|
+
idx: 0,
|
|
998
|
+
at(t) {
|
|
999
|
+
const pos = (clamp((t - tr.start) / dur) * W);
|
|
1000
|
+
if (pos < seq[tr.idx].t0) tr.idx = 0;
|
|
1001
|
+
while (tr.idx < seq.length - 1 && seq[tr.idx + 1].t0 <= pos) tr.idx++;
|
|
1002
|
+
const it = seq[tr.idx];
|
|
1003
|
+
return { v: VISEMES[it.v] || VISEMES.X, local: clamp((pos - it.t0) / (it.w || 1)) };
|
|
1004
|
+
},
|
|
1005
|
+
done: (t) => t - tr.start >= dur,
|
|
1006
|
+
/** Realinea con el índice de carácter que se está pronunciando (evento boundary del TTS). */
|
|
1007
|
+
sync(charIndex) {
|
|
1008
|
+
const i = seq.findIndex((it) => it.c >= charIndex);
|
|
1009
|
+
if (i < 0) return;
|
|
1010
|
+
tr.start = nowMs() - (seq[i].t0 / W) * dur;
|
|
1011
|
+
tr.idx = 0;
|
|
1012
|
+
},
|
|
1013
|
+
};
|
|
1014
|
+
return tr;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function speak(input, o = {}) {
|
|
1018
|
+
stopSpeaking();
|
|
1019
|
+
let seq;
|
|
1020
|
+
let dur;
|
|
1021
|
+
if (typeof input === 'number') {
|
|
1022
|
+
dur = clamp(input, 0, 600000);
|
|
1023
|
+
seq = babbleVisemes(dur, Math.floor(Math.random() * 1e6));
|
|
1024
|
+
} else {
|
|
1025
|
+
const text = String(input ?? '');
|
|
1026
|
+
seq = textToVisemes(text);
|
|
1027
|
+
dur = Number(o.durationMs) || Math.max(300, (text.length / 14 / (Number(o.rate) || 1)) * 1000);
|
|
1028
|
+
}
|
|
1029
|
+
let resolve;
|
|
1030
|
+
const pr = new Promise((r) => { resolve = r; });
|
|
1031
|
+
if (!seq.length || !dur || !alive) {
|
|
1032
|
+
resolve();
|
|
1033
|
+
return Object.assign(pr, { stop() {}, sync() {} });
|
|
1034
|
+
}
|
|
1035
|
+
const gain = Number(o.gain) || 1;
|
|
1036
|
+
const tr = visemeTrack(seq, dur);
|
|
1037
|
+
const sp = {
|
|
1038
|
+
resolve,
|
|
1039
|
+
tick(t) {
|
|
1040
|
+
if (tr.done(t)) return false;
|
|
1041
|
+
const { v, local } = tr.at(t);
|
|
1042
|
+
mo.target = clamp(v.open * (0.82 + 0.18 * Math.sin(Math.PI * local)) * gain);
|
|
1043
|
+
mo.tmsx = v.w;
|
|
1044
|
+
return true;
|
|
1045
|
+
},
|
|
1046
|
+
};
|
|
1047
|
+
speech = sp;
|
|
1048
|
+
wake();
|
|
1049
|
+
return Object.assign(pr, {
|
|
1050
|
+
stop() { if (speech === sp) endSpeech(); },
|
|
1051
|
+
sync(i) { if (speech === sp) tr.sync(i); },
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function lipsync(analyser, o = {}) {
|
|
1056
|
+
stopSpeaking();
|
|
1057
|
+
if (!analyser || typeof analyser.getByteFrequencyData !== 'function' || !alive) return () => {};
|
|
1058
|
+
const n = analyser.fftSize || 512;
|
|
1059
|
+
const buf = new Float32Array(n);
|
|
1060
|
+
const bytes = new Uint8Array(n);
|
|
1061
|
+
const freq = new Uint8Array(analyser.frequencyBinCount || n / 2);
|
|
1062
|
+
const rate = analyser.context?.sampleRate || 48000;
|
|
1063
|
+
const binHz = rate / n;
|
|
1064
|
+
const maxBin = Math.min(freq.length, Math.ceil(4000 / binHz));
|
|
1065
|
+
const gain = Number(o.gain) || 1;
|
|
1066
|
+
const tr = o.text ? visemeTrack(textToVisemes(o.text), Number(o.durationMs) || Math.max(300, (String(o.text).length / 14) * 1000)) : null;
|
|
1067
|
+
let peak = 0.05;
|
|
1068
|
+
const sp = {
|
|
1069
|
+
tick(t) {
|
|
1070
|
+
if (analyser.context?.state === 'closed') return false;
|
|
1071
|
+
let rms = 0;
|
|
1072
|
+
if (typeof analyser.getFloatTimeDomainData === 'function') {
|
|
1073
|
+
analyser.getFloatTimeDomainData(buf);
|
|
1074
|
+
for (let i = 0; i < n; i++) rms += buf[i] * buf[i];
|
|
1075
|
+
} else {
|
|
1076
|
+
analyser.getByteTimeDomainData(bytes);
|
|
1077
|
+
for (let i = 0; i < n; i++) { const v = (bytes[i] - 128) / 128; rms += v * v; }
|
|
1078
|
+
}
|
|
1079
|
+
rms = Math.sqrt(rms / n);
|
|
1080
|
+
// ganancia automática: lo que cuenta es el nivel relativo al pico reciente
|
|
1081
|
+
peak = Math.max(rms, peak * 0.995, 0.02);
|
|
1082
|
+
const lvl = clamp((rms - 0.006) / (peak * 0.7));
|
|
1083
|
+
const open = clamp(lvl ** 0.8 * gain);
|
|
1084
|
+
let w = 1;
|
|
1085
|
+
if (tr && !tr.done(t)) w = tr.at(t).v.w;
|
|
1086
|
+
else if (open > 0.05) {
|
|
1087
|
+
// timbre: centroide espectral bajo → boca redonda (o/u), alto → estirada (e/i)
|
|
1088
|
+
analyser.getByteFrequencyData(freq);
|
|
1089
|
+
let a = 0;
|
|
1090
|
+
let b = 0;
|
|
1091
|
+
for (let i = 1; i < maxBin; i++) { a += i * freq[i]; b += freq[i]; }
|
|
1092
|
+
const cHz = b ? (a / b) * binHz : 1000;
|
|
1093
|
+
w = clamp(0.6 + ((cHz - 500) / 1600) * 0.65, 0.6, 1.25);
|
|
1094
|
+
}
|
|
1095
|
+
mo.target = open;
|
|
1096
|
+
mo.tmsx = lerp(1, w, clamp(open * 2));
|
|
1097
|
+
return true;
|
|
1098
|
+
},
|
|
1099
|
+
};
|
|
1100
|
+
speech = sp;
|
|
1101
|
+
wake();
|
|
1102
|
+
return () => { if (speech === sp) endSpeech(); };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function stopSpeaking() {
|
|
1106
|
+
if (speech) endSpeech();
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
build();
|
|
1110
|
+
idleTick();
|
|
1111
|
+
// Si se montó oculto, reencuadrar al hacerse visible.
|
|
1112
|
+
let ro = null;
|
|
1113
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
1114
|
+
ro = new ResizeObserver(() => fit());
|
|
1115
|
+
ro.observe(container);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
return {
|
|
1119
|
+
svg,
|
|
1120
|
+
get spec() { return structuredClone(s); },
|
|
1121
|
+
/** Lo que está pasando ahora: ánimo, gesto, metamorfosis y efectos activos. */
|
|
1122
|
+
get state() {
|
|
1123
|
+
return { mood: moodName, gesture: gest?.name || null, morphs: [...morphs.keys()].filter((k) => morphs.get(k).phase !== 'out'), effects: fxs.map((f) => f.name), speaking: !!speech };
|
|
1124
|
+
},
|
|
1125
|
+
/** Apertura de boca 0..1 (lip-sync manual); se suaviza con ataque y relajación. */
|
|
1126
|
+
mouth(v) {
|
|
1127
|
+
mo.target = clamp(Number(v) || 0);
|
|
1128
|
+
wake();
|
|
1129
|
+
},
|
|
1130
|
+
/** Forma de boca: visema 'A','E','I','O','U','M','F','S','C' o 'X' (con apertura opcional). */
|
|
1131
|
+
viseme(id, open) {
|
|
1132
|
+
const v = VISEMES[id] || VISEMES.X;
|
|
1133
|
+
mo.target = clamp(open ?? v.open);
|
|
1134
|
+
mo.tmsx = v.w;
|
|
1135
|
+
wake();
|
|
1136
|
+
return !!VISEMES[id];
|
|
1137
|
+
},
|
|
1138
|
+
speak,
|
|
1139
|
+
lipsync,
|
|
1140
|
+
stopSpeaking,
|
|
1141
|
+
blink,
|
|
1142
|
+
/** Mirada: x,y en -1..1. */
|
|
1143
|
+
lookAt(x, y) {
|
|
1144
|
+
look.tx = clamp(Number(x) || 0, -1, 1);
|
|
1145
|
+
look.ty = clamp(Number(y) || 0, -1, 1);
|
|
1146
|
+
look.at = nowMs();
|
|
1147
|
+
wake();
|
|
1148
|
+
},
|
|
1149
|
+
/** Mirar hacia un punto de la pantalla. */
|
|
1150
|
+
lookAtPoint(clientX, clientY) {
|
|
1151
|
+
const r = svg.getBoundingClientRect();
|
|
1152
|
+
if (!r.width) return;
|
|
1153
|
+
this.lookAt((clientX - (r.left + r.width / 2)) / (window.innerWidth / 2), (clientY - (r.top + r.height * 0.45)) / (window.innerHeight / 2));
|
|
1154
|
+
},
|
|
1155
|
+
/** Ánimo (se interpola); { fx: false } para no lanzar su efecto. */
|
|
1156
|
+
mood(name, o = {}) {
|
|
1157
|
+
if (!MOOD[name]) return false;
|
|
1158
|
+
if (name === moodName) return true;
|
|
1159
|
+
moodName = name;
|
|
1160
|
+
clearEffects('mood');
|
|
1161
|
+
if (moodFx && o.fx !== false) for (const [fx, fo] of MOOD_FX[name] || []) effect(fx, fo || {});
|
|
1162
|
+
wake();
|
|
1163
|
+
return true;
|
|
1164
|
+
},
|
|
1165
|
+
/** Gesto de CHARACTER_GESTURES (o alias). o: { ms, dir: 1|-1, mirror } */
|
|
1166
|
+
gesture,
|
|
1167
|
+
/** Metamorfosis temporal: 'spikes'|'wings'|'hands'|'horns'|'puff'|'squish'|'stretch'|'melt'|'jelly'|'shape:<id>'. */
|
|
1168
|
+
morph,
|
|
1169
|
+
unmorph,
|
|
1170
|
+
/** Efecto de CHARACTER_EFFECTS: 'sparkles','sweat','blush','hearts','zzz','exclaim'… o: { ms, hold, dir } */
|
|
1171
|
+
effect,
|
|
1172
|
+
clearEffects,
|
|
1173
|
+
update(next) {
|
|
1174
|
+
s = normalizeCharacter(next);
|
|
1175
|
+
uid = `otsc${(++uidSeq).toString(36)}`;
|
|
1176
|
+
build();
|
|
1177
|
+
wake();
|
|
1178
|
+
},
|
|
1179
|
+
destroy() {
|
|
1180
|
+
alive = false;
|
|
1181
|
+
clearTimeout(blinkT);
|
|
1182
|
+
cancelAnimationFrame(raf);
|
|
1183
|
+
speech = null;
|
|
1184
|
+
ro?.disconnect();
|
|
1185
|
+
svg.remove();
|
|
1186
|
+
},
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
export { SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, ACCESSORIES, PRESETS, BODY_POINTS };
|
|
1191
|
+
export { ACCESSORY_GROUPS, partLabel } from './parts.js';
|