Spaces:
Running
Game: hero creation + selection flow; pin the Space
Browse filesShared hero creator (heroCreator.js) used by the Personas page and a new Game 'Create a Hero'/'Hero Details' modal: two-state layout (recruit ⇄ portrait panel), streamed details, generate portrait + quote + voice, barracks, fixed-size modal (no resize across states/streaming), and portrait repaint disabled unless the appearance changed.
Hero picker: cards show the saved portrait (or an animated class idle); picking opens a hero detail page (portrait/about/quote + Select) instead of spawning immediately; on Select the camera flies down to the spawn point and the hero drops in. Picker auto-refreshes on roster changes.
- web/comboBattler.js, web/mapSandbox.js: rebuilt bundles (flyTo + getSpawnWorld).
- README: pinned: true.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- README.md +1 -1
- web/comboBattler.js +41 -4
- web/heroCreator.js +520 -0
- web/mapSandbox.js +36 -3
- web/personaPanel.js +7 -508
- web/shell/persona.css +126 -0
- web/tiny.js +110 -13
|
@@ -5,7 +5,7 @@ colorFrom: red
|
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
-
pinned:
|
| 9 |
license: mit
|
| 10 |
short_description: Tiny Army — fighters write their own true legends
|
| 11 |
---
|
|
|
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
+
pinned: true
|
| 9 |
license: mit
|
| 10 |
short_description: Tiny Army — fighters write their own true legends
|
| 11 |
---
|
|
@@ -33,6 +33,7 @@ function createChunkedMap(pixi, host, config) {
|
|
| 33 |
let seed = config.seed ?? 1;
|
| 34 |
let zoom = Z_DEFAULT;
|
| 35 |
let cameraDirty = true, genPending = false, lastEmitTile = null;
|
|
|
|
| 36 |
const camera = { x: config.initialCamera?.x ?? CHUNKPX / 2, y: config.initialCamera?.y ?? CHUNKPX / 2 };
|
| 37 |
const chunks = /* @__PURE__ */ new Map();
|
| 38 |
const macroChunks = /* @__PURE__ */ new Map();
|
|
@@ -257,6 +258,21 @@ function createChunkedMap(pixi, host, config) {
|
|
| 257 |
function zoomBy(factor) {
|
| 258 |
if (app) zoomAt(factor, app.screen.width / 2, app.screen.height / 2);
|
| 259 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
function bindInput() {
|
| 261 |
const canvas = app.canvas;
|
| 262 |
const local = (cx, cy) => {
|
|
@@ -268,7 +284,7 @@ function createChunkedMap(pixi, host, config) {
|
|
| 268 |
return [it.next().value, it.next().value];
|
| 269 |
};
|
| 270 |
handlers.down = (e) => {
|
| 271 |
-
if (!enabled) return;
|
| 272 |
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
| 273 |
canvas.setPointerCapture?.(e.pointerId);
|
| 274 |
if (pointers.size === 1) {
|
|
@@ -308,7 +324,7 @@ function createChunkedMap(pixi, host, config) {
|
|
| 308 |
} else if (pointers.size === 0) drag.on = false;
|
| 309 |
};
|
| 310 |
handlers.wheel = (e) => {
|
| 311 |
-
if (!enabled) return;
|
| 312 |
e.preventDefault();
|
| 313 |
zoomAt(e.deltaY < 0 ? Z_STEP : 1 / Z_STEP, ...local(e.clientX, e.clientY));
|
| 314 |
};
|
|
@@ -346,6 +362,23 @@ function createChunkedMap(pixi, host, config) {
|
|
| 346 |
const PAN_SPEED = 6;
|
| 347 |
function tick(ticker) {
|
| 348 |
for (const fn of tickHooks) fn(ticker);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
if (!enabled) return;
|
| 350 |
let dx = 0, dy = 0;
|
| 351 |
for (const k of keys) {
|
|
@@ -490,7 +523,7 @@ function createChunkedMap(pixi, host, config) {
|
|
| 490 |
ctx = null;
|
| 491 |
texCache.clear();
|
| 492 |
}
|
| 493 |
-
return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, biomeAt, getBounds, zoomBy, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE7 };
|
| 494 |
}
|
| 495 |
|
| 496 |
// ../auto-battler/src/engine/rng.js
|
|
@@ -5125,7 +5158,11 @@ function mountComboBattler(pixi, host, opts = {}) {
|
|
| 5125 |
combatRoot = null;
|
| 5126 |
listeners.clear();
|
| 5127 |
}
|
| 5128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5129 |
if (typeof window !== "undefined") {
|
| 5130 |
window.__comboSnap = () => ctrl.getSnapshot();
|
| 5131 |
window.__combo = ctrl;
|
|
|
|
| 33 |
let seed = config.seed ?? 1;
|
| 34 |
let zoom = Z_DEFAULT;
|
| 35 |
let cameraDirty = true, genPending = false, lastEmitTile = null;
|
| 36 |
+
let flyAnim = null;
|
| 37 |
const camera = { x: config.initialCamera?.x ?? CHUNKPX / 2, y: config.initialCamera?.y ?? CHUNKPX / 2 };
|
| 38 |
const chunks = /* @__PURE__ */ new Map();
|
| 39 |
const macroChunks = /* @__PURE__ */ new Map();
|
|
|
|
| 258 |
function zoomBy(factor) {
|
| 259 |
if (app) zoomAt(factor, app.screen.width / 2, app.screen.height / 2);
|
| 260 |
}
|
| 261 |
+
function flyTo(wx, wy, z, ms = 800) {
|
| 262 |
+
return new Promise((resolve) => {
|
| 263 |
+
if (!app) {
|
| 264 |
+
resolve();
|
| 265 |
+
return;
|
| 266 |
+
}
|
| 267 |
+
if (flyAnim) {
|
| 268 |
+
const r = flyAnim.resolve;
|
| 269 |
+
flyAnim = null;
|
| 270 |
+
r && r();
|
| 271 |
+
}
|
| 272 |
+
const toZ = Math.min(Z_MAX, Math.max(bounds ? coverZoom() : Z_MIN, z));
|
| 273 |
+
flyAnim = { fromX: camera.x, fromY: camera.y, fromZ: zoom, toX: wx, toY: wy, toZ, t: 0, dur: Math.max(1, ms), resolve };
|
| 274 |
+
});
|
| 275 |
+
}
|
| 276 |
function bindInput() {
|
| 277 |
const canvas = app.canvas;
|
| 278 |
const local = (cx, cy) => {
|
|
|
|
| 284 |
return [it.next().value, it.next().value];
|
| 285 |
};
|
| 286 |
handlers.down = (e) => {
|
| 287 |
+
if (!enabled || flyAnim) return;
|
| 288 |
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
| 289 |
canvas.setPointerCapture?.(e.pointerId);
|
| 290 |
if (pointers.size === 1) {
|
|
|
|
| 324 |
} else if (pointers.size === 0) drag.on = false;
|
| 325 |
};
|
| 326 |
handlers.wheel = (e) => {
|
| 327 |
+
if (!enabled || flyAnim) return;
|
| 328 |
e.preventDefault();
|
| 329 |
zoomAt(e.deltaY < 0 ? Z_STEP : 1 / Z_STEP, ...local(e.clientX, e.clientY));
|
| 330 |
};
|
|
|
|
| 362 |
const PAN_SPEED = 6;
|
| 363 |
function tick(ticker) {
|
| 364 |
for (const fn of tickHooks) fn(ticker);
|
| 365 |
+
if (flyAnim) {
|
| 366 |
+
flyAnim.t += ticker.deltaMS;
|
| 367 |
+
const p = Math.min(1, flyAnim.t / flyAnim.dur);
|
| 368 |
+
const e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
|
| 369 |
+
camera.x = flyAnim.fromX + (flyAnim.toX - flyAnim.fromX) * e;
|
| 370 |
+
camera.y = flyAnim.fromY + (flyAnim.toY - flyAnim.fromY) * e;
|
| 371 |
+
zoom = flyAnim.fromZ + (flyAnim.toZ - flyAnim.fromZ) * e;
|
| 372 |
+
cameraDirty = true;
|
| 373 |
+
if (p >= 1) {
|
| 374 |
+
const r = flyAnim.resolve;
|
| 375 |
+
flyAnim = null;
|
| 376 |
+
r && r();
|
| 377 |
+
}
|
| 378 |
+
reconcile();
|
| 379 |
+
cameraDirty = false;
|
| 380 |
+
return;
|
| 381 |
+
}
|
| 382 |
if (!enabled) return;
|
| 383 |
let dx = 0, dy = 0;
|
| 384 |
for (const k of keys) {
|
|
|
|
| 523 |
ctx = null;
|
| 524 |
texCache.clear();
|
| 525 |
}
|
| 526 |
+
return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, biomeAt, getBounds, zoomBy, flyTo, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE7 };
|
| 527 |
}
|
| 528 |
|
| 529 |
// ../auto-battler/src/engine/rng.js
|
|
|
|
| 5158 |
combatRoot = null;
|
| 5159 |
listeners.clear();
|
| 5160 |
}
|
| 5161 |
+
function getSpawnWorld() {
|
| 5162 |
+
const b = map.getBounds();
|
| 5163 |
+
return b ? { x: b.x0 + 64 * TILE6, y: b.y1 - 64 * TILE6 } : { x: 0, y: 0 };
|
| 5164 |
+
}
|
| 5165 |
+
const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
|
| 5166 |
if (typeof window !== "undefined") {
|
| 5167 |
window.__comboSnap = () => ctrl.getSnapshot();
|
| 5168 |
window.__combo = ctrl;
|
|
@@ -0,0 +1,520 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Shared hero-creator — the recruit/stream/edit experience used by BOTH the Personas page
|
| 2 |
+
// (personaPanel.js, which adds a barracks roster around it) and the Game page's "Create a hero"
|
| 3 |
+
// modal (tiny.js). Renders the two-column `.persona-view` (class/seed/recruit controls on the left,
|
| 4 |
+
// streamed name/about/quote + voice + portrait on the right), runs the whole generation pipeline
|
| 5 |
+
// (local LLM → JSON persona, image engine → portrait, TTS → voice) and autosaves to the roster.
|
| 6 |
+
//
|
| 7 |
+
// const creator = mountHeroCreator(host, { extraControls, onSaved })
|
| 8 |
+
// creator.load(persona, { savedId }) // show an existing hero for editing
|
| 9 |
+
// creator.current() // { persona, savedId }
|
| 10 |
+
import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
|
| 11 |
+
import { extractLivePersona } from '/web/personaStream.js'
|
| 12 |
+
import { parsePersonaJson } from '/web/personaParse.js'
|
| 13 |
+
import { getPersonaSystem, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
|
| 14 |
+
import {
|
| 15 |
+
createVoiceWav, cloneVoiceWav, playWav, synthVoiceWav, speakVoiceLive, stopVoiceLive,
|
| 16 |
+
activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange, ttsBackendLabel,
|
| 17 |
+
} from '/web/tts.js'
|
| 18 |
+
import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio, putPortrait, getPortrait } from '/web/personaStore.js'
|
| 19 |
+
import { generatePortrait, imageBackendLabel, imageNeedsDownload, ensureImage } from '/web/imagen.js'
|
| 20 |
+
|
| 21 |
+
const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
|
| 22 |
+
const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
|
| 23 |
+
|
| 24 |
+
// Each class shows the idle pose of a fitting sprite (character slug → see
|
| 25 |
+
// characters.json). The sheet's front-right row is animated as a tiny looping
|
| 26 |
+
// icon beside the class name in the dropdown.
|
| 27 |
+
export const CLASS_SLUG = {
|
| 28 |
+
Warrior: 'true-heroes-iii-fighter',
|
| 29 |
+
Ranger: 'true-heroes-iii-ranger',
|
| 30 |
+
Monk: 'true-heroes-ii-bard',
|
| 31 |
+
Assassin: 'true-heroes-iv-ninja-assassin',
|
| 32 |
+
Mage: 'true-heroes-iii-wizard',
|
| 33 |
+
Paladin: 'true-heroes-ii-paladin',
|
| 34 |
+
Cleric: 'true-heroes-ii-cleric',
|
| 35 |
+
Knight: 'rts-humans-knight',
|
| 36 |
+
}
|
| 37 |
+
const ICON_PX = 30 // on-screen size of the class icon box
|
| 38 |
+
const ICON_ZOOM = 2.4 // sheets pad the character inside each cell — zoom in so it fills the box
|
| 39 |
+
const spriteUrl = (u) => (u || '').replace('/assets/', '/sprites/') // sheets serve at /sprites
|
| 40 |
+
|
| 41 |
+
function el(tag, props = {}, kids = []) {
|
| 42 |
+
const n = document.createElement(tag)
|
| 43 |
+
for (const [k, v] of Object.entries(props)) {
|
| 44 |
+
if (k === 'class') n.className = v
|
| 45 |
+
else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
|
| 46 |
+
else if (v != null) n.setAttribute(k, v)
|
| 47 |
+
}
|
| 48 |
+
for (const kid of [].concat(kids)) if (kid != null) n.append(kid)
|
| 49 |
+
return n
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Idle sheets are 4 rows (facings) × N frame columns; cell = height/4. We render
|
| 53 |
+
// the front-right row (row 0) and step across the columns to loop the idle — no
|
| 54 |
+
// canvas, just a sized background + the Web Animations API.
|
| 55 |
+
export function animateIdleIcon(box, idleUrl, sizePx = ICON_PX) {
|
| 56 |
+
box.getAnimations?.().forEach((a) => a.cancel())
|
| 57 |
+
box.style.backgroundImage = ''
|
| 58 |
+
const img = new Image()
|
| 59 |
+
img.onload = () => {
|
| 60 |
+
const cell = (img.naturalHeight / 4) || img.naturalHeight
|
| 61 |
+
const cols = Math.max(1, Math.round(img.naturalWidth / cell))
|
| 62 |
+
const rows = Math.max(1, Math.round(img.naturalHeight / cell))
|
| 63 |
+
const cellPx = sizePx * ICON_ZOOM // zoomed on-screen cell size (fills the box)
|
| 64 |
+
const off = (cellPx - sizePx) / 2 // inset to centre the box on a cell
|
| 65 |
+
box.style.backgroundImage = `url("${idleUrl}")`
|
| 66 |
+
box.style.backgroundSize = `${cols * cellPx}px ${rows * cellPx}px`
|
| 67 |
+
const y = `-${off}px` // row 0, vertically centred
|
| 68 |
+
box.animate(
|
| 69 |
+
[{ backgroundPosition: `-${off}px ${y}` }, { backgroundPosition: `-${cols * cellPx + off}px ${y}` }],
|
| 70 |
+
{ duration: cols * 110, iterations: Infinity, easing: `steps(${cols})` },
|
| 71 |
+
)
|
| 72 |
+
}
|
| 73 |
+
img.src = idleUrl
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// A class picker that mirrors a native <select> API (.value get/set, 'change'
|
| 77 |
+
// event) but renders an animated idle icon beside each class.
|
| 78 |
+
function makeClassDropdown(classes) {
|
| 79 |
+
const triggerIco = el('span', { class: 'persona-class-ico' })
|
| 80 |
+
const triggerLabel = el('span', { class: 'persona-classdrop-label' })
|
| 81 |
+
const trigger = el('button', { class: 'persona-input persona-classdrop-trigger', type: 'button' },
|
| 82 |
+
[triggerIco, triggerLabel, el('span', { class: 'persona-classdrop-chev' }, '▾')])
|
| 83 |
+
const menu = el('div', { class: 'persona-classdrop-menu' })
|
| 84 |
+
const root = el('div', { class: 'persona-classdrop' }, [trigger, menu])
|
| 85 |
+
|
| 86 |
+
let value = classes[0]
|
| 87 |
+
let icons = {} // class → idle sheet URL (filled by setIcons)
|
| 88 |
+
const optIco = {} // class → menu icon span
|
| 89 |
+
|
| 90 |
+
const items = classes.map((c) => {
|
| 91 |
+
const ico = el('span', { class: 'persona-class-ico' }); optIco[c] = ico
|
| 92 |
+
const it = el('button', { class: 'persona-classdrop-opt', type: 'button' }, [ico, el('span', {}, c)])
|
| 93 |
+
it.addEventListener('click', () => { set(c); close() })
|
| 94 |
+
return it
|
| 95 |
+
})
|
| 96 |
+
menu.append(...items)
|
| 97 |
+
|
| 98 |
+
function set(c) {
|
| 99 |
+
if (!classes.includes(c)) return
|
| 100 |
+
const changed = c !== value
|
| 101 |
+
value = c
|
| 102 |
+
triggerLabel.textContent = c
|
| 103 |
+
items.forEach((it, i) => it.classList.toggle('sel', classes[i] === c))
|
| 104 |
+
if (icons[c]) animateIdleIcon(triggerIco, icons[c])
|
| 105 |
+
if (changed) root.dispatchEvent(new Event('change'))
|
| 106 |
+
}
|
| 107 |
+
const close = () => root.classList.remove('open')
|
| 108 |
+
trigger.addEventListener('click', (e) => { e.stopPropagation(); root.classList.toggle('open') })
|
| 109 |
+
document.addEventListener('click', (e) => { if (!root.contains(e.target)) close() })
|
| 110 |
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
| 111 |
+
|
| 112 |
+
root.setIcons = (map) => {
|
| 113 |
+
icons = map
|
| 114 |
+
for (const c of classes) if (map[c]) animateIdleIcon(optIco[c], map[c])
|
| 115 |
+
if (map[value]) animateIdleIcon(triggerIco, map[value])
|
| 116 |
+
}
|
| 117 |
+
Object.defineProperty(root, 'value', { get: () => value, set: (v) => set(v) })
|
| 118 |
+
triggerLabel.textContent = value
|
| 119 |
+
items.forEach((it, i) => it.classList.toggle('sel', classes[i] === value))
|
| 120 |
+
return root
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// Resolve each class's idle sheet via characters.json and light up the dropdown.
|
| 124 |
+
async function loadClassIcons(dropdown) {
|
| 125 |
+
try {
|
| 126 |
+
const d = await fetch('/sprites/characters.json').then((r) => r.json())
|
| 127 |
+
const bySlug = {}
|
| 128 |
+
for (const p of d.packs || []) for (const c of p.characters || []) bySlug[c.slug] = c
|
| 129 |
+
const map = {}
|
| 130 |
+
for (const [cls, slug] of Object.entries(CLASS_SLUG)) {
|
| 131 |
+
const idle = bySlug[slug]?.idle
|
| 132 |
+
if (idle) map[cls] = spriteUrl(idle)
|
| 133 |
+
}
|
| 134 |
+
dropdown.setIcons(map)
|
| 135 |
+
} catch { /* no icons — the dropdown still works with labels only */ }
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Mount the creator into `host`. `opts.extraControls` (DOM) is appended into the controls aside
|
| 139 |
+
// (the Personas page passes its barracks roster here). `opts.onSaved(rec)` fires after every
|
| 140 |
+
// autosave (generate + inline edits + portrait/voice), so the host can refresh a roster, enable a
|
| 141 |
+
// "Save & Play" button, etc. Returns a controller (load / current / reset / stop).
|
| 142 |
+
export function mountHeroCreator(host, opts = {}) {
|
| 143 |
+
const DEFAULT_STATUS = 'Runs on your device — no cloud.'
|
| 144 |
+
const sel = makeClassDropdown(CLASSES)
|
| 145 |
+
loadClassIcons(sel)
|
| 146 |
+
const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
|
| 147 |
+
const stats = el('div', { class: 'persona-stats' })
|
| 148 |
+
const status = el('div', { class: 'persona-status' }, DEFAULT_STATUS)
|
| 149 |
+
const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit hero')
|
| 150 |
+
|
| 151 |
+
const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
|
| 152 |
+
const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
|
| 153 |
+
const quoteEl = el('blockquote', { class: 'persona-quote persona-edit', 'data-ph': 'A line they say…' })
|
| 154 |
+
const voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
|
| 155 |
+
const voicePickEl = el('select', { class: 'persona-input persona-voice-pick' })
|
| 156 |
+
const voicePickRow = el('div', { class: 'persona-voice-pick-row' },
|
| 157 |
+
[el('label', { class: 'persona-label' }, 'Voice'), voicePickEl])
|
| 158 |
+
const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice' }, '▶')
|
| 159 |
+
const voiceStatus = el('span', { class: 'persona-act-status' }) // "generating voice via …" beside ▶
|
| 160 |
+
const appearanceEl = el('div', { class: 'persona-appearance persona-edit', 'data-ph': 'How they look…' })
|
| 161 |
+
const portraitBtn = el('button', { class: 'persona-ico persona-portrait-btn', type: 'button', title: 'Paint portrait' }, '🎨')
|
| 162 |
+
const portraitStatus = el('span', { class: 'persona-act-status' }) // "painting via …" beside 🎨
|
| 163 |
+
const portraitImg = el('img', { class: 'persona-portrait-img', alt: '' })
|
| 164 |
+
const portraitWrap = el('div', { class: 'persona-portrait-wrap' }, [portraitImg])
|
| 165 |
+
// The general status line + token rate live INSIDE the debug section (only per-action voice/portrait
|
| 166 |
+
// notes show beside their buttons). No copy button.
|
| 167 |
+
const thinkEl = el('pre', { class: 'persona-think' })
|
| 168 |
+
const thinkWrap = el('details', { class: 'persona-think-wrap' },
|
| 169 |
+
[el('summary', {}, 'model output / debug (raw)'), status, stats, thinkEl])
|
| 170 |
+
|
| 171 |
+
// Plain section header (top line + small heading) with an optional right-side action.
|
| 172 |
+
const secHead = (title, action) =>
|
| 173 |
+
el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, title), action || el('span')])
|
| 174 |
+
// Header whose action is a create button with an inline status note to its RIGHT (full model name).
|
| 175 |
+
const actionHead = (title, button, note) =>
|
| 176 |
+
el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, title), el('div', { class: 'persona-sec-action' }, [button, note])])
|
| 177 |
+
|
| 178 |
+
const backBtn = el('button', { class: 'persona-back', type: 'button' }, '← Back')
|
| 179 |
+
// Optional barracks roster (saved heroes) — always visible in the left column when enabled.
|
| 180 |
+
const rosterEl = opts.showBarracks ? el('div', { class: 'persona-roster' }) : null
|
| 181 |
+
const barracksEl = opts.showBarracks
|
| 182 |
+
? el('div', { class: 'persona-barracks' }, [el('label', { class: 'persona-label persona-roster-label' }, 'Barracks (saved)'), rosterEl])
|
| 183 |
+
: null
|
| 184 |
+
|
| 185 |
+
// Left column has two states. STATE A (recruit): class + seed + Recruit. STATE B (a hero exists): a
|
| 186 |
+
// portrait panel — placeholder/image + 🎨 paint button & badge + the editable appearance prompt. The
|
| 187 |
+
// barracks (if any) and the model-output debug are shown in BOTH states; the debug is anchored bottom.
|
| 188 |
+
const recruitBox = el('div', { class: 'persona-recruit-box' }, [
|
| 189 |
+
el('label', { class: 'persona-label' }, 'Class'), sel,
|
| 190 |
+
el('label', { class: 'persona-label' }, 'Seed'), seed,
|
| 191 |
+
btn,
|
| 192 |
+
])
|
| 193 |
+
const portraitBox = el('div', { class: 'persona-portrait-panel' }, [
|
| 194 |
+
actionHead('Portrait', portraitBtn, portraitStatus),
|
| 195 |
+
portraitWrap,
|
| 196 |
+
appearanceEl,
|
| 197 |
+
])
|
| 198 |
+
const controls = el('aside', { class: 'persona-controls' }, [
|
| 199 |
+
recruitBox, portraitBox, barracksEl, thinkWrap,
|
| 200 |
+
])
|
| 201 |
+
const emptyEl = el('div', { class: 'persona-empty' }, opts.emptyText || 'Every legend starts here — pick a class and recruit your hero.')
|
| 202 |
+
const bodyEl = el('div', { class: 'persona-body' }, [
|
| 203 |
+
nameEl,
|
| 204 |
+
secHead('About'), aboutEl,
|
| 205 |
+
actionHead('Quote', playBtn, voiceStatus), quoteEl,
|
| 206 |
+
secHead('Voice design'), voiceEl, voicePickRow,
|
| 207 |
+
])
|
| 208 |
+
const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl])
|
| 209 |
+
const view = el('div', { class: 'persona-view' }, [controls, result])
|
| 210 |
+
host.appendChild(view)
|
| 211 |
+
|
| 212 |
+
// Back button: in the host-provided footer slot (the Game modal) or, by default, at the top of the
|
| 213 |
+
// portrait panel. It RESETS the creator and returns to the recruit state; only shown in STATE B.
|
| 214 |
+
if (opts.backSlot) opts.backSlot.prepend(backBtn)
|
| 215 |
+
else portraitBox.prepend(backBtn)
|
| 216 |
+
backBtn.addEventListener('click', () => resetAll())
|
| 217 |
+
|
| 218 |
+
function setLeftState(s) {
|
| 219 |
+
const portrait = s === 'portrait'
|
| 220 |
+
recruitBox.style.display = portrait ? 'none' : ''
|
| 221 |
+
portraitBox.style.display = portrait ? '' : 'none'
|
| 222 |
+
backBtn.style.display = portrait ? '' : 'none'
|
| 223 |
+
if (barracksEl) barracksEl.style.display = portrait ? 'none' : '' // barracks only in the recruit state
|
| 224 |
+
try { opts.onState?.(s) } catch { /* host hook */ }
|
| 225 |
+
}
|
| 226 |
+
setLeftState('recruit')
|
| 227 |
+
|
| 228 |
+
let lastPersona = null // the persona currently shown
|
| 229 |
+
let savedId = null // its roster id (set the moment it's shown — always saved)
|
| 230 |
+
let hasVoice = false // a cached voice file exists for this persona
|
| 231 |
+
let hasPortrait = false // a cached portrait exists for this persona
|
| 232 |
+
let portraitBusy = false
|
| 233 |
+
let portraitUrl = null // object URL for the shown image (revoked on replace)
|
| 234 |
+
let working = false
|
| 235 |
+
let busy = false
|
| 236 |
+
let playing = false // audio is currently sounding (▶ becomes ⏹)
|
| 237 |
+
|
| 238 |
+
const fireSaved = (rec) => { try { opts.onSaved?.(rec, { persona: lastPersona, savedId }) } catch { /* host hook */ } }
|
| 239 |
+
|
| 240 |
+
function setPlaying(on) {
|
| 241 |
+
playing = on
|
| 242 |
+
playBtn.classList.toggle('playing', on)
|
| 243 |
+
playBtn.textContent = on ? '⏹' : '▶'
|
| 244 |
+
if (on) playBtn.title = 'Stop'
|
| 245 |
+
else updateVoiceUI()
|
| 246 |
+
}
|
| 247 |
+
function stopVoice() { stopVoiceLive(); setPlaying(false) }
|
| 248 |
+
async function playBuf(arrayBuffer) {
|
| 249 |
+
setPlaying(true)
|
| 250 |
+
try { await playWav(arrayBuffer) } catch { /* autoplay blocked / cut short */ }
|
| 251 |
+
finally { setPlaying(false) }
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
function refreshVisibility() {
|
| 255 |
+
const show = !!lastPersona || busy
|
| 256 |
+
bodyEl.style.display = show ? '' : 'none'
|
| 257 |
+
emptyEl.style.display = show ? 'none' : ''
|
| 258 |
+
}
|
| 259 |
+
refreshVisibility()
|
| 260 |
+
|
| 261 |
+
const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A hero'} reporting for duty.`
|
| 262 |
+
const isDesign = () => activeEngineIsDesign()
|
| 263 |
+
const isNative = () => activeEngineIsNative()
|
| 264 |
+
const voiceNow = () => (isDesign() ? (lastPersona?.voice || '') : (lastPersona?.voiceId || ''))
|
| 265 |
+
const voiceUsed = () => (isDesign() ? (lastPersona?.voiceDesignUsed || '') : (lastPersona?.voiceIdUsed || ''))
|
| 266 |
+
const isDirty = () => !isNative() && hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || voiceNow() !== voiceUsed())
|
| 267 |
+
const designChanged = () => isDesign() && hasVoice && lastPersona && (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || '')
|
| 268 |
+
function updateVoiceUI() {
|
| 269 |
+
const needs = !!lastPersona && !isNative() && (!hasVoice || isDirty())
|
| 270 |
+
playBtn.classList.toggle('badged', needs)
|
| 271 |
+
if (playing) return
|
| 272 |
+
playBtn.title = (!lastPersona || isNative()) ? 'Play voice' : (!hasVoice ? 'Create voice' : (isDirty() ? 'Update & play' : 'Play voice'))
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
function refreshVoiceMode() {
|
| 276 |
+
const design = isDesign()
|
| 277 |
+
voiceEl.contentEditable = design ? 'true' : 'false'
|
| 278 |
+
voiceEl.classList.toggle('readonly', !design)
|
| 279 |
+
voiceEl.setAttribute('data-ph', design ? 'How they sound…' : '(only used by Qwen3-TTS Voice Design)')
|
| 280 |
+
voicePickRow.style.display = design ? 'none' : ''
|
| 281 |
+
if (!design) {
|
| 282 |
+
const voices = activeVoices()
|
| 283 |
+
voicePickEl.replaceChildren(...voices.map((v) => el('option', { value: v.id }, v.label)))
|
| 284 |
+
let cur = (lastPersona && lastPersona.voiceId) || activeDefaultVoice()
|
| 285 |
+
if (!voices.some((v) => v.id === cur)) cur = voices[0] ? voices[0].id : ''
|
| 286 |
+
voicePickEl.value = cur
|
| 287 |
+
if (lastPersona) lastPersona.voiceId = cur
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
voicePickEl.addEventListener('change', () => {
|
| 291 |
+
if (!lastPersona) return
|
| 292 |
+
lastPersona.voiceId = voicePickEl.value
|
| 293 |
+
autosave(); updateVoiceUI()
|
| 294 |
+
})
|
| 295 |
+
onTtsEngineChange(() => { stopVoice(); if (lastPersona) { refreshVoiceMode(); updateVoiceUI() } })
|
| 296 |
+
|
| 297 |
+
function autosave() {
|
| 298 |
+
if (!lastPersona) return
|
| 299 |
+
const rec = savePersona({ ...lastPersona, id: savedId, unitClass: lastPersona.unitClass || sel.value, seed: lastPersona.seed || seed.value })
|
| 300 |
+
savedId = rec.id
|
| 301 |
+
fireSaved(rec)
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
function editable(elm, field, { single = false } = {}) {
|
| 305 |
+
elm.contentEditable = 'true'
|
| 306 |
+
elm.spellcheck = false
|
| 307 |
+
if (single) elm.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); elm.blur() } })
|
| 308 |
+
if (field === 'quote' || field === 'voice') {
|
| 309 |
+
elm.addEventListener('input', () => { if (lastPersona) { lastPersona[field] = elm.textContent.trim(); updateVoiceUI() } })
|
| 310 |
+
}
|
| 311 |
+
elm.addEventListener('blur', () => {
|
| 312 |
+
if (!lastPersona) return
|
| 313 |
+
lastPersona[field] = elm.textContent.trim()
|
| 314 |
+
autosave(); updateVoiceUI()
|
| 315 |
+
})
|
| 316 |
+
}
|
| 317 |
+
editable(nameEl, 'name', { single: true })
|
| 318 |
+
editable(aboutEl, 'about')
|
| 319 |
+
editable(quoteEl, 'quote', { single: true })
|
| 320 |
+
editable(voiceEl, 'voice')
|
| 321 |
+
|
| 322 |
+
// ── Portrait ──────────────────────────────────────────────────────────────
|
| 323 |
+
const PORTRAIT_STYLE = 'Fantasy hero character portrait, painterly digital art, dramatic lighting, head and shoulders, detailed face, plain background.'
|
| 324 |
+
const buildAppearance = (p) => [
|
| 325 |
+
[p.name, p.unitClass && `a ${p.unitClass}`].filter(Boolean).join(', '),
|
| 326 |
+
(p.about || '').trim(),
|
| 327 |
+
].filter(Boolean).join('. ')
|
| 328 |
+
const appearanceFor = (p) => (p.appearance || '').trim() || buildAppearance(p)
|
| 329 |
+
const portraitDirty = () => hasPortrait && lastPersona && appearanceFor(lastPersona) !== (lastPersona.portraitUsed || '')
|
| 330 |
+
|
| 331 |
+
function setPortrait(blob) {
|
| 332 |
+
if (portraitUrl) { try { URL.revokeObjectURL(portraitUrl) } catch { /* ignore */ } }
|
| 333 |
+
portraitUrl = blob ? URL.createObjectURL(blob) : null
|
| 334 |
+
portraitImg.src = portraitUrl || ''
|
| 335 |
+
portraitWrap.classList.toggle('has-img', !!blob)
|
| 336 |
+
}
|
| 337 |
+
function updatePortraitUI() {
|
| 338 |
+
// Paintable when there's no portrait yet, or the appearance changed since the last one. When the
|
| 339 |
+
// portrait already matches the definition, repainting is disabled (nothing to redo).
|
| 340 |
+
const paintable = !!lastPersona && (!hasPortrait || portraitDirty())
|
| 341 |
+
portraitBtn.classList.toggle('badged', paintable)
|
| 342 |
+
portraitBtn.disabled = portraitBusy || (!!lastPersona && !paintable)
|
| 343 |
+
portraitBtn.title = !lastPersona ? 'Paint portrait' : (!hasPortrait ? 'Paint portrait' : (portraitDirty() ? 'Repaint portrait' : 'Portrait is up to date'))
|
| 344 |
+
}
|
| 345 |
+
appearanceEl.contentEditable = 'true'; appearanceEl.spellcheck = false
|
| 346 |
+
appearanceEl.addEventListener('input', () => { if (lastPersona) { lastPersona.appearance = appearanceEl.textContent.trim(); updatePortraitUI() } })
|
| 347 |
+
appearanceEl.addEventListener('blur', () => { if (!lastPersona) return; lastPersona.appearance = appearanceEl.textContent.trim(); autosave(); updatePortraitUI() })
|
| 348 |
+
|
| 349 |
+
async function makePortrait() {
|
| 350 |
+
if (portraitBusy || !lastPersona) return
|
| 351 |
+
if (hasPortrait && !portraitDirty()) return // no change to the appearance — don't repaint
|
| 352 |
+
autosave() // ensure an id to key the image
|
| 353 |
+
const appearance = appearanceFor(lastPersona)
|
| 354 |
+
portraitBusy = true; portraitBtn.classList.add('busy'); portraitBtn.disabled = true
|
| 355 |
+
try {
|
| 356 |
+
if (imageNeedsDownload()) {
|
| 357 |
+
portraitStatus.textContent = 'loading model…'
|
| 358 |
+
await ensureImage((f) => { portraitStatus.textContent = `downloading model… ${Math.round(f * 100)}%` })
|
| 359 |
+
}
|
| 360 |
+
portraitStatus.textContent = `painting via ${imageBackendLabel()}…`
|
| 361 |
+
const blob = await generatePortrait(`${appearance}. ${PORTRAIT_STYLE}`, { seed: 42 })
|
| 362 |
+
await putPortrait(savedId, blob)
|
| 363 |
+
lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
|
| 364 |
+
hasPortrait = true; setPortrait(blob); autosave()
|
| 365 |
+
portraitStatus.textContent = ''
|
| 366 |
+
} catch (e) { portraitStatus.textContent = `failed: ${e.message || e}` }
|
| 367 |
+
finally { portraitBusy = false; portraitBtn.classList.remove('busy'); portraitBtn.disabled = false; updatePortraitUI() }
|
| 368 |
+
}
|
| 369 |
+
portraitBtn.addEventListener('click', makePortrait)
|
| 370 |
+
|
| 371 |
+
async function showPersona(p, o = {}) {
|
| 372 |
+
stopVoice() // picking another hero cuts the current voice
|
| 373 |
+
lastPersona = { ...p }
|
| 374 |
+
savedId = o.savedId || null
|
| 375 |
+
nameEl.textContent = p.name || ''
|
| 376 |
+
aboutEl.textContent = p.about || ''
|
| 377 |
+
quoteEl.textContent = p.quote || ''
|
| 378 |
+
voiceEl.textContent = p.voice || ''
|
| 379 |
+
appearanceEl.textContent = p.appearance || buildAppearance(p)
|
| 380 |
+
hasVoice = savedId ? !!(await getAudio(savedId)) : false
|
| 381 |
+
let pblob = null
|
| 382 |
+
hasPortrait = savedId ? !!(pblob = await getPortrait(savedId)) : false
|
| 383 |
+
setPortrait(pblob)
|
| 384 |
+
refreshVoiceMode(); updateVoiceUI(); updatePortraitUI(); refreshVisibility()
|
| 385 |
+
setLeftState('portrait') // a hero exists → show the portrait panel in place of class/recruit
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
async function play() {
|
| 389 |
+
if (playing) { stopVoice(); return }
|
| 390 |
+
if (working || !lastPersona) return
|
| 391 |
+
const line = lineFor(lastPersona)
|
| 392 |
+
|
| 393 |
+
if (isNative()) {
|
| 394 |
+
setPlaying(true)
|
| 395 |
+
try { await speakVoiceLive(lastPersona.voiceId || '', line) }
|
| 396 |
+
catch (e) { voiceStatus.textContent = `failed: ${e.message || e}` }
|
| 397 |
+
finally { setPlaying(false) }
|
| 398 |
+
return
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
if (hasVoice && !isDirty()) {
|
| 402 |
+
const blob = savedId ? await getAudio(savedId) : null
|
| 403 |
+
if (blob) { await playBuf(await blob.arrayBuffer()); return }
|
| 404 |
+
hasVoice = false
|
| 405 |
+
}
|
| 406 |
+
if (isDesign() && !lastPersona.voice) { voiceStatus.textContent = 'add a voice design first'; return }
|
| 407 |
+
autosave() // ensure an id to key the audio
|
| 408 |
+
|
| 409 |
+
const design = isDesign()
|
| 410 |
+
const reclone = design && hasVoice && !designChanged()
|
| 411 |
+
working = true; playBtn.classList.add('busy'); playBtn.disabled = true
|
| 412 |
+
const verb = reclone ? 'updating' : (design ? 'designing' : 'generating')
|
| 413 |
+
voiceStatus.textContent = `${verb} voice via ${ttsBackendLabel()}…`
|
| 414 |
+
let wav = null
|
| 415 |
+
try {
|
| 416 |
+
if (design && reclone) {
|
| 417 |
+
const blob = await getAudio(savedId)
|
| 418 |
+
wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
|
| 419 |
+
} else if (design) {
|
| 420 |
+
wav = await createVoiceWav(lastPersona.voice, line)
|
| 421 |
+
} else {
|
| 422 |
+
wav = await synthVoiceWav(lastPersona.voiceId || '', line)
|
| 423 |
+
}
|
| 424 |
+
await putAudio(savedId, new Blob([wav], { type: 'audio/wav' }))
|
| 425 |
+
lastPersona.voiceQuote = line
|
| 426 |
+
lastPersona.voiceDesignUsed = lastPersona.voice || ''
|
| 427 |
+
lastPersona.voiceIdUsed = lastPersona.voiceId || ''
|
| 428 |
+
hasVoice = true; autosave()
|
| 429 |
+
voiceStatus.textContent = ''
|
| 430 |
+
} catch (e) { voiceStatus.textContent = `failed: ${e.message || e}` }
|
| 431 |
+
finally { working = false; playBtn.classList.remove('busy'); playBtn.disabled = false; updateVoiceUI() }
|
| 432 |
+
if (wav) await playBuf(wav.slice(0))
|
| 433 |
+
}
|
| 434 |
+
playBtn.addEventListener('click', play)
|
| 435 |
+
|
| 436 |
+
try {
|
| 437 |
+
new IntersectionObserver((entries) => {
|
| 438 |
+
for (const e of entries) {
|
| 439 |
+
if (!e.isIntersecting) { if (playing) stopVoice() }
|
| 440 |
+
else if (lastPersona) { refreshVoiceMode(); updateVoiceUI() }
|
| 441 |
+
}
|
| 442 |
+
}).observe(host)
|
| 443 |
+
} catch { /* no IntersectionObserver — playback just won't auto-stop on nav */ }
|
| 444 |
+
|
| 445 |
+
// ← Back: wipe every field and return to a blank recruit state.
|
| 446 |
+
function resetAll() {
|
| 447 |
+
stopVoice()
|
| 448 |
+
lastPersona = null; savedId = null; hasVoice = false; hasPortrait = false
|
| 449 |
+
nameEl.textContent = ''; aboutEl.textContent = ''; quoteEl.textContent = ''
|
| 450 |
+
voiceEl.textContent = ''; appearanceEl.textContent = ''
|
| 451 |
+
setPortrait(null)
|
| 452 |
+
voiceStatus.textContent = ''; portraitStatus.textContent = ''
|
| 453 |
+
stats.textContent = ''; status.textContent = DEFAULT_STATUS; thinkEl.textContent = ''; thinkWrap.open = false
|
| 454 |
+
updateVoiceUI(); updatePortraitUI(); refreshVisibility(); setLeftState('recruit')
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
// ── Barracks (saved heroes) — clicking one loads it for editing; always visible when enabled. ──
|
| 458 |
+
function renderRoster(personas) {
|
| 459 |
+
if (!rosterEl) return
|
| 460 |
+
if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No heroes saved yet.')); return }
|
| 461 |
+
rosterEl.replaceChildren(...personas.map((p) =>
|
| 462 |
+
el('div', { class: 'persona-roster-item' + (p.id === savedId ? ' active' : '') }, [
|
| 463 |
+
el('button', { class: 'persona-roster-name', type: 'button', title: 'View', onclick: () => showPersona(p, { savedId: p.id }).then(() => renderRoster(listPersonas())) },
|
| 464 |
+
`${p.name || 'Hero'}${p.unitClass ? ` · ${p.unitClass}` : ''}`),
|
| 465 |
+
el('button', { class: 'persona-roster-x', type: 'button', title: 'Remove', onclick: () => removePersona(p.id) }, '🗑'),
|
| 466 |
+
])))
|
| 467 |
+
}
|
| 468 |
+
if (opts.showBarracks) { renderRoster(listPersonas()); onRosterChange(renderRoster) }
|
| 469 |
+
|
| 470 |
+
async function generate() {
|
| 471 |
+
if (busy) return
|
| 472 |
+
busy = true; btn.disabled = true
|
| 473 |
+
setLeftState('portrait') // swap the left column to the portrait panel the moment we start
|
| 474 |
+
refreshVisibility()
|
| 475 |
+
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 476 |
+
nameEl.textContent = '…'; aboutEl.textContent = ''
|
| 477 |
+
quoteEl.textContent = ''; voiceEl.textContent = ''; appearanceEl.textContent = ''
|
| 478 |
+
lastPersona = null; savedId = null; hasVoice = false; hasPortrait = false
|
| 479 |
+
stopVoice(); setPortrait(null); voiceStatus.textContent = ''; portraitStatus.textContent = ''; updateVoiceUI(); updatePortraitUI()
|
| 480 |
+
thinkEl.textContent = ''; stats.textContent = '' // debug stays COLLAPSED — status/tokens update inside it
|
| 481 |
+
let acc = ''
|
| 482 |
+
try {
|
| 483 |
+
status.textContent = `loading ${currentModel().label}…`
|
| 484 |
+
await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
|
| 485 |
+
status.textContent = `writing on your device with ${currentModel().label}…`
|
| 486 |
+
await streamChat(getPersonaSystem(), personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
|
| 487 |
+
maxTokens: MAX_TOKENS,
|
| 488 |
+
onToken: (piece) => {
|
| 489 |
+
acc += piece
|
| 490 |
+
thinkEl.textContent = acc; thinkEl.scrollTop = thinkEl.scrollHeight
|
| 491 |
+
const live = extractLivePersona(stripThink(acc))
|
| 492 |
+
if (live.name) nameEl.textContent = live.name
|
| 493 |
+
if (live.about) aboutEl.textContent = live.about
|
| 494 |
+
},
|
| 495 |
+
onStats: (s) => { stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}` },
|
| 496 |
+
})
|
| 497 |
+
try {
|
| 498 |
+
const p = parsePersonaJson(stripThinkFinal(acc))
|
| 499 |
+
await showPersona(p)
|
| 500 |
+
autosave() // generated personas are saved immediately (no Save button)
|
| 501 |
+
status.textContent = 'enlisted ✓ (saved) — edit any field, or create a voice'
|
| 502 |
+
} catch (e) {
|
| 503 |
+
status.textContent = `the model rambled — couldn't parse a clean persona (${e.message || e})`
|
| 504 |
+
setLeftState('recruit') // back to recruit so they can retry
|
| 505 |
+
}
|
| 506 |
+
} catch (e) {
|
| 507 |
+
status.textContent = `couldn't run the local model: ${e.message || e}`
|
| 508 |
+
setLeftState('recruit')
|
| 509 |
+
} finally { busy = false; btn.disabled = false; refreshVisibility() }
|
| 510 |
+
}
|
| 511 |
+
btn.addEventListener('click', generate)
|
| 512 |
+
|
| 513 |
+
return {
|
| 514 |
+
root: view,
|
| 515 |
+
load: (p, o = {}) => showPersona(p, o),
|
| 516 |
+
current: () => ({ persona: lastPersona, savedId }),
|
| 517 |
+
reset: resetAll,
|
| 518 |
+
stop: stopVoice,
|
| 519 |
+
}
|
| 520 |
+
}
|
|
@@ -33,6 +33,7 @@ function createChunkedMap(pixi, host, config) {
|
|
| 33 |
let seed = config.seed ?? 1;
|
| 34 |
let zoom = Z_DEFAULT;
|
| 35 |
let cameraDirty = true, genPending = false, lastEmitTile = null;
|
|
|
|
| 36 |
const camera = { x: config.initialCamera?.x ?? CHUNKPX / 2, y: config.initialCamera?.y ?? CHUNKPX / 2 };
|
| 37 |
const chunks = /* @__PURE__ */ new Map();
|
| 38 |
const macroChunks = /* @__PURE__ */ new Map();
|
|
@@ -257,6 +258,21 @@ function createChunkedMap(pixi, host, config) {
|
|
| 257 |
function zoomBy(factor) {
|
| 258 |
if (app) zoomAt(factor, app.screen.width / 2, app.screen.height / 2);
|
| 259 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
function bindInput() {
|
| 261 |
const canvas = app.canvas;
|
| 262 |
const local = (cx, cy) => {
|
|
@@ -268,7 +284,7 @@ function createChunkedMap(pixi, host, config) {
|
|
| 268 |
return [it.next().value, it.next().value];
|
| 269 |
};
|
| 270 |
handlers.down = (e) => {
|
| 271 |
-
if (!enabled) return;
|
| 272 |
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
| 273 |
canvas.setPointerCapture?.(e.pointerId);
|
| 274 |
if (pointers.size === 1) {
|
|
@@ -308,7 +324,7 @@ function createChunkedMap(pixi, host, config) {
|
|
| 308 |
} else if (pointers.size === 0) drag.on = false;
|
| 309 |
};
|
| 310 |
handlers.wheel = (e) => {
|
| 311 |
-
if (!enabled) return;
|
| 312 |
e.preventDefault();
|
| 313 |
zoomAt(e.deltaY < 0 ? Z_STEP : 1 / Z_STEP, ...local(e.clientX, e.clientY));
|
| 314 |
};
|
|
@@ -346,6 +362,23 @@ function createChunkedMap(pixi, host, config) {
|
|
| 346 |
const PAN_SPEED = 6;
|
| 347 |
function tick(ticker) {
|
| 348 |
for (const fn of tickHooks) fn(ticker);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
if (!enabled) return;
|
| 350 |
let dx = 0, dy = 0;
|
| 351 |
for (const k of keys) {
|
|
@@ -490,7 +523,7 @@ function createChunkedMap(pixi, host, config) {
|
|
| 490 |
ctx = null;
|
| 491 |
texCache.clear();
|
| 492 |
}
|
| 493 |
-
return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, biomeAt, getBounds, zoomBy, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE6 };
|
| 494 |
}
|
| 495 |
|
| 496 |
// ../auto-battler/src/engine/rng.js
|
|
|
|
| 33 |
let seed = config.seed ?? 1;
|
| 34 |
let zoom = Z_DEFAULT;
|
| 35 |
let cameraDirty = true, genPending = false, lastEmitTile = null;
|
| 36 |
+
let flyAnim = null;
|
| 37 |
const camera = { x: config.initialCamera?.x ?? CHUNKPX / 2, y: config.initialCamera?.y ?? CHUNKPX / 2 };
|
| 38 |
const chunks = /* @__PURE__ */ new Map();
|
| 39 |
const macroChunks = /* @__PURE__ */ new Map();
|
|
|
|
| 258 |
function zoomBy(factor) {
|
| 259 |
if (app) zoomAt(factor, app.screen.width / 2, app.screen.height / 2);
|
| 260 |
}
|
| 261 |
+
function flyTo(wx, wy, z, ms = 800) {
|
| 262 |
+
return new Promise((resolve) => {
|
| 263 |
+
if (!app) {
|
| 264 |
+
resolve();
|
| 265 |
+
return;
|
| 266 |
+
}
|
| 267 |
+
if (flyAnim) {
|
| 268 |
+
const r = flyAnim.resolve;
|
| 269 |
+
flyAnim = null;
|
| 270 |
+
r && r();
|
| 271 |
+
}
|
| 272 |
+
const toZ = Math.min(Z_MAX, Math.max(bounds ? coverZoom() : Z_MIN, z));
|
| 273 |
+
flyAnim = { fromX: camera.x, fromY: camera.y, fromZ: zoom, toX: wx, toY: wy, toZ, t: 0, dur: Math.max(1, ms), resolve };
|
| 274 |
+
});
|
| 275 |
+
}
|
| 276 |
function bindInput() {
|
| 277 |
const canvas = app.canvas;
|
| 278 |
const local = (cx, cy) => {
|
|
|
|
| 284 |
return [it.next().value, it.next().value];
|
| 285 |
};
|
| 286 |
handlers.down = (e) => {
|
| 287 |
+
if (!enabled || flyAnim) return;
|
| 288 |
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
| 289 |
canvas.setPointerCapture?.(e.pointerId);
|
| 290 |
if (pointers.size === 1) {
|
|
|
|
| 324 |
} else if (pointers.size === 0) drag.on = false;
|
| 325 |
};
|
| 326 |
handlers.wheel = (e) => {
|
| 327 |
+
if (!enabled || flyAnim) return;
|
| 328 |
e.preventDefault();
|
| 329 |
zoomAt(e.deltaY < 0 ? Z_STEP : 1 / Z_STEP, ...local(e.clientX, e.clientY));
|
| 330 |
};
|
|
|
|
| 362 |
const PAN_SPEED = 6;
|
| 363 |
function tick(ticker) {
|
| 364 |
for (const fn of tickHooks) fn(ticker);
|
| 365 |
+
if (flyAnim) {
|
| 366 |
+
flyAnim.t += ticker.deltaMS;
|
| 367 |
+
const p = Math.min(1, flyAnim.t / flyAnim.dur);
|
| 368 |
+
const e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
|
| 369 |
+
camera.x = flyAnim.fromX + (flyAnim.toX - flyAnim.fromX) * e;
|
| 370 |
+
camera.y = flyAnim.fromY + (flyAnim.toY - flyAnim.fromY) * e;
|
| 371 |
+
zoom = flyAnim.fromZ + (flyAnim.toZ - flyAnim.fromZ) * e;
|
| 372 |
+
cameraDirty = true;
|
| 373 |
+
if (p >= 1) {
|
| 374 |
+
const r = flyAnim.resolve;
|
| 375 |
+
flyAnim = null;
|
| 376 |
+
r && r();
|
| 377 |
+
}
|
| 378 |
+
reconcile();
|
| 379 |
+
cameraDirty = false;
|
| 380 |
+
return;
|
| 381 |
+
}
|
| 382 |
if (!enabled) return;
|
| 383 |
let dx = 0, dy = 0;
|
| 384 |
for (const k of keys) {
|
|
|
|
| 523 |
ctx = null;
|
| 524 |
texCache.clear();
|
| 525 |
}
|
| 526 |
+
return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, biomeAt, getBounds, zoomBy, flyTo, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE6 };
|
| 527 |
}
|
| 528 |
|
| 529 |
// ../auto-battler/src/engine/rng.js
|
|
@@ -1,512 +1,11 @@
|
|
| 1 |
-
// Tiny Army persona panel — mounted by tiny.js into #persona-stage.
|
| 2 |
-
// (
|
| 3 |
-
//
|
| 4 |
-
|
| 5 |
-
// returning visitors. Modeled on woid's agent store.
|
| 6 |
-
import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
|
| 7 |
-
import { extractLivePersona } from '/web/personaStream.js'
|
| 8 |
-
import { parsePersonaJson } from '/web/personaParse.js'
|
| 9 |
-
import { getPersonaSystem, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
|
| 10 |
-
import {
|
| 11 |
-
createVoiceWav, cloneVoiceWav, playWav, synthVoiceWav, speakVoiceLive, stopVoiceLive,
|
| 12 |
-
activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange,
|
| 13 |
-
} from '/web/tts.js'
|
| 14 |
-
import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio, putPortrait, getPortrait } from '/web/personaStore.js'
|
| 15 |
-
import { generatePortrait, imageBackendLabel, imageNeedsDownload, ensureImage } from '/web/imagen.js'
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
// Each class shows the idle pose of a fitting sprite (character slug → see
|
| 21 |
-
// characters.json). The sheet's front-right row is animated as a tiny looping
|
| 22 |
-
// icon beside the class name in the dropdown.
|
| 23 |
-
export const CLASS_SLUG = {
|
| 24 |
-
Warrior: 'true-heroes-iii-fighter',
|
| 25 |
-
Ranger: 'true-heroes-iii-ranger',
|
| 26 |
-
Monk: 'true-heroes-ii-bard',
|
| 27 |
-
Assassin: 'true-heroes-iv-ninja-assassin',
|
| 28 |
-
Mage: 'true-heroes-iii-wizard',
|
| 29 |
-
Paladin: 'true-heroes-ii-paladin',
|
| 30 |
-
Cleric: 'true-heroes-ii-cleric',
|
| 31 |
-
Knight: 'rts-humans-knight',
|
| 32 |
-
}
|
| 33 |
-
const ICON_PX = 30 // on-screen size of the class icon box
|
| 34 |
-
const ICON_ZOOM = 2.4 // sheets pad the character inside each cell — zoom in so it fills the box
|
| 35 |
-
const spriteUrl = (u) => (u || '').replace('/assets/', '/sprites/') // sheets serve at /sprites
|
| 36 |
-
|
| 37 |
-
function el(tag, props = {}, kids = []) {
|
| 38 |
-
const n = document.createElement(tag)
|
| 39 |
-
for (const [k, v] of Object.entries(props)) {
|
| 40 |
-
if (k === 'class') n.className = v
|
| 41 |
-
else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
|
| 42 |
-
else if (v != null) n.setAttribute(k, v)
|
| 43 |
-
}
|
| 44 |
-
for (const kid of [].concat(kids)) if (kid != null) n.append(kid)
|
| 45 |
-
return n
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
// Idle sheets are 4 rows (facings) × N frame columns; cell = height/4. We render
|
| 49 |
-
// the front-right row (row 0) and step across the columns to loop the idle — no
|
| 50 |
-
// canvas, just a sized background + the Web Animations API. Each cell is scaled to
|
| 51 |
-
// ICON_ZOOM × the box and the box is centred on it, so the padded-in character
|
| 52 |
-
// fills the icon instead of floating tiny in a sea of transparent cell.
|
| 53 |
-
function animateIdleIcon(box, idleUrl) {
|
| 54 |
-
box.getAnimations?.().forEach((a) => a.cancel())
|
| 55 |
-
box.style.backgroundImage = ''
|
| 56 |
-
const img = new Image()
|
| 57 |
-
img.onload = () => {
|
| 58 |
-
const cell = (img.naturalHeight / 4) || img.naturalHeight
|
| 59 |
-
const cols = Math.max(1, Math.round(img.naturalWidth / cell))
|
| 60 |
-
const rows = Math.max(1, Math.round(img.naturalHeight / cell))
|
| 61 |
-
const cellPx = ICON_PX * ICON_ZOOM // zoomed on-screen cell size
|
| 62 |
-
const off = (cellPx - ICON_PX) / 2 // inset to centre the box on a cell
|
| 63 |
-
box.style.backgroundImage = `url("${idleUrl}")`
|
| 64 |
-
box.style.backgroundSize = `${cols * cellPx}px ${rows * cellPx}px`
|
| 65 |
-
const y = `-${off}px` // row 0, vertically centred
|
| 66 |
-
box.animate(
|
| 67 |
-
[{ backgroundPosition: `-${off}px ${y}` }, { backgroundPosition: `-${cols * cellPx + off}px ${y}` }],
|
| 68 |
-
{ duration: cols * 110, iterations: Infinity, easing: `steps(${cols})` },
|
| 69 |
-
)
|
| 70 |
-
}
|
| 71 |
-
img.src = idleUrl
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
// A class picker that mirrors a native <select> API (.value get/set, 'change'
|
| 75 |
-
// event) but renders an animated idle icon beside each class. Icons attach once
|
| 76 |
-
// characters.json resolves (root.setIcons), so the menu is usable immediately.
|
| 77 |
-
function makeClassDropdown(classes) {
|
| 78 |
-
const triggerIco = el('span', { class: 'persona-class-ico' })
|
| 79 |
-
const triggerLabel = el('span', { class: 'persona-classdrop-label' })
|
| 80 |
-
const trigger = el('button', { class: 'persona-input persona-classdrop-trigger', type: 'button' },
|
| 81 |
-
[triggerIco, triggerLabel, el('span', { class: 'persona-classdrop-chev' }, '▾')])
|
| 82 |
-
const menu = el('div', { class: 'persona-classdrop-menu' })
|
| 83 |
-
const root = el('div', { class: 'persona-classdrop' }, [trigger, menu])
|
| 84 |
-
|
| 85 |
-
let value = classes[0]
|
| 86 |
-
let icons = {} // class → idle sheet URL (filled by setIcons)
|
| 87 |
-
const optIco = {} // class → menu icon span
|
| 88 |
-
|
| 89 |
-
const items = classes.map((c) => {
|
| 90 |
-
const ico = el('span', { class: 'persona-class-ico' }); optIco[c] = ico
|
| 91 |
-
const it = el('button', { class: 'persona-classdrop-opt', type: 'button' }, [ico, el('span', {}, c)])
|
| 92 |
-
it.addEventListener('click', () => { set(c); close() })
|
| 93 |
-
return it
|
| 94 |
-
})
|
| 95 |
-
menu.append(...items)
|
| 96 |
-
|
| 97 |
-
function set(c) {
|
| 98 |
-
if (!classes.includes(c)) return
|
| 99 |
-
const changed = c !== value
|
| 100 |
-
value = c
|
| 101 |
-
triggerLabel.textContent = c
|
| 102 |
-
items.forEach((it, i) => it.classList.toggle('sel', classes[i] === c))
|
| 103 |
-
if (icons[c]) animateIdleIcon(triggerIco, icons[c])
|
| 104 |
-
if (changed) root.dispatchEvent(new Event('change'))
|
| 105 |
-
}
|
| 106 |
-
const close = () => root.classList.remove('open')
|
| 107 |
-
trigger.addEventListener('click', (e) => { e.stopPropagation(); root.classList.toggle('open') })
|
| 108 |
-
document.addEventListener('click', (e) => { if (!root.contains(e.target)) close() })
|
| 109 |
-
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
| 110 |
-
|
| 111 |
-
root.setIcons = (map) => {
|
| 112 |
-
icons = map
|
| 113 |
-
for (const c of classes) if (map[c]) animateIdleIcon(optIco[c], map[c])
|
| 114 |
-
if (map[value]) animateIdleIcon(triggerIco, map[value])
|
| 115 |
-
}
|
| 116 |
-
Object.defineProperty(root, 'value', { get: () => value, set: (v) => set(v) })
|
| 117 |
-
triggerLabel.textContent = value
|
| 118 |
-
items.forEach((it, i) => it.classList.toggle('sel', classes[i] === value))
|
| 119 |
-
return root
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
// Resolve each class's idle sheet via characters.json and light up the dropdown.
|
| 123 |
-
async function loadClassIcons(dropdown) {
|
| 124 |
-
try {
|
| 125 |
-
const d = await fetch('/sprites/characters.json').then((r) => r.json())
|
| 126 |
-
const bySlug = {}
|
| 127 |
-
for (const p of d.packs || []) for (const c of p.characters || []) bySlug[c.slug] = c
|
| 128 |
-
const map = {}
|
| 129 |
-
for (const [cls, slug] of Object.entries(CLASS_SLUG)) {
|
| 130 |
-
const idle = bySlug[slug]?.idle
|
| 131 |
-
if (idle) map[cls] = spriteUrl(idle)
|
| 132 |
-
}
|
| 133 |
-
dropdown.setIcons(map)
|
| 134 |
-
} catch { /* no icons — the dropdown still works with labels only */ }
|
| 135 |
-
}
|
| 136 |
|
| 137 |
export function mountPersonaPanel(host) {
|
| 138 |
-
|
| 139 |
-
loadClassIcons(sel)
|
| 140 |
-
const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
|
| 141 |
-
const stats = el('div', { class: 'persona-stats' })
|
| 142 |
-
const status = el('div', { class: 'persona-status' }, 'Runs on your device — no cloud.')
|
| 143 |
-
const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit hero')
|
| 144 |
-
const rosterEl = el('div', { class: 'persona-roster' })
|
| 145 |
-
|
| 146 |
-
const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
|
| 147 |
-
const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
|
| 148 |
-
const quoteEl = el('blockquote', { class: 'persona-quote persona-edit', 'data-ph': 'A line they say…' })
|
| 149 |
-
const voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
|
| 150 |
-
// Fixed-voice providers (Kokoro/Kitten/Web Speech) don't design from text — pick a
|
| 151 |
-
// named voice here instead. Hidden when the provider is Qwen3-TTS (Voice Design).
|
| 152 |
-
const voicePickEl = el('select', { class: 'persona-input persona-voice-pick' })
|
| 153 |
-
const voicePickRow = el('div', { class: 'persona-voice-pick-row' },
|
| 154 |
-
[el('label', { class: 'persona-label' }, 'Voice'), voicePickEl])
|
| 155 |
-
// ▶ play sits on the Quote heading and does everything: it (re)creates the voice when
|
| 156 |
-
// needed and replays it otherwise. A pulsing badge shows when there's no voice yet, or
|
| 157 |
-
// the quote/voice was edited since the last one was made.
|
| 158 |
-
const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice' }, '▶')
|
| 159 |
-
// Portrait: an editable appearance prompt + a 🎨 button that paints it (Z-Image/FLUX),
|
| 160 |
-
// cached per hero. Badge pulses when there's no portrait yet or the appearance changed.
|
| 161 |
-
const appearanceEl = el('div', { class: 'persona-appearance persona-edit', 'data-ph': 'How they look…' })
|
| 162 |
-
const portraitBtn = el('button', { class: 'persona-ico persona-portrait-btn', type: 'button', title: 'Paint portrait' }, '🎨')
|
| 163 |
-
const portraitImg = el('img', { class: 'persona-portrait-img', alt: '' })
|
| 164 |
-
const portraitWrap = el('div', { class: 'persona-portrait-wrap' }, [portraitImg])
|
| 165 |
-
const thinkEl = el('pre', { class: 'persona-think' })
|
| 166 |
-
const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
|
| 167 |
-
const thinkWrap = el('details', { class: 'persona-think-wrap' },
|
| 168 |
-
[el('summary', {}, 'model output / debug (raw)'), copyBtn, thinkEl])
|
| 169 |
-
|
| 170 |
-
// A section header: a top line + a small red heading, with an optional action on the right.
|
| 171 |
-
const secHead = (title, action) =>
|
| 172 |
-
el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, title), action || el('span')])
|
| 173 |
-
|
| 174 |
-
const controls = el('aside', { class: 'persona-controls' }, [
|
| 175 |
-
el('label', { class: 'persona-label' }, 'Class'), sel,
|
| 176 |
-
el('label', { class: 'persona-label' }, 'Seed'), seed,
|
| 177 |
-
btn, stats, status,
|
| 178 |
-
el('label', { class: 'persona-label persona-roster-label' }, 'Barracks (saved)'), rosterEl,
|
| 179 |
-
])
|
| 180 |
-
const emptyEl = el('div', { class: 'persona-empty' }, 'Recruit a hero, or pick one from the barracks.')
|
| 181 |
-
const bodyEl = el('div', { class: 'persona-body' }, [
|
| 182 |
-
nameEl,
|
| 183 |
-
secHead('About'), aboutEl,
|
| 184 |
-
secHead('Quote', playBtn), quoteEl,
|
| 185 |
-
secHead('Voice design'), voiceEl, voicePickRow,
|
| 186 |
-
secHead('Portrait', portraitBtn), appearanceEl, portraitWrap,
|
| 187 |
-
])
|
| 188 |
-
const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl, thinkWrap])
|
| 189 |
-
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
| 190 |
-
|
| 191 |
-
let lastPersona = null // the persona currently shown
|
| 192 |
-
let savedId = null // its roster id (set the moment it's shown — always saved)
|
| 193 |
-
let hasVoice = false // a cached voice file exists for this persona
|
| 194 |
-
let hasPortrait = false // a cached portrait exists for this persona
|
| 195 |
-
let portraitBusy = false
|
| 196 |
-
let portraitUrl = null // object URL for the shown image (revoked on replace)
|
| 197 |
-
let working = false
|
| 198 |
-
let busy = false
|
| 199 |
-
let playing = false // audio is currently sounding (▶ becomes ⏹)
|
| 200 |
-
|
| 201 |
-
// ▶ ⇄ ⏹: reflect playback state on the button so a second click stops it.
|
| 202 |
-
function setPlaying(on) {
|
| 203 |
-
playing = on
|
| 204 |
-
playBtn.classList.toggle('playing', on)
|
| 205 |
-
playBtn.textContent = on ? '⏹' : '▶'
|
| 206 |
-
if (on) playBtn.title = 'Stop'
|
| 207 |
-
else updateVoiceUI()
|
| 208 |
-
}
|
| 209 |
-
// Stop any sounding voice and reset the button (used on toggle, nav-away, new pick).
|
| 210 |
-
function stopVoice() { stopVoiceLive(); setPlaying(false) }
|
| 211 |
-
// Play a WAV buffer with the button reflecting play→stop→idle, even if it's cut short.
|
| 212 |
-
async function playBuf(arrayBuffer) {
|
| 213 |
-
setPlaying(true)
|
| 214 |
-
try { await playWav(arrayBuffer) } catch { /* autoplay blocked / cut short */ }
|
| 215 |
-
finally { setPlaying(false) }
|
| 216 |
-
}
|
| 217 |
-
|
| 218 |
-
// Hide the hero fields until a hero is generated or picked from the barracks.
|
| 219 |
-
function refreshVisibility() {
|
| 220 |
-
const show = !!lastPersona || busy
|
| 221 |
-
bodyEl.style.display = show ? '' : 'none'
|
| 222 |
-
emptyEl.style.display = show ? 'none' : ''
|
| 223 |
-
}
|
| 224 |
-
refreshVisibility()
|
| 225 |
-
|
| 226 |
-
// The line the voice actually says (quote, else about, else a fallback).
|
| 227 |
-
const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A hero'} reporting for duty.`
|
| 228 |
-
// Which "voice" identity drives the active provider, and what the cached file used.
|
| 229 |
-
// Qwen3-TTS → the free-form DESIGN text; others → the picked named voice id.
|
| 230 |
-
const isDesign = () => activeEngineIsDesign()
|
| 231 |
-
const isNative = () => activeEngineIsNative()
|
| 232 |
-
const voiceNow = () => (isDesign() ? (lastPersona?.voice || '') : (lastPersona?.voiceId || ''))
|
| 233 |
-
const voiceUsed = () => (isDesign() ? (lastPersona?.voiceDesignUsed || '') : (lastPersona?.voiceIdUsed || ''))
|
| 234 |
-
// Cached audio is stale if the line or the voice identity changed since it was made.
|
| 235 |
-
// Native (Web Speech) never caches, so it's never "dirty".
|
| 236 |
-
const isDirty = () => !isNative() && hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || voiceNow() !== voiceUsed())
|
| 237 |
-
// Only Qwen3 has a clone step: re-speak the SAME timbre when just the words changed.
|
| 238 |
-
// A changed DESIGN text means a new timbre → re-design (cloning would keep the old voice).
|
| 239 |
-
const designChanged = () => isDesign() && hasVoice && lastPersona && (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || '')
|
| 240 |
-
// Badge when there's a persona but no current voice (none yet, or it went stale).
|
| 241 |
-
function updateVoiceUI() {
|
| 242 |
-
const needs = !!lastPersona && !isNative() && (!hasVoice || isDirty())
|
| 243 |
-
playBtn.classList.toggle('badged', needs)
|
| 244 |
-
if (playing) return // 'Stop' title owned by setPlaying while sounding
|
| 245 |
-
playBtn.title = (!lastPersona || isNative()) ? 'Play voice' : (!hasVoice ? 'Create voice' : (isDirty() ? 'Update & play' : 'Play voice'))
|
| 246 |
-
}
|
| 247 |
-
|
| 248 |
-
// Reflect the active provider: Qwen3-TTS designs from the editable text; the others
|
| 249 |
-
// use a named voice (text design goes read-only, the voice picker appears). Called on
|
| 250 |
-
// show + whenever the panel comes back into view (the provider is set in Settings).
|
| 251 |
-
function refreshVoiceMode() {
|
| 252 |
-
const design = isDesign()
|
| 253 |
-
voiceEl.contentEditable = design ? 'true' : 'false'
|
| 254 |
-
voiceEl.classList.toggle('readonly', !design)
|
| 255 |
-
voiceEl.setAttribute('data-ph', design ? 'How they sound…' : '(only used by Qwen3-TTS Voice Design)')
|
| 256 |
-
voicePickRow.style.display = design ? 'none' : ''
|
| 257 |
-
if (!design) {
|
| 258 |
-
const voices = activeVoices()
|
| 259 |
-
voicePickEl.replaceChildren(...voices.map((v) => el('option', { value: v.id }, v.label)))
|
| 260 |
-
let cur = (lastPersona && lastPersona.voiceId) || activeDefaultVoice()
|
| 261 |
-
if (!voices.some((v) => v.id === cur)) cur = voices[0] ? voices[0].id : ''
|
| 262 |
-
voicePickEl.value = cur
|
| 263 |
-
if (lastPersona) lastPersona.voiceId = cur
|
| 264 |
-
}
|
| 265 |
-
}
|
| 266 |
-
voicePickEl.addEventListener('change', () => {
|
| 267 |
-
if (!lastPersona) return
|
| 268 |
-
lastPersona.voiceId = voicePickEl.value
|
| 269 |
-
autosave(); updateVoiceUI()
|
| 270 |
-
})
|
| 271 |
-
// The provider is chosen on the Settings tab; re-render voice controls when it changes.
|
| 272 |
-
onTtsEngineChange(() => { stopVoice(); if (lastPersona) { refreshVoiceMode(); updateVoiceUI() } })
|
| 273 |
-
|
| 274 |
-
function autosave() {
|
| 275 |
-
if (!lastPersona) return
|
| 276 |
-
const rec = savePersona({ ...lastPersona, id: savedId, unitClass: lastPersona.unitClass || sel.value, seed: lastPersona.seed || seed.value })
|
| 277 |
-
savedId = rec.id
|
| 278 |
-
}
|
| 279 |
-
|
| 280 |
-
// Make a field click-to-edit; persist on blur (always save after edit — no button).
|
| 281 |
-
function editable(elm, field, { single = false } = {}) {
|
| 282 |
-
elm.contentEditable = 'true'
|
| 283 |
-
elm.spellcheck = false
|
| 284 |
-
if (single) elm.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); elm.blur() } })
|
| 285 |
-
// Update the badge immediately as you type; persist on blur.
|
| 286 |
-
if (field === 'quote' || field === 'voice') {
|
| 287 |
-
elm.addEventListener('input', () => { if (lastPersona) { lastPersona[field] = elm.textContent.trim(); updateVoiceUI() } })
|
| 288 |
-
}
|
| 289 |
-
elm.addEventListener('blur', () => {
|
| 290 |
-
if (!lastPersona) return
|
| 291 |
-
lastPersona[field] = elm.textContent.trim()
|
| 292 |
-
autosave(); updateVoiceUI()
|
| 293 |
-
})
|
| 294 |
-
}
|
| 295 |
-
editable(nameEl, 'name', { single: true })
|
| 296 |
-
editable(aboutEl, 'about')
|
| 297 |
-
editable(quoteEl, 'quote', { single: true })
|
| 298 |
-
editable(voiceEl, 'voice')
|
| 299 |
-
|
| 300 |
-
// ── Portrait ──────────────────────────────────────────────────────────────
|
| 301 |
-
const PORTRAIT_STYLE = 'Fantasy hero character portrait, painterly digital art, dramatic lighting, head and shoulders, detailed face, plain background.'
|
| 302 |
-
const buildAppearance = (p) => [
|
| 303 |
-
[p.name, p.unitClass && `a ${p.unitClass}`].filter(Boolean).join(', '),
|
| 304 |
-
(p.about || '').trim(),
|
| 305 |
-
].filter(Boolean).join('. ')
|
| 306 |
-
const appearanceFor = (p) => (p.appearance || '').trim() || buildAppearance(p)
|
| 307 |
-
const portraitDirty = () => hasPortrait && lastPersona && appearanceFor(lastPersona) !== (lastPersona.portraitUsed || '')
|
| 308 |
-
|
| 309 |
-
function setPortrait(blob) {
|
| 310 |
-
if (portraitUrl) { try { URL.revokeObjectURL(portraitUrl) } catch { /* ignore */ } }
|
| 311 |
-
portraitUrl = blob ? URL.createObjectURL(blob) : null
|
| 312 |
-
portraitImg.src = portraitUrl || ''
|
| 313 |
-
portraitWrap.classList.toggle('has-img', !!blob)
|
| 314 |
-
}
|
| 315 |
-
function updatePortraitUI() {
|
| 316 |
-
portraitBtn.classList.toggle('badged', !!lastPersona && (!hasPortrait || portraitDirty()))
|
| 317 |
-
portraitBtn.title = !hasPortrait ? 'Paint portrait' : 'Repaint portrait'
|
| 318 |
-
}
|
| 319 |
-
// Make the appearance field click-to-edit (drives the prompt + the badge).
|
| 320 |
-
appearanceEl.contentEditable = 'true'; appearanceEl.spellcheck = false
|
| 321 |
-
appearanceEl.addEventListener('input', () => { if (lastPersona) { lastPersona.appearance = appearanceEl.textContent.trim(); updatePortraitUI() } })
|
| 322 |
-
appearanceEl.addEventListener('blur', () => { if (!lastPersona) return; lastPersona.appearance = appearanceEl.textContent.trim(); autosave(); updatePortraitUI() })
|
| 323 |
-
|
| 324 |
-
// 🎨 Generate (or regenerate) the portrait from the appearance prompt; cache + persist.
|
| 325 |
-
async function makePortrait() {
|
| 326 |
-
if (portraitBusy || !lastPersona) return
|
| 327 |
-
autosave() // ensure an id to key the image
|
| 328 |
-
const appearance = appearanceFor(lastPersona)
|
| 329 |
-
portraitBusy = true; portraitBtn.classList.add('busy'); portraitBtn.disabled = true
|
| 330 |
-
const prev = status.textContent
|
| 331 |
-
try {
|
| 332 |
-
// In-browser engines (Janus) download the model on first use — show progress.
|
| 333 |
-
if (imageNeedsDownload()) {
|
| 334 |
-
status.textContent = 'loading portrait model…'
|
| 335 |
-
await ensureImage((f) => { status.textContent = `downloading portrait model… ${Math.round(f * 100)}% (one-time)` })
|
| 336 |
-
}
|
| 337 |
-
status.textContent = `painting with ${imageBackendLabel()}…`
|
| 338 |
-
const blob = await generatePortrait(`${appearance}. ${PORTRAIT_STYLE}`, { seed: 42 })
|
| 339 |
-
await putPortrait(savedId, blob)
|
| 340 |
-
lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
|
| 341 |
-
hasPortrait = true; setPortrait(blob); autosave()
|
| 342 |
-
status.textContent = prev
|
| 343 |
-
} catch (e) { status.textContent = `portrait failed: ${e.message || e}` }
|
| 344 |
-
finally { portraitBusy = false; portraitBtn.classList.remove('busy'); portraitBtn.disabled = false; updatePortraitUI() }
|
| 345 |
-
}
|
| 346 |
-
portraitBtn.addEventListener('click', makePortrait)
|
| 347 |
-
|
| 348 |
-
async function showPersona(p, opts = {}) {
|
| 349 |
-
stopVoice() // picking another hero cuts the current voice
|
| 350 |
-
lastPersona = { ...p }
|
| 351 |
-
savedId = opts.savedId || null
|
| 352 |
-
nameEl.textContent = p.name || ''
|
| 353 |
-
aboutEl.textContent = p.about || ''
|
| 354 |
-
quoteEl.textContent = p.quote || ''
|
| 355 |
-
voiceEl.textContent = p.voice || ''
|
| 356 |
-
appearanceEl.textContent = p.appearance || buildAppearance(p)
|
| 357 |
-
hasVoice = savedId ? !!(await getAudio(savedId)) : false
|
| 358 |
-
let pblob = null
|
| 359 |
-
hasPortrait = savedId ? !!(pblob = await getPortrait(savedId)) : false
|
| 360 |
-
setPortrait(pblob)
|
| 361 |
-
refreshVoiceMode(); updateVoiceUI(); updatePortraitUI(); refreshVisibility()
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
// ▶ The one voice button: if the cached voice is current, just replay it. If the voice
|
| 365 |
-
// DESIGN text changed (or there's no voice yet) → DESIGN a fresh timbre. If only the
|
| 366 |
-
// spoken line changed → CLONE the last voice (same timbre, new words). Cache + save over.
|
| 367 |
-
async function play() {
|
| 368 |
-
if (playing) { stopVoice(); return } // second click while sounding → stop
|
| 369 |
-
if (working || !lastPersona) return
|
| 370 |
-
const line = lineFor(lastPersona)
|
| 371 |
-
|
| 372 |
-
// Native provider (Web Speech): can't render to a file — speak the line live.
|
| 373 |
-
if (isNative()) {
|
| 374 |
-
setPlaying(true)
|
| 375 |
-
try { await speakVoiceLive(lastPersona.voiceId || '', line) }
|
| 376 |
-
catch (e) { status.textContent = `voice failed: ${e.message || e}` }
|
| 377 |
-
finally { setPlaying(false) }
|
| 378 |
-
return
|
| 379 |
-
}
|
| 380 |
-
|
| 381 |
-
// Up-to-date voice exists → just replay the cached file.
|
| 382 |
-
if (hasVoice && !isDirty()) {
|
| 383 |
-
const blob = savedId ? await getAudio(savedId) : null
|
| 384 |
-
if (blob) { await playBuf(await blob.arrayBuffer()); return }
|
| 385 |
-
hasVoice = false // cache vanished — fall through to re-make it
|
| 386 |
-
}
|
| 387 |
-
if (isDesign() && !lastPersona.voice) { status.textContent = 'add a voice design first'; return }
|
| 388 |
-
autosave() // ensure an id to key the audio
|
| 389 |
-
|
| 390 |
-
const design = isDesign()
|
| 391 |
-
// Qwen3 clones (same timbre, new words) only when the design text is unchanged;
|
| 392 |
-
// fixed-voice providers always re-synth the line in the picked named voice.
|
| 393 |
-
const reclone = design && hasVoice && !designChanged()
|
| 394 |
-
// Generating → the ▶ becomes a loading spinner (.busy) until the WAV is ready.
|
| 395 |
-
working = true; playBtn.classList.add('busy'); playBtn.disabled = true
|
| 396 |
-
const prev = status.textContent
|
| 397 |
-
status.textContent = reclone ? 'updating the voice…' : (design ? 'designing the voice…' : 'creating the voice…')
|
| 398 |
-
let wav = null
|
| 399 |
-
try {
|
| 400 |
-
if (design && reclone) {
|
| 401 |
-
const blob = await getAudio(savedId)
|
| 402 |
-
wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
|
| 403 |
-
} else if (design) {
|
| 404 |
-
wav = await createVoiceWav(lastPersona.voice, line)
|
| 405 |
-
} else {
|
| 406 |
-
wav = await synthVoiceWav(lastPersona.voiceId || '', line)
|
| 407 |
-
}
|
| 408 |
-
await putAudio(savedId, new Blob([wav], { type: 'audio/wav' })) // save over
|
| 409 |
-
lastPersona.voiceQuote = line
|
| 410 |
-
lastPersona.voiceDesignUsed = lastPersona.voice || ''
|
| 411 |
-
lastPersona.voiceIdUsed = lastPersona.voiceId || ''
|
| 412 |
-
hasVoice = true; autosave()
|
| 413 |
-
status.textContent = prev
|
| 414 |
-
} catch (e) { status.textContent = `voice failed: ${e.message || e}` }
|
| 415 |
-
finally { working = false; playBtn.classList.remove('busy'); playBtn.disabled = false; updateVoiceUI() }
|
| 416 |
-
if (wav) await playBuf(wav.slice(0)) // spinner cleared → now toggles to ⏹ while sounding
|
| 417 |
-
}
|
| 418 |
-
playBtn.addEventListener('click', play)
|
| 419 |
-
|
| 420 |
-
// Navigating to another tab hides this stage (Gradio sets display:none) → the host
|
| 421 |
-
// stops intersecting; cut the voice so it doesn't keep playing off-screen.
|
| 422 |
-
try {
|
| 423 |
-
new IntersectionObserver((entries) => {
|
| 424 |
-
for (const e of entries) {
|
| 425 |
-
if (!e.isIntersecting) { if (playing) stopVoice() }
|
| 426 |
-
else if (lastPersona) { refreshVoiceMode(); updateVoiceUI() } // provider may have changed in Settings
|
| 427 |
-
}
|
| 428 |
-
}).observe(host)
|
| 429 |
-
} catch { /* no IntersectionObserver — playback just won't auto-stop on nav */ }
|
| 430 |
-
|
| 431 |
-
// ── Barracks roster (saved heroes) ──────────────────────────────────────
|
| 432 |
-
function renderRoster(personas) {
|
| 433 |
-
if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No heroes saved yet.')); return }
|
| 434 |
-
rosterEl.replaceChildren(...personas.map((p) =>
|
| 435 |
-
el('div', { class: 'persona-roster-item' + (p.id === savedId ? ' active' : '') }, [
|
| 436 |
-
el('button', { class: 'persona-roster-name', type: 'button', title: 'View', onclick: () => showPersona(p, { savedId: p.id }).then(() => renderRoster(listPersonas())) },
|
| 437 |
-
`${p.name || 'Hero'}${p.unitClass ? ` · ${p.unitClass}` : ''}`),
|
| 438 |
-
el('button', { class: 'persona-roster-x', type: 'button', title: 'Remove', onclick: () => removePersona(p.id) }, '🗑'),
|
| 439 |
-
])))
|
| 440 |
-
}
|
| 441 |
-
renderRoster(listPersonas())
|
| 442 |
-
onRosterChange(renderRoster)
|
| 443 |
-
|
| 444 |
-
let lastDebug = ''
|
| 445 |
-
function buildDebug(outcome, acc) {
|
| 446 |
-
const stripped = stripThinkFinal(acc || '')
|
| 447 |
-
return [
|
| 448 |
-
'=== TINY ARMY · PERSONA DEBUG ===',
|
| 449 |
-
`engine: ${getEngineId()} · ${backendLabel()}`,
|
| 450 |
-
`model: ${currentModelId()} (${currentModel().label})`,
|
| 451 |
-
`input: class=${sel.value} seed=${seed.value || '(none)'} maxTokens=${MAX_TOKENS}`,
|
| 452 |
-
`outcome: ${outcome}`,
|
| 453 |
-
`--- raw output (${(acc || '').length} chars) ---`, acc || '(empty)',
|
| 454 |
-
`--- after stripThink → parser (${stripped.length} chars) ---`, stripped || '(empty)',
|
| 455 |
-
].join('\n')
|
| 456 |
-
}
|
| 457 |
-
copyBtn.addEventListener('click', async () => {
|
| 458 |
-
const text = lastDebug || buildDebug('(no generation yet)', '')
|
| 459 |
-
try {
|
| 460 |
-
await navigator.clipboard.writeText(text)
|
| 461 |
-
copyBtn.textContent = '✓ copied'; setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
|
| 462 |
-
} catch {
|
| 463 |
-
thinkEl.textContent = text; thinkWrap.open = true
|
| 464 |
-
const r = document.createRange(); r.selectNodeContents(thinkEl)
|
| 465 |
-
const s = getSelection(); s.removeAllRanges(); s.addRange(r)
|
| 466 |
-
copyBtn.textContent = 'selected ↓ — ⌘/Ctrl+C'
|
| 467 |
-
}
|
| 468 |
-
})
|
| 469 |
-
|
| 470 |
-
async function generate() {
|
| 471 |
-
if (busy) return
|
| 472 |
-
busy = true; btn.disabled = true; refreshVisibility()
|
| 473 |
-
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 474 |
-
nameEl.textContent = '…'; aboutEl.textContent = ''
|
| 475 |
-
quoteEl.textContent = ''; voiceEl.textContent = ''; appearanceEl.textContent = ''
|
| 476 |
-
lastPersona = null; savedId = null; hasVoice = false; hasPortrait = false
|
| 477 |
-
stopVoice(); setPortrait(null); updateVoiceUI(); updatePortraitUI()
|
| 478 |
-
thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
|
| 479 |
-
let acc = ''
|
| 480 |
-
try {
|
| 481 |
-
status.textContent = `loading ${currentModel().label}…`
|
| 482 |
-
await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
|
| 483 |
-
status.textContent = `writing on your device with ${currentModel().label}…`
|
| 484 |
-
await streamChat(getPersonaSystem(), personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
|
| 485 |
-
maxTokens: MAX_TOKENS,
|
| 486 |
-
onToken: (piece) => {
|
| 487 |
-
acc += piece
|
| 488 |
-
thinkEl.textContent = acc; thinkEl.scrollTop = thinkEl.scrollHeight
|
| 489 |
-
const live = extractLivePersona(stripThink(acc))
|
| 490 |
-
if (live.name) nameEl.textContent = live.name
|
| 491 |
-
if (live.about) aboutEl.textContent = live.about
|
| 492 |
-
},
|
| 493 |
-
onStats: (s) => { stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}` },
|
| 494 |
-
})
|
| 495 |
-
try {
|
| 496 |
-
const p = parsePersonaJson(stripThinkFinal(acc))
|
| 497 |
-
await showPersona(p)
|
| 498 |
-
autosave() // generated personas are saved immediately (no Save button)
|
| 499 |
-
renderRoster(listPersonas())
|
| 500 |
-
status.textContent = 'enlisted ✓ (saved) — edit any field, or create a voice'
|
| 501 |
-
lastDebug = buildDebug('parsed OK', acc); thinkWrap.open = false
|
| 502 |
-
} catch (e) {
|
| 503 |
-
status.textContent = `the model rambled — couldn't parse a clean persona (${e.message || e}) · 📋 Copy debug`
|
| 504 |
-
lastDebug = buildDebug('PARSE ERROR: ' + (e.message || e), acc); thinkWrap.open = true
|
| 505 |
-
}
|
| 506 |
-
} catch (e) {
|
| 507 |
-
status.textContent = `couldn't run the local model: ${e.message || e} · 📋 Copy debug`
|
| 508 |
-
lastDebug = buildDebug('EXCEPTION: ' + (e.message || e) + (e.stack ? '\n' + e.stack : ''), acc); thinkWrap.open = true
|
| 509 |
-
} finally { busy = false; btn.disabled = false; refreshVisibility() }
|
| 510 |
-
}
|
| 511 |
-
btn.addEventListener('click', generate)
|
| 512 |
}
|
|
|
|
| 1 |
+
// Tiny Army persona panel — mounted by tiny.js into #persona-stage. It's just the shared hero
|
| 2 |
+
// creator (heroCreator.js — recruit/stream/edit/voice/portrait, autosaved) with its barracks roster
|
| 3 |
+
// of saved heroes enabled.
|
| 4 |
+
import { mountHeroCreator, CLASS_SLUG } from '/web/heroCreator.js'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
+
// Re-exported so existing importers (tiny.js) keep working.
|
| 7 |
+
export { CLASS_SLUG }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
export function mountPersonaPanel(host) {
|
| 10 |
+
mountHeroCreator(host, { showBarracks: true })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
}
|
|
@@ -138,6 +138,9 @@
|
|
| 138 |
border: 1.5px solid var(--p-ink) !important; border-radius: 0 !important; padding: 3px 9px !important;
|
| 139 |
}
|
| 140 |
.persona-ico:hover { background: var(--p-paper-2) !important; }
|
|
|
|
|
|
|
|
|
|
| 141 |
.persona-ico.busy { cursor: default; }
|
| 142 |
/* Working (voice/portrait) → hide the glyph and spin a small ring in its place. */
|
| 143 |
.persona-ico.busy { color: transparent !important; }
|
|
@@ -355,3 +358,126 @@
|
|
| 355 |
.model-select { font-size: 15px !important; padding: 9px 10px !important; }
|
| 356 |
.persona-go, .persona-go-alt { padding: 12px !important; }
|
| 357 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
border: 1.5px solid var(--p-ink) !important; border-radius: 0 !important; padding: 3px 9px !important;
|
| 139 |
}
|
| 140 |
.persona-ico:hover { background: var(--p-paper-2) !important; }
|
| 141 |
+
/* Disabled = up to date (no change to repaint); dim it (but not while busy — that shows a spinner). */
|
| 142 |
+
.persona-ico:disabled:not(.busy) { opacity: .4; cursor: default; }
|
| 143 |
+
.persona-ico:disabled:not(.busy):hover { background: var(--p-card) !important; }
|
| 144 |
.persona-ico.busy { cursor: default; }
|
| 145 |
/* Working (voice/portrait) → hide the glyph and spin a small ring in its place. */
|
| 146 |
.persona-ico.busy { color: transparent !important; }
|
|
|
|
| 358 |
.model-select { font-size: 15px !important; padding: 9px 10px !important; }
|
| 359 |
.persona-go, .persona-go-alt { padding: 12px !important; }
|
| 360 |
}
|
| 361 |
+
|
| 362 |
+
/* ── Game-page "Create a Hero" modal — hosts the shared creator (.persona-view) in a card over the
|
| 363 |
+
* dimmed map. Palette vars are re-declared here so the modal chrome (head/foot) matches the
|
| 364 |
+
* parchment body even though those vars are normally scoped to .persona-view. */
|
| 365 |
+
.hero-modal-backdrop {
|
| 366 |
+
position: fixed; inset: 0; z-index: 1100; display: flex; align-items: center; justify-content: center;
|
| 367 |
+
padding: 20px; background: rgba(8, 11, 16, .62); backdrop-filter: blur(2px);
|
| 368 |
+
}
|
| 369 |
+
.hero-modal {
|
| 370 |
+
--p-ink: #141821; --p-muted: #6d6a5f; --p-paper: #f3ebdc; --p-paper-2: #ece2cc;
|
| 371 |
+
--p-card: #fbf6ea; --p-transmit: #d8271a;
|
| 372 |
+
--p-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 373 |
+
/* FIXED height so the modal never resizes between Create a Hero ⇄ Hero Details or while text
|
| 374 |
+
* streams in — the two columns scroll internally instead. */
|
| 375 |
+
width: min(940px, 96vw); height: min(620px, 90vh); display: flex; flex-direction: column;
|
| 376 |
+
background: var(--p-paper); color: var(--p-ink); font-family: var(--p-sans);
|
| 377 |
+
border-radius: 14px; overflow: hidden; box-shadow: 0 24px 70px rgba(0, 0, 0, .55);
|
| 378 |
+
}
|
| 379 |
+
.hero-modal-head {
|
| 380 |
+
display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
|
| 381 |
+
padding: 12px 16px; border-bottom: 1px solid var(--p-paper-2); background: var(--p-card);
|
| 382 |
+
}
|
| 383 |
+
.hero-modal-title { font-weight: 700; font-size: 16px; letter-spacing: .01em; }
|
| 384 |
+
.hero-modal-x {
|
| 385 |
+
border: 0; background: none; color: var(--p-muted); font-size: 20px; line-height: 1;
|
| 386 |
+
cursor: pointer; padding: 4px 8px; border-radius: 8px;
|
| 387 |
+
}
|
| 388 |
+
.hero-modal-x:hover { background: var(--p-paper-2); color: var(--p-ink); }
|
| 389 |
+
.hero-modal-body { flex: 1; min-height: 0; overflow: hidden; }
|
| 390 |
+
/* The creator's two-column view fills the fixed body; each column scrolls inside it. */
|
| 391 |
+
.hero-modal-body .persona-view { height: 100%; }
|
| 392 |
+
.hero-modal-foot {
|
| 393 |
+
display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-shrink: 0;
|
| 394 |
+
padding: 12px 16px; border-top: 1px solid var(--p-paper-2); background: var(--p-card);
|
| 395 |
+
}
|
| 396 |
+
.hero-modal-cancel, .hero-modal-save {
|
| 397 |
+
font-family: var(--p-sans); font-weight: 600; font-size: 14px; cursor: pointer;
|
| 398 |
+
padding: 9px 16px; border-radius: 9px; border: 1px solid var(--p-paper-2);
|
| 399 |
+
}
|
| 400 |
+
.hero-modal-cancel { background: transparent; color: var(--p-muted); }
|
| 401 |
+
.hero-modal-cancel:hover { color: var(--p-ink); border-color: var(--p-muted); }
|
| 402 |
+
.hero-modal-save { background: var(--p-transmit); color: #fff; border-color: var(--p-transmit); }
|
| 403 |
+
.hero-modal-save:hover { filter: brightness(1.06); }
|
| 404 |
+
.hero-modal-save:disabled { opacity: .45; cursor: not-allowed; filter: none; }
|
| 405 |
+
|
| 406 |
+
/* The "+ Create hero" card in the Game hero picker. */
|
| 407 |
+
.hero-pick-create {
|
| 408 |
+
border-style: dashed !important; color: #cfd6df !important;
|
| 409 |
+
}
|
| 410 |
+
.hero-pick-create:hover { border-color: #4a5765 !important; background: rgba(40, 48, 60, .92) !important; }
|
| 411 |
+
.hero-pick-create .hero-pick-plus { font-size: 26px; line-height: 1; color: #aab3c0; }
|
| 412 |
+
|
| 413 |
+
/* ── Left-column states: recruit controls (A) ⇄ portrait panel (B). The model output is anchored to
|
| 414 |
+
* the bottom of the column; the portrait box shows a framed placeholder until it's painted. ── */
|
| 415 |
+
.persona-recruit-box, .persona-portrait-panel { display: flex; flex-direction: column; gap: 8px; }
|
| 416 |
+
.persona-controls > .persona-think-wrap { margin-top: auto; } /* anchor model output to the bottom */
|
| 417 |
+
.persona-back {
|
| 418 |
+
align-self: flex-start; font-family: var(--p-mono); font-size: 11px; letter-spacing: .06em;
|
| 419 |
+
color: var(--p-muted); background: none; border: 0; cursor: pointer; padding: 2px 0;
|
| 420 |
+
}
|
| 421 |
+
.persona-back:hover { color: var(--p-ink); }
|
| 422 |
+
/* Portrait box: always a framed square in the panel (placeholder before painting, image after). */
|
| 423 |
+
.persona-portrait-panel .persona-portrait-wrap {
|
| 424 |
+
display: flex; align-items: center; justify-content: center; margin-top: 4px;
|
| 425 |
+
width: 100%; aspect-ratio: 1 / 1; background: var(--p-card);
|
| 426 |
+
border: 1.5px dashed var(--p-muted); box-shadow: 4px 4px 0 var(--p-transmit);
|
| 427 |
+
}
|
| 428 |
+
.persona-portrait-panel .persona-portrait-wrap.has-img { border-style: solid; border-color: var(--p-ink); }
|
| 429 |
+
.persona-portrait-panel .persona-portrait-wrap:not(.has-img)::after {
|
| 430 |
+
content: 'portrait appears here'; font-family: var(--p-mono); font-size: 10px;
|
| 431 |
+
letter-spacing: .12em; text-transform: uppercase; color: var(--p-muted);
|
| 432 |
+
}
|
| 433 |
+
.persona-portrait-panel .persona-portrait-img {
|
| 434 |
+
width: 100%; height: 100%; aspect-ratio: auto; object-fit: cover; border: 0; box-shadow: none; display: none;
|
| 435 |
+
}
|
| 436 |
+
.persona-portrait-panel .persona-portrait-wrap.has-img .persona-portrait-img { display: block; }
|
| 437 |
+
|
| 438 |
+
/* Inline per-action status (e.g. "painting via …", "generating voice via …") beside a section's
|
| 439 |
+
* create button; the button + note sit together on the right of the heading. */
|
| 440 |
+
/* Action header: the create button with its status note to the RIGHT; the note wraps (never
|
| 441 |
+
* truncates) so the full model name always shows. */
|
| 442 |
+
.persona-sec-action { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
|
| 443 |
+
.persona-sec-action > .persona-ico { flex-shrink: 0; }
|
| 444 |
+
/* The note stays BESIDE the button and wraps its own text (tiny font), so it never pushes the
|
| 445 |
+
* portrait/box below it down even with a full model name. */
|
| 446 |
+
.persona-act-status {
|
| 447 |
+
flex: 1; min-width: 0;
|
| 448 |
+
font-family: var(--p-mono); font-size: 8px; letter-spacing: .02em; color: var(--p-muted);
|
| 449 |
+
white-space: normal; line-height: 1.2;
|
| 450 |
+
}
|
| 451 |
+
/* Always-visible barracks block in the left column. */
|
| 452 |
+
.persona-barracks { display: flex; flex-direction: column; gap: 4px; }
|
| 453 |
+
/* Status + token rate now live inside the debug <details>; give them a little breathing room. */
|
| 454 |
+
.persona-think-wrap > .persona-status, .persona-think-wrap > .persona-stats { margin: 6px 0 0; }
|
| 455 |
+
/* ← Back in the modal footer sits to the LEFT of Cancel / Save & Play. */
|
| 456 |
+
.hero-modal-foot { gap: 10px; }
|
| 457 |
+
.hero-modal-foot .persona-back { margin-right: auto; font-size: 13px; }
|
| 458 |
+
|
| 459 |
+
/* ── Hero detail page (Game): a dark card shown before spawning — big portrait/idle + about/quote
|
| 460 |
+
* and a Select button. ── */
|
| 461 |
+
.hero-detail-backdrop {
|
| 462 |
+
position: fixed; inset: 0; z-index: 1100; display: flex; align-items: center; justify-content: center;
|
| 463 |
+
padding: 20px; background: rgba(8, 11, 16, .7); backdrop-filter: blur(2px); font-family: var(--tac-font, system-ui);
|
| 464 |
+
}
|
| 465 |
+
.hero-detail {
|
| 466 |
+
width: min(380px, 94vw); max-height: 92vh; overflow: auto; display: flex; flex-direction: column;
|
| 467 |
+
background: #14181f; color: #e8e8e8; border: 1px solid #2a3340; border-radius: 14px; box-shadow: 0 24px 70px rgba(0, 0, 0, .6);
|
| 468 |
+
}
|
| 469 |
+
.hero-detail-portrait {
|
| 470 |
+
width: 240px; height: 240px; margin: 18px auto 4px; border-radius: 12px;
|
| 471 |
+
background: #0b0e12 center no-repeat; background-size: cover; border: 1px solid #20262e; box-shadow: 0 4px 18px rgba(0, 0, 0, .4);
|
| 472 |
+
}
|
| 473 |
+
.hero-detail-info { padding: 8px 18px 4px; display: flex; flex-direction: column; gap: 8px; }
|
| 474 |
+
.hero-detail-name { font-size: 22px; font-weight: 700; line-height: 1.1; }
|
| 475 |
+
.hero-detail-class { color: #8a93a0; font-size: 11px; letter-spacing: .12em; text-transform: uppercase; margin-top: -4px; }
|
| 476 |
+
.hero-detail-about { font-size: 14px; line-height: 1.55; color: #c8cdd4; }
|
| 477 |
+
.hero-detail-quote { margin: 2px 0 0; padding: 2px 0 2px 12px; border-left: 3px solid #d8271a; font-style: italic; color: #e8d8a0; font-size: 15px; line-height: 1.4; }
|
| 478 |
+
.hero-detail-foot { display: flex; gap: 10px; justify-content: flex-end; padding: 14px 18px 18px; }
|
| 479 |
+
.hero-detail-back, .hero-detail-select { font: 600 14px var(--tac-font, system-ui); cursor: pointer; padding: 9px 18px; border-radius: 10px; border: 1px solid #2a3340; }
|
| 480 |
+
.hero-detail-back { background: transparent; color: #aab3c0; }
|
| 481 |
+
.hero-detail-back:hover { color: #e8e8e8; border-color: #4a5765; }
|
| 482 |
+
.hero-detail-select { background: #d8271a; color: #fff; border-color: #d8271a; }
|
| 483 |
+
.hero-detail-select:hover { filter: brightness(1.08); }
|
|
@@ -10,7 +10,8 @@ import { mountEnemiesSandbox } from '/web/enemiesSandbox.js'
|
|
| 10 |
import { mountMapSandbox } from '/web/mapSandbox.js'
|
| 11 |
import { mountComboBattler } from '/web/comboBattler.js'
|
| 12 |
import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
|
| 13 |
-
import {
|
|
|
|
| 14 |
import { mountDiaryPanel } from '/web/diaryPanel.js'
|
| 15 |
import { mountSettingsPanel } from '/web/settingsPanel.js'
|
| 16 |
import { mountSkillForgePanel } from '/web/skillForgePanel.js'
|
|
@@ -215,9 +216,49 @@ const buildPlayer = (chars, p) => {
|
|
| 215 |
const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
|
| 216 |
return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit: { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' } }
|
| 217 |
}
|
| 218 |
-
|
| 219 |
-
//
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
const bar = document.createElement('div')
|
| 222 |
bar.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:6;display:flex;flex-direction:column;align-items:center;gap:8px;padding:14px 12px 18px;pointer-events:none;background:linear-gradient(to top,rgba(8,11,16,.86),rgba(8,11,16,0));font-family:var(--tac-font,system-ui)'
|
| 223 |
const title = document.createElement('div')
|
|
@@ -228,10 +269,10 @@ function buildHeroPicker(host, personas, chars, onPick) {
|
|
| 228 |
for (const p of personas) {
|
| 229 |
const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
|
| 230 |
const card = document.createElement('button')
|
| 231 |
-
card.style.cssText =
|
| 232 |
const av = document.createElement('div')
|
| 233 |
-
av.style.cssText = 'width:48px;height:48px;border-radius:8px;background:#0b0e12
|
| 234 |
-
|
| 235 |
const nm = document.createElement('div'); nm.textContent = p?.name || pc?.name || 'Hero'
|
| 236 |
nm.style.cssText = 'max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
|
| 237 |
card.append(av, nm)
|
|
@@ -239,9 +280,51 @@ function buildHeroPicker(host, personas, chars, onPick) {
|
|
| 239 |
card.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); onPick(p) })
|
| 240 |
row.appendChild(card)
|
| 241 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
bar.append(title, row); host.appendChild(bar)
|
| 243 |
return bar
|
| 244 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
whenEl('battle-stage', async (el) => {
|
| 246 |
unprose(el)
|
| 247 |
const chars = await loadChars()
|
|
@@ -255,15 +338,29 @@ whenEl('battle-stage', async (el) => {
|
|
| 255 |
comboCtrl = mountComboBattler(PIXI, el, { seed: 1, rosters })
|
| 256 |
await comboCtrl.ready
|
| 257 |
const FALLBACK = { name: 'Fighter', unitClass: 'Warrior' }
|
|
|
|
| 258 |
let picker = null
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
const personas = listPersonas()
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
comboCtrl.selectHero(buildPlayer(chars, p))
|
| 265 |
-
})
|
| 266 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
showPicker()
|
| 268 |
comboCtrl.onChange((s) => { if (s.over) showPicker() })
|
|
|
|
| 269 |
})
|
|
|
|
| 10 |
import { mountMapSandbox } from '/web/mapSandbox.js'
|
| 11 |
import { mountComboBattler } from '/web/comboBattler.js'
|
| 12 |
import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
|
| 13 |
+
import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
|
| 14 |
+
import { listPersonas, onRosterChange, getPortrait } from '/web/personaStore.js'
|
| 15 |
import { mountDiaryPanel } from '/web/diaryPanel.js'
|
| 16 |
import { mountSettingsPanel } from '/web/settingsPanel.js'
|
| 17 |
import { mountSkillForgePanel } from '/web/skillForgePanel.js'
|
|
|
|
| 216 |
const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
|
| 217 |
return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit: { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' } }
|
| 218 |
}
|
| 219 |
+
const PICK_CARD_CSS = 'display:flex;flex-direction:column;align-items:center;gap:4px;width:76px;padding:8px 6px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.92);color:#e8e8e8;cursor:pointer;font:600 11px var(--tac-font,system-ui)'
|
| 220 |
+
// Fill a square box with the hero's saved PORTRAIT (if any), else an animated idle GIF-style sprite
|
| 221 |
+
// of their class (same loop as the class selector). `sizePx` sizes the idle-sprite cell math.
|
| 222 |
+
async function fillAvatar(box, persona, chars, sizePx) {
|
| 223 |
+
let blob = null
|
| 224 |
+
try { if (persona?.id) blob = await getPortrait(persona.id) } catch { /* none */ }
|
| 225 |
+
box.getAnimations?.().forEach((a) => a.cancel())
|
| 226 |
+
if (blob) {
|
| 227 |
+
box.style.backgroundImage = `url(${URL.createObjectURL(blob)})`
|
| 228 |
+
box.style.backgroundSize = 'cover'; box.style.backgroundPosition = 'center'; box.style.imageRendering = 'auto'
|
| 229 |
+
} else {
|
| 230 |
+
box.style.imageRendering = 'pixelated'
|
| 231 |
+
const pc = chars[CLASS_SLUG[persona?.unitClass]] || chars['true-heroes-iii-fighter']
|
| 232 |
+
if (pc?.idle) animateIdleIcon(box, spriteUrl(pc.idle), sizePx)
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
// Hero detail page (before spawning): big portrait/idle + name/class/about/quote and a Select button.
|
| 236 |
+
// onSelect fires when confirmed; Back / backdrop just close (returns to the picker).
|
| 237 |
+
function openHeroDetail(host, persona, chars, onSelect) {
|
| 238 |
+
const backdrop = document.createElement('div'); backdrop.className = 'hero-detail-backdrop'
|
| 239 |
+
const card = document.createElement('div'); card.className = 'hero-detail'
|
| 240 |
+
const portrait = document.createElement('div'); portrait.className = 'hero-detail-portrait'
|
| 241 |
+
fillAvatar(portrait, persona, chars, 240)
|
| 242 |
+
const info = document.createElement('div'); info.className = 'hero-detail-info'
|
| 243 |
+
const name = document.createElement('div'); name.className = 'hero-detail-name'; name.textContent = persona?.name || 'Hero'
|
| 244 |
+
info.append(name)
|
| 245 |
+
if (persona?.unitClass) { const c = document.createElement('div'); c.className = 'hero-detail-class'; c.textContent = persona.unitClass; info.append(c) }
|
| 246 |
+
if (persona?.about) { const a = document.createElement('div'); a.className = 'hero-detail-about'; a.textContent = persona.about; info.append(a) }
|
| 247 |
+
if (persona?.quote) { const q = document.createElement('blockquote'); q.className = 'hero-detail-quote'; q.textContent = persona.quote; info.append(q) }
|
| 248 |
+
const foot = document.createElement('div'); foot.className = 'hero-detail-foot'
|
| 249 |
+
const back = document.createElement('button'); back.className = 'hero-detail-back'; back.type = 'button'; back.textContent = 'Back'
|
| 250 |
+
const select = document.createElement('button'); select.className = 'hero-detail-select'; select.type = 'button'; select.textContent = 'Select ▶'
|
| 251 |
+
foot.append(back, select)
|
| 252 |
+
card.append(portrait, info, foot); backdrop.append(card); host.appendChild(backdrop)
|
| 253 |
+
const close = () => backdrop.remove()
|
| 254 |
+
back.addEventListener('click', close)
|
| 255 |
+
backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
|
| 256 |
+
select.addEventListener('click', () => { close(); onSelect() })
|
| 257 |
+
}
|
| 258 |
+
// Bottom-of-screen hero picker: a card per saved persona (idle-sprite avatar + name) plus a
|
| 259 |
+
// "+ Create hero" card. onPick gets the chosen persona; onCreate opens the create modal. Overlays
|
| 260 |
+
// the map (pointer-events only on the cards, so the map still pans).
|
| 261 |
+
function buildHeroPicker(host, personas, chars, onPick, onCreate) {
|
| 262 |
const bar = document.createElement('div')
|
| 263 |
bar.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:6;display:flex;flex-direction:column;align-items:center;gap:8px;padding:14px 12px 18px;pointer-events:none;background:linear-gradient(to top,rgba(8,11,16,.86),rgba(8,11,16,0));font-family:var(--tac-font,system-ui)'
|
| 264 |
const title = document.createElement('div')
|
|
|
|
| 269 |
for (const p of personas) {
|
| 270 |
const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
|
| 271 |
const card = document.createElement('button')
|
| 272 |
+
card.style.cssText = PICK_CARD_CSS
|
| 273 |
const av = document.createElement('div')
|
| 274 |
+
av.style.cssText = 'width:48px;height:48px;border-radius:8px;background:#0b0e12 no-repeat;border:1px solid #20262e'
|
| 275 |
+
fillAvatar(av, p, chars, 48) // portrait if saved, else animated class idle
|
| 276 |
const nm = document.createElement('div'); nm.textContent = p?.name || pc?.name || 'Hero'
|
| 277 |
nm.style.cssText = 'max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
|
| 278 |
card.append(av, nm)
|
|
|
|
| 280 |
card.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); onPick(p) })
|
| 281 |
row.appendChild(card)
|
| 282 |
}
|
| 283 |
+
// "+ Create hero" card → opens the create modal.
|
| 284 |
+
const create = document.createElement('button')
|
| 285 |
+
create.className = 'hero-pick-create'; create.style.cssText = PICK_CARD_CSS
|
| 286 |
+
const plus = document.createElement('div'); plus.className = 'hero-pick-plus'; plus.textContent = '+'
|
| 287 |
+
plus.style.cssText = 'width:48px;height:48px;display:flex;align-items:center;justify-content:center'
|
| 288 |
+
const clbl = document.createElement('div'); clbl.textContent = 'Create hero'; clbl.style.cssText = 'max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
|
| 289 |
+
create.append(plus, clbl)
|
| 290 |
+
create.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); onCreate && onCreate() })
|
| 291 |
+
row.appendChild(create)
|
| 292 |
bar.append(title, row); host.appendChild(bar)
|
| 293 |
return bar
|
| 294 |
}
|
| 295 |
+
// "Create a Hero" modal over the map: hosts the shared creator (heroCreator.js — stream details,
|
| 296 |
+
// generate a portrait + quote + voice, autosaved to the roster). onCreated(persona) fires on
|
| 297 |
+
// "Save & Play"; Cancel/✕/backdrop just close (a generated hero is already saved to the barracks).
|
| 298 |
+
function openCreateModal(host, onCreated) {
|
| 299 |
+
const backdrop = document.createElement('div'); backdrop.className = 'hero-modal-backdrop'
|
| 300 |
+
const card = document.createElement('div'); card.className = 'hero-modal'
|
| 301 |
+
const head = document.createElement('div'); head.className = 'hero-modal-head'
|
| 302 |
+
const title = document.createElement('div'); title.className = 'hero-modal-title'; title.textContent = 'Create a Hero'
|
| 303 |
+
const x = document.createElement('button'); x.className = 'hero-modal-x'; x.type = 'button'; x.title = 'Close'; x.textContent = '✕'
|
| 304 |
+
head.append(title, x)
|
| 305 |
+
const body = document.createElement('div'); body.className = 'hero-modal-body'
|
| 306 |
+
const foot = document.createElement('div'); foot.className = 'hero-modal-foot'
|
| 307 |
+
const cancel = document.createElement('button'); cancel.className = 'hero-modal-cancel'; cancel.type = 'button'; cancel.textContent = 'Cancel'
|
| 308 |
+
const save = document.createElement('button'); save.className = 'hero-modal-save'; save.type = 'button'; save.textContent = 'Save & Play ▶'; save.disabled = true
|
| 309 |
+
foot.append(cancel, save) // the creator prepends its ← Back here (shown only once a hero exists)
|
| 310 |
+
card.append(head, body, foot); backdrop.append(card); host.appendChild(backdrop)
|
| 311 |
+
|
| 312 |
+
// Save & Play is enabled once a hero is saved; ← Back resets the creator → recruit state → disabled.
|
| 313 |
+
const creator = mountHeroCreator(body, {
|
| 314 |
+
showBarracks: true,
|
| 315 |
+
backSlot: foot,
|
| 316 |
+
onSaved: () => { save.disabled = false },
|
| 317 |
+
onState: (s) => { title.textContent = s === 'portrait' ? 'Hero Details' : 'Create a Hero'; if (s === 'recruit') save.disabled = true },
|
| 318 |
+
})
|
| 319 |
+
const close = () => { try { creator.stop() } catch { /* ignore */ } backdrop.remove() }
|
| 320 |
+
x.addEventListener('click', close)
|
| 321 |
+
cancel.addEventListener('click', close)
|
| 322 |
+
backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
|
| 323 |
+
save.addEventListener('click', () => {
|
| 324 |
+
const cur = creator.current(); if (!cur.persona) return
|
| 325 |
+
close(); onCreated(cur.persona)
|
| 326 |
+
})
|
| 327 |
+
}
|
| 328 |
whenEl('battle-stage', async (el) => {
|
| 329 |
unprose(el)
|
| 330 |
const chars = await loadChars()
|
|
|
|
| 338 |
comboCtrl = mountComboBattler(PIXI, el, { seed: 1, rosters })
|
| 339 |
await comboCtrl.ready
|
| 340 |
const FALLBACK = { name: 'Fighter', unitClass: 'Warrior' }
|
| 341 |
+
const OVERVIEW_ZOOM = 0.45, GAMEPLAY_ZOOM = 2.5
|
| 342 |
let picker = null
|
| 343 |
+
// Cinematic: the picker presents a zoomed-out overview; confirming a hero flies the camera DOWN to
|
| 344 |
+
// the spawn point and drops them in there.
|
| 345 |
+
const flyToOverview = () => { const b = comboCtrl.map.getBounds(); if (b) comboCtrl.map.flyTo((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, OVERVIEW_ZOOM, 700).catch(() => {}) }
|
| 346 |
+
const spawnWithFly = (p) => {
|
| 347 |
+
picker?.remove(); picker = null
|
| 348 |
+
const s = comboCtrl.getSpawnWorld()
|
| 349 |
+
comboCtrl.selectHero(buildPlayer(chars, p))
|
| 350 |
+
comboCtrl.map.flyTo(s.x, s.y, GAMEPLAY_ZOOM, 1000).catch(() => {})
|
| 351 |
+
}
|
| 352 |
+
// Picking a hero opens its detail page (portrait/about/quote + Select); Select confirms → spawnWithFly.
|
| 353 |
+
const onPick = (p) => openHeroDetail(el, p, chars, () => spawnWithFly(p))
|
| 354 |
+
const buildPicker = () => {
|
| 355 |
const personas = listPersonas()
|
| 356 |
+
return buildHeroPicker(el, personas.length ? personas : [FALLBACK], chars, onPick,
|
| 357 |
+
() => openCreateModal(el, spawnWithFly))
|
|
|
|
|
|
|
| 358 |
}
|
| 359 |
+
const showPicker = () => { if (!picker) { picker = buildPicker(); flyToOverview() } }
|
| 360 |
+
// Rebuild the open picker whenever the roster changes — so a hero just created (or removed) shows
|
| 361 |
+
// up in the "Choose your hero" bar without waiting for the next time it reopens.
|
| 362 |
+
const refreshPicker = () => { if (picker) { picker.remove(); picker = buildPicker() } }
|
| 363 |
showPicker()
|
| 364 |
comboCtrl.onChange((s) => { if (s.over) showPicker() })
|
| 365 |
+
onRosterChange(refreshPicker)
|
| 366 |
})
|