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:
+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">
|
||||
|
||||
Reference in New Issue
Block a user