@7ots/cli 0.1.7 → 0.1.9

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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/cli/7ots.mjs +28 -4
  3. package/cli/lib/account.mjs +152 -2
  4. package/cli/lib/assistant.mjs +106 -41
  5. package/cli/lib/brain.mjs +13 -2
  6. package/cli/lib/config.mjs +3 -0
  7. package/cli/lib/inferences.mjs +30 -6
  8. package/cli/lib/integrations.mjs +69 -5
  9. package/cli/lib/orquesta.mjs +44 -1
  10. package/cli/lib/pet-server.mjs +344 -11
  11. package/cli/pet/electron/director.cjs +37 -5
  12. package/cli/pet/electron/main.cjs +92 -10
  13. package/cli/pet/pet.html +149 -10
  14. package/cli/pet/settings.html +545 -42
  15. package/dist/7ots.esm.js +216 -137
  16. package/dist/7ots.esm.js.map +4 -4
  17. package/dist/7ots.iife.js +216 -137
  18. package/dist/7ots.iife.js.map +4 -4
  19. package/package.json +1 -1
  20. package/server/channels/agent.mjs +7 -1
  21. package/server/channels/index.mjs +3 -2
  22. package/server/platform/connect.mjs +4 -1
  23. package/server/platform/db.mjs +9 -1
  24. package/server/platform/device.mjs +101 -4
  25. package/server/platform/mods.mjs +222 -0
  26. package/server/platform/orquesta.mjs +78 -14
  27. package/server/platform/profile.mjs +161 -0
  28. package/server/platform/routes.mjs +38 -33
  29. package/server/platform/runtime.mjs +3 -1
  30. package/server/platform/store.mjs +1 -1
  31. package/server/platform/vm.mjs +370 -91
  32. package/server/server.mjs +12 -2
  33. package/src/avatar/three.js +3 -2
  34. package/src/character/AvatarEditor.js +95 -7
  35. package/src/character/Character.js +116 -17
  36. package/src/character/TradingCard.js +226 -0
  37. package/src/character/mods/index.js +4 -0
  38. package/src/character/mods/orc.js +64 -0
  39. package/src/character/mods.js +251 -0
  40. package/src/character/more/acc-head.js +213 -0
  41. package/src/character/more/acc-rest.js +342 -0
  42. package/src/character/more/brows.js +39 -0
  43. package/src/character/more/cheeks.js +78 -0
  44. package/src/character/more/eyes.js +288 -0
  45. package/src/character/more/mouths.js +259 -0
  46. package/src/character/more/patterns.js +120 -0
  47. package/src/character/parts.js +90 -0
  48. package/src/character/skin.js +166 -1
  49. package/src/character/styles.js +1285 -9
  50. package/src/character3d/Character3D.js +148 -35
  51. package/src/character3d/index.js +7 -1
  52. package/src/character3d/model.js +367 -0
  53. package/src/i18n/messages/character.js +34 -22
  54. package/src/i18n/messages/platform.js +15 -0
  55. package/src/identity/random.js +17 -3
  56. package/src/identity/schema.js +10 -0
  57. package/src/index.js +1 -0
@@ -11,6 +11,8 @@
11
11
  import * as THREE from 'three';
12
12
  import { normalizeCharacter, layout, bodyPoints } from '../character/Character.js';
13
13
  import { stylizeSpec } from '../character/styles.js';
14
+ import { applyMods, modPart } from '../character/mods.js';
15
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
14
16
 
15
17
  /**
16
18
  * Acabado 3D de cada estilo artístico (styles.js): la paleta sale de stylizeSpec y aquí se elige
@@ -19,20 +21,34 @@ import { stylizeSpec } from '../character/styles.js';
19
21
  const STYLE_FINISH = {
20
22
  ukiyoe: 'toon', glass: 'glossy', bauhaus: 'toon', manuscript: 'toon', glitch: 'neon', clay: 'clay', ink: 'sketch',
21
23
  pop: 'toon', watercolor: 'soft3d', pixel: 'toon', synthwave: 'neon', nouveau: 'toon', cubism: 'toon', crayon: 'sketch', blueprint: 'neon', pointillism: 'soft3d', woodcut: 'sketch',
24
+ riso: 'toon', dotmatrix: 'toon', chalk: 'sketch', sticker: 'glossy', felt: 'clay', papercut: 'soft3d', lowpoly: 'toon', marble: 'soft3d', candy: 'glossy',
25
+ holo: 'glossy', sumie: 'sketch', tarot: 'toon', retro70: 'toon', mosaic: 'toon', neonsign: 'neon', graffiti: 'toon', thermal: 'soft3d',
22
26
  };
27
+ /** Piezas del catálogo 2 sin modelo 3D propio → la existente más parecida (campo `as3d` en parts.js). */
28
+ function alias3D(s) {
29
+ s.eyes = { ...s.eyes, type: as3d('eyes', s.eyes.type) };
30
+ s.brows = { ...s.brows, type: as3d('brows', s.brows.type) };
31
+ s.mouth = { ...s.mouth, type: as3d('mouths', s.mouth.type) };
32
+ s.cheeks = { ...s.cheeks, type: as3d('cheeks', s.cheeks.type) };
33
+ s.body = { ...s.body, pattern: as3d('patterns', s.body.pattern) };
34
+ s.finish = as3d('finishes', s.finish);
35
+ s.accessories = s.accessories.map((a) => ({ ...a, id: as3d('accessories', a.id) }));
36
+ return s;
37
+ }
23
38
  function styled3D(spec) {
24
- const s = stylizeSpec(spec);
39
+ const s = alias3D(stylizeSpec(spec));
25
40
  if (!s._style) return s;
26
41
  const f2 = s.finish;
27
42
  s.finish = STYLE_FINISH[s._style.id] || (f2 === 'clay' || f2 === 'sketch' ? f2 : s._style.lw > 0 ? 'toon' : f2 === 'flat' ? 'soft3d' : f2);
28
43
  return s;
29
44
  }
30
- import { shade, luminance } from '../character/parts.js';
45
+ import { shade, luminance, as3d } from '../character/parts.js';
31
46
  import { textureOf } from '../character/skin.js';
32
47
  import { GESTURES, GESTURE_ALIAS, VISEMES, EASE, clamp, lerp, approach, textToVisemes, babbleVisemes, HAND_POSES, MORPHS, EFFECTS, shapePoints, seeded } from '../character/motion.js';
33
48
  import { U, bodyGeometry, surfaceProbe } from './body.js';
34
49
  import { bodyMaterials, surfaceMaterial, mat, shadowTexture, CHEEK_IDS } from './materials.js';
35
50
  import { FX3D, glyph, disposeFx } from './fx.js';
51
+ import { buildModel, tickModel, modelGesture } from './model.js';
36
52
 
37
53
  const DEG = Math.PI / 180;
38
54
  const TAU = Math.PI * 2;
@@ -93,6 +109,26 @@ const nowMs = () => performance.now();
93
109
  const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
94
110
  const shared = (o) => { o.userData.shared = true; return o; };
95
111
 
112
+ // glb de los mods: uno por url, compartido entre ots (sus geometrías no se liberan con el rig)
113
+ const GLB = new Map();
114
+ function loadGlb(url) {
115
+ if (!GLB.has(url)) {
116
+ const p = new Promise((res, rej) => {
117
+ const loader = new GLTFLoader();
118
+ const done = (g) => res(g.scene);
119
+ if (url.startsWith('data:')) {
120
+ const bin = atob(url.slice(url.indexOf(',') + 1));
121
+ const buf = new Uint8Array(bin.length);
122
+ for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
123
+ loader.parse(buf.buffer, '', done, rej);
124
+ } else loader.load(url, done, undefined, rej);
125
+ });
126
+ p.catch(() => GLB.delete(url));
127
+ GLB.set(url, p);
128
+ }
129
+ return GLB.get(url);
130
+ }
131
+
96
132
  /** Malla con posición/rotación/escala opcionales. */
97
133
  function M(geo, material, p, r, s) {
98
134
  const m = new THREE.Mesh(geo, material);
@@ -143,7 +179,10 @@ export class Character3D {
143
179
  this.clearMorphObjects();
144
180
  for (const f of this.fxs) disposeFx(f.obj);
145
181
  this.fxs = [];
146
- const s = (this.spec = styled3D(normalizeCharacter(spec)));
182
+ const s = (this.spec = styled3D(applyMods(normalizeCharacter(spec), normalizeCharacter)));
183
+ this.modelMode = false;
184
+ this.modelTok = null;
185
+ if (s._model && s._model.url !== this.modelFailed) return this.buildModel(s); // mod de cuerpo completo (model.js)
147
186
  const st = s._style; // estilo artístico: sin pelo, con su paleta y su contorno
148
187
  const pts = bodyPoints(s);
149
188
  const L = (this.L = layout(s));
@@ -155,7 +194,7 @@ export class Character3D {
155
194
  const kk = (this.kk = Math.sqrt(W * H) / 1.25); // escala de accesorios (1 ≈ ot medio)
156
195
  const Y = (this.Y = (y) => (B.bottom - y) * U);
157
196
  const X = (this.X = (x) => (x - L.cx) * U);
158
- const tex = (this.tex = textureOf(s));
197
+ const tex = (this.tex = as3d('textures', textureOf(s)));
159
198
  const plush = (this.plush = !st && ((this.opts.material || 'plush') === 'plush' || ['fur', 'fuzz', 'feathers'].includes(tex)));
160
199
  const furLen = (this.furLen = plush ? (tex === 'fuzz' || tex === 'none' ? 0.05 : 0.065) * kk : 0);
161
200
  const density = (this.density = furLen ? (this.quality === 'high' ? 1 / (furLen * 0.2) : 1 / (furLen * 0.32)) : 0);
@@ -287,7 +326,7 @@ export class Character3D {
287
326
  if (!o) continue;
288
327
  // pivote en el centro del accesorio: escala/giro de la spec alrededor de sí mismo
289
328
  o.updateMatrixWorld(true);
290
- const c = o.userData.surface ? new THREE.Vector3() : new THREE.Box3().setFromObject(o).getCenter(new THREE.Vector3());
329
+ const c = o.userData.surface ? new THREE.Vector3() : o.userData.pivot ? o.userData.pivot.clone() : new THREE.Box3().setFromObject(o).getCenter(new THREE.Vector3());
291
330
  const piv = G(o);
292
331
  o.position.sub(c);
293
332
  piv.position.copy(c);
@@ -1147,16 +1186,77 @@ export class Character3D {
1147
1186
  return g;
1148
1187
  }
1149
1188
  default:
1150
- return null;
1189
+ return this.spec._modParts?.[id] ? this.buildModPart(this.spec._modParts[id], a, c) : null;
1151
1190
  }
1152
1191
  }
1153
1192
 
1193
+ /**
1194
+ * Pieza de un mod: su glb (unidades del mod: 1 = 1 px de un ot de 120 de ancho, y hacia
1195
+ * arriba) o, si no trae, su SVG en un plano. Se carga en segundo plano; el pivote es el ancla.
1196
+ */
1197
+ buildModPart(p, a, c) {
1198
+ const { L, X, Y, probe } = c;
1199
+ const at = L.anchors[p.anchor] || L.anchors.center;
1200
+ const sc = (L.box.w / 120) * U;
1201
+ const x0 = X(at.x);
1202
+ const y0 = Y(at.y);
1203
+ const z0 = p.depth === 'center' ? 0 : (probe.front(x0, y0)?.p.z ?? 0) + this.furLen;
1204
+ const g = G();
1205
+ g.position.set(x0, y0, z0);
1206
+ g.userData.pivot = g.position.clone();
1207
+ const plane = () => this.modPlane(p, a, sc);
1208
+ if (!p.glb) return g.add(plane()), g;
1209
+ const holder = G();
1210
+ holder.scale.setScalar(sc);
1211
+ g.add(holder);
1212
+ const tint = p.tint === 'body' ? this.spec.body.color : p.tint === 'color' ? a.color : '';
1213
+ loadGlb(p.glb).then((scene) => {
1214
+ const o = scene.clone(true);
1215
+ o.traverse((m) => {
1216
+ if (!m.isMesh) return;
1217
+ m.geometry.userData.shared = true;
1218
+ if (tint) {
1219
+ m.material = m.material.clone();
1220
+ m.material.color?.set(tint);
1221
+ m.userData.ownMaterial = true;
1222
+ }
1223
+ });
1224
+ holder.add(o);
1225
+ }).catch(() => g.add(plane()));
1226
+ return g;
1227
+ }
1228
+
1229
+ /** SVG de una pieza de mod pintado en un plano (respaldo 3D de los mods sin glb). */
1230
+ modPlane(p, a, sc) {
1231
+ const [vx, vy, vw, vh] = p.view;
1232
+ const def = modPart(this.spec, a.id, shade);
1233
+ const inner = def ? def.draw({ k: 1, color: a.color, color2: a.color2, body: this.spec.body.color }) : '';
1234
+ const px = 256 / Math.max(vw, vh);
1235
+ const cv = document.createElement('canvas');
1236
+ cv.width = Math.max(2, Math.round(vw * px));
1237
+ cv.height = Math.max(2, Math.round(vh * px));
1238
+ const tex = new THREE.CanvasTexture(cv);
1239
+ tex.colorSpace = THREE.SRGBColorSpace;
1240
+ const img = new Image();
1241
+ img.onload = () => {
1242
+ cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
1243
+ tex.needsUpdate = true;
1244
+ };
1245
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vx} ${vy} ${vw} ${vh}" width="${cv.width}" height="${cv.height}">${inner}</svg>`)}`;
1246
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(vw * sc, vh * sc), new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide }));
1247
+ m.position.set((vx + vw / 2) * sc, -(vy + vh / 2) * sc, p.layer === 'back' ? -0.01 : 0.01);
1248
+ m.userData.ownMaterial = true;
1249
+ return m;
1250
+ }
1251
+
1154
1252
  disposeRig() {
1155
1253
  this.rig.traverse((m) => {
1156
1254
  if (!m.isMesh) return;
1157
1255
  if (!m.geometry.userData.shared) m.geometry.dispose();
1158
1256
  if (m.userData.ownMaterial) m.material.dispose();
1159
1257
  });
1258
+ this.model?.mixer?.stopAllAction();
1259
+ this.model = null;
1160
1260
  this.rig.clear();
1161
1261
  if (this.shadow) {
1162
1262
  this.shadow.material.dispose();
@@ -1176,6 +1276,7 @@ export class Character3D {
1176
1276
 
1177
1277
  /** Avanza un frame. t: performance.now() ms; dt: ms. */
1178
1278
  tick(t, dt) {
1279
+ if (this.modelMode) return this.tickModel(t, dt);
1179
1280
  const rm = reducedMotion();
1180
1281
  const idle = this.opts.idle !== false;
1181
1282
  // parpadeo y miradas espontáneas
@@ -1228,26 +1329,7 @@ export class Character3D {
1228
1329
  }
1229
1330
  if (rm) Object.assign(pose, { x: 0, y: 0, r: 0, sx: 1, sy: 1 });
1230
1331
 
1231
- // metamorfosis: fases como en 2D (entra con rebote, sale suave)
1232
- let ksx = 1;
1233
- let ksy = 1;
1234
- const act = {};
1235
- for (const [slot, mm] of this.morphs) {
1236
- if (mm.phase !== 'out' && t >= mm.until) { mm.phase = 'out'; mm.outAt = t; mm.k0 = mm.k; }
1237
- if (mm.phase === 'in') {
1238
- const q = clamp((t - mm.start) / MORPH_IN);
1239
- mm.k = rm ? 1 : mm.shape ? EASE.inOut(q) : EASE.backOut(q);
1240
- if (q >= 1) mm.phase = 'on';
1241
- } else if (mm.phase === 'on') mm.k = 1;
1242
- else {
1243
- const q = clamp((t - mm.outAt) / MORPH_OUT);
1244
- mm.k = rm ? 0 : mm.k0 * (1 - EASE.inOut(q));
1245
- if (q >= 1) { this.morphs.delete(slot); this.endMorph(slot); continue; }
1246
- }
1247
- act[slot] = mm;
1248
- const sc = SCALE_MORPH[slot];
1249
- if (sc) { const [a, b] = sc(mm.k); ksx *= a; ksy *= b; }
1250
- }
1332
+ const { ksx, ksy, act } = this.tickMorphs(t, rm);
1251
1333
  if (act.shape && !act.shape.ready) this.startShape(act.shape);
1252
1334
  const shapeK = act.shape?.ready ? act.shape.k : 0;
1253
1335
  this.bodyU.uMorph.value.set(act.melt?.k || 0, rm ? 0 : act.jelly?.k || 0, shapeK, 0);
@@ -1385,7 +1467,42 @@ export class Character3D {
1385
1467
  hnd.scale.setScalar(Math.max(0.01, mhd.k * (p.s || 1)) * (type === 'open' ? 1 : 0.9));
1386
1468
  }
1387
1469
 
1388
- // efectos
1470
+ const blush = this.tickEffects(t, rm);
1471
+ this.bodyU.uBlush.value = blush;
1472
+ this.fxg.position.copy(rig.position);
1473
+ this.fxg.rotation.y = this.yaw;
1474
+
1475
+ for (const f of this.furs) f.u.uTime.value = sec;
1476
+ for (const a of this.anims) a(rm ? 0 : sec);
1477
+ }
1478
+
1479
+ /** Pinchos: conos instanciados en el contorno (sin la base) y en la espalda. */
1480
+ /** Fases de las metamorfosis como en 2D (entra con rebote, sale suave): { ksx, ksy, act }. */
1481
+ tickMorphs(t, rm) {
1482
+ let ksx = 1;
1483
+ let ksy = 1;
1484
+ const act = {};
1485
+ for (const [slot, mm] of this.morphs) {
1486
+ if (mm.phase !== 'out' && t >= mm.until) { mm.phase = 'out'; mm.outAt = t; mm.k0 = mm.k; }
1487
+ if (mm.phase === 'in') {
1488
+ const q = clamp((t - mm.start) / MORPH_IN);
1489
+ mm.k = rm ? 1 : mm.shape ? EASE.inOut(q) : EASE.backOut(q);
1490
+ if (q >= 1) mm.phase = 'on';
1491
+ } else if (mm.phase === 'on') mm.k = 1;
1492
+ else {
1493
+ const q = clamp((t - mm.outAt) / MORPH_OUT);
1494
+ mm.k = rm ? 0 : mm.k0 * (1 - EASE.inOut(q));
1495
+ if (q >= 1) { this.morphs.delete(slot); this.endMorph(slot); continue; }
1496
+ }
1497
+ act[slot] = mm;
1498
+ const sc = SCALE_MORPH[slot];
1499
+ if (sc) { const [a, b] = sc(mm.k); ksx *= a; ksy *= b; }
1500
+ }
1501
+ return { ksx, ksy, act };
1502
+ }
1503
+
1504
+ /** Avanza los efectos; devuelve el rubor que piden. */
1505
+ tickEffects(t, rm) {
1389
1506
  let blush = 0;
1390
1507
  if (this.fxs.length) {
1391
1508
  const c = this.fxc;
@@ -1409,15 +1526,9 @@ export class Character3D {
1409
1526
  return true;
1410
1527
  });
1411
1528
  }
1412
- this.bodyU.uBlush.value = blush;
1413
- this.fxg.position.copy(rig.position);
1414
- this.fxg.rotation.y = this.yaw;
1415
-
1416
- for (const f of this.furs) f.u.uTime.value = sec;
1417
- for (const a of this.anims) a(rm ? 0 : sec);
1529
+ return blush;
1418
1530
  }
1419
1531
 
1420
- /** Pinchos: conos instanciados en el contorno (sin la base) y en la espalda. */
1421
1532
  makeSpikes(color) {
1422
1533
  const L = this.L;
1423
1534
  const list = [];
@@ -1515,7 +1626,7 @@ export class Character3D {
1515
1626
 
1516
1627
  /** Limpia lo que deja una metamorfosis al terminar. */
1517
1628
  endMorph(slot) {
1518
- if (slot === 'shape') {
1629
+ if (slot === 'shape' && this.B) {
1519
1630
  for (const b of this.bodyMeshes) b.geometry = this.B.geometry;
1520
1631
  this.shapeGeo?.dispose();
1521
1632
  this.shapeGeo = null;
@@ -1794,3 +1905,5 @@ export class Character3D {
1794
1905
  this.root.removeFromParent();
1795
1906
  }
1796
1907
  }
1908
+
1909
+ Object.assign(Character3D.prototype, { buildModel, tickModel, modelGesture });
@@ -93,6 +93,7 @@ export function createCharacter3D(el, spec, opts = {}) {
93
93
  const ch = new Character3D(spec, { quality: opts.quality || 'high', material: opts.material || 'plush', moodFx: opts.moodFx });
94
94
  scene.add(ch.root);
95
95
  let aspect = 0;
96
+ let rev = ch.boundsRev;
96
97
  const frame = () => {
97
98
  const b = ch.bounds;
98
99
  const size = b.getSize(new THREE.Vector3());
@@ -114,8 +115,9 @@ export function createCharacter3D(el, spec, opts = {}) {
114
115
  bleed: opts.bleed || 0,
115
116
  tick(t, dt) {
116
117
  const a = this.rect.width / this.rect.height;
117
- if (Math.abs(a - aspect) > 1e-3) {
118
+ if (Math.abs(a - aspect) > 1e-3 || rev !== ch.boundsRev) {
118
119
  aspect = a;
120
+ rev = ch.boundsRev; // un mod de cuerpo completo cambia la caja al cargar su modelo
119
121
  camera.aspect = a;
120
122
  frame();
121
123
  }
@@ -239,6 +241,10 @@ export function createCrowd3D(el, opts = {}) {
239
241
  camera.aspect = a;
240
242
  fit();
241
243
  }
244
+ if (chars.some((c) => c.boundsRev !== c.seenRev)) {
245
+ for (const c of chars) c.seenRev = c.boundsRev;
246
+ fit();
247
+ }
242
248
  for (const c of chars) {
243
249
  c.tick(t, dt);
244
250
  c.lod(pixelHeight(this, c));
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Mod de cuerpo completo: un modelo 3D (VRM o GLB) en lugar del cuerpo procedural del ot.
3
+ *
4
+ * El modelo vive dentro del mismo `rig` de Character3D, así que gestos sin animación propia,
5
+ * efectos, ánimos y el encuadre siguen funcionando. Lo que el modelo traiga se aprovecha:
6
+ * - VRM (0.x y 1.0): expresiones estándar (aa/ih/ou/ee/oh, blink, happy, angry, sad,
7
+ * relaxed, surprised) y el hueso de la cabeza para mirar.
8
+ * - GLB: morph targets por nombre (blink, jawOpen/mouthOpen, happy/smile, sad, angry,
9
+ * surprised) y un hueso «head» si lo hay.
10
+ * - Animaciones: «idle» en bucle; las que se llamen como un gesto (wave, dance, yes, no,
11
+ * jump, thumbsup…) sustituyen a la pose procedural de ese gesto.
12
+ * Unidades: el modelo se escala a la altura de un ot (su caja), con y hacia arriba y de frente a +z
13
+ * (los VRM 0.x miran a -z y se giran solos).
14
+ */
15
+
16
+ import * as THREE from 'three';
17
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
18
+ import { layout } from '../character/Character.js';
19
+ import { EASE, clamp, approach } from '../character/motion.js';
20
+ import { U } from './body.js';
21
+ import { shadowTexture } from './materials.js';
22
+
23
+ const DEG = Math.PI / 180;
24
+ const TAU = Math.PI * 2;
25
+
26
+ /** Animación del modelo para cada gesto de 7ots (por nombre del clip). */
27
+ const CLIP_FOR = {
28
+ wave: /wave|hello|greet|hi\b/i,
29
+ dance: /dance/i,
30
+ celebrate: /victory|celebrat|cheer|dance/i,
31
+ jump: /jump/i,
32
+ bounce: /jump|hop/i,
33
+ nod: /\byes\b|nod/i,
34
+ shake: /^no$|\bno\b|shake_?head/i,
35
+ thumbsup: /thumbs?_?up|\byes\b/i,
36
+ thumbsdown: /thumbs?_?down|\bno\b/i,
37
+ bow: /bow/i,
38
+ think: /think/i,
39
+ clap: /clap/i,
40
+ point: /point/i,
41
+ laugh: /laugh/i,
42
+ stomp: /stomp|punch/i,
43
+ shrug: /shrug/i,
44
+ dizzy: /dizz|death|die/i,
45
+ yawn: /yawn|sit/i,
46
+ fly: /fly/i,
47
+ };
48
+ const IDLE = /idle|breath|stand/i;
49
+
50
+ /** Nombre de expresión VRM 0.x → VRM 1.0. */
51
+ const VRM0 = { a: 'aa', i: 'ih', u: 'ou', e: 'ee', o: 'oh', joy: 'happy', sorrow: 'sad', fun: 'relaxed', angry: 'angry', blink: 'blink', surprised: 'surprised' };
52
+ /** Morph targets de un GLB cualquiera → expresión. */
53
+ const MORPH_NAMES = [
54
+ ['aa', /jaw_?open|mouth_?open|viseme_?aa|^v_?aa$|^aa$|^a$/i],
55
+ ['oh', /viseme_?(oh|o|ou)$|^oh$|^o$|mouth_?(funnel|pucker)/i],
56
+ ['blink', /^blink$|blink_?both|eyes?_?clos|^eyeblink$|^blink/i],
57
+ ['happy', /happy|joy|smile/i],
58
+ ['sad', /sad|sorrow/i],
59
+ ['angry', /angry|anger|\bmad\b/i],
60
+ ['surprised', /surpris|shock/i],
61
+ ];
62
+ /** Ánimo de 7ots → expresiones del modelo (con su peso). */
63
+ const MOOD_EXPR = {
64
+ happy: { happy: 0.8 },
65
+ love: { happy: 0.7, relaxed: 0.3 },
66
+ angry: { angry: 0.85 },
67
+ disgust: { angry: 0.5 },
68
+ sad: { sad: 0.85 },
69
+ fear: { surprised: 0.5, sad: 0.3 },
70
+ surprised: { surprised: 0.9 },
71
+ sleep: { relaxed: 0.6 },
72
+ };
73
+
74
+ function loadGltf(url) {
75
+ return new Promise((res, rej) => {
76
+ const loader = new GLTFLoader();
77
+ if (url.startsWith('data:')) {
78
+ const bin = atob(url.slice(url.indexOf(',') + 1));
79
+ const buf = new Uint8Array(bin.length);
80
+ for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
81
+ loader.parse(buf.buffer, '', res, rej);
82
+ } else loader.load(url, res, undefined, rej);
83
+ });
84
+ }
85
+
86
+ const meshesUnder = (obj) => {
87
+ const out = [];
88
+ obj.traverse((o) => { if (o.isMesh && o.morphTargetInfluences) out.push(o); });
89
+ return out;
90
+ };
91
+
92
+ /** Expresiones del modelo: { nombre: [{ mesh, index, w }] }. */
93
+ async function expressionsOf(gltf) {
94
+ const ex = {};
95
+ const add = (name, mesh, index, w) => { if (Number.isInteger(index)) (ex[name] ||= []).push({ mesh, index, w }); };
96
+ const { parser } = gltf;
97
+ const ext = parser.json.extensions || {};
98
+ if (ext.VRMC_vrm) {
99
+ const all = { ...ext.VRMC_vrm.expressions?.preset, ...ext.VRMC_vrm.expressions?.custom };
100
+ for (const [name, e] of Object.entries(all)) {
101
+ for (const b of e?.morphTargetBinds || []) {
102
+ const node = await parser.getDependency('node', b.node).catch(() => null);
103
+ if (node) for (const m of meshesUnder(node)) add(name, m, b.index, b.weight ?? 1);
104
+ }
105
+ }
106
+ } else if (ext.VRM) {
107
+ const byMesh = new Map();
108
+ gltf.scene.traverse((o) => {
109
+ const a = parser.associations.get(o);
110
+ if (o.isMesh && a?.meshes != null) (byMesh.get(a.meshes) || byMesh.set(a.meshes, []).get(a.meshes)).push(o);
111
+ });
112
+ for (const g of ext.VRM.blendShapeMaster?.blendShapeGroups || []) {
113
+ const name = VRM0[String(g.presetName || '').toLowerCase()];
114
+ if (!name) continue;
115
+ for (const b of g.binds || []) for (const m of byMesh.get(b.mesh) || []) add(name, m, b.index, (b.weight ?? 100) / 100);
116
+ }
117
+ } else {
118
+ gltf.scene.traverse((o) => {
119
+ if (!o.isMesh || !o.morphTargetDictionary) return;
120
+ for (const [key, index] of Object.entries(o.morphTargetDictionary)) {
121
+ const hit = MORPH_NAMES.find(([, re]) => re.test(key));
122
+ if (hit) add(hit[0], o, index, 1);
123
+ }
124
+ });
125
+ }
126
+ return ex;
127
+ }
128
+
129
+ /** Hueso de la cabeza (VRM humanoid, o el primer hueso «head»). */
130
+ async function headOf(gltf) {
131
+ const { parser } = gltf;
132
+ const ext = parser.json.extensions || {};
133
+ const idx = ext.VRMC_vrm?.humanoid?.humanBones?.head?.node ?? ext.VRM?.humanoid?.humanBones?.find((b) => b.bone === 'head')?.node;
134
+ if (idx != null) return parser.getDependency('node', idx).catch(() => null);
135
+ let head = null;
136
+ const re = /^(mixamorig:?)?head$|^head[_\d]*$/i;
137
+ gltf.scene.traverse((o) => { if (!head && o.isBone && re.test(o.name)) head = o; });
138
+ // sin esqueleto: un nodo «head» rígido (modelos hechos por partes) también sirve
139
+ if (!head) gltf.scene.traverse((o) => { if (!head && !o.isMesh && re.test(o.name)) head = o; });
140
+ return head;
141
+ }
142
+
143
+ /**
144
+ * Construye el ot como modelo (llamado desde Character3D.build con la ficha ya resuelta).
145
+ * `this` es la instancia de Character3D.
146
+ */
147
+ export function buildModel(s) {
148
+ const m = s._model;
149
+ const L = (this.L = layout(s));
150
+ const W = (this.W = L.box.w * U);
151
+ const H = (this.H = Math.max(L.box.h * U, W) * 1.2);
152
+ this.modelMode = true;
153
+ this.kk = Math.sqrt(W * H) / 1.25;
154
+ this.depth = W * 0.6;
155
+ this.furLen = 0;
156
+ this.plush = false;
157
+ this.maxShells = 0;
158
+ this.pal = { body: s.body.color };
159
+ this.furs = [];
160
+ this.parts = [];
161
+ this.anims = [];
162
+ this.bodyMeshes = [];
163
+ this.eyes = [];
164
+ this.brows = [];
165
+ this.hands = [];
166
+ this.eyeY = H * 0.86;
167
+ this.r3 = W * 0.07;
168
+ this.model = { ex: {}, w: {}, head: null, q0: null, mixer: null, clips: [], idle: null, act: null, ready: false };
169
+ this.fxc = { W, H, D: this.depth, eyeY: this.eyeY, r: this.r3, sp: W * 0.12, mouth: new THREE.Vector3(0, H * 0.78, this.depth * 0.5), low: this.quality === 'low', tears: [] };
170
+
171
+ const sh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: shadowTexture(), transparent: true, depthWrite: false, toneMapped: false }));
172
+ sh.rotation.x = -Math.PI / 2;
173
+ sh.position.y = 0.002;
174
+ sh.scale.set(W * 1.2, this.depth * 2, 1);
175
+ sh.renderOrder = -1;
176
+ this.shadow = sh;
177
+ this.root.add(sh);
178
+
179
+ // caja provisional mientras carga (el encuadre se rehace al llegar el modelo)
180
+ this.bounds = new THREE.Box3(new THREE.Vector3(-W / 2, 0, -this.depth / 2), new THREE.Vector3(W / 2, H, this.depth / 2));
181
+ this.boundsRev = (this.boundsRev || 0) + 1;
182
+
183
+ const tok = (this.modelTok = {});
184
+ loadGltf(m.url)
185
+ .then(async (gltf) => {
186
+ if (this.modelTok !== tok) return;
187
+ const scene = gltf.scene;
188
+ const ext = gltf.parser.json.extensions || {};
189
+ if (ext.VRM) scene.rotation.y = Math.PI; // VRM 0.x mira a -z
190
+ scene.traverse((o) => {
191
+ if (!o.isMesh) return;
192
+ o.frustumCulled = false;
193
+ if (!Array.isArray(o.material)) o.userData.ownMaterial = true;
194
+ });
195
+ scene.updateMatrixWorld(true);
196
+ const box = new THREE.Box3().setFromObject(scene);
197
+ const size = box.getSize(new THREE.Vector3());
198
+ const c = box.getCenter(new THREE.Vector3());
199
+ if (!(size.y > 0)) throw new Error('empty model');
200
+ scene.position.set(-c.x, -box.min.y, -c.z);
201
+ const holder = new THREE.Group();
202
+ holder.add(scene);
203
+ const k = (H / size.y) * m.scale;
204
+ holder.scale.setScalar(k);
205
+ holder.rotation.y = m.rot * DEG;
206
+ holder.position.y = m.y * H;
207
+ this.rig.add(holder);
208
+
209
+ const md = this.model;
210
+ md.ex = await expressionsOf(gltf);
211
+ md.head = await headOf(gltf);
212
+ if (this.modelTok !== tok) return;
213
+ if (md.head) md.q0 = md.head.quaternion.clone();
214
+ if (gltf.animations.length) {
215
+ md.mixer = new THREE.AnimationMixer(scene);
216
+ md.clips = gltf.animations;
217
+ const idle = md.clips.find((a) => IDLE.test(a.name));
218
+ if (idle) {
219
+ md.idle = md.mixer.clipAction(idle);
220
+ md.idle.play();
221
+ }
222
+ md.mixer.addEventListener('finished', (e) => {
223
+ if (e.action !== md.act) return;
224
+ md.act = null;
225
+ e.action.fadeOut(0.25);
226
+ md.idle?.reset().fadeIn(0.25).play();
227
+ });
228
+ }
229
+ md.ready = true;
230
+
231
+ // encuadre y puntos de los efectos con el modelo real
232
+ this.rig.updateMatrixWorld(true);
233
+ const rb = new THREE.Box3().setFromObject(holder);
234
+ this.bounds = rb;
235
+ this.depth = Math.max(0.05, rb.max.z - rb.min.z);
236
+ this.W = Math.max(0.05, rb.max.x - rb.min.x);
237
+ this.shadow.scale.set(this.W * 1.1, this.depth * 1.6, 1);
238
+ if (md.head) {
239
+ const hp = md.head.getWorldPosition(new THREE.Vector3());
240
+ this.rig.worldToLocal(hp);
241
+ this.eyeY = this.fxc.eyeY = hp.y + H * 0.04;
242
+ this.fxc.mouth.set(hp.x, hp.y - H * 0.01, rb.max.z * 0.6);
243
+ }
244
+ this.fxc.W = this.W;
245
+ this.fxc.D = this.depth;
246
+ this.boundsRev++;
247
+ })
248
+ .catch((e) => {
249
+ if (this.modelTok !== tok) return;
250
+ console.warn('[7ots] modelo 3D del mod no disponible:', e?.message || e);
251
+ // sin modelo: un ot normal (la ficha sin el modelo)
252
+ const { _model, ...rest } = s;
253
+ this.modelFailed = m.url;
254
+ this.build({ ...rest, mods: (rest.mods || []).filter((x) => !x.model) });
255
+ });
256
+ }
257
+
258
+ /** Gesto con la animación propia del modelo, si la trae. Devuelve true si la usó. */
259
+ export function modelGesture(name) {
260
+ const md = this.model;
261
+ if (!md?.mixer) return false;
262
+ const re = CLIP_FOR[name];
263
+ const clip = re && md.clips.find((a) => re.test(a.name) && !IDLE.test(a.name));
264
+ if (!clip) return false;
265
+ const a = md.mixer.clipAction(clip);
266
+ if (md.act && md.act !== a) md.act.fadeOut(0.2);
267
+ a.reset();
268
+ a.setLoop(THREE.LoopOnce, 1);
269
+ a.clampWhenFinished = false;
270
+ a.fadeIn(0.2).play();
271
+ md.idle?.fadeOut(0.2);
272
+ md.act = a;
273
+ return true;
274
+ }
275
+
276
+ /** Un frame del ot-modelo (equivale a Character3D.tick). */
277
+ export function tickModel(t, dt) {
278
+ const rm = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
279
+ const idle = this.opts.idle !== false;
280
+ const md = this.model;
281
+ if (idle && t >= this.nextBlink) {
282
+ if (this.moodName !== 'sleep' && !rm) {
283
+ this.blinkAt = t;
284
+ if (t - this.look.at > 3000 && Math.random() < 0.45) {
285
+ this.look.tx = Math.random() < 0.5 ? 0 : (Math.random() - 0.5) * 0.9;
286
+ this.look.ty = Math.random() < 0.5 ? 0 : (Math.random() - 0.5) * 0.5;
287
+ }
288
+ }
289
+ this.nextBlink = t + 1800 + Math.random() * 3600;
290
+ }
291
+ if (this.speech && !this.speech.tick(t)) this.endSpeech();
292
+ const mo = this.mo;
293
+ mo.open = approach(mo.open, mo.target, dt, mo.target > mo.open ? 35 : 70);
294
+ mo.msx = approach(mo.msx, mo.tmsx, dt, 60);
295
+ const look = this.look;
296
+ look.x = rm ? look.tx : approach(look.x, look.tx, dt, 70);
297
+ look.y = rm ? look.ty : approach(look.y, look.ty, dt, 70);
298
+ this.sleepK = approach(this.sleepK, this.moodName === 'sleep' ? 1 : 0, dt, rm ? 1 : 260);
299
+
300
+ // gesto: la animación del modelo, o la pose procedural sobre todo el cuerpo
301
+ const pose = { x: 0, y: 0, r: 0, sx: 1, sy: 1, blink: 0 };
302
+ let spin = 0;
303
+ const gest = this.gest;
304
+ if (gest) {
305
+ const age = t - gest.start;
306
+ const tt = clamp(age / gest.ms);
307
+ if (gest.clip === undefined) gest.clip = md.ready && this.modelGesture(gest.name);
308
+ if (!gest.clip) {
309
+ Object.assign(pose, gest.def.pose(tt, rm ? 0 : age, gest));
310
+ if (gest.dir < 0) { pose.x = -pose.x; pose.r = -pose.r; }
311
+ pose.sx = 1 + (pose.sx - 1) * 0.4; // un muñeco se aplasta menos que un ot
312
+ pose.sy = 1 + (pose.sy - 1) * 0.4;
313
+ if (gest.name === 'spin') spin = EASE.inOut(clamp((tt - 0.12) / 0.76)) * TAU;
314
+ }
315
+ for (const f of gest.def.fx || []) {
316
+ if (tt >= f[0] && !gest.fired.has(f)) {
317
+ gest.fired.add(f);
318
+ this.effect(f[1], { dir: gest.dir, ...(f[2] || {}) });
319
+ }
320
+ }
321
+ if (tt >= 1) this.endGesture();
322
+ }
323
+ if (rm) Object.assign(pose, { x: 0, y: 0, r: 0, sx: 1, sy: 1 });
324
+ const { ksx, ksy } = this.tickMorphs(t, rm);
325
+
326
+ const W = this.W;
327
+ const H = this.H;
328
+ const sec = t / 1000;
329
+ const breath = rm || !idle || md.idle ? 0 : Math.sin(sec * TAU / 3.4 + this.phase);
330
+ const rig = this.rig;
331
+ rig.position.set(pose.x * W, -pose.y * H, 0);
332
+ rig.rotation.set(md.head ? 0 : look.y * 0.1, this.yaw + look.x * (md.head ? 0.12 : 0.3) + spin + (rm || !idle ? 0 : Math.sin(sec * 0.7 + this.phase) * 0.03), -(pose.r + look.x * 1.5) * DEG);
333
+ rig.scale.set(pose.sx * ksx * (1 - 0.004 * breath), pose.sy * ksy * (1 + 0.01 * breath), pose.sx * ksx);
334
+ const air = Math.max(0, -pose.y);
335
+ this.shadow.material.opacity = 1 - air * 1.5;
336
+
337
+ // animaciones y cabeza
338
+ if (md.head && md.q0) md.head.quaternion.copy(md.q0);
339
+ md.mixer?.update(rm ? 0 : dt / 1000);
340
+ if (md.head && md.head.parent) {
341
+ const pw = md.head.parent.getWorldQuaternion(new THREE.Quaternion());
342
+ const rw = rig.getWorldQuaternion(new THREE.Quaternion());
343
+ const d = new THREE.Quaternion().setFromEuler(new THREE.Euler(look.y * 0.35, look.x * 0.55, 0, 'YXZ'));
344
+ d.premultiply(rw).multiply(rw.clone().invert()); // giro en el espacio del ot
345
+ md.head.quaternion.premultiply(pw.clone().invert().multiply(d).multiply(pw));
346
+ }
347
+
348
+ // expresiones
349
+ const blink = Math.max(this.blinkValue(t), pose.blink, this.sleepK > 0.02 ? Math.min(1, this.sleepK * 1.1) : 0);
350
+ const round = clamp((1 - mo.msx) / 0.35);
351
+ const want = { ...(MOOD_EXPR[this.moodName] || {}) };
352
+ for (const k in md.w) if (!(k in want)) want[k] = 0;
353
+ for (const k in want) md.w[k] = rm ? want[k] : approach(md.w[k] || 0, want[k], dt, 120);
354
+ const val = { ...md.w, blink: Math.max(blink, md.w.blink || 0), aa: mo.open * (md.ex.oh ? 1 - round : 1), oh: mo.open * round };
355
+ if (val.happy > 0.3) val.blink = Math.max(val.blink * (1 - val.happy), 0); // VRM: happy ya entrecierra los ojos
356
+ const touched = new Set();
357
+ for (const name in md.ex) for (const b of md.ex[name]) if (!touched.has(b)) { b.mesh.morphTargetInfluences[b.index] = 0; touched.add(b); }
358
+ for (const name in md.ex) {
359
+ const v = val[name] || 0;
360
+ if (!v) continue;
361
+ for (const b of md.ex[name]) b.mesh.morphTargetInfluences[b.index] = Math.min(1, b.mesh.morphTargetInfluences[b.index] + v * b.w);
362
+ }
363
+
364
+ this.tickEffects(t, rm);
365
+ this.fxg.position.copy(rig.position);
366
+ this.fxg.rotation.y = this.yaw;
367
+ }