@7ots/cli 0.1.0 → 0.1.3
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 +17 -1
- package/cli/7ots.mjs +136 -36
- package/cli/lib/assistant.mjs +337 -0
- package/cli/lib/brain.mjs +10 -7
- package/cli/lib/i18n.mjs +87 -17
- package/cli/lib/install.mjs +29 -1
- package/cli/lib/pet-core.mjs +3 -2
- package/cli/lib/pet-server.mjs +30 -0
- package/cli/lib/prompts.mjs +200 -8
- package/cli/lib/terminal.mjs +27 -6
- package/cli/pet/electron/main.cjs +79 -28
- package/cli/pet/pet.html +55 -6
- package/dist/7ots.esm.js +370 -142
- package/dist/7ots.esm.js.map +4 -4
- package/dist/7ots.iife.js +370 -142
- package/dist/7ots.iife.js.map +4 -4
- package/llms.txt +199 -7
- package/package.json +1 -2
- package/server/channels/apuchat.mjs +8 -5
- package/server/channels/apumail.mjs +13 -3
- package/server/channels/index.mjs +5 -3
- package/server/platform/apuchat.mjs +266 -0
- package/server/platform/auth.mjs +39 -10
- package/server/platform/connect.mjs +347 -0
- package/server/platform/db.mjs +55 -0
- package/server/platform/look.mjs +20 -0
- package/server/platform/orquesta.mjs +499 -0
- package/server/platform/routes.mjs +57 -8
- package/server/platform/runtime.mjs +75 -15
- package/server/platform/store.mjs +130 -9
- package/server/server.mjs +14 -3
- package/skills/7ots/SKILL.md +1 -1
- package/src/avatar/AvatarStage.js +54 -9
- package/src/avatar/three.js +62 -0
- package/src/character/AvatarEditor.js +174 -50
- package/src/character/Character.js +164 -26
- package/src/character/motion.js +95 -34
- package/src/character/parts.js +43 -12
- package/src/character/skin.js +412 -0
- package/src/character/styles.js +1429 -0
- package/src/character3d/Character3D.js +1693 -0
- package/src/character3d/body.js +154 -0
- package/src/character3d/fx.js +323 -0
- package/src/character3d/index.js +281 -0
- package/src/character3d/materials.js +429 -0
- package/src/character3d/stage.js +187 -0
- package/src/i18n/messages/character.js +18 -42
- package/src/i18n/messages/platform.js +135 -0
- package/src/identity/schema.js +16 -8
- 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
|
-
|
|
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 (
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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="${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(() =>
|
|
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(
|
|
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
|
-
|
|
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';
|
package/src/character/motion.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
402
|
-
let
|
|
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
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
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 [
|
|
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
|
-
|
|
424
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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 = {
|