polats Claude Opus 4.8 (1M context) commited on
Commit
68fbbfb
·
1 Parent(s): a1a7733

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>

Files changed (2) hide show
  1. web/characterChat.js +13 -3
  2. 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
- const wav = activeEngineIsDesign() ? await createVoiceWav(p.voice || '', text) : await synthVoiceWav(p.voiceId || currentVoiceId(), text)
 
 
 
 
 
 
 
 
 
 
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
- const setSheet = (open) => { sheetOpen = open; sheet.style.transform = open ? 'translateX(0)' : 'translateX(100%)'; if (open) renderSheet() }
 
 
 
 
 
 
 
 
 
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