Spaces:
Running
Running
Fix first-spawn hero recognition; tour portrait/voice; lighter cinematic
Browse files- Created heroes now spawn with their roster id (openCreateModal carries savedId
through to spawnWithFly), so portrait/voice/skills/chat are recognized on the
FIRST spawn instead of only after re-picking from the roster.
- Guided tour gains explicit "paint portrait" + "create voice" steps (data-tut
markers + onPortrait/onVoice hooks) before Save & Play, so later skill/chat
steps have real media.
- Cinematic: harden startCinematic early-returns to tear down partial builds if
cancelled mid-setup; cut warbands 6→4 anchors (~60 vs ~90 sprites) to fix
title-screen slowdowns. (Armies were already cleaned up between runs.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/comboBattler.js +8 -2
- web/heroCreator.js +4 -2
- web/tiny.js +13 -3
web/comboBattler.js
CHANGED
|
@@ -6896,10 +6896,16 @@ function mountComboBattler(pixi, host, opts = {}) {
|
|
| 6896 |
map.getEntityLayer().addChild(combatRoot);
|
| 6897 |
const firstSheet = groups[0]?.units?.[0]?.sheets;
|
| 6898 |
const ch = await contentHeight(firstSheet?.idle || firstSheet?.walk);
|
| 6899 |
-
if (!alive || !cinematic)
|
|
|
|
|
|
|
|
|
|
| 6900 |
depthScale.v = SPRITE_TILES * TILE8 / ((ch || 24) * G) * CINE_SPRITE_MUL;
|
| 6901 |
R = await createCombatRenderer({ pixi, defsById: {}, layers: { units, fx, projLayer: proj }, coords: { mapX: (x) => x, mapY: (y) => y, depthOf: () => depthScale.v }, getBattle: () => battle });
|
| 6902 |
-
if (!alive || !cinematic)
|
|
|
|
|
|
|
|
|
|
| 6903 |
let n = 0;
|
| 6904 |
const anchors = [];
|
| 6905 |
for (const g of groups) {
|
|
|
|
| 6896 |
map.getEntityLayer().addChild(combatRoot);
|
| 6897 |
const firstSheet = groups[0]?.units?.[0]?.sheets;
|
| 6898 |
const ch = await contentHeight(firstSheet?.idle || firstSheet?.walk);
|
| 6899 |
+
if (!alive || !cinematic) {
|
| 6900 |
+
teardownCombat();
|
| 6901 |
+
return [];
|
| 6902 |
+
}
|
| 6903 |
depthScale.v = SPRITE_TILES * TILE8 / ((ch || 24) * G) * CINE_SPRITE_MUL;
|
| 6904 |
R = await createCombatRenderer({ pixi, defsById: {}, layers: { units, fx, projLayer: proj }, coords: { mapX: (x) => x, mapY: (y) => y, depthOf: () => depthScale.v }, getBattle: () => battle });
|
| 6905 |
+
if (!alive || !cinematic) {
|
| 6906 |
+
teardownCombat();
|
| 6907 |
+
return [];
|
| 6908 |
+
}
|
| 6909 |
let n = 0;
|
| 6910 |
const anchors = [];
|
| 6911 |
for (const g of groups) {
|
web/heroCreator.js
CHANGED
|
@@ -156,10 +156,10 @@ export function mountHeroCreator(host, opts = {}) {
|
|
| 156 |
const voicePickEl = el('select', { class: 'persona-input persona-voice-pick' })
|
| 157 |
const voicePickRow = el('div', { class: 'persona-voice-pick-row' },
|
| 158 |
[el('label', { class: 'persona-label' }, 'Voice'), voicePickEl])
|
| 159 |
-
const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice' }, '▶')
|
| 160 |
const voiceStatus = el('span', { class: 'persona-act-status' }) // "generating voice via …" beside ▶
|
| 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 portraitStatus = el('span', { class: 'persona-act-status' }) // "painting via …" beside 🎨
|
| 164 |
const portraitImg = el('img', { class: 'persona-portrait-img', alt: '' })
|
| 165 |
const portraitWrap = el('div', { class: 'persona-portrait-wrap' }, [portraitImg])
|
|
@@ -363,6 +363,7 @@ export function mountHeroCreator(host, opts = {}) {
|
|
| 363 |
await putPortrait(savedId, blob)
|
| 364 |
lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
|
| 365 |
hasPortrait = true; setPortrait(blob); autosave()
|
|
|
|
| 366 |
portraitStatus.textContent = ''
|
| 367 |
} catch (e) { portraitStatus.textContent = `failed: ${e.message || e}` }
|
| 368 |
finally { portraitBusy = false; portraitBtn.classList.remove('busy'); portraitBtn.disabled = false; updatePortraitUI() }
|
|
@@ -428,6 +429,7 @@ export function mountHeroCreator(host, opts = {}) {
|
|
| 428 |
lastPersona.voiceDesignUsed = lastPersona.voice || ''
|
| 429 |
lastPersona.voiceIdUsed = lastPersona.voiceId || ''
|
| 430 |
hasVoice = true; autosave()
|
|
|
|
| 431 |
voiceStatus.textContent = ''
|
| 432 |
} catch (e) { voiceStatus.textContent = `failed: ${e.message || e}` }
|
| 433 |
finally { working = false; playBtn.classList.remove('busy'); playBtn.disabled = false; updateVoiceUI() }
|
|
|
|
| 156 |
const voicePickEl = el('select', { class: 'persona-input persona-voice-pick' })
|
| 157 |
const voicePickRow = el('div', { class: 'persona-voice-pick-row' },
|
| 158 |
[el('label', { class: 'persona-label' }, 'Voice'), voicePickEl])
|
| 159 |
+
const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice', 'data-tut': 'voice' }, '▶')
|
| 160 |
const voiceStatus = el('span', { class: 'persona-act-status' }) // "generating voice via …" beside ▶
|
| 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', 'data-tut': 'portrait' }, '🎨')
|
| 163 |
const portraitStatus = el('span', { class: 'persona-act-status' }) // "painting via …" beside 🎨
|
| 164 |
const portraitImg = el('img', { class: 'persona-portrait-img', alt: '' })
|
| 165 |
const portraitWrap = el('div', { class: 'persona-portrait-wrap' }, [portraitImg])
|
|
|
|
| 363 |
await putPortrait(savedId, blob)
|
| 364 |
lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
|
| 365 |
hasPortrait = true; setPortrait(blob); autosave()
|
| 366 |
+
try { opts.onPortrait?.() } catch { /* host hook */ }
|
| 367 |
portraitStatus.textContent = ''
|
| 368 |
} catch (e) { portraitStatus.textContent = `failed: ${e.message || e}` }
|
| 369 |
finally { portraitBusy = false; portraitBtn.classList.remove('busy'); portraitBtn.disabled = false; updatePortraitUI() }
|
|
|
|
| 429 |
lastPersona.voiceDesignUsed = lastPersona.voice || ''
|
| 430 |
lastPersona.voiceIdUsed = lastPersona.voiceId || ''
|
| 431 |
hasVoice = true; autosave()
|
| 432 |
+
try { opts.onVoice?.() } catch { /* host hook */ }
|
| 433 |
voiceStatus.textContent = ''
|
| 434 |
} catch (e) { voiceStatus.textContent = `failed: ${e.message || e}` }
|
| 435 |
finally { working = false; playBtn.classList.remove('busy'); playBtn.disabled = false; updateVoiceUI() }
|
web/tiny.js
CHANGED
|
@@ -32,7 +32,9 @@ const TUTORIAL_STEPS = [
|
|
| 32 |
{ anchor: 'center', nextLabel: 'Begin', skipLabel: 'No thanks', text: 'Welcome, commander. Your champions aren’t chosen from a roster — each is forged anew, body and soul. A quick tour?' },
|
| 33 |
{ anchor: 'bottom-center', advanceOn: 'create-open', target: '[data-tut="create"]', text: 'Recruit your first champion — tap “+ Create hero”.' },
|
| 34 |
{ anchor: 'center', advanceOn: 'persona', target: '[data-tut="recruit"]', text: 'Choose a calling and Recruit. Their name and legend are written for them alone — here and now.' },
|
| 35 |
-
{ anchor: 'center', advanceOn: '
|
|
|
|
|
|
|
| 36 |
{ anchor: 'bottom-left', advanceOn: 'move', target: '[data-tut="move"]', text: 'Lead them with the stick — or WASD on a keyboard.' },
|
| 37 |
{ anchor: 'bottom-right', advanceOn: 'kill', target: '[data-tut="attack"]', text: 'Strike with ⚔ (or Space). Most foes fall in a blow or two — thin the ranks.' },
|
| 38 |
{ anchor: 'bottom-right', target: '[data-tut="dodge"]', text: 'Roll with ⟲ (or Shift) to slip a blow — untouchable for a heartbeat.' },
|
|
@@ -403,6 +405,8 @@ function openCreateModal(host, onCreated) {
|
|
| 403 |
showBarracks: true,
|
| 404 |
backSlot: foot,
|
| 405 |
onSaved: () => { save.disabled = false; try { tut.emit('persona') } catch { /* ignore */ } },
|
|
|
|
|
|
|
| 406 |
onState: (s) => { title.textContent = s === 'portrait' ? 'Hero Details' : 'Create a Hero'; if (s === 'recruit') save.disabled = true },
|
| 407 |
})
|
| 408 |
const close = () => { try { creator.stop() } catch { /* ignore */ } backdrop.remove() }
|
|
@@ -411,7 +415,11 @@ function openCreateModal(host, onCreated) {
|
|
| 411 |
backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
|
| 412 |
save.addEventListener('click', () => {
|
| 413 |
const cur = creator.current(); if (!cur.persona) return
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
})
|
| 416 |
}
|
| 417 |
whenEl('battle-stage', async (el) => {
|
|
@@ -479,7 +487,9 @@ whenEl('battle-stage', async (el) => {
|
|
| 479 |
// pulled from the whole pool so groups read as a varied mix of characters and monsters.
|
| 480 |
const buildCineGroups = () => {
|
| 481 |
if (!ROSTER_POOL.length) return []
|
| 482 |
-
|
|
|
|
|
|
|
| 483 |
return anchors.map((an) => {
|
| 484 |
const br = (rosters[an.biome] && rosters[an.biome].length) ? rosters[an.biome] : ROSTER_POOL
|
| 485 |
const n = 13 + (Math.random() * 5 | 0) // ~15 per group (13–17)
|
|
|
|
| 32 |
{ anchor: 'center', nextLabel: 'Begin', skipLabel: 'No thanks', text: 'Welcome, commander. Your champions aren’t chosen from a roster — each is forged anew, body and soul. A quick tour?' },
|
| 33 |
{ anchor: 'bottom-center', advanceOn: 'create-open', target: '[data-tut="create"]', text: 'Recruit your first champion — tap “+ Create hero”.' },
|
| 34 |
{ anchor: 'center', advanceOn: 'persona', target: '[data-tut="recruit"]', text: 'Choose a calling and Recruit. Their name and legend are written for them alone — here and now.' },
|
| 35 |
+
{ anchor: 'center', advanceOn: 'portrait', target: '[data-tut="portrait"]', text: 'Give them a face — tap 🎨 to paint their portrait. (Needed before they take the field.)' },
|
| 36 |
+
{ anchor: 'center', advanceOn: 'voice', target: '[data-tut="voice"]', text: 'And a voice — tap ▶ to hear them speak their line. This is the voice they’ll cry out in battle.' },
|
| 37 |
+
{ anchor: 'center', advanceOn: 'spawn', target: '[data-tut="save"]', text: 'Now they’re whole — Save & Play to bring them to the field.' },
|
| 38 |
{ anchor: 'bottom-left', advanceOn: 'move', target: '[data-tut="move"]', text: 'Lead them with the stick — or WASD on a keyboard.' },
|
| 39 |
{ anchor: 'bottom-right', advanceOn: 'kill', target: '[data-tut="attack"]', text: 'Strike with ⚔ (or Space). Most foes fall in a blow or two — thin the ranks.' },
|
| 40 |
{ anchor: 'bottom-right', target: '[data-tut="dodge"]', text: 'Roll with ⟲ (or Shift) to slip a blow — untouchable for a heartbeat.' },
|
|
|
|
| 405 |
showBarracks: true,
|
| 406 |
backSlot: foot,
|
| 407 |
onSaved: () => { save.disabled = false; try { tut.emit('persona') } catch { /* ignore */ } },
|
| 408 |
+
onPortrait: () => { try { tut.emit('portrait') } catch { /* ignore */ } },
|
| 409 |
+
onVoice: () => { try { tut.emit('voice') } catch { /* ignore */ } },
|
| 410 |
onState: (s) => { title.textContent = s === 'portrait' ? 'Hero Details' : 'Create a Hero'; if (s === 'recruit') save.disabled = true },
|
| 411 |
})
|
| 412 |
const close = () => { try { creator.stop() } catch { /* ignore */ } backdrop.remove() }
|
|
|
|
| 415 |
backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
|
| 416 |
save.addEventListener('click', () => {
|
| 417 |
const cur = creator.current(); if (!cur.persona) return
|
| 418 |
+
// The creator tracks the roster id separately (savedId) from the persona object — carry it
|
| 419 |
+
// through so spawnWithFly resolves the SAVED record (portrait/voice in IndexedDB, equipped
|
| 420 |
+
// skills, chat history). Without the id the freshly-created hero spawns "anonymous" and its
|
| 421 |
+
// portrait/voice/skills/chat aren't recognized until you re-pick it from the roster.
|
| 422 |
+
close(); onCreated(cur.savedId ? { ...cur.persona, id: cur.savedId } : cur.persona)
|
| 423 |
})
|
| 424 |
}
|
| 425 |
whenEl('battle-stage', async (el) => {
|
|
|
|
| 487 |
// pulled from the whole pool so groups read as a varied mix of characters and monsters.
|
| 488 |
const buildCineGroups = () => {
|
| 489 |
if (!ROSTER_POOL.length) return []
|
| 490 |
+
// 4 anchors × ~15 = ~60 decor sprites — enough armies for the tour without the per-frame cost
|
| 491 |
+
// of ~90 animated sprites (which caused slowdowns).
|
| 492 |
+
const anchors = comboCtrl.getCineAnchors?.(4) || []
|
| 493 |
return anchors.map((an) => {
|
| 494 |
const br = (rosters[an.biome] && rosters[an.biome].length) ? rosters[an.biome] : ROSTER_POOL
|
| 495 |
const n = 13 + (Math.random() * 5 | 0) // ~15 per group (13–17)
|