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:
+13
-2
@@ -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 { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState, type PlaythroughSummary, type RuntimeNode } from './narrative'
|
||||
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 { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||
|
||||
@@ -66,6 +67,7 @@ export function App() {
|
||||
const [splashBusy, setSplashBusy] = useState(false)
|
||||
const [playthrough, setPlaythrough] = useState<PlaythroughSummary | null>(null)
|
||||
const [runtimeNode, setRuntimeNode] = useState<RuntimeNode | null>(null)
|
||||
const [muted, setMuted] = useState(audio.isMuted())
|
||||
const saveTimer = useRef<number | undefined>(undefined)
|
||||
const boardRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -151,6 +153,12 @@ export function App() {
|
||||
} catch { setStatus('COULD NOT ADVANCE') }
|
||||
}, [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(() => {
|
||||
if (!adminMenuOpen) return
|
||||
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 (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).
|
||||
if (runtimeNode?.kind === 'cutscene') return <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 === 'cutscene') return <>{audioToggle}<CutsceneHost componentKey={runtimeNode.componentKey} label={runtimeNode.label} onComplete={() => 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 (!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')
|
||||
return <main className="desktop">
|
||||
{audioToggle}
|
||||
<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>
|
||||
<nav>
|
||||
|
||||
@@ -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
@@ -4,7 +4,8 @@ import { CUTSCENE_COMPONENT_KEYS, DialoguePreview } from './narrative'
|
||||
|
||||
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
|
||||
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 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 }) {
|
||||
const [graph, setGraph] = useState<Graph | null>(null)
|
||||
const [templates, setTemplates] = useState<LevelTemplate[]>([])
|
||||
const [audioAssets, setAudioAssets] = useState<AudioAsset[]>([])
|
||||
const [view, setView] = useState({ x: 60, y: 60, zoom: 1 })
|
||||
const [selectedId, setSelectedId] = 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')) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}, [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 rect = canvasRef.current?.getBoundingClientRect()
|
||||
@@ -149,7 +155,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
|
||||
</div>
|
||||
|
||||
{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)}
|
||||
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)) } }}
|
||||
@@ -169,8 +175,8 @@ function nodeSummary(node: StoryNode, templates: LevelTemplate[]) {
|
||||
return ''
|
||||
}
|
||||
|
||||
function NodeInspector({ node, graph, templates, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
|
||||
node: StoryNode; graph: Graph; templates: LevelTemplate[]
|
||||
function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
|
||||
node: StoryNode; graph: Graph; templates: LevelTemplate[]; audioAssets: AudioAsset[]
|
||||
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
|
||||
}) {
|
||||
@@ -192,6 +198,12 @@ function NodeInspector({ node, graph, templates, onPatch, onSetEntry, onDelete,
|
||||
</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>}
|
||||
<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>
|
||||
{node.terminals.map(t => <div key={t.id} className="ins-terminal">
|
||||
|
||||
+4
-1
@@ -1,7 +1,8 @@
|
||||
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 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 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
|
||||
|
||||
const pick = (choice: RuntimeUtterance) => {
|
||||
if (!inline) audio.sfx('choice')
|
||||
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
|
||||
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 (children.length === 0) { onExit(current.terminalKey ?? undefined); return }
|
||||
if (options.length > 0) return // a branch — wait for a choice
|
||||
if (!inline) audio.sfx('advance')
|
||||
setCurrentId(children[0].id) // linear next line
|
||||
}
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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; }
|
||||
.graph-node-preview { position: absolute; 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; }
|
||||
|
||||
Reference in New Issue
Block a user