@7ots/cli 0.1.1 → 0.1.4

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 (51) hide show
  1. package/.env.example +17 -1
  2. package/cli/7ots.mjs +144 -36
  3. package/cli/lib/assistant.mjs +347 -0
  4. package/cli/lib/brain.mjs +10 -7
  5. package/cli/lib/config.mjs +8 -1
  6. package/cli/lib/i18n.mjs +84 -11
  7. package/cli/lib/install.mjs +29 -1
  8. package/cli/lib/pet-core.mjs +3 -2
  9. package/cli/lib/pet-server.mjs +133 -13
  10. package/cli/lib/prompts.mjs +199 -7
  11. package/cli/lib/terminal.mjs +27 -6
  12. package/cli/pet/electron/main.cjs +96 -32
  13. package/cli/pet/pet.html +59 -6
  14. package/cli/pet/settings.html +430 -0
  15. package/dist/7ots.esm.js +370 -142
  16. package/dist/7ots.esm.js.map +4 -4
  17. package/dist/7ots.iife.js +370 -142
  18. package/dist/7ots.iife.js.map +4 -4
  19. package/llms.txt +198 -6
  20. package/package.json +1 -1
  21. package/server/channels/apuchat.mjs +8 -5
  22. package/server/channels/apumail.mjs +13 -3
  23. package/server/channels/index.mjs +5 -3
  24. package/server/platform/apuchat.mjs +266 -0
  25. package/server/platform/auth.mjs +39 -10
  26. package/server/platform/connect.mjs +347 -0
  27. package/server/platform/db.mjs +55 -0
  28. package/server/platform/look.mjs +20 -0
  29. package/server/platform/orquesta.mjs +499 -0
  30. package/server/platform/routes.mjs +57 -8
  31. package/server/platform/runtime.mjs +75 -15
  32. package/server/platform/store.mjs +130 -9
  33. package/server/server.mjs +14 -3
  34. package/src/avatar/AvatarStage.js +54 -9
  35. package/src/avatar/three.js +62 -0
  36. package/src/character/AvatarEditor.js +174 -50
  37. package/src/character/Character.js +164 -26
  38. package/src/character/motion.js +95 -34
  39. package/src/character/parts.js +43 -12
  40. package/src/character/skin.js +412 -0
  41. package/src/character/styles.js +1429 -0
  42. package/src/character3d/Character3D.js +1693 -0
  43. package/src/character3d/body.js +154 -0
  44. package/src/character3d/fx.js +323 -0
  45. package/src/character3d/index.js +281 -0
  46. package/src/character3d/materials.js +429 -0
  47. package/src/character3d/stage.js +187 -0
  48. package/src/i18n/messages/character.js +18 -42
  49. package/src/i18n/messages/platform.js +135 -0
  50. package/src/identity/schema.js +16 -8
  51. package/src/index.js +1 -0
@@ -13,16 +13,24 @@
13
13
  * const stop = ch.lipsync(analyserNode); // …o del audio real (Web Audio)
14
14
  * ch.mouth(0.7); ch.lookAt(0.5, -0.2); ch.update(otroSpec);
15
15
  *
16
+ * La piel (textura de superficie: pelo, escamas, plumas…, `texture`) y la unión de las piezas
17
+ * que crecen del cuerpo (pinchos, alas, brazos, orejas…) están en skin.js.
18
+ *
16
19
  * Gestos, metamorfosis, efectos y visemas están en motion.js. Todo es temporal salvo que se
17
20
  * pida { hold: true }, y con prefers-reduced-motion el cuerpo no se mueve.
18
21
  *
22
+ * Estilos artísticos (`style`: ukiyo-e, plastilina, vitral, Bauhaus, manuscrito, glitch, tinta)
23
+ * en styles.js: remapean la paleta, añaden un filtro y una capa al cuerpo y un fondo a juego.
24
+ *
19
25
  * Todo el SVG sale de catálogos propios; lo que llega del usuario se reduce a ids de catálogo,
20
26
  * colores #rrggbb y números acotados (normalizeCharacter), así que es seguro inyectarlo.
21
27
  */
22
28
 
23
29
  import {
24
- BODY_POINTS, SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, ACCESSORIES, PRESETS, shade, luminance,
30
+ BODY_POINTS, SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, PRESETS, shade, luminance,
25
31
  } from './parts.js';
32
+ import { renderSkin, joinSVG } from './skin.js';
33
+ import { STYLE_IDS, stylizeSpec, styleLayers, styleSeed, backdropIn, backdropCSS } from './styles.js';
26
34
  import { translator } from '../i18n/index.js';
27
35
  import {
28
36
  GESTURES, GESTURE_ALIAS, MORPHS, EFFECTS, VISEMES, POSE0, EASE, clamp, lerp, approach, seeded,
@@ -63,6 +71,7 @@ const MOUTH = byId(MOUTHS);
63
71
  const CHEEK = byId(CHEEKS);
64
72
  const PATTERN = byId(PATTERNS);
65
73
  const FINISH = byId(FINISHES);
74
+ const TEXTURE = byId(TEXTURES);
66
75
  const ACC = byId(ACCESSORIES);
67
76
  const PRESET = byId(PRESETS);
68
77
 
@@ -70,6 +79,7 @@ export const DEFAULT_CHARACTER = Object.freeze({
70
79
  v: 1,
71
80
  body: { shape: 'pou', points: null, color: '#a0714f', pattern: 'belly', patternColor: '#c9a27e' },
72
81
  finish: 'glossy',
82
+ texture: 'auto',
73
83
  outline: 'auto',
74
84
  eyes: { type: 'round', size: 1, spacing: 1, y: 0, iris: '#3b82f6', white: '#ffffff', ink: '#1a1a24' },
75
85
  brows: { type: 'none', color: '' },
@@ -137,7 +147,11 @@ export function normalizeCharacter(input = {}) {
137
147
  patternColor: hex(body.patternColor, shade(bodyColor, 0.3)),
138
148
  },
139
149
  finish: pickId(FINISH, i.finish ?? base.finish, 'glossy'),
150
+ // textura de superficie; 'auto' (o ausente, specs antiguas) la elige la propia spec (skin.js)
151
+ texture: pickId(TEXTURE, i.texture ?? base.texture, 'auto'),
140
152
  outline: ['auto', 'none', 'thin', 'bold'].includes(i.outline) ? i.outline : 'auto',
153
+ // estilo artístico (styles.js); 'none' no se guarda: las specs de siempre no cambian
154
+ ...(STYLE_IDS.includes(i.style ?? base.style) && (i.style ?? base.style) !== 'none' ? { style: i.style ?? base.style } : {}),
141
155
  eyes: {
142
156
  type: pickId(EYE, eyes.type, 'round'),
143
157
  size: num(eyes.size, 1, 0.4, 2.2),
@@ -282,6 +296,8 @@ function palette(spec) {
282
296
  function outlineOf(spec, pal) {
283
297
  const o = spec.outline;
284
298
  if (o === 'none') return null;
299
+ // el estilo artístico manda en el trazo (lw 0: sin contorno, p. ej. plastilina)
300
+ if (spec._style) return spec._style.lw ? { color: spec._style.ink, w: spec._style.lw } : null;
285
301
  if (o === 'bold' || spec.finish === 'toon') return { color: '#1f2937', w: 4.5 };
286
302
  if (spec.finish === 'sketch') return { color: '#1f2937', w: 2.2 };
287
303
  if (spec.finish === 'neon') return { color: spec.body.color, w: 3.5 };
@@ -334,9 +350,15 @@ function mouthSVG(spec, L, st, pal) {
334
350
  return `<g transform="translate(${R(L.cx)} ${R(L.mouthY)})${sc}">${MOUTH[spec.mouth.type].draw(p)}</g>`;
335
351
  }
336
352
 
337
- function accessorySVG(a, spec, L, pal, clipUrl) {
353
+ /** ¿Esta pieza crece del cuerpo? Solo si sigue cerca de su ancla (si el usuario la movió, va encima como antes). */
354
+ function growing(a, part) {
355
+ return !!part.grow && !!part.shape && Math.abs(a.x) <= 24 && a.y <= 12 && a.y >= -40 && Math.abs(a.rot) <= 35 && a.scale >= 0.6;
356
+ }
357
+
358
+ function accessorySVG(a, spec, L, pal, clipUrl, skin, key) {
338
359
  const part = ACC[a.id];
339
360
  const at = L.anchors[part.anchor] || L.anchors.center;
361
+ const grow = growing(a, part);
340
362
  const c = {
341
363
  k: L.box.w / 120,
342
364
  m: L.mw / 16,
@@ -352,20 +374,45 @@ function accessorySVG(a, spec, L, pal, clipUrl) {
352
374
  body: pal.body,
353
375
  above: at.y - L.box.y,
354
376
  below: L.anchors.bottom.y - at.y,
377
+ grow,
355
378
  };
356
379
  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
380
  const main = `<g transform="${tf}">${part.draw(c)}</g>`;
358
381
  const extra = part.extra ? `<g transform="${tf}">${part.extra(c)}</g>` : '';
359
382
  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;
383
+ if (grow) {
384
+ // unida: luz del cuerpo (y su textura si es de piel, p. ej. orejas) + sombra de contacto
385
+ const shapes = part.shape(c).map((sh) => ({ ...sh, tf: sh.tf ? `${tf} ${sh.tf}` : tf }));
386
+ let over = '';
387
+ for (const sh of shapes) {
388
+ const T = ` transform="${sh.tf}"`;
389
+ if (sh.sw) {
390
+ if (skin.lit) over += `<path d="${sh.d}"${T} fill="none" stroke="${skin.lit}" stroke-width="${sh.sw}" stroke-linecap="round"/>`;
391
+ continue;
392
+ }
393
+ if (skin.lit) over += `<path d="${sh.d}"${T} fill="${skin.lit}"/>`;
394
+ if (part.grow === 'skin' && skin.tex) over += `<path d="${sh.d}"${T} fill="${skin.tex}"/>`;
395
+ }
396
+ const j = joinSVG(skin, shapes, key);
397
+ return { svg: main + over + j.shadow + extra, mask: j.mask };
398
+ }
399
+ if (part.clip) return { svg: clip(main) + extra, mask: '' };
400
+ if (part.clipExtra) return { svg: main + clip(extra), mask: '' };
401
+ return { svg: main + extra, mask: '' };
402
+ }
403
+
404
+ /** Máscara del contorno del cuerpo: blanco salvo donde nacen las piezas unidas (en negro). */
405
+ export function outlineMask(uid, inner) {
406
+ return `<mask id="${uid}-om" maskUnits="userSpaceOnUse" x="-600" y="-600" width="1400" height="1400"><rect x="-600" y="-600" width="1400" height="1400" fill="#fff"/>${inner}</mask>`;
363
407
  }
364
408
 
365
409
  /**
366
410
  * Piezas del SVG por capas (lo usa createCharacter para animar sin re-renderizar todo).
367
411
  * 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 }}
412
+ * opts.detail: 'full' | 'lite' (sin filtros en la textura) | 'off' (sin textura).
413
+ * opts.live: la máscara del contorno la gestiona quien llama (outlineMask con `mask` + la de las metamorfosis).
414
+ * opts.animate: animar los filtros del estilo (glitch); spec ya estilizada con stylizeSpec.
415
+ * @returns {{ viewBox:string, defs:string, back:string, body:string, cheeks:string, eyes:string, brows:string, mouth:string, front:string, mask:string, filter:string, skin:object, L:object }}
369
416
  */
370
417
  export function renderLayers(spec, state = {}, opts = {}) {
371
418
  const st = { open: 0, blink: 0, lx: 0, ly: 0, mood: 'neutral', browLift: 0, ...state };
@@ -381,9 +428,14 @@ export function renderLayers(spec, state = {}, opts = {}) {
381
428
 
382
429
  let defs = `<clipPath id="${id}-clip"><path d="${d}"/></clipPath>`;
383
430
  let fill = col;
431
+ let stops = null;
384
432
  if (f === 'glossy' || f === 'soft3d' || f === 'clay') {
385
433
  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>`;
434
+ stops = [[0, shade(col, a)], [0.55, col], [1, shade(col, b)]];
435
+ // en espacio de usuario (con la caja del cuerpo): las piezas unidas que lo usan siguen la misma luz
436
+ defs += `<radialGradient id="${id}-g" gradientUnits="userSpaceOnUse" cx=".36" cy=".28" r=".85" gradientTransform="translate(${R(box.x)} ${R(box.y)}) scale(${R(box.w)} ${R(box.h)})">${stops.map(([o, c]) => `<stop offset="${o}" stop-color="${c}"/>`).join('')}</radialGradient>`;
437
+ // la misma luz, en translúcido, para piezas de otro color (cuernos, orejas…)
438
+ defs += `<radialGradient id="${id}-lit" cx="32%" cy="24%" r="90%"><stop offset="0" stop-color="#fff" stop-opacity=".3"/><stop offset=".5" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".22"/></radialGradient>`;
387
439
  fill = `url(#${id}-g)`;
388
440
  }
389
441
  if (f === 'soft3d') defs += `<filter id="${id}-soft" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="${R(box.w * 0.06)}"/></filter>`;
@@ -394,13 +446,38 @@ export function renderLayers(spec, state = {}, opts = {}) {
394
446
  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
447
  }
396
448
 
449
+ // Piel: textura de superficie y lo que comparten las piezas unidas (metamorfosis y accesorios)
450
+ const bodyFill = f === 'neon' ? shade(col, -0.72) : fill;
451
+ const sk = renderSkin(spec, L, opts.pts ? layout(spec) : L, { id, d, fill: bodyFill, col, line: ol, quality: opts.detail || 'full' });
452
+ defs += sk.defs;
453
+ const skin = {
454
+ uid: id, d, box, clip: clipUrl, fill: bodyFill, stops: f === 'neon' ? null : stops, solid: bodyFill.startsWith('#') ? bodyFill : col,
455
+ lit: stops ? `url(#${id}-lit)` : '', tex: sk.part, texture: sk.tex,
456
+ line: ol ? ol.color : '', lw: ol ? R(Math.min(ol.w, 3.2)) : 0, lineOp: ol?.op || 0,
457
+ };
458
+
459
+ // Accesorios (los que crecen del cuerpo van detrás y recortan su contorno)
460
+ let back = '';
461
+ let front = '';
462
+ let mask = '';
463
+ spec.accessories.forEach((a, i) => {
464
+ const part = ACC[a.id];
465
+ const x = accessorySVG(a, spec, L, pal, clipUrl, skin, `a${i}`);
466
+ if (part.layer === 'back' || growing(a, part)) back += x.svg;
467
+ else front += x.svg;
468
+ mask += x.mask;
469
+ });
470
+ const masked = opts.live || !!mask;
471
+ if (mask && !opts.live) defs += outlineMask(id, mask);
472
+
397
473
  // Capa del cuerpo
398
474
  let body = '';
399
475
  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)"` : ''}/>`;
476
+ body += `<path d="${d}" fill="${bodyFill}"${f === 'neon' ? ` filter="url(#${id}-glow)"` : ''}/>`;
401
477
  let inner = '';
402
478
  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
479
  if (spec.body.pattern !== 'none') inner += PATTERN[spec.body.pattern].draw({ box, color: spec.body.patternColor, eyeY: L.eyeY, r: L.r });
480
+ inner += sk.inner;
404
481
  if (f === 'glossy') {
405
482
  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
483
  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"/>`;
@@ -412,14 +489,15 @@ export function renderLayers(spec, state = {}, opts = {}) {
412
489
  }
413
490
  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
491
  if (f === 'sketch') inner += `<path d="${d}" fill="url(#${id}-hatch)" transform="translate(${R(box.w * 0.05)} ${R(box.h * 0.06)})"/>`;
492
+ const sty = styleLayers(spec, L, { uid: id, d, quality: opts.detail || 'full', animate: opts.animate });
493
+ defs += sty.defs;
494
+ inner += sty.inner;
415
495
  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)"` : ''}/>`;
496
+ 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)"` : ''}${masked ? ` mask="url(#${id}-om)"` : ''}/>`;
497
+ body += sk.rim;
417
498
 
418
499
  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
500
 
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
501
  return {
424
502
  L,
425
503
  uid: id,
@@ -432,6 +510,9 @@ export function renderLayers(spec, state = {}, opts = {}) {
432
510
  brows: browsSVG(spec, L, st, pal),
433
511
  mouth: mouthSVG(spec, L, st, pal),
434
512
  front,
513
+ mask,
514
+ filter: sty.filter,
515
+ skin,
435
516
  };
436
517
  }
437
518
 
@@ -439,13 +520,17 @@ export function renderLayers(spec, state = {}, opts = {}) {
439
520
  * SVG completo como string (sirve en Node, para miniaturas o para <img src="data:…">).
440
521
  * @param {object} spec (se normaliza)
441
522
  * @param {{open?:number, blink?:number, lx?:number, ly?:number, mood?:string}} [state]
442
- * @param {{viewBox?:string, title?:string}} [opts]
523
+ * @param {{viewBox?:string, title?:string, backdrop?:boolean}} [opts]
524
+ * backdrop: con estilo artístico, pintar su fondo detrás (defecto true).
443
525
  */
444
526
  export function renderCharacter(spec, state = {}, opts = {}) {
445
- const s = normalizeCharacter(spec);
527
+ const src = normalizeCharacter(spec);
528
+ const s = stylizeSpec(src);
446
529
  const x = renderLayers(s, state, opts);
447
530
  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>`;
531
+ const parts = `${x.back}${x.body}${x.cheeks}${x.eyes}${x.brows}${x.mouth}${x.front}`;
532
+ const bd = src.style && opts.backdrop !== false ? backdropIn(src.style, styleSeed(src), x.viewBox, `${x.uid}-bd`) : '';
533
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${x.viewBox}" role="img"${opts.title ? '' : ' aria-hidden="true"'}>${title}<defs>${x.defs}</defs>${bd}${x.filter ? `<g filter="${x.filter}">${parts}</g>` : parts}</svg>`;
449
534
  }
450
535
 
451
536
  // ───────────────────────────── vivo ─────────────────────────────
@@ -490,6 +575,7 @@ const mixPts = (a, b, k) => a.map((p, i) => [lerp(p[0], b[i][0], k), lerp(p[1],
490
575
  * idle: respirar, parpadear y mirar alrededor solo (defecto true).
491
576
  * moodFx: efectos al cambiar de ánimo (pinchos al enfadarse, zzz al dormir…; defecto true).
492
577
  * zoom: alejar el encuadre cuando una metamorfosis no cabe (defecto true con fit 'all'/'body').
578
+ * backdrop: con estilo artístico, su fondo como background del <svg> (defecto true).
493
579
  */
494
580
  export function createCharacter(container, spec, opts = {}) {
495
581
  const NS = 'http://www.w3.org/2000/svg';
@@ -505,8 +591,12 @@ export function createCharacter(container, spec, opts = {}) {
505
591
  const fitMode = opts.fit || 'all';
506
592
  const canZoom = opts.zoom !== false && (fitMode === 'all' || fitMode === 'body');
507
593
  const reduced = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
594
+ // equipos modestos: textura sin filtros
595
+ const lowEnd = typeof navigator !== 'undefined' && ((navigator.hardwareConcurrency || 8) <= 2 || (navigator.deviceMemory || 8) <= 2);
508
596
 
509
- let s = normalizeCharacter(spec);
597
+ // src: la spec tal cual (la que se devuelve); s: la que se pinta (con el estilo artístico aplicado)
598
+ let src = normalizeCharacter(spec);
599
+ let s = stylizeSpec(src);
510
600
  let uid = `otsc${(++uidSeq).toString(36)}`;
511
601
  let g = {};
512
602
  let L = layout(s);
@@ -532,11 +622,25 @@ export function createCharacter(container, spec, opts = {}) {
532
622
  let speech = null;
533
623
  let zoom = 1;
534
624
  let vb = null;
535
- const keys = { body: '', eyes: '', brows: '', mouth: '', rig: '', mback: '', mfront: '', fx: '', fxface: '' };
625
+ let skin = null;
626
+ let accMask = '';
627
+ let morphMask = '';
628
+ let px = 240;
629
+ const keys = { body: '', eyes: '', brows: '', mouth: '', rig: '', mback: '', mfront: '', fx: '', fxface: '', mask: '', detail: '' };
630
+
631
+ /**
632
+ * Nivel de detalle de la piel: sin textura si se ve diminuto; sin filtros mientras el contorno
633
+ * se deforma (se repinta cada frame), con movimiento reducido o en equipos modestos.
634
+ */
635
+ function detail(key) {
636
+ if (opts.detail === 'full' || opts.detail === 'lite' || opts.detail === 'off') return opts.detail;
637
+ if (px < 56) return 'off';
638
+ return key !== 'base' || lowEnd || reduced() ? 'lite' : 'full';
639
+ }
536
640
 
537
641
  function build() {
538
642
  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>`;
643
+ svg.innerHTML = `<style>${STYLE}</style><defs data-p="defs"></defs><defs data-p="mask"></defs><g data-p="styled"><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></g>`;
540
644
  g = { rig: svg.querySelector('.otsc-rig') };
541
645
  for (const el of svg.querySelectorAll('[data-p]')) g[el.dataset.p] = el;
542
646
  if (!idle) {
@@ -544,6 +648,11 @@ export function createCharacter(container, spec, opts = {}) {
544
648
  svg.querySelector('.otsc-sway').setAttribute('class', '');
545
649
  }
546
650
  for (const k in keys) keys[k] = '';
651
+ // fondo del estilo: imagen de fondo del <svg> (se rasteriza una vez, no se repinta al animar)
652
+ const bd = src.style && opts.backdrop !== false ? backdropCSS(src.style, styleSeed(src)) : '';
653
+ svg.style.backgroundImage = bd;
654
+ svg.style.backgroundSize = bd ? 'cover' : '';
655
+ svg.style.backgroundPosition = bd ? 'center bottom' : '';
547
656
  L0 = layout(s);
548
657
  paintBody(bodyPoints(s), 'base');
549
658
  paintFace(faceState());
@@ -551,15 +660,32 @@ export function createCharacter(container, spec, opts = {}) {
551
660
  }
552
661
 
553
662
  function paintBody(pts, key) {
554
- const x = renderLayers(s, faceState(), { uid, pts });
663
+ const dt = detail(key);
664
+ const x = renderLayers(s, faceState(), { uid, pts, live: true, detail: dt, animate: !reduced() });
555
665
  L = x.L;
666
+ if ((g.styled.getAttribute('filter') || '') !== x.filter) {
667
+ if (x.filter) g.styled.setAttribute('filter', x.filter);
668
+ else g.styled.removeAttribute('filter');
669
+ }
670
+ skin = x.skin;
671
+ accMask = x.mask;
556
672
  g.defs.innerHTML = x.defs;
557
673
  g.back.innerHTML = x.back;
558
674
  g.body.innerHTML = x.body;
559
675
  g.cheeks.innerHTML = x.cheeks;
560
676
  g.front.innerHTML = x.front;
561
677
  keys.body = key;
678
+ keys.detail = dt;
562
679
  keys.eyes = keys.brows = keys.mouth = '';
680
+ paintMask();
681
+ }
682
+
683
+ /** Máscara del contorno: accesorios unidos + piezas de las metamorfosis activas. */
684
+ function paintMask() {
685
+ const m = accMask + morphMask;
686
+ if (m === keys.mask && g.mask.firstChild) return;
687
+ g.mask.innerHTML = outlineMask(uid, m);
688
+ keys.mask = m;
563
689
  }
564
690
 
565
691
  /** Estado de cara de este frame: ánimo interpolado + retoques del gesto + boca + mirada. */
@@ -736,30 +862,33 @@ export function createCharacter(container, spec, opts = {}) {
736
862
  }
737
863
  }
738
864
  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);
865
+ if (bodyKey !== keys.body || detail(bodyKey) !== keys.detail) paintBody(pts, bodyKey);
740
866
 
741
867
  let mback = '';
742
868
  let mfront = '';
869
+ let mmask = '';
743
870
  if (drawn.length) {
744
871
  const ol = outlineOf(s, pal);
745
872
  const c = {
746
873
  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
874
  eyeY: L.eyeY, mouthY: L.mouthY, sp: L.sp, r: L.r, mw: L.mw, pal,
748
875
  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),
876
+ hr: Math.max(6, L.box.w * 0.1), skin,
750
877
  };
751
878
  c.aw = c.hr * 0.55;
752
879
  // orden: alas detrás de todo, luego pinchos, cuernos y brazos
753
880
  const order = ['wings', 'spikes', 'horns', 'hands'];
754
881
  drawn.sort((a, b) => order.indexOf(a[0].name) - order.indexOf(b[0].name));
755
882
  for (const [m, age] of drawn) {
756
- const [bk, fr] = m.def.draw(c, m.k, age, { ...m.o, still: rm });
883
+ const [bk, fr, mk] = m.def.draw(c, m.k, age, { ...m.o, still: rm });
757
884
  mback += bk;
758
885
  mfront += fr;
886
+ mmask += mk || '';
759
887
  }
760
888
  }
761
889
  if (mback !== keys.mback) { g.mback.innerHTML = mback; keys.mback = mback; }
762
890
  if (mfront !== keys.mfront) { g.mfront.innerHTML = mfront; keys.mfront = mfront; }
891
+ if (mmask !== morphMask) { morphMask = mmask; paintMask(); }
763
892
 
764
893
  // 8) efectos
765
894
  let fxFront = '';
@@ -1106,18 +1235,24 @@ export function createCharacter(container, spec, opts = {}) {
1106
1235
  if (speech) endSpeech();
1107
1236
  }
1108
1237
 
1238
+ px = container.clientWidth || px;
1109
1239
  build();
1110
1240
  idleTick();
1111
1241
  // Si se montó oculto, reencuadrar al hacerse visible.
1112
1242
  let ro = null;
1113
1243
  if (typeof ResizeObserver !== 'undefined') {
1114
- ro = new ResizeObserver(() => fit());
1244
+ ro = new ResizeObserver(() => {
1245
+ // tamaño en pantalla: decide si la textura se ve (ver detail)
1246
+ px = container.clientWidth || px;
1247
+ fit();
1248
+ if (detail(keys.body) !== keys.detail) wake();
1249
+ });
1115
1250
  ro.observe(container);
1116
1251
  }
1117
1252
 
1118
1253
  return {
1119
1254
  svg,
1120
- get spec() { return structuredClone(s); },
1255
+ get spec() { return structuredClone(src); },
1121
1256
  /** Lo que está pasando ahora: ánimo, gesto, metamorfosis y efectos activos. */
1122
1257
  get state() {
1123
1258
  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 };
@@ -1171,8 +1306,10 @@ export function createCharacter(container, spec, opts = {}) {
1171
1306
  effect,
1172
1307
  clearEffects,
1173
1308
  update(next) {
1174
- s = normalizeCharacter(next);
1309
+ src = normalizeCharacter(next);
1310
+ s = stylizeSpec(src);
1175
1311
  uid = `otsc${(++uidSeq).toString(36)}`;
1312
+ morphMask = '';
1176
1313
  build();
1177
1314
  wake();
1178
1315
  },
@@ -1187,5 +1324,6 @@ export function createCharacter(container, spec, opts = {}) {
1187
1324
  };
1188
1325
  }
1189
1326
 
1190
- export { SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, ACCESSORIES, PRESETS, BODY_POINTS };
1327
+ export { SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, PRESETS, BODY_POINTS };
1191
1328
  export { ACCESSORY_GROUPS, partLabel } from './parts.js';
1329
+ export { STYLES as CHARACTER_STYLES } from './styles.js';
@@ -15,7 +15,8 @@
15
15
  * Aquí no se toca el DOM y todo lo que se pinta sale de números y colores ya saneados.
16
16
  */
17
17
 
18
- import { BODY_POINTS, SHAPES, shade } from './parts.js';
18
+ import { BODY_POINTS, SHAPES } from './parts.js';
19
+ import { skinPaths, skinAt, joinSVG } from './skin.js';
19
20
 
20
21
  const R = (v) => Math.round(v * 100) / 100;
21
22
  const TAU = Math.PI * 2;
@@ -312,37 +313,61 @@ function shoulder(c, s) {
312
313
  return { x: lerp(x, c.cx, 0.14), y };
313
314
  }
314
315
 
316
+ /**
317
+ * Piel con la que se pintan las piezas (la da Character.js en c.skin). Sin ella (llamadas
318
+ * antiguas) se pinta con el color plano del cuerpo y sin unión.
319
+ */
320
+ function skinOf(c) {
321
+ return c.skin || { fill: c.fill, solid: c.fill, stops: null, tex: '', lit: '', line: c.line, lw: c.lw, lineOp: 0, box: { x: c.cx - c.W / 2, y: c.top, w: c.W, h: c.H }, d: '', uid: '' };
322
+ }
323
+ const join = (sk, shapes, key) => (sk.uid ? joinSVG(sk, shapes, key) : { shadow: '', mask: '' });
324
+
315
325
  function handSVG(h, s, c) {
316
326
  const hr = c.hr * h.s;
317
- const st = `fill="${c.fill}" stroke="${c.line}" stroke-width="${c.lw}" stroke-linejoin="round"`;
327
+ const sk = skinOf(c);
328
+ // la mano toma el color del degradado del cuerpo en su sitio (y su textura)
329
+ const fill = sk.stops ? skinAt(sk, h.x, h.y) : sk.solid;
330
+ const st = `fill="${fill}" stroke="${c.line}" stroke-width="${c.lw}" stroke-linejoin="round"`;
318
331
  const groove = (d) => `<path d="${d}" stroke="${c.line}" stroke-width="${R(c.lw * 0.8)}" fill="none" stroke-linecap="round" opacity=".55"/>`;
319
- let g = '';
320
332
  let rot = h.ang;
321
- if (h.type === 'open') {
322
- g = `<ellipse cx="${R(-0.78 * hr)}" cy="${R(0.15 * hr)}" rx="${R(0.3 * hr)}" ry="${R(0.52 * hr)}" transform="rotate(-38 ${R(-0.78 * hr)} ${R(0.15 * hr)})" ${st}/>`;
323
- g += `<ellipse rx="${R(0.88 * hr)}" ry="${R(1.05 * hr)}" ${st}/>`;
324
- g += groove(`M${R(-0.28 * hr)} ${R(-0.98 * hr)}V${R(-0.45 * hr)}M${R(0.28 * hr)} ${R(-0.98 * hr)}V${R(-0.45 * hr)}`);
325
- } else {
326
- const fist = `<circle r="${R(0.82 * hr)}" ${st}/>${groove(`M${R(-0.5 * hr)} ${R(-0.2 * hr)}Q0 ${R(0.15 * hr)} ${R(0.5 * hr)} ${R(-0.2 * hr)}`)}`;
327
- if (h.type === 'point') g = `<rect x="${R(-0.24 * hr)}" y="${R(-1.95 * hr)}" width="${R(0.48 * hr)}" height="${R(1.5 * hr)}" rx="${R(0.24 * hr)}" ${st}/>${fist}`;
328
- else if (h.type === 'thumb' || h.type === 'thumbdown') {
329
- rot = 0;
333
+ let shapes = '';
334
+ let grooves = '';
335
+ const mk = (a) => {
336
+ if (h.type === 'open') {
337
+ return `<ellipse cx="${R(-0.78 * hr)}" cy="${R(0.15 * hr)}" rx="${R(0.3 * hr)}" ry="${R(0.52 * hr)}" transform="rotate(-38 ${R(-0.78 * hr)} ${R(0.15 * hr)})" ${a}/><ellipse rx="${R(0.88 * hr)}" ry="${R(1.05 * hr)}" ${a}/>`;
338
+ }
339
+ const fist = `<circle r="${R(0.82 * hr)}" ${a}/>`;
340
+ if (h.type === 'point') return `<rect x="${R(-0.24 * hr)}" y="${R(-1.95 * hr)}" width="${R(0.48 * hr)}" height="${R(1.5 * hr)}" rx="${R(0.24 * hr)}" ${a}/>${fist}`;
341
+ if (h.type === 'thumb' || h.type === 'thumbdown') {
330
342
  const up = h.type === 'thumb' ? -1 : 1;
331
- g = `<rect x="${R(-0.22 * hr)}" y="${R(up < 0 ? -1.75 * hr : 0.25 * hr)}" width="${R(0.44 * hr)}" height="${R(1.5 * hr)}" rx="${R(0.22 * hr)}" ${st}/>${fist}`;
332
- } else g = fist;
333
- }
334
- return `<g transform="translate(${R(h.x)} ${R(h.y)}) scale(${s} 1) rotate(${R(rot)})">${g}</g>`;
343
+ return `<rect x="${R(-0.22 * hr)}" y="${R(up < 0 ? -1.75 * hr : 0.25 * hr)}" width="${R(0.44 * hr)}" height="${R(1.5 * hr)}" rx="${R(0.22 * hr)}" ${a}/>${fist}`;
344
+ }
345
+ return fist;
346
+ };
347
+ if (h.type === 'open') grooves = groove(`M${R(-0.28 * hr)} ${R(-0.98 * hr)}V${R(-0.45 * hr)}M${R(0.28 * hr)} ${R(-0.98 * hr)}V${R(-0.45 * hr)}`);
348
+ else grooves = groove(`M${R(-0.5 * hr)} ${R(-0.2 * hr)}Q0 ${R(0.15 * hr)} ${R(0.5 * hr)} ${R(-0.2 * hr)}`);
349
+ if (h.type === 'thumb' || h.type === 'thumbdown') rot = 0;
350
+ shapes = mk(st) + (sk.tex ? mk(`fill="${sk.tex}"`) : '');
351
+ // sombra de contacto: solo cae sobre el cuerpo (recortada a él)
352
+ const shadow = sk.clip && hr > 1 ? `<ellipse cx="${R(h.x + hr * 0.12)}" cy="${R(h.y + hr * 0.4)}" rx="${R(hr * 0.95)}" ry="${R(hr * 0.85)}" fill="#000" opacity=".14" clip-path="${sk.clip}"/>` : '';
353
+ return `${shadow}<g transform="translate(${R(h.x)} ${R(h.y)}) scale(${s} 1) rotate(${R(rot)})">${shapes}${grooves}</g>`;
335
354
  }
336
355
 
356
+ /** Brazo de goma: nace dentro del cuerpo (detrás), con su piel, y corta el contorno al salir. */
337
357
  function armSVG(sh, h, s, c) {
338
358
  const dx = h.x - sh.x;
339
359
  const dy = h.y - sh.y;
340
360
  const d = Math.hypot(dx, dy) || 1;
361
+ const sk = skinOf(c);
341
362
  // codo hacia fuera y abajo (brazo de goma)
342
363
  const ex = (sh.x + h.x) / 2 + s * d * 0.16;
343
364
  const ey = (sh.y + h.y) / 2 + d * 0.12;
344
365
  const path = `M${R(sh.x)} ${R(sh.y)}Q${R(ex)} ${R(ey)} ${R(h.x)} ${R(h.y)}`;
345
- return `<path d="${path}" stroke="${c.line}" stroke-width="${R(c.aw + c.lw * 2)}" fill="none" stroke-linecap="round"/><path d="${path}" stroke="${c.fill}" stroke-width="${R(c.aw)}" fill="none" stroke-linecap="round"/>`;
366
+ const w = R(c.aw);
367
+ let out = `<path d="${path}" stroke="${c.line}" stroke-width="${R(c.aw + c.lw * 2)}" fill="none" stroke-linecap="round"/><path d="${path}" stroke="${sk.fill}" stroke-width="${w}" fill="none" stroke-linecap="round"/>`;
368
+ if (sk.tex) out += `<path d="${path}" stroke="${sk.tex}" stroke-width="${w}" fill="none"/>`;
369
+ const j = join(sk, [{ d: path, sw: c.aw, sl: c.W * 0.12 }], 'arm');
370
+ return [out + j.shadow, j.mask];
346
371
  }
347
372
 
348
373
  function drawHands(c, k, age, o) {
@@ -350,39 +375,53 @@ function drawHands(c, k, age, o) {
350
375
  const kk = clamp(k, 0, 1.15);
351
376
  let back = '';
352
377
  let front = '';
378
+ let mask = '';
353
379
  for (const s of [-1, 1]) {
354
380
  const sh = shoulder(c, s);
355
381
  let h = handPos(o.pose || 'rest', s, age, dir, c);
356
382
  if (o.from && o.blend < 1) h = mixHand(handPos(o.from, s, age, dir, c), h, EASE.inOut(o.blend));
357
383
  // al aparecer, la mano sale del hombro
358
384
  const hh = { ...h, x: lerp(sh.x, h.x, Math.min(1, kk)), y: lerp(sh.y, h.y, Math.min(1, kk)), s: h.s * Math.max(0.01, kk) };
359
- back += armSVG(sh, hh, s, { ...c, aw: c.aw * Math.min(1, kk) });
385
+ const [arm, am] = armSVG(sh, hh, s, { ...c, aw: c.aw * Math.min(1, kk) });
386
+ back += arm;
387
+ mask += am;
360
388
  front += handSVG(hh, s, c);
361
389
  }
362
- return [back, front];
390
+ return [back, front, mask];
363
391
  }
364
392
 
365
393
  // ───────────────────────────── alas, pinchos, cuernos ─────────────────────────────
366
394
 
367
395
  const WING = 'M0 0C18 -36 64 -56 96 -44C88 -32 82 -28 72 -25C74 -14 66 -8 54 -8C54 3 42 8 30 6C20 11 7 9 0 0Z';
368
396
  const WING_LINES = 'M8 -4C30 -20 52 -30 76 -36M10 0C28 -10 42 -16 58 -20';
397
+ /** Aplica f(x, y) a cada par de coordenadas de un path absoluto (M/C/L/Q). */
398
+ const mapPath = (d, f) => d.replace(/(-?\d*\.?\d+)\s+(-?\d*\.?\d+)/g, (_, a, b) => f(+a, +b).map(R).join(' '));
369
399
 
370
400
  function drawWings(c, k, age, o) {
371
401
  const fast = o.flap === 'fast';
372
402
  const amp = o.still ? 0 : fast ? 32 : 11;
373
403
  const hz = fast ? 3.2 : 0.9;
374
404
  const sc = (c.W / 120) * 0.95 * Math.max(0.01, k);
375
- const fill = c.pal.dark ? shade(c.pal.body, 0.35) : shade(c.pal.body, 0.55);
405
+ const sk = skinOf(c);
376
406
  let out = '';
407
+ const shapes = [];
377
408
  for (const s of [-1, 1]) {
378
409
  const p = c.pts;
379
410
  const [a, b] = s > 0 ? [p[3], p[4]] : [p[13], p[12]];
380
411
  const x = lerp(lerp(a[0], b[0], 0.5), c.cx, 0.16);
381
412
  const y = lerp(a[1], b[1], 0.5);
382
- const flap = -12 + S(age, hz) * amp;
383
- out += `<g transform="translate(${R(x)} ${R(y)}) scale(${s} 1) rotate(${R(flap)}) scale(${R(sc)})"><path d="${WING}" fill="${fill}" stroke="${c.line}" stroke-width="${R(c.lw / sc)}" stroke-linejoin="round"/><path d="${WING_LINES}" stroke="${c.line}" stroke-width="${R((c.lw * 0.8) / sc)}" fill="none" stroke-linecap="round" opacity=".45"/></g>`;
413
+ const flap = ((-12 + S(age, hz) * amp) * Math.PI) / 180;
414
+ const cs = Math.cos(flap) * sc;
415
+ const sn = Math.sin(flap) * sc;
416
+ // en coordenadas del cuerpo (no en un <g> transformado): así comparte degradado y textura
417
+ const f = (px, py) => [x + s * (px * cs - py * sn), y + px * sn + py * cs];
418
+ const d = mapPath(WING, f);
419
+ shapes.push({ d });
420
+ out += skinPaths(sk, d, { extra: `<path d="${d}" fill="#fff" fill-opacity=".14"/>` });
421
+ out += `<path d="${mapPath(WING_LINES, f)}" stroke="${sk.line || c.line}" stroke-width="${R(c.lw * 0.8)}" fill="none" stroke-linecap="round" opacity=".45"/>`;
384
422
  }
385
- return [out, ''];
423
+ const j = join(sk, shapes, 'wg');
424
+ return [out + j.shadow, '', j.mask];
386
425
  }
387
426
 
388
427
  /** Normal exterior del punto i del contorno (los puntos van en sentido horario desde arriba). */
@@ -398,8 +437,8 @@ function normalAt(pts, i) {
398
437
 
399
438
  function drawSpikes(c, k, age, o) {
400
439
  const pts = c.pts;
401
- const fill = shade(c.pal.body, c.pal.dark ? 0.2 : -0.18);
402
- let out = '';
440
+ const sk = skinOf(c);
441
+ let d = '';
403
442
  for (let i = 0; i < BODY_POINTS; i++) {
404
443
  if (i >= 6 && i <= 10) continue; // abajo no: apoya en el suelo
405
444
  const order = Math.min(i, BODY_POINTS - i);
@@ -409,25 +448,46 @@ function drawSpikes(c, k, age, o) {
409
448
  const [x, y] = pts[i];
410
449
  const len = c.W * (0.17 + (order % 2) * 0.05) * ki * (1 + (o.still ? 0 : S(age, 2.5, i) * 0.06));
411
450
  const hb = c.W * 0.075;
412
- const bx = x - nx * 4;
413
- const by = y - ny * 4;
414
- out += `<path d="M${R(bx - tx * hb)} ${R(by - ty * hb)}L${R(x + nx * len)} ${R(y + ny * len)}L${R(bx + tx * hb)} ${R(by + ty * hb)}Z" fill="${fill}" stroke="${c.line}" stroke-width="${c.lw}" stroke-linejoin="round"/>`;
451
+ // base hundida en el cuerpo y ensanchada (filete): los flancos cóncavos se funden con él
452
+ const sink = Math.max(4, hb * 0.8);
453
+ const fl = hb * (0.6 + 0.9 * Math.min(1, ki));
454
+ const bx = x - nx * sink;
455
+ const by = y - ny * sink;
456
+ const qx = x + nx * len * 0.3;
457
+ const qy = y + ny * len * 0.3;
458
+ const qw = hb * 0.42;
459
+ d += `M${R(bx - tx * fl)} ${R(by - ty * fl)}Q${R(qx - tx * qw)} ${R(qy - ty * qw)} ${R(x + nx * len)} ${R(y + ny * len)}Q${R(qx + tx * qw)} ${R(qy + ty * qw)} ${R(bx + tx * fl)} ${R(by + ty * fl)}Z`;
415
460
  }
416
- return [out, ''];
461
+ if (!d) return ['', '', ''];
462
+ const j = join(sk, [{ d }], 'sp');
463
+ return [skinPaths(sk, d) + j.shadow, '', j.mask];
417
464
  }
418
465
 
419
466
  function drawHorns(c, k, age, o) {
420
467
  const pts = c.pts;
468
+ const sk = skinOf(c);
421
469
  const h = c.H * 0.24 * EASE.backOut(clamp(k));
422
470
  const w = c.W * 0.075;
423
- const fill = o.color || '#f3e9d2';
424
- let out = '';
471
+ let d = '';
472
+ let rings = '';
425
473
  for (const s of [-1, 1]) {
426
474
  const [x, y] = pts[s > 0 ? 1 : 15];
427
475
  const xx = lerp(x, c.cx, 0.1);
428
- out += `<path d="M${R(xx - w)} ${R(y + 6)}Q${R(xx - w * 0.4)} ${R(y - h * 0.55)} ${R(xx + s * w * 1.1)} ${R(y - h)}Q${R(xx + w * 0.3)} ${R(y - h * 0.4)} ${R(xx + w)} ${R(y + 6)}Z" fill="${fill}" stroke="${c.line}" stroke-width="${c.lw}" stroke-linejoin="round"/>`;
476
+ const yb = y + Math.max(6, w * 1.3);
477
+ const tip = [xx + s * w * 1.1, y - h];
478
+ // base acampanada y hundida en la cabeza
479
+ d += `M${R(xx - w * 1.7)} ${R(yb)}Q${R(xx - w * 1.05)} ${R(y)} ${R(xx - w * 0.85)} ${R(y - h * 0.12)}Q${R(xx - w * 0.4)} ${R(y - h * 0.55)} ${R(tip[0])} ${R(tip[1])}Q${R(xx + w * 0.3)} ${R(y - h * 0.4)} ${R(xx + w * 0.85)} ${R(y - h * 0.1)}Q${R(xx + w * 1.05)} ${R(y)} ${R(xx + w * 1.7)} ${R(yb)}Z`;
480
+ // anillos del cuerno
481
+ for (const t of [0.28, 0.5]) {
482
+ const l = [lerp(xx - w * 0.85, tip[0], t), lerp(y - h * 0.12, tip[1], t)];
483
+ const r = [lerp(xx + w * 0.85, tip[0], t), lerp(y - h * 0.1, tip[1], t)];
484
+ rings += `M${R(l[0])} ${R(l[1])}Q${R((l[0] + r[0]) / 2)} ${R((l[1] + r[1]) / 2 + w * 0.35)} ${R(r[0])} ${R(r[1])}`;
485
+ }
429
486
  }
430
- return [out, ''];
487
+ const furry = ['fur', 'fuzz', 'feathers'].includes(sk.texture);
488
+ const extra = h > 2 ? `<path d="${rings}" fill="none" stroke="${sk.line || c.line}" stroke-width="${R(c.lw * 0.7)}" stroke-linecap="round" opacity=".35"/>` : '';
489
+ const j = join(sk, [{ d }], 'hn');
490
+ return [skinPaths(sk, d, { fill: o.color, tex: !furry && !o.color, extra }) + j.shadow, '', j.mask];
431
491
  }
432
492
 
433
493
  // ───────────────────────────── deformaciones del contorno ─────────────────────────────
@@ -449,7 +509,8 @@ export function ptsBox(pts) {
449
509
  }
450
510
 
451
511
  /**
452
- * Metamorfosis. Las de dibujo devuelven [detrás, delante]; las de deformación transforman
512
+ * Metamorfosis. Las de dibujo devuelven [detrás, delante, máscara] (la máscara, siluetas en
513
+ * negro que recortan el contorno del cuerpo donde nace la pieza); las de deformación transforman
453
514
  * los 16 puntos del contorno (y con él cara y accesorios). `room`: cuánto alejar el encuadre.
454
515
  */
455
516
  export const MORPHS = {