@7ots/cli 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/cli/7ots.mjs +13 -1
- package/cli/lib/account.mjs +152 -2
- package/cli/lib/assistant.mjs +107 -38
- package/cli/lib/brain.mjs +13 -2
- package/cli/lib/config.mjs +5 -1
- package/cli/lib/inferences.mjs +30 -6
- package/cli/lib/integrations.mjs +69 -5
- package/cli/lib/orquesta.mjs +44 -1
- package/cli/lib/pet-server.mjs +352 -11
- package/cli/lib/prompts.mjs +31 -17
- package/cli/lib/prowl.mjs +110 -0
- package/cli/pet/electron/director.cjs +37 -5
- package/cli/pet/electron/main.cjs +92 -10
- package/cli/pet/pet.html +149 -10
- package/cli/pet/settings.html +549 -43
- package/dist/7ots.esm.js +216 -137
- package/dist/7ots.esm.js.map +4 -4
- package/dist/7ots.iife.js +216 -137
- package/dist/7ots.iife.js.map +4 -4
- package/llms.txt +1 -1
- package/package.json +17 -14
- package/server/channels/agent.mjs +7 -1
- package/server/channels/index.mjs +3 -2
- package/server/platform/connect.mjs +4 -1
- package/server/platform/db.mjs +14 -1
- package/server/platform/device.mjs +101 -4
- package/server/platform/mods.mjs +222 -0
- package/server/platform/orquesta.mjs +78 -14
- package/server/platform/profile.mjs +161 -0
- package/server/platform/routes.mjs +39 -33
- package/server/platform/runtime.mjs +5 -3
- package/server/platform/store.mjs +1 -1
- package/server/platform/vm.mjs +688 -17
- package/server/server.mjs +12 -2
- package/server/tts.mjs +17 -4
- package/src/avatar/three.js +3 -2
- package/src/character/AvatarEditor.js +95 -7
- package/src/character/Character.js +116 -17
- package/src/character/TradingCard.js +226 -0
- package/src/character/mods/index.js +4 -0
- package/src/character/mods/orc.js +64 -0
- package/src/character/mods.js +251 -0
- package/src/character/more/acc-head.js +213 -0
- package/src/character/more/acc-rest.js +342 -0
- package/src/character/more/brows.js +39 -0
- package/src/character/more/cheeks.js +78 -0
- package/src/character/more/eyes.js +288 -0
- package/src/character/more/mouths.js +259 -0
- package/src/character/more/patterns.js +120 -0
- package/src/character/parts.js +100 -0
- package/src/character/skin.js +166 -1
- package/src/character/styles.js +1285 -9
- package/src/character3d/Character3D.js +253 -37
- package/src/character3d/index.js +7 -1
- package/src/character3d/model.js +367 -0
- package/src/i18n/messages/character.js +37 -22
- package/src/i18n/messages/platform.js +33 -0
- package/src/identity/random.js +17 -3
- package/src/identity/schema.js +10 -0
- package/src/index.js +1 -0
package/server/server.mjs
CHANGED
|
@@ -106,10 +106,20 @@ if (PLATFORM) {
|
|
|
106
106
|
|
|
107
107
|
// ───────────────────────────── servidor ─────────────────────────────
|
|
108
108
|
|
|
109
|
+
// Commit desplegado: scripts/deploy.sh escribe REVISION (JSON) en la raíz. Sin él (dev) → null.
|
|
110
|
+
const REVISION = (() => {
|
|
111
|
+
try { return JSON.parse(readFileSync(join(ROOT, 'REVISION'), 'utf8')); } catch { return null; }
|
|
112
|
+
})();
|
|
113
|
+
|
|
109
114
|
const server = createServer(async (req, res) => {
|
|
110
115
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
111
116
|
const t = reqT(req);
|
|
117
|
+
if (REVISION?.commit) res.setHeader('X-7ots-Revision', REVISION.commit);
|
|
112
118
|
try {
|
|
119
|
+
if (url.pathname === '/version.json' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
120
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*' });
|
|
121
|
+
return res.end(req.method === 'HEAD' ? undefined : JSON.stringify(REVISION || { commit: null }));
|
|
122
|
+
}
|
|
113
123
|
if (platform && ((await platform(req, res, url)) || (await auth(req, res, url)))) return;
|
|
114
124
|
if (PLATFORM && (url.pathname === '/app' || url.pathname.startsWith('/app/'))) return await serveApp(req, res, url.pathname);
|
|
115
125
|
if (url.pathname === '/llms.txt' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
@@ -322,8 +332,8 @@ async function serveBackoffice(req, res, pathname, dir = 'backoffice') {
|
|
|
322
332
|
if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
|
|
323
333
|
const data = await readFile(file);
|
|
324
334
|
const headers = { 'Content-Type': MIME[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
|
|
325
|
-
//
|
|
326
|
-
headers['Content-Security-Policy'] =
|
|
335
|
+
// Nada se puede enmarcar (clickjacking).
|
|
336
|
+
headers['Content-Security-Policy'] = "frame-ancestors 'none'";
|
|
327
337
|
res.writeHead(200, headers);
|
|
328
338
|
res.end(data);
|
|
329
339
|
} catch {
|
package/server/tts.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* grok → xAI (POST https://api.x.ai/v1/tts). XAI_API_KEY, XAI_TTS_VOICE (eve, ara, leo, rex, sal o voz propia)
|
|
12
12
|
* fish → Fish Audio (POST https://api.fish.audio/v1/tts). FISH_API_KEY, FISH_VOICE_ID (reference_id;
|
|
13
13
|
* vacío = voz propia del modelo), FISH_MODEL (vacío = s2.1-pro; va en la cabecera `model`)
|
|
14
|
-
* openai → (compatibilidad) OPENAI_API_KEY, OPENAI_TTS_MODEL (gpt-4o-mini-tts), OPENAI_TTS_VOICE (alloy)
|
|
14
|
+
* openai → (compatibilidad) OPENAI_API_KEY (solo si LLM_BASE_URL es OpenAI o está vacío; si no, OPENAI_TTS_API_KEY), OPENAI_TTS_MODEL (gpt-4o-mini-tts), OPENAI_TTS_VOICE (alloy)
|
|
15
15
|
* none → 501: el widget usa la voz del navegador (speechSynthesis)
|
|
16
16
|
*
|
|
17
17
|
* La voz de la identidad del agente (server/identity.mjs → voice) manda sobre las variables:
|
|
@@ -39,10 +39,22 @@ export function ttsProvider(env = process.env) {
|
|
|
39
39
|
if (env.ELEVENLABS_API_KEY) return 'elevenlabs';
|
|
40
40
|
if (env.XAI_API_KEY) return 'grok';
|
|
41
41
|
if (env.FISH_API_KEY) return 'fish';
|
|
42
|
-
if (env
|
|
42
|
+
if (openaiTtsKey(env)) return 'openai';
|
|
43
43
|
return 'none';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* ¿OPENAI_API_KEY sirve para la voz de OpenAI? No si el LLM apunta a otro servicio compatible
|
|
48
|
+
* (LLM_BASE_URL = DeepSeek, Groq, OpenRouter…): esa clave es de ese servicio y OpenAI la rechazaría
|
|
49
|
+
* (antes: auto-detección → 'openai' → 502 en cada frase). OPENAI_TTS_API_KEY la fuerza.
|
|
50
|
+
*/
|
|
51
|
+
export function openaiTtsKey(env = process.env) {
|
|
52
|
+
if (env.OPENAI_TTS_API_KEY) return env.OPENAI_TTS_API_KEY;
|
|
53
|
+
if (!env.OPENAI_API_KEY) return '';
|
|
54
|
+
const base = String(env.LLM_BASE_URL || '').trim();
|
|
55
|
+
return !base || /^https?:\/\/api\.openai\.com(\/|$)/i.test(base) ? env.OPENAI_API_KEY : '';
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
export async function synthesize({ text, voice = {}, env = process.env }) {
|
|
47
59
|
const provider = PROVIDERS.includes(voice.provider) ? voice.provider : voice.provider === 'browser' ? 'none' : ttsProvider(env);
|
|
48
60
|
const input = String(text || '').slice(0, 1500);
|
|
@@ -120,8 +132,9 @@ export async function synthesize({ text, voice = {}, env = process.env }) {
|
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
if (provider === 'openai') {
|
|
123
|
-
|
|
124
|
-
|
|
135
|
+
const key = openaiTtsKey(env);
|
|
136
|
+
if (!key) throw i18nError(501, 'server.tts.noKey', { provider: 'OpenAI', key: env.OPENAI_API_KEY ? 'OPENAI_TTS_API_KEY' : 'OPENAI_API_KEY' });
|
|
137
|
+
const openai = await openaiClient(key); // TTS siempre contra OpenAI (no LLM_BASE_URL)
|
|
125
138
|
const model = voice.model || env.OPENAI_TTS_MODEL || 'gpt-4o-mini-tts';
|
|
126
139
|
const res = await openai.audio.speech.create({
|
|
127
140
|
model,
|
package/src/avatar/three.js
CHANGED
|
@@ -42,12 +42,13 @@ export function loadThree() {
|
|
|
42
42
|
threeP ??= (async () => {
|
|
43
43
|
if (globalThis.__OTS_THREE__?.three) return globalThis.__OTS_THREE__;
|
|
44
44
|
const u = (p) => `${base()}/${p}/+esm`;
|
|
45
|
-
const [three, BufferGeometryUtils, RoomEnvironment] = await Promise.all([
|
|
45
|
+
const [three, BufferGeometryUtils, RoomEnvironment, GLTFLoader] = await Promise.all([
|
|
46
46
|
import(/* @vite-ignore */ /* webpackIgnore: true */ `${base()}/+esm`),
|
|
47
47
|
import(/* @vite-ignore */ /* webpackIgnore: true */ u('examples/jsm/utils/BufferGeometryUtils.js')),
|
|
48
48
|
import(/* @vite-ignore */ /* webpackIgnore: true */ u('examples/jsm/environments/RoomEnvironment.js')),
|
|
49
|
+
import(/* @vite-ignore */ /* webpackIgnore: true */ u('examples/jsm/loaders/GLTFLoader.js')),
|
|
49
50
|
]);
|
|
50
|
-
return (globalThis.__OTS_THREE__ = { three, BufferGeometryUtils, RoomEnvironment });
|
|
51
|
+
return (globalThis.__OTS_THREE__ = { three, BufferGeometryUtils, RoomEnvironment, GLTFLoader });
|
|
51
52
|
})();
|
|
52
53
|
threeP.catch(() => { threeP = null; });
|
|
53
54
|
return threeP;
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* - Personaje 3D: la MISMA spec y las mismas pestañas, con vista previa 3D (src/character3d, se
|
|
14
14
|
* arrastra para girarlo) y material peluche/vinilo. Los puntos arrastrables son del 2D: en 3D se
|
|
15
15
|
* ocultan (forma, ojos y boca se cambian desde las pestañas).
|
|
16
|
-
* -
|
|
16
|
+
* - Carta: la carta coleccionable holográfica del ot (la del pet de escritorio) con su personaje, o
|
|
17
|
+
* con una imagen propia como arte; se elige la lámina (holo, prisma, cosmos, oro, sin lámina).
|
|
18
|
+
* Nombre, rol y bio de la carta: propiedad `el.identity` u `opts.identity` (+ `opts.otsId`).
|
|
17
19
|
*
|
|
18
20
|
* Idioma: atributo `lang` (o la propiedad nativa `el.lang`, u `opts.locale` en createAvatarEditor).
|
|
19
21
|
* Cambiarlo vuelve a pintar los textos en caliente conservando el valor, la pestaña y el historial.
|
|
@@ -23,25 +25,27 @@
|
|
|
23
25
|
|
|
24
26
|
import {
|
|
25
27
|
normalizeCharacter, renderCharacter, createCharacter, applyPreset, randomCharacter, layout, bodyPoints,
|
|
26
|
-
SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, ACCESSORY_GROUPS,
|
|
28
|
+
SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, ACCESSORY_GROUPS, BODY_POINTS,
|
|
27
29
|
CHARACTER_MOODS, CHARACTER_GESTURES, CHARACTER_MORPHS, partLabel,
|
|
28
30
|
} from './Character.js';
|
|
29
31
|
import { translator, detectLocale, getLocale, onLocaleChange, formatNumber } from '../i18n/index.js';
|
|
30
32
|
import { STYLES as ART_STYLES } from './styles.js';
|
|
31
33
|
import { loadCharacter3D } from '../avatar/three.js';
|
|
34
|
+
import { createTradingCard, CARD_FOILS } from './TradingCard.js';
|
|
32
35
|
|
|
33
36
|
const VIEW = [-20, -50, 240, 270];
|
|
34
37
|
const AXIS = 100;
|
|
35
38
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
36
39
|
|
|
37
40
|
const KINDS = ['character', 'plush', 'image'];
|
|
41
|
+
const RANDOM_N = 12;
|
|
38
42
|
const MATERIALS = ['plush', 'vinyl'];
|
|
39
43
|
|
|
40
44
|
const TABS = ['styles', 'shape', 'color', 'eyes', 'mouth', 'face', 'acc'];
|
|
41
45
|
const GESTURES = CHARACTER_GESTURES;
|
|
42
46
|
// Morphs temporales para la vista previa (las formas "shape:<id>" se ven ya en la pestaña de forma).
|
|
43
47
|
const MORPHS = CHARACTER_MORPHS.filter((m) => !m.startsWith('shape'));
|
|
44
|
-
const OUTLINES = ['auto', 'none', 'thin', 'bold'];
|
|
48
|
+
const OUTLINES = ['auto', 'none', 'thin', 'bold', 'tone', 'sticker'];
|
|
45
49
|
const MAX_ACC = 16;
|
|
46
50
|
|
|
47
51
|
const CSS = `
|
|
@@ -111,7 +115,15 @@ input[type=text],input[type=url]{font:inherit;width:100%;padding:7px 9px;border-
|
|
|
111
115
|
:host([simple]) [data-kind=image],:host([simple]) [data-bar=export]{display:none!important}
|
|
112
116
|
:host([simple]) .wrap{border:0;border-radius:0;background:transparent}
|
|
113
117
|
.alt{padding:18px;display:grid;gap:14px;max-width:640px}
|
|
118
|
+
.rh{display:flex;align-items:center;gap:10px}
|
|
119
|
+
.rh .chip{margin-left:auto}
|
|
120
|
+
.opt i.ph{display:block;aspect-ratio:1;border-radius:10px;background:var(--ae-line);opacity:.5;animation:ph 1.2s ease-in-out infinite alternate}
|
|
121
|
+
@keyframes ph{to{opacity:.15}}
|
|
114
122
|
.alt img{max-width:160px;border-radius:14px;border:1px solid var(--ae-line)}
|
|
123
|
+
.alt.cardv{max-width:none;grid-template-columns:minmax(220px,340px) minmax(220px,1fr);align-items:start;gap:22px}
|
|
124
|
+
.alt .tcard{padding:8px 0}
|
|
125
|
+
.alt .side{display:grid;gap:14px;align-content:start}
|
|
126
|
+
@media (max-width:640px){.alt.cardv{grid-template-columns:1fr}}
|
|
115
127
|
`;
|
|
116
128
|
|
|
117
129
|
const SWATCHES = ['#a0714f', '#f1c27d', '#fcd9b6', '#e0a97e', '#8d5524', '#ef4444', '#f97316', '#facc15', '#84cc16', '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7', '#ec4899', '#f8fafc', '#94a3b8', '#1f2937'];
|
|
@@ -125,6 +137,7 @@ export function normalizeEditorValue(v = {}) {
|
|
|
125
137
|
material: MATERIALS.includes(v.material) ? v.material : 'plush',
|
|
126
138
|
avatar: { url: typeof v.avatar?.url === 'string' ? v.avatar.url : '', body: v.avatar?.body === 'M' ? 'M' : 'F' },
|
|
127
139
|
image: typeof v.image === 'string' ? v.image : '',
|
|
140
|
+
card: { foil: CARD_FOILS.includes(v.card?.foil) ? v.card.foil : '' },
|
|
128
141
|
};
|
|
129
142
|
}
|
|
130
143
|
|
|
@@ -133,6 +146,8 @@ export function normalizeEditorValue(v = {}) {
|
|
|
133
146
|
* @param {{ value?: object, onChange?: (v:object)=>void, plush?: boolean, simple?: boolean, theme?: 'light'|'dark', locale?: string }} [opts]
|
|
134
147
|
* plush: false oculta el tipo «Personaje 3D» (se acepta también el antiguo `realistic: false`).
|
|
135
148
|
* locale: idioma de la interfaz ('es', 'en', 'pt'…); se puede cambiar luego con `el.lang = 'en'`.
|
|
149
|
+
* lookFree: async (characters[]) => boolean[]: qué caras están libres; las sugerencias al azar de la
|
|
150
|
+
* pestaña Estilos solo muestran las libres (en 7ots.com cada ot es único). Sin ella se muestran todas.
|
|
136
151
|
*/
|
|
137
152
|
export function createAvatarEditor(container, opts = {}) {
|
|
138
153
|
const el = document.createElement('ots-avatar-editor');
|
|
@@ -140,6 +155,8 @@ export function createAvatarEditor(container, opts = {}) {
|
|
|
140
155
|
if (opts.simple) el.setAttribute('simple', ''); // sin selector de tipo ni exportar (p. ej. la portada)
|
|
141
156
|
if (opts.theme) el.setAttribute('theme', opts.theme);
|
|
142
157
|
if (opts.locale) el.setAttribute('lang', opts.locale);
|
|
158
|
+
if (opts.identity || opts.otsId) el.identity = { ...(opts.identity || {}), id: opts.otsId, createdAt: opts.createdAt };
|
|
159
|
+
if (opts.lookFree) el.lookFree = opts.lookFree;
|
|
143
160
|
if (opts.value) el.value = opts.value;
|
|
144
161
|
if (opts.onChange) el.addEventListener('change', (e) => opts.onChange(e.detail));
|
|
145
162
|
container.appendChild(el);
|
|
@@ -195,6 +212,13 @@ export class AvatarEditor extends Base {
|
|
|
195
212
|
this._renderAll();
|
|
196
213
|
}
|
|
197
214
|
|
|
215
|
+
/** Datos de la carta (name, role, bio, tagline, id, createdAt); no forman parte del valor. */
|
|
216
|
+
get identity() { return this._ident || {}; }
|
|
217
|
+
set identity(i) {
|
|
218
|
+
this._ident = { ...(i || {}) };
|
|
219
|
+
if (this._ready && this._v.kind === 'image') this._renderImage();
|
|
220
|
+
}
|
|
221
|
+
|
|
198
222
|
get value() { return structuredClone(this._v); }
|
|
199
223
|
set value(v) {
|
|
200
224
|
this._v = normalizeEditorValue(v);
|
|
@@ -220,7 +244,7 @@ export class AvatarEditor extends Base {
|
|
|
220
244
|
this._applyLocale();
|
|
221
245
|
});
|
|
222
246
|
});
|
|
223
|
-
if (this._ready) return;
|
|
247
|
+
if (this._ready) return this._renderKind();
|
|
224
248
|
this._ready = true;
|
|
225
249
|
this._t = translator(this._resolveLocale());
|
|
226
250
|
const root = this.attachShadow({ mode: 'open' });
|
|
@@ -277,6 +301,7 @@ export class AvatarEditor extends Base {
|
|
|
277
301
|
cancelAnimationFrame(this._raf3);
|
|
278
302
|
this._raf3 = 0;
|
|
279
303
|
this._ch3?.destroy();
|
|
304
|
+
this._dropCard();
|
|
280
305
|
this._ch3 = null;
|
|
281
306
|
this._ch3P = null;
|
|
282
307
|
}
|
|
@@ -437,15 +462,36 @@ export class AvatarEditor extends Base {
|
|
|
437
462
|
this.$('[data-bar=material]').classList.toggle('hidden', !plush);
|
|
438
463
|
this.$$('[data-material]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.material === this._v.material)));
|
|
439
464
|
if (k === 'image') this._renderImage();
|
|
465
|
+
else this._dropCard();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
_dropCard() {
|
|
469
|
+
if (!this._card) return;
|
|
470
|
+
this._card.destroy();
|
|
471
|
+
this._card = null;
|
|
472
|
+
this.$('[data-view=image]').innerHTML = '';
|
|
440
473
|
}
|
|
441
474
|
|
|
442
475
|
_renderImage() {
|
|
443
476
|
const img = this._v.image;
|
|
444
477
|
const t = this._t;
|
|
445
|
-
this.$('[data-view=image]')
|
|
478
|
+
const view = this.$('[data-view=image]');
|
|
479
|
+
const id = this.identity;
|
|
480
|
+
const look = { ...(id.look || {}), kind: 'image', image: /^https?:\/\//.test(img) ? img : '', character: this._v.character };
|
|
481
|
+
const data = { identity: { ...id, look }, id: id.id, createdAt: id.createdAt, foil: this._v.card.foil, locale: this.locale };
|
|
482
|
+
const foil = this._v.card.foil;
|
|
483
|
+
if (!view.querySelector('.tcard')) {
|
|
484
|
+
view.classList.add('cardv');
|
|
485
|
+
view.innerHTML = `<div class="tcard"></div><div class="side"></div>`;
|
|
486
|
+
this._card?.destroy();
|
|
487
|
+
this._card = createTradingCard(view.querySelector('.tcard'), data);
|
|
488
|
+
} else this._card.update(data);
|
|
489
|
+
view.querySelector('.side').innerHTML = `
|
|
446
490
|
<div><h4>${esc(t('editor.image.title'))}</h4><p class="hint" style="margin:0">${esc(t('editor.image.intro'))}</p></div>
|
|
491
|
+
<div><span class="hint" style="margin:0 0 6px;display:block">${esc(t('editor.image.foil'))}</span>
|
|
492
|
+
<span class="chips"><button type="button" class="chip" data-foil="" aria-pressed="${!foil}">${esc(t('editor.image.foils.auto'))}</button>${CARD_FOILS.map((f) => `<button type="button" class="chip" data-foil="${f}" aria-pressed="${foil === f}">${esc(t(`editor.image.foils.${f}`))}</button>`).join('')}</span></div>
|
|
447
493
|
<label class="f">${esc(t('editor.image.url'))}<input type="url" data-m="image" placeholder="${esc(t('editor.image.placeholder'))}" value="${esc(img)}"></label>
|
|
448
|
-
|
|
494
|
+
<p class="hint" style="margin:0">${esc(t('editor.image.hint'))}</p>`;
|
|
449
495
|
}
|
|
450
496
|
|
|
451
497
|
_thumbView(kind) {
|
|
@@ -467,6 +513,38 @@ export class AvatarEditor extends Base {
|
|
|
467
513
|
.join('')}</div>`;
|
|
468
514
|
}
|
|
469
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Sugerencias al azar que nadie tiene (si hay `lookFree`): reemplazan a los estilos fijos, que en
|
|
518
|
+
* 7ots.com ya estarían tomados. Se piden de a lotes y se guardan hasta que se pida «Otros».
|
|
519
|
+
*/
|
|
520
|
+
_randomGrid(view) {
|
|
521
|
+
if (!this._rand) {
|
|
522
|
+
this._loadRandoms();
|
|
523
|
+
return `<div class="grid">${'<button type="button" class="opt" disabled><i class="ph"></i></button>'.repeat(RANDOM_N)}</div>`;
|
|
524
|
+
}
|
|
525
|
+
return `<div class="grid">${this._rand
|
|
526
|
+
.map((ch, i) => `<button type="button" class="opt" data-rand="${i}" title="🎲">${renderCharacter({ ...ch, style: 'none' }, { open: 0 }, { viewBox: view })}</button>`)
|
|
527
|
+
.join('')}</div>`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async _loadRandoms() {
|
|
531
|
+
if (this._randBusy) return;
|
|
532
|
+
this._randBusy = true;
|
|
533
|
+
const out = [];
|
|
534
|
+
try {
|
|
535
|
+
for (let round = 0; round < 4 && out.length < RANDOM_N; round++) {
|
|
536
|
+
const batch = Array.from({ length: RANDOM_N + 4 }, () => normalizeCharacter(randomCharacter()));
|
|
537
|
+
let free = batch.map(() => true);
|
|
538
|
+
if (this.lookFree) free = await Promise.resolve(this.lookFree(batch)).catch(() => free);
|
|
539
|
+
out.push(...batch.filter((_, i) => free?.[i] !== false));
|
|
540
|
+
}
|
|
541
|
+
} finally {
|
|
542
|
+
this._randBusy = false;
|
|
543
|
+
}
|
|
544
|
+
this._rand = out.slice(0, RANDOM_N);
|
|
545
|
+
if (this._ready && this._tab === 'styles') this._renderPanel();
|
|
546
|
+
}
|
|
547
|
+
|
|
470
548
|
/**
|
|
471
549
|
* Tira de estilos artísticos (styles.js): el personaje actual en cada estilo, con su fondo.
|
|
472
550
|
* Son muchos SVG con filtros: cada miniatura se rasteriza una vez como <img> estática, solo
|
|
@@ -540,7 +618,7 @@ export class AvatarEditor extends Base {
|
|
|
540
618
|
let h = '';
|
|
541
619
|
switch (this._tab) {
|
|
542
620
|
case 'styles':
|
|
543
|
-
h = `${this._artStyles()}<h4>${T('editor.styles.title')}
|
|
621
|
+
h = `${this._artStyles()}<h4 class="rh">${T('editor.styles.title')}<button type="button" class="chip" data-a="reroll">🎲 ${T('editor.styles.more')}</button></h4>${this._randomGrid(full)}
|
|
544
622
|
<p class="hint">${T('editor.styles.hint')}</p>
|
|
545
623
|
<label class="c" style="margin-top:8px"><input type="checkbox" data-keep-body ${this._keepBody ? 'checked' : ''}> ${T('editor.styles.keepBody')}</label>`;
|
|
546
624
|
break;
|
|
@@ -794,6 +872,14 @@ export class AvatarEditor extends Base {
|
|
|
794
872
|
this._drawOverlay();
|
|
795
873
|
return;
|
|
796
874
|
}
|
|
875
|
+
if (d.rand) {
|
|
876
|
+
const pick = structuredClone(this._rand?.[+d.rand]);
|
|
877
|
+
if (!pick) return;
|
|
878
|
+
return this._set((n) => {
|
|
879
|
+
const keep = this._keepBody ? { body: n.body, finish: n.finish } : {};
|
|
880
|
+
Object.assign(n, pick, keep, { style: n.style });
|
|
881
|
+
});
|
|
882
|
+
}
|
|
797
883
|
if (d.preset) return this._set((n) => Object.assign(n, applyPreset(n, d.preset, this._keepBody ? ['body', 'finish'] : [])));
|
|
798
884
|
if (d.artstyle) return this._set((n) => { n.style = d.artstyle; });
|
|
799
885
|
if (d.shape) return this._set((n) => { n.body.shape = d.shape; n.body.points = null; });
|
|
@@ -825,6 +911,7 @@ export class AvatarEditor extends Base {
|
|
|
825
911
|
this._renderPanel();
|
|
826
912
|
return this._drawOverlay();
|
|
827
913
|
}
|
|
914
|
+
if (d.foil !== undefined) return this._setMeta((v) => (v.card = { foil: d.foil }));
|
|
828
915
|
if (d.material) {
|
|
829
916
|
this._setMeta((v) => (v.material = d.material));
|
|
830
917
|
return this._sync3d();
|
|
@@ -834,6 +921,7 @@ export class AvatarEditor extends Base {
|
|
|
834
921
|
case 'undo': return this._undo();
|
|
835
922
|
case 'redo': return this._redo();
|
|
836
923
|
case 'random': return this._set((n) => Object.assign(n, randomCharacter()));
|
|
924
|
+
case 'reroll': this._rand = null; return this._renderPanel();
|
|
837
925
|
case 'sym': this._sym = !this._sym; b.setAttribute('aria-pressed', String(this._sym)); return this._drawOverlay();
|
|
838
926
|
case 'reshape': return this._set((n) => { n.body.points = null; });
|
|
839
927
|
case 'squash':
|
|
@@ -23,13 +23,15 @@
|
|
|
23
23
|
* en styles.js: remapean la paleta, añaden un filtro y una capa al cuerpo y un fondo a juego.
|
|
24
24
|
*
|
|
25
25
|
* Todo el SVG sale de catálogos propios; lo que llega del usuario se reduce a ids de catálogo,
|
|
26
|
-
* colores #rrggbb y números acotados (normalizeCharacter), así que es seguro inyectarlo.
|
|
26
|
+
* colores #rrggbb y números acotados (normalizeCharacter), así que es seguro inyectarlo. Los mods
|
|
27
|
+
* (`mods`, mods.js) traen SVG propio: se reescribe con una lista cerrada de etiquetas y atributos.
|
|
27
28
|
*/
|
|
28
29
|
|
|
29
30
|
import {
|
|
30
|
-
BODY_POINTS, SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, PRESETS, shade, luminance,
|
|
31
|
+
BODY_POINTS, SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, PRESETS, CATALOG_V1, shade, luminance,
|
|
31
32
|
} from './parts.js';
|
|
32
33
|
import { renderSkin, joinSVG } from './skin.js';
|
|
34
|
+
import { normalizeMods, applyMods, modPart } from './mods.js';
|
|
33
35
|
import { STYLE_IDS, stylizeSpec, styleLayers, styleSeed, backdropIn, backdropCSS } from './styles.js';
|
|
34
36
|
import { translator } from '../i18n/index.js';
|
|
35
37
|
import {
|
|
@@ -149,7 +151,7 @@ export function normalizeCharacter(input = {}) {
|
|
|
149
151
|
finish: pickId(FINISH, i.finish ?? base.finish, 'glossy'),
|
|
150
152
|
// textura de superficie; 'auto' (o ausente, specs antiguas) la elige la propia spec (skin.js)
|
|
151
153
|
texture: pickId(TEXTURE, i.texture ?? base.texture, 'auto'),
|
|
152
|
-
outline:
|
|
154
|
+
outline: OUTLINES.includes(i.outline) ? i.outline : 'auto',
|
|
153
155
|
// estilo artístico (styles.js); 'none' no se guarda: las specs de siempre no cambian
|
|
154
156
|
...(STYLE_IDS.includes(i.style ?? base.style) && (i.style ?? base.style) !== 'none' ? { style: i.style ?? base.style } : {}),
|
|
155
157
|
eyes: {
|
|
@@ -183,9 +185,15 @@ export function normalizeCharacter(input = {}) {
|
|
|
183
185
|
color: hex(a.color, ACC[a.id].color || '#1f2937'),
|
|
184
186
|
color2: hex(a.color2, ACC[a.id].color2 || '#ffffff'),
|
|
185
187
|
})),
|
|
188
|
+
...(Array.isArray(i.mods) && i.mods.length ? { mods: normalizeMods(i.mods) } : {}),
|
|
186
189
|
};
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
/** La ficha que se pinta: la base con sus mods aplicados (mods.js). */
|
|
193
|
+
export function resolveCharacter(spec) {
|
|
194
|
+
return applyMods(normalizeCharacter(spec), normalizeCharacter);
|
|
195
|
+
}
|
|
196
|
+
|
|
189
197
|
/** Aplica un estilo (preset) conservando lo que se indique en `keep` (p. ej. ['body']). */
|
|
190
198
|
export function applyPreset(spec, id, keep = []) {
|
|
191
199
|
const next = normalizeCharacter({ preset: id });
|
|
@@ -193,8 +201,13 @@ export function applyPreset(spec, id, keep = []) {
|
|
|
193
201
|
return next;
|
|
194
202
|
}
|
|
195
203
|
|
|
196
|
-
/**
|
|
197
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Personaje aleatorio (determinista con `seed`).
|
|
206
|
+
* opts.legacy: sortea solo entre las piezas del catálogo 1 (CATALOG_V1), con la misma secuencia
|
|
207
|
+
* que antes de 2026-10: las semillas antiguas siguen dando exactamente el mismo ot.
|
|
208
|
+
*/
|
|
209
|
+
export function randomCharacter(seed = Math.random() * 1e9, { legacy = false } = {}) {
|
|
210
|
+
const v1 = (list, k) => (legacy ? list.slice(0, CATALOG_V1[k]) : list);
|
|
198
211
|
let s = Math.floor(seed) >>> 0 || 1;
|
|
199
212
|
const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32);
|
|
200
213
|
const pick = (list) => list[Math.floor(rnd() * list.length)];
|
|
@@ -208,16 +221,18 @@ export function randomCharacter(seed = Math.random() * 1e9) {
|
|
|
208
221
|
return `#${[f(0), f(8), f(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
209
222
|
};
|
|
210
223
|
const groups = new Map();
|
|
211
|
-
|
|
224
|
+
// Los sombreros con `random: false` del catálogo 1 solo entran en el sorteo nuevo (no legacy).
|
|
225
|
+
const accPool = legacy ? v1(ACCESSORIES, 'accessories').filter((a) => a.random !== false) : ACCESSORIES.filter((a, i) => a.random !== false || i < CATALOG_V1.accessories);
|
|
226
|
+
for (const a of accPool) if (a.group !== 'extra') groups.set(a.group, [...(groups.get(a.group) || []), a]);
|
|
212
227
|
const acc = [];
|
|
213
228
|
for (const [, list] of groups) if (rnd() < 0.35) acc.push({ id: pick(list).id });
|
|
214
229
|
return normalizeCharacter({
|
|
215
|
-
body: { shape: pick(SHAPES).id, color: hsl(hue, 0.55, 0.62), pattern: rnd() < 0.5 ? 'none' : pick(PATTERNS).id, patternColor: hsl((hue + 30) % 360, 0.5, 0.75) },
|
|
216
|
-
finish: pick(FINISHES.filter((f) => f.id !== 'sketch')).id,
|
|
217
|
-
eyes: { type: pick(EYES).id, iris: hsl(Math.floor(rnd() * 360), 0.7, 0.5), size: 0.85 + rnd() * 0.4, spacing: 0.85 + rnd() * 0.3 },
|
|
218
|
-
brows: { type: rnd() < 0.5 ? 'none' : pick(BROWS).id },
|
|
219
|
-
mouth: { type: pick(MOUTHS).id },
|
|
220
|
-
cheeks: { type: rnd() < 0.5 ? 'blush' : pick(CHEEKS).id },
|
|
230
|
+
body: { shape: pick(v1(SHAPES, 'shapes')).id, color: hsl(hue, 0.55, 0.62), pattern: rnd() < 0.5 ? 'none' : pick(v1(PATTERNS, 'patterns')).id, patternColor: hsl((hue + 30) % 360, 0.5, 0.75) },
|
|
231
|
+
finish: pick(v1(FINISHES, 'finishes').filter((f) => f.id !== 'sketch' && f.random !== false)).id,
|
|
232
|
+
eyes: { type: pick(v1(EYES, 'eyes')).id, iris: hsl(Math.floor(rnd() * 360), 0.7, 0.5), size: 0.85 + rnd() * 0.4, spacing: 0.85 + rnd() * 0.3 },
|
|
233
|
+
brows: { type: rnd() < 0.5 ? 'none' : pick(v1(BROWS, 'brows')).id },
|
|
234
|
+
mouth: { type: pick(v1(MOUTHS, 'mouths')).id },
|
|
235
|
+
cheeks: { type: rnd() < 0.5 ? 'blush' : pick(v1(CHEEKS, 'cheeks')).id },
|
|
221
236
|
accessories: acc,
|
|
222
237
|
});
|
|
223
238
|
}
|
|
@@ -280,6 +295,9 @@ export function layout(spec, points = null) {
|
|
|
280
295
|
let uidSeq = 0;
|
|
281
296
|
const R = (v) => Math.round(v * 100) / 100;
|
|
282
297
|
|
|
298
|
+
/** Contornos del cuerpo. 'tone': grueso, del color del cuerpo oscurecido; 'sticker': borde blanco de pegatina. */
|
|
299
|
+
export const OUTLINES = ['auto', 'none', 'thin', 'bold', 'tone', 'sticker'];
|
|
300
|
+
|
|
283
301
|
function palette(spec) {
|
|
284
302
|
const body = spec.body.color;
|
|
285
303
|
const dark = luminance(body) < 0.28;
|
|
@@ -293,14 +311,77 @@ function palette(spec) {
|
|
|
293
311
|
};
|
|
294
312
|
}
|
|
295
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Acabados del catálogo 2 (2026-10): degradado del cuerpo (stops [offset, color, opacidad?]) y
|
|
316
|
+
* capa interior recortada al cuerpo (brillos, reflejos). Los 7 originales siguen en renderLayers.
|
|
317
|
+
*/
|
|
318
|
+
const FINISH2 = {
|
|
319
|
+
pastel: {
|
|
320
|
+
grad: (c) => ({ stops: [[0, shade(c, 0.4)], [1, shade(c, 0.12)]] }),
|
|
321
|
+
inner: ({ d, box, col }) => `<path d="${d}" fill="none" stroke="#fff" stroke-width="${R(box.w * 0.05)}" opacity=".35"/><path d="${d}" fill="none" stroke="${shade(col, -0.2)}" stroke-width="${R(box.w * 0.035)}" opacity=".18" transform="translate(${R(box.w * 0.015)} ${R(box.h * 0.02)})"/>`,
|
|
322
|
+
},
|
|
323
|
+
metal: {
|
|
324
|
+
grad: (c) => ({ x2: 0.25, stops: [[0, shade(c, 0.6)], [0.3, shade(c, 0.12)], [0.48, shade(c, -0.38)], [0.56, shade(c, 0.32)], [0.8, shade(c, 0.05)], [1, shade(c, -0.42)]] }),
|
|
325
|
+
inner: ({ box }) => {
|
|
326
|
+
const x = box.x + box.w * 0.24;
|
|
327
|
+
const y = box.y + box.h * 0.3;
|
|
328
|
+
return `<ellipse cx="${R(x)}" cy="${R(y)}" rx="${R(box.w * 0.05)}" ry="${R(box.h * 0.2)}" transform="rotate(14 ${R(x)} ${R(y)})" fill="#fff" opacity=".7"/><circle cx="${R(box.x + box.w * 0.36)}" cy="${R(box.y + box.h * 0.1)}" r="${R(box.w * 0.03)}" fill="#fff" opacity=".85"/><ellipse cx="${R(box.x + box.w * 0.78)}" cy="${R(box.y + box.h * 0.62)}" rx="${R(box.w * 0.025)}" ry="${R(box.h * 0.12)}" fill="#fff" opacity=".35"/>`;
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
pearl: {
|
|
332
|
+
grad: (c) => ({ radial: true, stops: [[0, shade(c, 0.62)], [0.6, shade(c, 0.18)], [1, shade(c, -0.18)]] }),
|
|
333
|
+
defs: (id, c, box) => `<linearGradient id="${id}-iri" gradientUnits="userSpaceOnUse" x1="${R(box.x)}" y1="${R(box.y)}" x2="${R(box.x + box.w)}" y2="${R(box.y + box.h)}"><stop offset="0" stop-color="#f9a8d4"/><stop offset=".35" stop-color="#a5f3fc"/><stop offset=".65" stop-color="#fef08a"/><stop offset="1" stop-color="#c4b5fd"/></linearGradient>`,
|
|
334
|
+
inner: ({ id, d, box }) => `<path d="${d}" fill="url(#${id}-iri)" opacity=".26"/><ellipse cx="${R(box.x + box.w * 0.32)}" cy="${R(box.y + box.h * 0.22)}" rx="${R(box.w * 0.17)}" ry="${R(box.h * 0.09)}" transform="rotate(-30 ${R(box.x + box.w * 0.32)} ${R(box.y + box.h * 0.22)})" fill="#fff" opacity=".6"/><path d="${d}" fill="none" stroke="#fff" stroke-width="${R(box.w * 0.04)}" opacity=".3"/>`,
|
|
335
|
+
},
|
|
336
|
+
glass: {
|
|
337
|
+
grad: (c) => ({ radial: true, cx: 0.5, cy: 0.4, r: 0.7, stops: [[0, shade(c, 0.35), 0.5], [0.7, c, 0.66], [1, shade(c, -0.25), 0.88]] }),
|
|
338
|
+
inner: ({ d, box, col }) => {
|
|
339
|
+
const x = box.x + box.w * 0.2;
|
|
340
|
+
const y = box.y + box.h * 0.38;
|
|
341
|
+
return `<path d="${d}" fill="none" stroke="#fff" stroke-width="${R(box.w * 0.05)}" opacity=".45"/><ellipse cx="${R(box.x + box.w * 0.55)}" cy="${R(box.y + box.h * 0.95)}" rx="${R(box.w * 0.3)}" ry="${R(box.h * 0.1)}" fill="${shade(col, 0.6)}" opacity=".45"/><path d="M${R(x)} ${R(y + box.h * 0.12)}Q${R(x)} ${R(y - box.h * 0.12)} ${R(x + box.w * 0.16)} ${R(y - box.h * 0.22)}" stroke="#fff" stroke-width="${R(box.w * 0.045)}" fill="none" stroke-linecap="round" opacity=".85"/><circle cx="${R(x + box.w * 0.25)}" cy="${R(y - box.h * 0.25)}" r="${R(box.w * 0.025)}" fill="#fff" opacity=".9"/>`;
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
velvet: {
|
|
345
|
+
grad: (c) => ({ radial: true, cx: 0.48, cy: 0.42, r: 0.62, stops: [[0, shade(c, 0.1)], [0.7, shade(c, -0.08)], [1, shade(c, -0.42)]] }),
|
|
346
|
+
defs: (id) => `<filter id="${id}-vel" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="1.4" numOctaves="1" seed="11"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .7 0"/></filter>`,
|
|
347
|
+
inner: ({ id, d, box }) => `<rect x="${R(box.x - 5)}" y="${R(box.y - 5)}" width="${R(box.w + 10)}" height="${R(box.h + 10)}" filter="url(#${id}-vel)" opacity=".12"/><path d="${d}" fill="none" stroke="#fff" stroke-width="${R(box.w * 0.025)}" opacity=".25" transform="translate(${R(-box.w * 0.012)} ${R(-box.h * 0.012)})"/>`,
|
|
348
|
+
},
|
|
349
|
+
holo: {
|
|
350
|
+
grad: (c) => ({ radial: true, stops: [[0, shade(c, 0.45)], [0.55, shade(c, 0.1)], [1, shade(c, -0.22)]] }),
|
|
351
|
+
defs: (id, c, box) => `<linearGradient id="${id}-holo" gradientUnits="userSpaceOnUse" x1="${R(box.x)}" y1="${R(box.y + box.h)}" x2="${R(box.x + box.w)}" y2="${R(box.y)}"><stop offset="0" stop-color="#ff7ad9"/><stop offset=".25" stop-color="#ffd36e"/><stop offset=".5" stop-color="#7affc8"/><stop offset=".75" stop-color="#7ab8ff"/><stop offset="1" stop-color="#c47aff"/></linearGradient>`,
|
|
352
|
+
inner: ({ id, d, box }) => {
|
|
353
|
+
let bands = '';
|
|
354
|
+
for (let i = 0; i < 5; i++) {
|
|
355
|
+
const x = box.x - box.w * 0.3 + i * box.w * 0.32;
|
|
356
|
+
bands += `M${R(x)} ${R(box.y + box.h + 6)}L${R(x + box.w * 0.6)} ${R(box.y - 6)}`;
|
|
357
|
+
}
|
|
358
|
+
return `<path d="${d}" fill="url(#${id}-holo)" opacity=".42"/><path d="${bands}" stroke="#fff" stroke-width="${R(box.w * 0.05)}" opacity=".22"/><ellipse cx="${R(box.x + box.w * 0.3)}" cy="${R(box.y + box.h * 0.2)}" rx="${R(box.w * 0.14)}" ry="${R(box.h * 0.07)}" transform="rotate(-32 ${R(box.x + box.w * 0.3)} ${R(box.y + box.h * 0.2)})" fill="#fff" opacity=".6"/>`;
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
vinyl: {
|
|
362
|
+
grad: (c) => ({ stops: [[0, c], [1, c]] }),
|
|
363
|
+
inner: ({ d, box, col }) => {
|
|
364
|
+
const x = box.x + box.w * 0.3;
|
|
365
|
+
const y = box.y + box.h * 0.2;
|
|
366
|
+
return `<path d="${d}" fill="none" stroke="${shade(col, -0.18)}" stroke-width="${R(box.w * 0.09)}" transform="translate(${R(box.w * 0.035)} ${R(box.h * 0.035)})"/><ellipse cx="${R(x)}" cy="${R(y)}" rx="${R(box.w * 0.11)}" ry="${R(box.h * 0.055)}" transform="rotate(-30 ${R(x)} ${R(y)})" fill="#fff" opacity=".9"/><circle cx="${R(box.x + box.w * 0.44)}" cy="${R(box.y + box.h * 0.12)}" r="${R(box.w * 0.022)}" fill="#fff" opacity=".9"/>`;
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
296
371
|
function outlineOf(spec, pal) {
|
|
297
372
|
const o = spec.outline;
|
|
298
373
|
if (o === 'none') return null;
|
|
299
374
|
// el estilo artístico manda en el trazo (lw 0: sin contorno, p. ej. plastilina)
|
|
300
375
|
if (spec._style) return spec._style.lw ? { color: spec._style.ink, w: spec._style.lw } : null;
|
|
376
|
+
if (o === 'tone') return { color: shade(spec.body.color, -0.55), w: 4 };
|
|
377
|
+
if (o === 'sticker') return { color: '#ffffff', w: 6, under: { color: '#1f2937', w: 8.6 } };
|
|
301
378
|
if (o === 'bold' || spec.finish === 'toon') return { color: '#1f2937', w: 4.5 };
|
|
302
379
|
if (spec.finish === 'sketch') return { color: '#1f2937', w: 2.2 };
|
|
303
380
|
if (spec.finish === 'neon') return { color: spec.body.color, w: 3.5 };
|
|
381
|
+
// acabados del catálogo 2
|
|
382
|
+
if (spec.finish === 'vinyl' && o === 'auto') return { color: shade(spec.body.color, -0.5), w: 3.2 };
|
|
383
|
+
if (spec.finish === 'glass' && o === 'auto') return { color: shade(spec.body.color, -0.3), w: 2, op: 0.7 };
|
|
384
|
+
if (spec.finish === 'metal' && o === 'auto') return { color: shade(spec.body.color, -0.6), w: 2.2, op: 0.8 };
|
|
304
385
|
if (o === 'thin' || (o === 'auto' && spec.finish !== 'flat')) return { color: pal.line, w: 2.2, op: 0.55 };
|
|
305
386
|
return null;
|
|
306
387
|
}
|
|
@@ -355,8 +436,11 @@ function growing(a, part) {
|
|
|
355
436
|
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
437
|
}
|
|
357
438
|
|
|
439
|
+
/** Accesorio de catálogo o pieza de un mod. */
|
|
440
|
+
const accOf = (spec, id) => ACC[id] || modPart(spec, id, shade);
|
|
441
|
+
|
|
358
442
|
function accessorySVG(a, spec, L, pal, clipUrl, skin, key) {
|
|
359
|
-
const part =
|
|
443
|
+
const part = accOf(spec, a.id);
|
|
360
444
|
const at = L.anchors[part.anchor] || L.anchors.center;
|
|
361
445
|
const grow = growing(a, part);
|
|
362
446
|
const c = {
|
|
@@ -445,6 +529,17 @@ export function renderLayers(spec, state = {}, opts = {}) {
|
|
|
445
529
|
defs += `<filter id="${id}-rough"><feTurbulence type="turbulence" baseFrequency=".035" numOctaves="2" seed="3"/><feDisplacementMap in="SourceGraphic" scale="3.5"/></filter>`;
|
|
446
530
|
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>`;
|
|
447
531
|
}
|
|
532
|
+
const f2 = FINISH2[f];
|
|
533
|
+
if (f2) {
|
|
534
|
+
const g = f2.grad(col);
|
|
535
|
+
stops = g.stops;
|
|
536
|
+
const st = stops.map(([o, c, a]) => `<stop offset="${o}" stop-color="${c}"${a !== undefined ? ` stop-opacity="${a}"` : ''}/>`).join('');
|
|
537
|
+
const T = `gradientUnits="userSpaceOnUse" gradientTransform="translate(${R(box.x)} ${R(box.y)}) scale(${R(box.w)} ${R(box.h)})"`;
|
|
538
|
+
defs += g.radial ? `<radialGradient id="${id}-g" ${T} cx="${g.cx ?? 0.36}" cy="${g.cy ?? 0.28}" r="${g.r ?? 0.85}">${st}</radialGradient>` : `<linearGradient id="${id}-g" ${T} x1="0" y1="0" x2="${g.x2 ?? 0}" y2="1">${st}</linearGradient>`;
|
|
539
|
+
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>`;
|
|
540
|
+
defs += f2.defs ? f2.defs(id, col, box) : '';
|
|
541
|
+
fill = `url(#${id}-g)`;
|
|
542
|
+
}
|
|
448
543
|
|
|
449
544
|
// Piel: textura de superficie y lo que comparten las piezas unidas (metamorfosis y accesorios)
|
|
450
545
|
const bodyFill = f === 'neon' ? shade(col, -0.72) : fill;
|
|
@@ -461,7 +556,8 @@ export function renderLayers(spec, state = {}, opts = {}) {
|
|
|
461
556
|
let front = '';
|
|
462
557
|
let mask = '';
|
|
463
558
|
spec.accessories.forEach((a, i) => {
|
|
464
|
-
const part =
|
|
559
|
+
const part = accOf(spec, a.id);
|
|
560
|
+
if (!part) return;
|
|
465
561
|
const x = accessorySVG(a, spec, L, pal, clipUrl, skin, `a${i}`);
|
|
466
562
|
if (part.layer === 'back' || growing(a, part)) back += x.svg;
|
|
467
563
|
else front += x.svg;
|
|
@@ -489,10 +585,12 @@ export function renderLayers(spec, state = {}, opts = {}) {
|
|
|
489
585
|
}
|
|
490
586
|
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"/>`;
|
|
491
587
|
if (f === 'sketch') inner += `<path d="${d}" fill="url(#${id}-hatch)" transform="translate(${R(box.w * 0.05)} ${R(box.h * 0.06)})"/>`;
|
|
588
|
+
if (f2) inner += f2.inner({ id, d, box, col, L });
|
|
492
589
|
const sty = styleLayers(spec, L, { uid: id, d, quality: opts.detail || 'full', animate: opts.animate });
|
|
493
590
|
defs += sty.defs;
|
|
494
591
|
inner += sty.inner;
|
|
495
592
|
if (inner) body += `<g clip-path="${clipUrl}">${inner}</g>`;
|
|
593
|
+
if (ol?.under) body += `<path d="${d}" fill="none" stroke="${ol.under.color}" stroke-width="${ol.under.w}" stroke-linejoin="round"${masked ? ` mask="url(#${id}-om)"` : ''}/>`;
|
|
496
594
|
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
595
|
body += sk.rim;
|
|
498
596
|
|
|
@@ -525,7 +623,7 @@ export function renderLayers(spec, state = {}, opts = {}) {
|
|
|
525
623
|
*/
|
|
526
624
|
export function renderCharacter(spec, state = {}, opts = {}) {
|
|
527
625
|
const src = normalizeCharacter(spec);
|
|
528
|
-
const s = stylizeSpec(src);
|
|
626
|
+
const s = stylizeSpec(applyMods(src, normalizeCharacter));
|
|
529
627
|
const x = renderLayers(s, state, opts);
|
|
530
628
|
const title = opts.title ? `<title>${String(opts.title).replace(/[<>&"]/g, '')}</title>` : '';
|
|
531
629
|
const parts = `${x.back}${x.body}${x.cheeks}${x.eyes}${x.brows}${x.mouth}${x.front}`;
|
|
@@ -596,7 +694,7 @@ export function createCharacter(container, spec, opts = {}) {
|
|
|
596
694
|
|
|
597
695
|
// src: la spec tal cual (la que se devuelve); s: la que se pinta (con el estilo artístico aplicado)
|
|
598
696
|
let src = normalizeCharacter(spec);
|
|
599
|
-
let s = stylizeSpec(src);
|
|
697
|
+
let s = stylizeSpec(applyMods(src, normalizeCharacter));
|
|
600
698
|
let uid = `otsc${(++uidSeq).toString(36)}`;
|
|
601
699
|
let g = {};
|
|
602
700
|
let L = layout(s);
|
|
@@ -1307,7 +1405,7 @@ export function createCharacter(container, spec, opts = {}) {
|
|
|
1307
1405
|
clearEffects,
|
|
1308
1406
|
update(next) {
|
|
1309
1407
|
src = normalizeCharacter(next);
|
|
1310
|
-
s = stylizeSpec(src);
|
|
1408
|
+
s = stylizeSpec(applyMods(src, normalizeCharacter));
|
|
1311
1409
|
uid = `otsc${(++uidSeq).toString(36)}`;
|
|
1312
1410
|
morphMask = '';
|
|
1313
1411
|
build();
|
|
@@ -1327,3 +1425,4 @@ export function createCharacter(container, spec, opts = {}) {
|
|
|
1327
1425
|
export { SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, PRESETS, BODY_POINTS };
|
|
1328
1426
|
export { ACCESSORY_GROUPS, partLabel } from './parts.js';
|
|
1329
1427
|
export { STYLES as CHARACTER_STYLES } from './styles.js';
|
|
1428
|
+
export { normalizeMods, normalizeMod, sanitizeSvg, modName, MOD_ANCHORS, MAX_MODS } from './mods.js';
|