Sound: per-node scene music + synthesized SFX (zero-dep)

- migration 023: story_nodes.music_asset_id (references the asset store).
- src/audio.ts: a tiny audio manager — one looping scene-music track (fade,
  dedup, gesture-primed autoplay) and synthesized one-shot SFX; global mute.
- Runtime: RuntimeNode.musicUrl; music follows the current node (null inherits,
  a finished playthrough stops). SFX blips on dialogue advance/choice (not in the
  editor preview). Floating mute toggle during play.
- Graph inspector: a "Scene music" picker sourced from uploaded audio assets.

No external library (we did not adopt react-winamp — it is a React 16 CRA app,
not an installable package). Bundle grew ~2 KB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:51:44 +02:00
co-authored by Claude Opus 4.8
parent 0b1e5e7fc7
commit dafc70b047
9 changed files with 135 additions and 22 deletions
+4
View File
@@ -0,0 +1,4 @@
-- Optional scene music per story node. The runtime plays/loops it while the node
-- is active; a NULL value inherits whatever is already playing (so a track set on
-- one node carries through the region until another node changes it).
ALTER TABLE osint.story_nodes ADD COLUMN music_asset_id UUID REFERENCES osint.assets(id);
+3 -3
View File
@@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => {
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const firstRun: string[] = [] const firstRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(22) expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(23)
const client = new Client({ connectionString: testDatabaseUrl }) const client = new Client({ connectionString: testDatabaseUrl })
await client.connect() await client.connect()
@@ -49,7 +49,7 @@ suite('PostgreSQL migrations', () => {
])) ]))
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue'])) expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations') const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
expect(ledger.rows[0].count).toBe('22') expect(ledger.rows[0].count).toBe('23')
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`) const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset'])) expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`) const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
@@ -58,7 +58,7 @@ suite('PostgreSQL migrations', () => {
const secondRun: string[] = [] const secondRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(22) expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(23)
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
}) })
}) })
+7 -6
View File
@@ -16,7 +16,7 @@ export type RuntimeUtterance = {
} }
export type RuntimeNode = { export type RuntimeNode = {
id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string
componentKey?: string | null; levelSlug?: string | null componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null
utterances?: RuntimeUtterance[]; rootId?: string | null utterances?: RuntimeUtterance[]; rootId?: string | null
} }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null } export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
@@ -60,7 +60,7 @@ export interface NarrativeRepository {
deletePose(npcId: string, poseKey: string): Promise<NpcDto | null> deletePose(npcId: string, poseKey: string): Promise<NpcDto | null>
} }
type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null } type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null; music_asset_id: string | null }
export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository { export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository {
// ---- Runtime: walking the story graph ------------------------------------- // ---- Runtime: walking the story graph -------------------------------------
@@ -97,11 +97,12 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
} }
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> { async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> {
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0] const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
if (!node) return null if (!node) return null
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key } const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug } if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl }
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, ...(await resolveDialogueGraph(node.id)) } if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl }
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, ...(await resolveDialogueGraph(node.id)) }
return null // gates are auto-resolved during advance and never surfaced return null // gates are auto-resolved during advance and never surfaced
} }
+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 TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
export type StoryNodeDto = { export type StoryNodeDto = {
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null
terminals: TerminalDto[] terminals: TerminalDto[]
} }
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] } 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 { export interface StoryGraphRepository {
getGraph(mysteryId: string): Promise<StoryGraphDto | null> getGraph(mysteryId: string): Promise<StoryGraphDto | null>
createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | 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 }>): 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>
deleteNode(nodeId: string): Promise<boolean> deleteNode(nodeId: string): Promise<boolean>
addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null> 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 }> 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]) 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 if (!mystery.rows[0]) return null
const [nodes, terminals] = await Promise.all([ 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 }>( 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 FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]), '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; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number }>( 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 `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]), 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, mysteryId, entryNodeId: mystery.rows[0].entry_node_id,
nodes: nodes.rows.map(row => ({ nodes: nodes.rows.map(row => ({
id: row.id, nodeType: row.node_type, label: row.label, hasUtterances: row.has_utterances, 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, xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key, musicAssetId: row.music_asset_id,
terminals: byNode.get(row.id) || [], terminals: byNode.get(row.id) || [],
})), })),
} }
@@ -117,6 +117,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
if (input.hasUtterances !== undefined) set('has_utterances', input.hasUtterances) if (input.hasUtterances !== undefined) set('has_utterances', input.hasUtterances)
if (input.componentKey !== undefined) set('component_key', input.componentKey || null) if (input.componentKey !== undefined) set('component_key', input.componentKey || null)
if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || 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 (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values) if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values)
const graph = await loadGraph(mysteryId) const graph = await loadGraph(mysteryId)
return graph?.nodes.find(node => node.id === nodeId) ?? null return graph?.nodes.find(node => node.id === nodeId) ?? null
+13 -2
View File
@@ -3,6 +3,7 @@ import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText,
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types' import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types'
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState, type PlaythroughSummary, type RuntimeNode } from './narrative' import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState, type PlaythroughSummary, type RuntimeNode } from './narrative'
import { AdminPanel } from './admin' import { AdminPanel } from './admin'
import { audio } from './audio'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry' import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
@@ -66,6 +67,7 @@ export function App() {
const [splashBusy, setSplashBusy] = useState(false) const [splashBusy, setSplashBusy] = useState(false)
const [playthrough, setPlaythrough] = useState<PlaythroughSummary | null>(null) const [playthrough, setPlaythrough] = useState<PlaythroughSummary | null>(null)
const [runtimeNode, setRuntimeNode] = useState<RuntimeNode | null>(null) const [runtimeNode, setRuntimeNode] = useState<RuntimeNode | null>(null)
const [muted, setMuted] = useState(audio.isMuted())
const saveTimer = useRef<number | undefined>(undefined) const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null) const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -151,6 +153,12 @@ export function App() {
} catch { setStatus('COULD NOT ADVANCE') } } catch { setStatus('COULD NOT ADVANCE') }
}, [playthrough, applyState]) }, [playthrough, applyState])
// Scene music follows the current node (null inherits; a finished playthrough stops).
useEffect(() => {
if (runtimeNode?.musicUrl) audio.setMusic(runtimeNode.musicUrl)
else if (playthrough?.status === 'finished') audio.setMusic(null)
}, [runtimeNode, playthrough])
useEffect(() => { useEffect(() => {
if (!adminMenuOpen) return if (!adminMenuOpen) return
const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) } const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) }
@@ -383,11 +391,13 @@ export function App() {
} }
} }
const audioToggle = playthrough ? <button className={`audio-toggle${muted ? ' muted' : ''}`} title={muted ? 'Unmute' : 'Mute'} onClick={() => setMuted(audio.toggleMute())}></button> : null
if (adminRoute) return <AdminPanel /> if (adminRoute) return <AdminPanel />
if (splashOpen) return <SplashScreen hasResume={false} busy={splashBusy} status={status} onNewGame={startNewGame} onResume={() => setSplashOpen(false)} /> if (splashOpen) return <SplashScreen hasResume={false} busy={splashBusy} status={status} onNewGame={startNewGame} onResume={() => setSplashOpen(false)} />
// Story-graph runtime: cutscene and dialogue nodes play full-screen (no board). // Story-graph runtime: cutscene and dialogue nodes play full-screen (no board).
if (runtimeNode?.kind === 'cutscene') return <CutsceneHost componentKey={runtimeNode.componentKey} label={runtimeNode.label} onComplete={() => advance()} /> if (runtimeNode?.kind === 'cutscene') return <>{audioToggle}<CutsceneHost componentKey={runtimeNode.componentKey} label={runtimeNode.label} onComplete={() => advance()} /></>
if (runtimeNode?.kind === 'dialogue') return <DialoguePlayer node={{ utterances: runtimeNode.utterances || [], rootId: runtimeNode.rootId ?? null }} onExit={advance} /> if (runtimeNode?.kind === 'dialogue') return <>{audioToggle}<DialoguePlayer node={{ utterances: runtimeNode.utterances || [], rootId: runtimeNode.rootId ?? null }} onExit={advance} /></>
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} /> if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div> if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
@@ -411,6 +421,7 @@ export function App() {
}) })
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline') const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
return <main className="desktop"> return <main className="desktop">
{audioToggle}
<header className="menubar"> <header className="menubar">
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div> <div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav> <nav>
+76
View File
@@ -0,0 +1,76 @@
// Lightweight game audio: one looping "scene music" track plus synthesized one-shot
// SFX. Zero dependencies. Browsers block autoplay until a user gesture, so music/SFX
// are primed on the first pointer interaction.
type Sfx = 'advance' | 'choice' | 'sting'
const MUSIC_VOLUME = 0.5
let ctx: AudioContext | null = null
function audioContext() {
if (!ctx) { const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (AC) ctx = new AC() }
return ctx
}
const music = typeof Audio !== 'undefined' ? new Audio() : null
if (music) music.loop = true
let currentUrl: string | null = null
let muted = false
let pending = false
let fadeTimer: number | undefined
function fade(target: number, done?: () => void) {
if (!music) return
window.clearInterval(fadeTimer)
const step = (target - music.volume) / 12 || (target > music.volume ? 0.1 : -0.1)
fadeTimer = window.setInterval(() => {
if (!music) return
const next = music.volume + step
if ((step >= 0 && next >= target) || (step <= 0 && next <= target)) { music.volume = target; window.clearInterval(fadeTimer); done?.() }
else music.volume = Math.max(0, Math.min(1, next))
}, 40)
}
function startMusic() {
if (!music || !currentUrl || muted) return
const promise = music.play()
if (promise) promise.then(() => { pending = false; fade(MUSIC_VOLUME) }).catch(() => { pending = true })
}
export const audio = {
// A null url inherits whatever is already playing; a new url crossfades to it.
setMusic(url: string | null) {
if (!music || url === currentUrl) return
currentUrl = url
if (!url) { fade(0, () => music.pause()); pending = false; return }
music.src = url
music.volume = 0
startMusic()
},
toggleMute() {
muted = !muted
if (muted) fade(0)
else if (currentUrl) startMusic()
return muted
},
isMuted() { return muted },
sfx(kind: Sfx) {
if (muted) return
const context = audioContext()
if (!context || context.state !== 'running') return
const osc = context.createOscillator(), gain = context.createGain()
osc.type = 'sine'
osc.frequency.value = kind === 'choice' ? 540 : kind === 'sting' ? 300 : 400
const now = context.currentTime
gain.gain.setValueAtTime(0.0001, now)
gain.gain.exponentialRampToValueAtTime(0.07, now + 0.008)
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.12)
osc.connect(gain).connect(context.destination)
osc.start(now); osc.stop(now + 0.13)
},
// Resume the context and retry pending music on a user gesture.
resume() {
void audioContext()?.resume?.()
if (pending) startMusic()
},
}
if (typeof window !== 'undefined') window.addEventListener('pointerdown', () => audio.resume())
+17 -5
View File
@@ -4,7 +4,8 @@ import { CUTSCENE_COMPONENT_KEYS, DialoguePreview } from './narrative'
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number } type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
type StoryNode = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean; xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; terminals: Terminal[] } type StoryNode = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean; xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; terminals: Terminal[] }
type AudioAsset = { id: string; originalName: string; mimeType: string }
type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] } type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] }
type LevelTemplate = { versionId: string; slug: string; name: string; version: number } type LevelTemplate = { versionId: string; slug: string; name: string; version: number }
@@ -26,6 +27,7 @@ async function api<T>(url: string, method: string, body?: unknown): Promise<T> {
export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { mysteryId: string; title: string; onClose: () => void; setStatus: (message: string) => void }) { export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { mysteryId: string; title: string; onClose: () => void; setStatus: (message: string) => void }) {
const [graph, setGraph] = useState<Graph | null>(null) const [graph, setGraph] = useState<Graph | null>(null)
const [templates, setTemplates] = useState<LevelTemplate[]>([]) const [templates, setTemplates] = useState<LevelTemplate[]>([])
const [audioAssets, setAudioAssets] = useState<AudioAsset[]>([])
const [view, setView] = useState({ x: 60, y: 60, zoom: 1 }) const [view, setView] = useState({ x: 60, y: 60, zoom: 1 })
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [wiringFrom, setWiringFrom] = useState<string | null>(null) const [wiringFrom, setWiringFrom] = useState<string | null>(null)
@@ -37,7 +39,11 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
try { setGraph(await api<Graph>(`/api/admin/mysteries/${mysteryId}/graph`, 'GET')) } try { setGraph(await api<Graph>(`/api/admin/mysteries/${mysteryId}/graph`, 'GET')) }
catch (error) { setStatus(String((error as Error).message || error)) } catch (error) { setStatus(String((error as Error).message || error)) }
}, [mysteryId, setStatus]) }, [mysteryId, setStatus])
useEffect(() => { void reload(); api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {}) }, [reload]) useEffect(() => {
void reload()
api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {})
api<AudioAsset[]>('/api/admin/assets', 'GET').then(list => setAudioAssets(list.filter(a => a.mimeType.startsWith('audio/')))).catch(() => {})
}, [reload])
const centerInBoard = () => { const centerInBoard = () => {
const rect = canvasRef.current?.getBoundingClientRect() const rect = canvasRef.current?.getBoundingClientRect()
@@ -149,7 +155,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
</div> </div>
{selected && <aside className="graph-inspector"> {selected && <aside className="graph-inspector">
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates} <NodeInspector key={selected.id} node={selected} graph={graph} templates={templates} audioAssets={audioAssets}
onEditUtterances={() => setUtterancesNode(selected)} onEditUtterances={() => setUtterancesNode(selected)}
onPatch={body => patchNode(selected.id, body)} onPatch={body => patchNode(selected.id, body)}
onSetEntry={async () => { try { await api(`/api/admin/mysteries/${mysteryId}/entry`, 'PUT', { nodeId: selected.id }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }} onSetEntry={async () => { try { await api(`/api/admin/mysteries/${mysteryId}/entry`, 'PUT', { nodeId: selected.id }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
@@ -169,8 +175,8 @@ function nodeSummary(node: StoryNode, templates: LevelTemplate[]) {
return '' return ''
} }
function NodeInspector({ node, graph, templates, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: { function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
node: StoryNode; graph: Graph; templates: LevelTemplate[] node: StoryNode; graph: Graph; templates: LevelTemplate[]; audioAssets: AudioAsset[]
onPatch: (body: Record<string, unknown>) => void; onSetEntry: () => void; onDelete: () => void onPatch: (body: Record<string, unknown>) => void; onSetEntry: () => void; onDelete: () => void
onAddTerminal: () => void; onTerminalPatch: (id: string, body: Record<string, unknown>) => void; onTerminalDelete: (id: string) => void; onEditUtterances: () => void onAddTerminal: () => void; onTerminalPatch: (id: string, body: Record<string, unknown>) => void; onTerminalDelete: (id: string) => void; onEditUtterances: () => void
}) { }) {
@@ -192,6 +198,12 @@ function NodeInspector({ node, graph, templates, onPatch, onSetEntry, onDelete,
</label>} </label>}
{(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && <label className="ins-check"><input type="checkbox" checked={node.hasUtterances} onChange={e => onPatch({ hasUtterances: e.target.checked })} /> Has utterances</label>} {(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && <label className="ins-check"><input type="checkbox" checked={node.hasUtterances} onChange={e => onPatch({ hasUtterances: e.target.checked })} /> Has utterances</label>}
{(node.nodeType === 'dialogue' || node.hasUtterances) && <button className="ins-utterances" onClick={onEditUtterances}>Edit utterances </button>} {(node.nodeType === 'dialogue' || node.hasUtterances) && <button className="ins-utterances" onClick={onEditUtterances}>Edit utterances </button>}
<label className="ins-field"><span>Scene music</span>
<select value={node.musicAssetId || ''} onChange={e => onPatch({ musicAssetId: e.target.value || null })}>
<option value=""> none / inherit </option>
{audioAssets.map(asset => <option key={asset.id} value={asset.id}>{asset.originalName}</option>)}
</select>
</label>
<div className="ins-terminals-head"><span>Output terminals</span><button onClick={onAddTerminal}>+ Add</button></div> <div className="ins-terminals-head"><span>Output terminals</span><button onClick={onAddTerminal}>+ Add</button></div>
{node.terminals.map(t => <div key={t.id} className="ins-terminal"> {node.terminals.map(t => <div key={t.id} className="ins-terminal">
+4 -1
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState, type FC } from 'react' import { useEffect, useMemo, useRef, useState, type FC } from 'react'
import { audio } from './audio'
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null } export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null } export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string } export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null } export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
@@ -84,6 +85,7 @@ export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utte
}, [currentId, fullText, reduced]) // eslint-disable-line react-hooks/exhaustive-deps }, [currentId, fullText, reduced]) // eslint-disable-line react-hooks/exhaustive-deps
const pick = (choice: RuntimeUtterance) => { const pick = (choice: RuntimeUtterance) => {
if (!inline) audio.sfx('choice')
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0]) if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
else onExit(choice.terminalKey ?? undefined) else onExit(choice.terminalKey ?? undefined)
} }
@@ -93,6 +95,7 @@ export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utte
if (!done) { setCharCount(fullText.length); return } if (!done) { setCharCount(fullText.length); return }
if (children.length === 0) { onExit(current.terminalKey ?? undefined); return } if (children.length === 0) { onExit(current.terminalKey ?? undefined); return }
if (options.length > 0) return // a branch — wait for a choice if (options.length > 0) return // a branch — wait for a choice
if (!inline) audio.sfx('advance')
setCurrentId(children[0].id) // linear next line setCurrentId(children[0].id) // linear next line
} }
useEffect(() => { useEffect(() => {
+5
View File
@@ -587,3 +587,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.dialogue-preview-empty { display: grid; place-items: center; height: 100%; color: #5f7b73; font: 9px IBM Plex Mono; } .dialogue-preview-empty { display: grid; place-items: center; height: 100%; color: #5f7b73; font: 9px IBM Plex Mono; }
.graph-node-preview { position: absolute; z-index: 6; } .graph-node-preview { position: absolute; z-index: 6; }
.utterance-preview-dock { position: absolute; left: 16px; bottom: 16px; z-index: 6; } .utterance-preview-dock { position: absolute; left: 16px; bottom: 16px; z-index: 6; }
/* Audio mute toggle (floats over cutscene/dialogue/board) */
.audio-toggle { position: fixed; top: 14px; right: 14px; z-index: 300; width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #3c5a52; background: #0a211de6; color: #d58a46; font-size: 16px; line-height: 1; cursor: pointer; }
.audio-toggle:hover { border-color: #6f8f85; color: #e7b57e; }
.audio-toggle.muted { color: #5f7b73; text-decoration: line-through; }