Spaces:
Running
Running
Sidebar dismiss-on-tap + chat voice clones the character's created voice
Browse files- Character sidebar: a transparent backdrop over the game while it's open dismisses it on
tap (nested overlays keep their own backdrops; sheet sits above it).
- Chat voice: design engines (Qwen3/VoxCPM) now CLONE from the character's created voice
(getAudio(id) reference + voiceQuote/voice), the same path heroCreator uses — so every
chat line shares one consistent voice instead of re-designing (which drifts). Fixed-voice
engines unchanged; falls back to design if no reference exists yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/characterChat.js +13 -3
- web/tiny.js +15 -3
web/characterChat.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
| 5 |
import { streamChat, ensureModel, currentModelId } from '/web/runtime.js'
|
| 6 |
import { noThink, stripThinkFinal } from '/web/personaPrompts.js'
|
| 7 |
import { buildContext } from '/web/characterContext.js'
|
| 8 |
-
import { getPersona, patchPersona, putChatAudio, getChatAudio } from '/web/personaStore.js'
|
| 9 |
import {
|
| 10 |
-
ensureTts, createVoiceWav, synthVoiceWav, speakVoiceLive, playWav,
|
| 11 |
activeEngineIsDesign, activeEngineIsNative, currentVoiceId, getAutoNarrate,
|
| 12 |
stopPreview, stopVoiceLive,
|
| 13 |
} from '/web/tts.js'
|
|
@@ -48,7 +48,17 @@ export function mountCharacterChat(host, personaId, { onUpdate, getLive } = {})
|
|
| 48 |
if (blob) bytes = await blob.arrayBuffer()
|
| 49 |
else {
|
| 50 |
await ensureTts()
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
bytes = wav instanceof Blob ? await wav.arrayBuffer() : wav
|
| 53 |
try { await putChatAudio(id, new Blob([bytes.slice(0)])) } catch { /* cache best-effort */ }
|
| 54 |
}
|
|
|
|
| 5 |
import { streamChat, ensureModel, currentModelId } from '/web/runtime.js'
|
| 6 |
import { noThink, stripThinkFinal } from '/web/personaPrompts.js'
|
| 7 |
import { buildContext } from '/web/characterContext.js'
|
| 8 |
+
import { getPersona, patchPersona, putChatAudio, getChatAudio, getAudio } from '/web/personaStore.js'
|
| 9 |
import {
|
| 10 |
+
ensureTts, createVoiceWav, cloneVoiceWav, synthVoiceWav, speakVoiceLive, playWav,
|
| 11 |
activeEngineIsDesign, activeEngineIsNative, currentVoiceId, getAutoNarrate,
|
| 12 |
stopPreview, stopVoiceLive,
|
| 13 |
} from '/web/tts.js'
|
|
|
|
| 48 |
if (blob) bytes = await blob.arrayBuffer()
|
| 49 |
else {
|
| 50 |
await ensureTts()
|
| 51 |
+
let wav
|
| 52 |
+
if (activeEngineIsDesign()) {
|
| 53 |
+
// Clone from the character's CREATED voice (the cached reference from the creation
|
| 54 |
+
// screen) so every chat line shares one consistent voice instead of re-designing
|
| 55 |
+
// (which drifts). Same path heroCreator uses; falls back to design if no reference yet.
|
| 56 |
+
const ref = await getAudio(p.id)
|
| 57 |
+
wav = ref ? await cloneVoiceWav(await ref.arrayBuffer(), p.voiceQuote || '', text, p.voice || '')
|
| 58 |
+
: await createVoiceWav(p.voice || '', text)
|
| 59 |
+
} else {
|
| 60 |
+
wav = await synthVoiceWav(p.voiceId || currentVoiceId(), text)
|
| 61 |
+
}
|
| 62 |
bytes = wav instanceof Blob ? await wav.arrayBuffer() : wav
|
| 63 |
try { await putChatAudio(id, new Blob([bytes.slice(0)])) } catch { /* cache best-effort */ }
|
| 64 |
}
|
web/tiny.js
CHANGED
|
@@ -222,6 +222,9 @@ const GAME_ROSTERS = {
|
|
| 222 |
// Persona class → engine profession (the engine has templates + skills for these five).
|
| 223 |
const PERSONA_PROF = { Warrior: 'Warrior', Ranger: 'Ranger', Monk: 'Monk', Assassin: 'Assassin', Mage: 'Necromancer', Paladin: 'Monk', Cleric: 'Monk', Knight: 'Warrior' }
|
| 224 |
const ENEMY_AGGRO = 220 // FIELD units (~8 tiles): enemy idles until the player is this near
|
|
|
|
|
|
|
|
|
|
| 225 |
// A persona → controllable hero (class → sheets + engine profession).
|
| 226 |
const buildPlayer = (chars, p) => {
|
| 227 |
const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
|
|
@@ -232,7 +235,7 @@ const buildPlayer = (chars, p) => {
|
|
| 232 |
if (equipped.length) unit.skills = equipped
|
| 233 |
// Leveling makes the fighter stronger: level + spent points → rank (skill scaling) + HP/damage.
|
| 234 |
const sb = statBonuses(p?.progression)
|
| 235 |
-
unit.rank = sb.rank; unit.hpMul = sb.hpMul; unit.dmgMul = sb.dmgMul
|
| 236 |
return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit }
|
| 237 |
}
|
| 238 |
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)'
|
|
@@ -349,7 +352,7 @@ whenEl('battle-stage', async (el) => {
|
|
| 349 |
const chars = await loadChars()
|
| 350 |
const buildRoster = (list) => list.map((e) => {
|
| 351 |
const c = chars[e.slug]; if (!c) return null
|
| 352 |
-
return { name: e.name, sheets: sheetsOf(c), unit: { name: e.name, stats: e.stats, attackType: e.attackType, skills: [], aggroRadius: ENEMY_AGGRO } }
|
| 353 |
}).filter(Boolean)
|
| 354 |
const rosters = Object.fromEntries(Object.entries(GAME_ROSTERS).map(([k, v]) => [k, buildRoster(v)]))
|
| 355 |
// Mount with NO hero → the map shows and the player picks a persona from the bottom picker. The
|
|
@@ -696,7 +699,16 @@ whenEl('battle-stage', async (el) => {
|
|
| 696 |
sw.addEventListener('click', () => { setSheet(false); showPicker() }); sheet.append(sw)
|
| 697 |
}
|
| 698 |
const updateHp = (hero) => { if (!sheet._hp || !hero) return; const pct = hero.maxHp ? Math.max(0, Math.min(100, hero.hp / hero.maxHp * 100)) : 0; sheet._hp.lab.textContent = `HP ${hero.hp} / ${hero.maxHp}`; sheet._hp.fill.style.width = pct + '%'; sheet._hp.fill.style.background = pct > 33 ? '#34d058' : '#ff3b30' }
|
| 699 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
sheetBtn.addEventListener('click', () => setSheet(!sheetOpen))
|
| 701 |
el.append(sheetBtn, sheet)
|
| 702 |
|
|
|
|
| 222 |
// Persona class → engine profession (the engine has templates + skills for these five).
|
| 223 |
const PERSONA_PROF = { Warrior: 'Warrior', Ranger: 'Ranger', Monk: 'Monk', Assassin: 'Assassin', Mage: 'Necromancer', Paladin: 'Monk', Cleric: 'Monk', Knight: 'Warrior' }
|
| 224 |
const ENEMY_AGGRO = 220 // FIELD units (~8 tiles): enemy idles until the player is this near
|
| 225 |
+
// Ranged reach is authored in arena GW units (BOW_GW=1000 ≈ 37 roam tiles — whole-screen). The
|
| 226 |
+
// roam map is far smaller, so scale ranged weapon range + kite distance down to ~8 tiles here.
|
| 227 |
+
const ROAM_RANGE_MUL = 0.22
|
| 228 |
// A persona → controllable hero (class → sheets + engine profession).
|
| 229 |
const buildPlayer = (chars, p) => {
|
| 230 |
const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
|
|
|
|
| 235 |
if (equipped.length) unit.skills = equipped
|
| 236 |
// Leveling makes the fighter stronger: level + spent points → rank (skill scaling) + HP/damage.
|
| 237 |
const sb = statBonuses(p?.progression)
|
| 238 |
+
unit.rank = sb.rank; unit.hpMul = sb.hpMul; unit.dmgMul = sb.dmgMul; unit.rangeMul = ROAM_RANGE_MUL
|
| 239 |
return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit }
|
| 240 |
}
|
| 241 |
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)'
|
|
|
|
| 352 |
const chars = await loadChars()
|
| 353 |
const buildRoster = (list) => list.map((e) => {
|
| 354 |
const c = chars[e.slug]; if (!c) return null
|
| 355 |
+
return { name: e.name, sheets: sheetsOf(c), unit: { name: e.name, stats: e.stats, attackType: e.attackType, skills: [], aggroRadius: ENEMY_AGGRO, rangeMul: ROAM_RANGE_MUL } }
|
| 356 |
}).filter(Boolean)
|
| 357 |
const rosters = Object.fromEntries(Object.entries(GAME_ROSTERS).map(([k, v]) => [k, buildRoster(v)]))
|
| 358 |
// Mount with NO hero → the map shows and the player picks a persona from the bottom picker. The
|
|
|
|
| 699 |
sw.addEventListener('click', () => { setSheet(false); showPicker() }); sheet.append(sw)
|
| 700 |
}
|
| 701 |
const updateHp = (hero) => { if (!sheet._hp || !hero) return; const pct = hero.maxHp ? Math.max(0, Math.min(100, hero.hp / hero.maxHp * 100)) : 0; sheet._hp.lab.textContent = `HP ${hero.hp} / ${hero.maxHp}`; sheet._hp.fill.style.width = pct + '%'; sheet._hp.fill.style.background = pct > 33 ? '#34d058' : '#ff3b30' }
|
| 702 |
+
// A transparent backdrop over the game area while the sheet is open — tapping the game dismisses
|
| 703 |
+
// the sidebar (the sheet itself sits above it; nested overlays like chat have their own backdrop).
|
| 704 |
+
let sheetBackdrop = null
|
| 705 |
+
const setSheet = (open) => {
|
| 706 |
+
sheetOpen = open; sheet.style.transform = open ? 'translateX(0)' : 'translateX(100%)'
|
| 707 |
+
if (open) {
|
| 708 |
+
renderSheet()
|
| 709 |
+
if (!sheetBackdrop) { sheetBackdrop = document.createElement('div'); sheetBackdrop.style.cssText = 'position:absolute;inset:0;z-index:7;background:transparent'; sheetBackdrop.addEventListener('pointerdown', () => setSheet(false)); el.append(sheetBackdrop) }
|
| 710 |
+
} else { sheetBackdrop?.remove(); sheetBackdrop = null }
|
| 711 |
+
}
|
| 712 |
sheetBtn.addEventListener('click', () => setSheet(!sheetOpen))
|
| 713 |
el.append(sheetBtn, sheet)
|
| 714 |
|