Listado de mejoras concretas del sistema, con archivos exactos, puntos de inserción y verificación. Cada mejora es independiente: se puede implementar y validar por separado. Ordenadas por relación valor/esfuerzo.
Convenciones del repo a respetar siempre:
- i18n bilingüe EN/ES para cualquier texto nuevo (claves en
src/modules/shared/i18n.js). - Tests:
npm run test:unit(Node) y suite completanpm run check. - Render de verificación:
npm run render -- --template <id>.
Qué: el AO retro por vertex-colors solo se hornea en el import JSON
(json-import.js). Las plantillas del panel (clic en TEMPLATES) pasan por
instantiateTemplateDefinition y nunca hornean retroAO, aunque el JSON de
la plantilla lo declare.
Archivos:
src/modules/viewport/templates.js(funcióninstantiateTemplateDefinition, línea ~907).src/modules/viewport/vertex-colors.js(ya exportabakeRetroAOynormalizeRetroAO; se usan enjson-import.jscomo referencia).
Pasos:
-
En
templates.js, importarbakeRetroAOynormalizeRetroAOdesde./vertex-colors.js. -
Dentro de
instantiateTemplateDefinition, justo después deconst group = buildGroupFromDefinition(def);, añadir:const retroAO = normalizeRetroAO(def.retroAO); if (retroAO) bakeRetroAO(group, { strength: retroAO.strength });
-
Marcar una plantilla de prueba con
"retroAO": true(p. ej.src/data/templates/characters/n64_slime_reference_cm.json).
Verificación: npm run render -- --template n64_slime_reference_cm → la
captura muestra degradado vertical (abajo más oscuro). npm run check en verde.
Qué: hoy el render CLI solo acepta JSON CharacterModel/legacy o
--template. Una receta de Avatar Forge ({ "version": 2, "bodyPresetId", "headMoldId", "features": {...} }, el formato que copia el botón COPY
RECIPE) no se puede renderizar.
Archivos: scripts/render-template.mjs (función importIntoScene).
Pasos:
-
En
importIntoScene, dentro delelseque procesapayloadText, antes de llamar ahandleImportSubmit(), detectar la receta:const parsed = JSON.parse(text); const isAvatarRecipe = parsed && parsed.version === 2 && typeof parsed.bodyPresetId === 'string' && typeof parsed.headMoldId === 'string';
-
Si
isAvatarRecipe, importar el builder y construir el grupo:const { buildAvatarGroup } = await import('/src/modules/avatar/avatar-builder.js'); group = await buildAvatarGroup(parsed); state.userObjects.add(group); format = 'avatar-recipe'; name = parsed.label || 'AVATAR';
y saltar el
handleImportSubmit()actual. -
Mantener el resto del flujo (bounds, frontSign, budget) igual.
Verificación: copiar una receta desde el forge a tmp/recipe.json y
ejecutar npm run render -- tmp/recipe.json → genera capturas y report.json.
Qué: la luz de relleno añadida en importIntoScene es fija (desde el
frente). Las vistas profile y back siguen capturando el lado en sombra.
Archivos: scripts/render-template.mjs.
Pasos:
-
Eliminar el bloque de fill light de
importIntoScene. -
En
frameView(page, view), dentro delpage.evaluate, tras posicionar la cámara, clonar la luz clave y orientarla desde la cámara hacia el centro:const keyLight = state.scene.getObjectByProperty('isDirectionalLight', true); if (keyLight) { state.scene.children .filter((n) => n.userData.__renderFill) .forEach((n) => state.scene.remove(n)); const fill = keyLight.clone(); fill.userData.__renderFill = true; fill.intensity = keyLight.intensity * 0.85; fill.position.set( state.camera.position.x, state.camera.position.y + span * 0.4, state.camera.position.z ); state.scene.add(fill); }
-
Así cada vista captura con relleno desde su propia dirección.
Verificación: npm run render -- --template psx_black_mage_cm --views front,profile,back
→ las tres capturas legibles, sin lado negro.
Qué: el style budget solo avisa en consola. Para CI conviene fallar.
Archivos: scripts/render-template.mjs.
Pasos:
- En
parseArgs, aceptar--strict(options.strict = true). - En
main(), tras construirreport, sioptions.strict && !report.style.withinBudget, imprimir los warnings yprocess.exitCode = 1.
Verificación: npm run render -- --template psx_black_mage_cm --strict
→ exit 1 (mage tiene 900 tris). Con n64_fenix_chick_cm --strict → exit 0.
Qué: frameView usa distance = span * 1.65 con span = max(width, height, depth). En modelos muy anchos o muy altos el encuadre queda flojo o
cortado.
Archivos: scripts/render-template.mjs (frameView).
Pasos:
- Leer
state.camera.fovystate.camera.aspect. - Calcular:
const fitH = size.height / (2 * Math.tan((state.camera.fov * Math.PI / 180) / 2)); const fitW = size.width / (2 * Math.tan((state.camera.fov * Math.PI / 180) / 2) * state.camera.aspect); const distance = Math.max(fitH, fitW, size.depth * 1.2) * 1.25;
- Sustituir el
span * 1.65por esedistance.
Verificación: renders de psx_drake_pup_cm (ancho) y psx_black_mage_cm
(alto) → ambos caben enteros con margen pequeño.
Qué: el foco de cámara PREVIEW_FOCUS_HEAD existe y se activa solo de
forma implícita al tocar selects de cara. No hay botón para el usuario.
Archivos:
src/modules/avatar/avatar-html.js(header del preview, junto a los botones FRONT/3-4/SIDE).src/modules/avatar/avatar-ui.js(ya exponefocusAvatarForgePreviewinternamente).
Pasos:
- En el header del preview añadir un grupo de 2 botones:
<button data-preview-focus="full">FULL</button>y<button data-preview-focus="head">HEAD</button>. - En
avatar-ui.js, dentro deinitAvatarForge, listener por delegación en#avatar-preview-view-controls(o un contenedor nuevo):focusAvatarForgePreview(button.dataset.previewFocus)— la funciónfocusAvatarForgePreview(value)ya existe y acepta'full' | 'head'. - Marcar activo con la misma clase que usan los botones de vista
(
bg-[#00d0ff]).
Verificación: abrir forge, pulsar HEAD → la cámara encuadra la cabeza; FULL → cuerpo entero.
Qué: navigator.clipboard puede denegar permiso (contextos no seguros,
headless). Hoy solo muestra error.
Archivos: src/modules/avatar/avatar-ui.js (copyAvatarRecipeToClipboard).
Pasos:
-
En el
catch, intentar fallback clásico:const textarea = document.createElement('textarea'); textarea.value = payload; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); const ok = document.execCommand('copy'); textarea.remove(); showToast(t(ok ? 'avatarRecipeCopied' : 'avatarRecipeCopyFailed'));
Verificación: en navegador normal → toast de copiado; forzando el fallo (devtools, denegar permiso) → el fallback copia igualmente.
Qué: con TURNTABLE activo, los botones FRONT/3-4/SIDE fijan la vista pero el auto-rotate la mueve al instante; y al activar turntable la vista queda "fijada" en un botón que ya no refleja la realidad.
Archivos: src/modules/avatar/avatar-ui.js (setTurntableEnabled y
setAvatarForgePreviewView).
Pasos:
- En
setTurntableEnabled(true): poneravatarForgeState.previewViewPinned = falsey llamar asyncPreviewViewControls()(desmarca los botones). - En
setAvatarForgePreviewView: siavatarForgeState.turntableEnabled, llamar primero asetTurntableEnabled(false)(así la vista fijada gana y el toggle refleja el estado real).
Verificación: activar turntable (gira), pulsar FRONT (para de girar y se ve de frente), reactivar turntable (gira de nuevo y ningún botón queda marcado).
Qué: la receta declara animationProfile: 'HUMANOID_STANDARD_AVATAR_BASE'
pero el preview es estático. Reproducir idle daría vida al preview.
Archivos:
src/modules/avatar/avatar-preview-runtime.js(crearTHREE.AnimationMixer).src/modules/avatar/avatar-ui.js(loopanimateya existe: actualizar el mixer conpreviewClock.getDelta()).- Reutilizar
src/data/skeletons/humanoid_standard.jsony el sistema de rig desrc/modules/animation/rigging-utils.js(rebuildRigAnimationsForGroupya genera clips para grupos conskeletonId).
Pasos:
- Tras
buildAvatarGroup(recipe)enrebuildPreview, llamar arebuildRigAnimationsForGroup(previewGroup, { skeletonId: 'HUMANOID_STANDARD', animationProfile: 'HUMANOID_STANDARD_AVATAR_BASE' })para obtener los clips. - Crear
mixer = new THREE.AnimationMixer(previewGroup)ymixer.clipAction(clips.find(c => c.name === 'idle')).play(). - En
animate():avatarForgeState.previewMixer?.update(delta). - Guardar
previewMixeren el estado y destruirlo enclearPreviewGroup. - Añadir toggle
ANIMen el header del preview (clave i18n nuevaavatarPreviewAnim) para activar/pausar (action.paused = !on).
Riesgo: el rig sintético puede mover pivots; si se ven artefactos, hacer la animación opt-in solo con el toggle (apagado por defecto).
Verificación: abrir forge → el avatar hace idle (sube/baja sutilmente la
cadera). npm run check en verde.
Qué: ojos/cejas/boca/fullface ya tienen cicladores; pelo/nariz/orejas solo select. Mismo patrón UX.
Archivos:
src/modules/avatar/avatar-html.js(sección HAIR AND EXTRAS).src/modules/avatar/avatar-form-view.js.
Pasos:
- En
avatar-form-view.jsextenderFACE_CYCLE_SELECT_IDScon:hair: 'avatar-hair-select',nose: 'avatar-nose-select',ears: 'avatar-ear-select'(ojo: también alimenta la galería; mejor crear un mapa separadoEXTRA_CYCLE_SELECT_IDSpara no abrir la galería de sprites en estos). - En
avatar-html.js, bajo cada select, añadir la fila de botones ‹ índice › igual que la dedata-face-cyclepero condata-extra-cycle="hair"etc. - En
bindAvatarFormListeners, replicar el handler dedata-face-cycleparadata-extra-cycle(mismo algoritmo de avance circular + dispatchchange).
Verificación: pulsar › en HAIR cambia el preset y el preview se reconstruye.
Qué: al cerrar el forge se pierde la receta en curso. Guardar borrador.
Archivos: src/modules/avatar/avatar-ui.js.
Pasos:
- Clave
lowpoly64.avatarForgeDraft. - En
updateRecipe, guardar debounced (300 ms)JSON.stringify(avatarForgeState.recipe). - En
openAvatarForge, si NO haytargetGroupy existe borrador, ofrecer restaurarlo: cargarlo directamente yshowToast(t('avatarDraftRestored'))(nueva clave i18n). Al confirmar (CREATE/UPDATE) o cancelar con el botón CANCEL, borrar la clave.
Verificación: editar, cerrar sin crear, reabrir → la receta vuelve; crear el avatar → el borrador se limpia.
Qué: chips de héroe, dados, turntable y copy no tienen cobertura.
Archivos: nuevo tests/e2e/avatar-forge-revamp.spec.js; referencia de
helpers en tests/e2e/helpers/avatar-forge.js.
Pasos:
- Spec Playwright que abra la app y el forge.
- Asserts:
document.querySelectorAll('[data-hero-preset]').length === 6; 4 botones[data-dice-section]; existe#avatar-turntable-toggley#avatar-copy-recipe-btn. - Click en
[data-hero-preset="mage_shadow"]→#avatar-label-inputvale "Shadow Mage" y el select de cuerpo valepsx_slim. - Click en dado FACE → cambia el valor de
#avatar-eye-select(muestrear antes/después con varios intentos por si el azar repite). - Toggle turntable → assert estado interno vía
getAvatarForgePreviewDiagnostics()o eval del checkbox.
Verificación: npx playwright test tests/e2e/avatar-forge-revamp.spec.js en verde.
Qué: el panel TEMPLATES lista ~300 plantillas como texto. Generar PNGs con el propio render CLI y mostrarlos.
Pasos:
- Script
scripts/render-thumbnails.mjs: para cadaTEMPLATE_REGISTRY, llamar al mismo flujo headless del render CLI con--views front --size 192x144 --out public/thumbnails/<id>y copiar el PNG apublic/thumbnails/<id>.png. - Añadir script npm
"thumbnails": "node ./scripts/render-thumbnails.mjs". - En el item del panel de plantillas (donde se pinta cada nombre; buscar el
render de la lista en
src/modules/viewport/ui.js), añadir<img loading="lazy" src="/thumbnails/<id>.png" alt="">con fallback si 404 (ocultar img). .gitignoreparapublic/thumbnails/si no se quieren commitear.
Verificación: npm run thumbnails (subset pequeño primero) y abrir el
panel → se ven imágenes.
Qué: el aviso de style budget solo aparece en import JSON. Al añadir una plantilla desde el panel no hay feedback.
Archivos: src/modules/viewport/templates.js (addTemplate, línea ~973).
Pasos:
-
Tras
const group = instantiateTemplateDefinition(def);, evaluar:const { evaluateStyleBudget } = await import('./style-budget.js'); const { warnStyleBudgetOverage } = await import('./style-budget.js'); // si existe el helper; si no, replicar el toast de json-import.js const result = evaluateStyleBudget(group); if (!result.withinBudget) warnStyleBudgetOverage(result);
(En
json-import.jsestá el patrón exacto de toast a reutilizar; siwarnStyleBudgetOveragevive allí, exportarlo desdestyle-budget.jsy usarlo en ambos sitios.)
Verificación: instanciar n64_skull_knight_cm (872 tris) desde el panel
→ toast de aviso; instanciar n64_fenix_chick_cm (420) → sin toast.
Qué: varios archivos mezclan CRLF y LF (p. ej.
src/modules/avatar/avatar-html.js), lo que rompe herramientas de edición
por coincidencia exacta.
Pasos:
- Crear
.gitattributesen la raíz:* text=auto eol=lf - Script de una pasada (Node): recorrer
src/**/*.js,scripts/**/*.mjs,tests/**/*.js,*.mdy reescribir convirtiendo\r\n→\n. - Commit aparte, sin otros cambios.
Verificación: git diff --stat muestra solo cambios de EOL;
npm run check en verde.
Qué: src/modules/shared/i18n.js tiene textos de ayuda del forge pero no
menciona Starter Heroes, dados, turntable ni copy recipe.
Pasos:
- Buscar en
src/help.jslas entradasavatarForge*(EN ~línea 346, ES ~línea 599). - Añadir una línea por control en ambos idiomas, p. ej. EN: "Starter Heroes load a full curated recipe in one click; dice buttons randomize a single section; TURNTABLE spins the preview; COPY RECIPE puts the recipe JSON on the clipboard."
Verificación: abrir HELP dentro de la app y leer la sección del forge.
- Más héroes iniciales: añadir 2–3 presets a
src/data/avatar/catalog/forge-hero-presets.js(p. ej. un "Ancient Sage" conbrow_elder+mouth_beard_gap+ palettecool_ash). Sin más código. - Variantes de los 6 modelos: clonar una plantilla de
src/data/templates/characters/cambiando paleta (materialyfaceColors/vertexColors) para crear, p. ej., "Mago Carmesí" desdepsx_black_mage_cm. - Ala del draco más rica: en
scripts/forge-new-characters.mjs, el ala delpsx_drake_pup_cmson 2 triángulos; subdividir en 4 añadiendo un vértice medio y 2 caras más mejora la silueta por ~8 tris.
- C3 (EOL) — facilita todo lo demás.
- A1, A3, A4, A5 — tooling de render (pequeños, independientes).
- B1, B2, B3 — retoques del forge (pequeños).
- B7 — tests de lo nuevo.
- C2, C4 — feedback y docs.
- A2 — recetas en render CLI.
- B5, B6 — UX del forge.
- C1 — thumbnails (más largo).
- B4 — animación en preview (la más arriesgada, dejar al final).