Add per-node scene music volume control

Author a music track and volume (0-100%) per story node; the runtime
scales it to 0-1 and applies it without restarting the track when only
the level changes. Adds migration 024, threads music_volume through the
repository/runtime, and gives the node inspector a volume slider.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 21:02:16 +02:00
co-authored by Claude Opus 4.8
parent 80e5f548ac
commit 1b3ff1e78c
8 changed files with 36 additions and 22 deletions
+6 -5
View File
@@ -13,7 +13,7 @@ export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'll
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
export type StoryNodeDto = {
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number
terminals: TerminalDto[]
}
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] }
@@ -37,7 +37,7 @@ const DEFAULT_TERMINALS: Record<StoryNodeType, { key: string; label: string }[]>
export interface StoryGraphRepository {
getGraph(mysteryId: string): Promise<StoryGraphDto | null>
createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | null>
updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null }>): Promise<StoryNodeDto | null>
updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number }>): Promise<StoryNodeDto | null>
deleteNode(nodeId: string): Promise<boolean>
addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null>
updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null }>): Promise<{ ok: boolean; error?: string }>
@@ -61,8 +61,8 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
const mystery = await pool.query<{ id: string; entry_node_id: string | null }>('SELECT id,entry_node_id FROM osint.mysteries WHERE id=$1', [mysteryId])
if (!mystery.rows[0]) return null
const [nodes, terminals] = await Promise.all([
pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null; music_asset_id: string | null }>(
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null; music_asset_id: string | null; music_volume: number }>(
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number }>(
`SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order FROM osint.story_node_terminals t
JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]),
@@ -77,7 +77,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
mysteryId, entryNodeId: mystery.rows[0].entry_node_id,
nodes: nodes.rows.map(row => ({
id: row.id, nodeType: row.node_type, label: row.label, hasUtterances: row.has_utterances,
xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key, musicAssetId: row.music_asset_id,
xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key, musicAssetId: row.music_asset_id, musicVolume: row.music_volume,
terminals: byNode.get(row.id) || [],
})),
}
@@ -118,6 +118,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
if (input.componentKey !== undefined) set('component_key', input.componentKey || null)
if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null)
if (input.musicAssetId !== undefined) set('music_asset_id', input.musicAssetId || null)
if (input.musicVolume !== undefined) set('music_volume', Math.max(0, Math.min(100, Math.round(input.musicVolume))))
if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values)
const graph = await loadGraph(mysteryId)
return graph?.nodes.find(node => node.id === nodeId) ?? null