Compare commits
8
Commits
3b72f38d00
...
1893bf23af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1893bf23af | ||
|
|
1b3ff1e78c | ||
|
|
80e5f548ac | ||
|
|
6d4026476e | ||
|
|
dafc70b047 | ||
|
|
0b1e5e7fc7 | ||
|
|
f10d7584d2 | ||
|
|
7fe8fadd8a |
@@ -0,0 +1,139 @@
|
||||
# Persistent boards, gated exhibits, and citation codes
|
||||
|
||||
Status: **proposed** (design locked, not implemented). Extends the story flow graph
|
||||
([story-graph.md](story-graph.md)) and the narrative layer
|
||||
([narrative-todo.md](narrative-todo.md)); interlocks with the Claim/Case Report
|
||||
work in [TODO.md](TODO.md) Milestone 5. Depends on the **flags / case-state**
|
||||
primitive (a per-playthrough key→value store) that the gates and dialogue effects
|
||||
also read and write.
|
||||
|
||||
## Why
|
||||
|
||||
A mystery is broken into short pieces so we can **meter how much evidence the
|
||||
player carries at once**. Interruptions — a phone call, a knock, a revealed note —
|
||||
should not throw the player off the board they are working on. So a single
|
||||
investigation scene is a **persistent, player-mutated board** that several graph
|
||||
nodes return to, and new evidence **arrives into that same board** when the story
|
||||
warrants it rather than being dumped up front or split across duplicated templates.
|
||||
|
||||
Two capabilities, independent and both wanted:
|
||||
|
||||
1. **A board that survives across nodes** — `[board] → [phone call] → [same board]`
|
||||
returns the player to their exact arrangement and connections.
|
||||
2. **Flag-gated exhibits inside that board**, revealed with a diegetic "it arrived"
|
||||
moment (reuses the existing document locator beam).
|
||||
|
||||
## Board identity and reuse
|
||||
|
||||
An authored level node **cannot** reference a clone id — the clone is created per
|
||||
playthrough at runtime. Instead:
|
||||
|
||||
- A **level node** references a `level_template_version_id` **plus a `board_key`**
|
||||
(a logical board slot, e.g. `"harbor-desk"`). `board_key` defaults to the
|
||||
template if the author doesn't care about sharing.
|
||||
- A **playthrough** holds a map `board_key → level_id` (the mutable clone).
|
||||
- Entering a level node:
|
||||
- if the playthrough already has a clone for that `board_key` → **reuse it**
|
||||
(the player's mutated board, arrangement and connections intact);
|
||||
- otherwise → **clone the template** and record `board_key → new level_id`.
|
||||
|
||||
`board_key` is the thing that makes two nodes share a board. Two nodes on the same
|
||||
template with **different** keys get **separate** boards.
|
||||
|
||||
## Gated exhibits
|
||||
|
||||
- Every exhibit is **cloned in**, including gated ones; a gated exhibit carries a
|
||||
**`reveal` condition over flags**. The player's saved board already contains the
|
||||
note — it is simply inert until the flag flips.
|
||||
- **Visibility is computed server-side at load.** Given the playthrough's flags,
|
||||
the play-mode payload includes only exhibits whose `reveal` condition is met;
|
||||
hidden exhibits are stripped from the response (same discipline that already
|
||||
hides authoring-only fields like `expectedPartyKind`). Players cannot peek
|
||||
unrevealed evidence in the API.
|
||||
- **Gate the edges too.** Any `exhibit_connection` or event link whose endpoint is
|
||||
a not-yet-revealed exhibit is hidden until that exhibit appears, so there are no
|
||||
dangling references.
|
||||
|
||||
## The arrival reveal
|
||||
|
||||
- The board's effective content can change **while the player is standing on it** —
|
||||
the phone is an always-available surface, so a call can set a flag mid-scene. So
|
||||
reveal is **not load-only**: after any phone/dialogue interaction that sets flags,
|
||||
re-evaluate board visibility and reveal live (the phone overlay refetches
|
||||
visibility on hang-up).
|
||||
- Track a per-playthrough **`revealed-seen` set**. On (re)load or after an
|
||||
interaction, any exhibit now visible but not yet in the set → play the arrival
|
||||
animation (a sibling/reuse of the **document locator beam**), then add it to the
|
||||
set. The flourish fires **once**, not on every reload.
|
||||
|
||||
## Reset and New Game
|
||||
|
||||
- **Reset = tidy the desk back to the delivered state.** Authored exhibits return
|
||||
to their arrival positions and folders; the player's rearrangement is undone.
|
||||
**Flags are untouched**, so anything a flag has already revealed is still
|
||||
"arrived" and is restored at its authored position.
|
||||
- **New Game** re-clones the whole tree and is therefore the only thing that
|
||||
**resets flags** and returns gated exhibits to hidden.
|
||||
|
||||
**Open decision — player notes on reset.** Milestone 5's rule is "reset discards
|
||||
player-created claims." Current lean: reset also removes player-created exhibits
|
||||
(it restores the *delivered* state), and New Game is the only wipe of flags. The
|
||||
alternative is a gentler reset that re-files arrived exhibits but leaves the
|
||||
player's own notes in place. Settle before implementing.
|
||||
|
||||
## Exhibit citation codes (A / B / M)
|
||||
|
||||
A single running number breaks when players create many note exhibits, so codes
|
||||
live in fixed namespaces:
|
||||
|
||||
- **Authored exhibits: group letter + index** — `A1, A2, …` for the initial
|
||||
dossier, `B1, B2, …` for a batch that arrives later via a flag reveal, etc.
|
||||
**The letter is the arrival group**, which ties straight into gated reveal: "new
|
||||
evidence arrived" *is* the B-series lighting up. Indices are assigned **within
|
||||
each group at freeze time** and frozen in the template, so authored codes never
|
||||
shift, no matter what the player does.
|
||||
- **Player-created exhibits: a running `M` (miscellaneous) series** — `M1, M2,
|
||||
M3…` in creation order, in their own namespace so player note-making cannot
|
||||
disturb authored codes. M-numbering is per-level-instance and (given the reset
|
||||
lean above) restarts only when player notes are cleared.
|
||||
- The author chooses an exhibit's **group**; the system owns the **numbering**.
|
||||
|
||||
These codes are the **citation token everywhere** — board, luggage-tag Claims, the
|
||||
Case Report, and the LLM gate.
|
||||
|
||||
## LLM gate contract
|
||||
|
||||
- A gate can be **scripted to require specific authored codes** — e.g. "the report
|
||||
must cite `A1` and `B2`, connected." That authored requirement list is the
|
||||
deterministic backbone *under* the LLM, per the section-9 intent of scripting the
|
||||
cognitive shim rather than trusting it blind.
|
||||
- If the player types the full report freehand, they must include the Exhibit codes;
|
||||
the gate checks the required codes are present (and, later, that the reasoning
|
||||
holds).
|
||||
|
||||
## Relationship to fresh templates
|
||||
|
||||
This **complements**, not replaces, new templates:
|
||||
|
||||
- **Shared `board_key`** — interruptions *within* one investigation scene (calls, a
|
||||
knock, a revealed note). Cleanly subsumes the earlier "note appears on the next
|
||||
level" idea: the note is a gated exhibit on the *same* board that reveals after
|
||||
the call.
|
||||
- **A fresh template** — the player genuinely moves to a new location/chapter with a
|
||||
different board.
|
||||
|
||||
## Implemented vs deferred
|
||||
|
||||
Nothing here is built yet. New plumbing, smallest-first:
|
||||
|
||||
1. **Flags** — the per-playthrough key→value store (shared with gates/dialogue).
|
||||
2. **`board_key` reuse** in level traversal + the playthrough `board_key → level_id`
|
||||
map (clone-or-reuse).
|
||||
3. **`reveal` conditions on exhibits** + server-side play-mode visibility filtering
|
||||
(exhibits and their edges).
|
||||
4. **`revealed-seen` set** + the arrival animation (locator-beam sibling), live
|
||||
after flag changes.
|
||||
5. **A / B / M citation codes** — authored group + frozen index, player M-series;
|
||||
surfaced on the board and threaded into Claims/report/LLM gate.
|
||||
|
||||
Reset (desk-restore, flags-persist) and New Game (full re-clone) semantics as above.
|
||||
@@ -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);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Per-node scene-music volume (0-100%). Applied whenever a node sets a track;
|
||||
-- selecting the same track on a later node with a different volume just adjusts
|
||||
-- the level without restarting the music.
|
||||
ALTER TABLE osint.story_nodes ADD COLUMN music_volume SMALLINT NOT NULL DEFAULT 100 CHECK (music_volume BETWEEN 0 AND 100);
|
||||
Generated
+65
@@ -19,6 +19,7 @@
|
||||
"pg": "8.16.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"three": "^0.185.1",
|
||||
"tsx": "4.20.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,6 +33,7 @@
|
||||
"@types/pg": "8.15.4",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/three": "^0.185.4",
|
||||
"@vitejs/plugin-react": "4.5.2",
|
||||
"concurrently": "9.1.2",
|
||||
"typescript": "5.8.3",
|
||||
@@ -632,6 +634,13 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -1569,6 +1578,13 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1817,6 +1833,35 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.185.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz",
|
||||
"integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz",
|
||||
@@ -2692,6 +2737,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
@@ -3143,6 +3195,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
@@ -4077,6 +4136,12 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"pg": "8.16.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"three": "^0.185.1",
|
||||
"tsx": "4.20.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -41,6 +42,7 @@
|
||||
"@types/pg": "8.15.4",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/three": "^0.185.4",
|
||||
"@vitejs/plugin-react": "4.5.2",
|
||||
"concurrently": "9.1.2",
|
||||
"typescript": "5.8.3",
|
||||
|
||||
@@ -41,11 +41,18 @@ function requireOk(response: Response, action: string) {
|
||||
return response.text().then(body => { throw new Error(`${action} failed (${response.status}): ${body}`) })
|
||||
}
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', txt: 'text/plain',
|
||||
}
|
||||
function mimeFor(filename: string) { return MIME_BY_EXT[filename.split('.').pop()?.toLowerCase() || ''] || 'application/octet-stream' }
|
||||
|
||||
async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument, authorization?: string) {
|
||||
if (!document.asset) return undefined
|
||||
const assetPath = path.resolve(manifestDir, document.asset)
|
||||
const filename = path.basename(assetPath)
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([await readFile(assetPath)]), path.basename(assetPath))
|
||||
form.append('file', new Blob([await readFile(assetPath)], { type: mimeFor(filename) }), filename)
|
||||
const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', headers: authorization ? { authorization } : undefined, body: form }), `Upload ${document.asset}`)
|
||||
return await response.json() as CaseDocument
|
||||
}
|
||||
|
||||
@@ -145,6 +145,33 @@ function requireEditing(res: express.Response) {
|
||||
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
|
||||
try { res.json(await narrative.listMysteries()) } catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/mysteries/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const ok = await narrative.deleteMystery(String(req.params.id))
|
||||
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Mystery not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
// Shared asset library (images, audio, PDFs) — reuses the immutable, deduplicated
|
||||
// osint.assets store; bytes served via GET /api/assets/:id.
|
||||
app.get('/api/admin/assets', requireAdmin, async (_req, res, next) => {
|
||||
try { res.json(await narrative.listAssets()) } catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/assets', requireAdmin, upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
if (!req.file) return res.status(400).json({ error: 'A file is required' })
|
||||
res.status(201).json(await narrative.uploadAsset(req.file))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/assets/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const outcome = await narrative.deleteAsset(String(req.params.id))
|
||||
if (outcome === 'deleted') return res.json({ ok: true })
|
||||
res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'Asset is in use' : 'Asset not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.get('/api/admin/npcs', requireAdmin, async (_req, res, next) => {
|
||||
try { res.json(await narrative.listNpcs()) } catch (error) { next(error) }
|
||||
})
|
||||
@@ -262,6 +289,10 @@ app.post('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next)
|
||||
app.get('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
|
||||
try { res.json(await storyGraph.listUtterances(String(req.params.id))) } catch (error) { next(error) }
|
||||
})
|
||||
// Resolved runtime dialogue tree for the editor's live preview (same resolver as play).
|
||||
app.get('/api/admin/story-nodes/:id/dialogue', requireAdmin, async (req, res, next) => {
|
||||
try { res.json(await narrative.resolveDialogue(String(req.params.id))) } catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
|
||||
@@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => {
|
||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||
const firstRun: string[] = []
|
||||
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(24)
|
||||
|
||||
const client = new Client({ connectionString: testDatabaseUrl })
|
||||
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']))
|
||||
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('24')
|
||||
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']))
|
||||
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[] = []
|
||||
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(24)
|
||||
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cloneBoard } from './boardClone.js'
|
||||
import type { ObjectStorage } from './objectStorage.js'
|
||||
|
||||
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
||||
export type AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string }
|
||||
export type PoseDto = { poseKey: string; assetId: string; url: string }
|
||||
export type NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: PoseDto[]; inUse: boolean }
|
||||
export type MysterySummary = { id: string; slug: string; title: string; nodes: number }
|
||||
@@ -15,7 +16,7 @@ export type RuntimeUtterance = {
|
||||
}
|
||||
export type RuntimeNode = {
|
||||
id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string
|
||||
componentKey?: string | null; levelSlug?: string | null
|
||||
componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number
|
||||
utterances?: RuntimeUtterance[]; rootId?: string | null
|
||||
}
|
||||
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
||||
@@ -42,10 +43,15 @@ export function resolvePoseAssetId(
|
||||
|
||||
export interface NarrativeRepository {
|
||||
authorMystery(input: MysteryAuthoring): Promise<{ slug: string }>
|
||||
resolveDialogue(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }>
|
||||
createPlaythrough(userId: string, mysterySlug?: string): Promise<PlaythroughState | null>
|
||||
getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null>
|
||||
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||
listMysteries(): Promise<MysterySummary[]>
|
||||
deleteMystery(id: string): Promise<boolean>
|
||||
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
||||
listAssets(): Promise<AssetDto[]>
|
||||
deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
||||
listNpcs(): Promise<NpcDto[]>
|
||||
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null }): Promise<NpcDto>
|
||||
updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null }): Promise<NpcDto | null>
|
||||
@@ -54,7 +60,7 @@ export interface NarrativeRepository {
|
||||
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; music_volume: number }
|
||||
|
||||
export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository {
|
||||
// ---- Runtime: walking the story graph -------------------------------------
|
||||
@@ -91,11 +97,13 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
||||
}
|
||||
|
||||
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,music_volume FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
|
||||
if (!node) return null
|
||||
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key }
|
||||
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug }
|
||||
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, ...(await resolveDialogueGraph(node.id)) }
|
||||
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
|
||||
const musicVolume = node.music_volume / 100
|
||||
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
|
||||
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
|
||||
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) }
|
||||
return null // gates are auto-resolved during advance and never surfaced
|
||||
}
|
||||
|
||||
@@ -192,6 +200,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
||||
return { slug: input.slug }
|
||||
},
|
||||
|
||||
resolveDialogue(nodeId) { return resolveDialogueGraph(nodeId) },
|
||||
|
||||
async createPlaythrough(userId, mysterySlug) {
|
||||
const client = await pool.connect()
|
||||
let playthroughId: string
|
||||
@@ -259,6 +269,36 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
||||
return result.rows.map(row => ({ id: row.id, slug: row.slug, title: row.title, nodes: Number(row.nodes) }))
|
||||
},
|
||||
|
||||
async deleteMystery(id) {
|
||||
const result = await pool.query('DELETE FROM osint.mysteries WHERE id=$1', [id])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
},
|
||||
|
||||
async uploadAsset(file) {
|
||||
const id = await storeAsset(file)
|
||||
const row = (await pool.query<{ original_name: string; mime_type: string; byte_size: string }>(
|
||||
'SELECT original_name,mime_type,byte_size FROM osint.assets WHERE id=$1', [id])).rows[0]
|
||||
return { id, originalName: row.original_name, mimeType: row.mime_type, byteSize: Number(row.byte_size), url: `/api/assets/${id}` }
|
||||
},
|
||||
|
||||
async listAssets() {
|
||||
const result = await pool.query<{ id: string; original_name: string; mime_type: string; byte_size: string }>(
|
||||
'SELECT id,original_name,mime_type,byte_size FROM osint.assets ORDER BY created_at DESC')
|
||||
return result.rows.map(row => ({ id: row.id, originalName: row.original_name, mimeType: row.mime_type, byteSize: Number(row.byte_size), url: `/api/assets/${row.id}` }))
|
||||
},
|
||||
|
||||
async deleteAsset(id) {
|
||||
// document_exhibits.asset_id is ON DELETE RESTRICT, so an in-use asset raises a
|
||||
// foreign-key violation (23503) rather than deleting.
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM osint.assets WHERE id=$1', [id])
|
||||
return (result.rowCount ?? 0) > 0 ? 'deleted' : 'not_found'
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === '23503') return 'in_use'
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async listNpcs() {
|
||||
const npcs = await pool.query<{ id: string }>('SELECT id FROM osint.npcs WHERE mystery_id IS NULL ORDER BY name')
|
||||
return (await Promise.all(npcs.rows.map(row => loadNpc(row.id)))).filter((npc): npc is NpcDto => npc !== null)
|
||||
|
||||
@@ -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
|
||||
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 }>): 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 }>(
|
||||
'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]),
|
||||
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,
|
||||
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) || [],
|
||||
})),
|
||||
}
|
||||
@@ -117,6 +117,8 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
||||
if (input.hasUtterances !== undefined) set('has_utterances', input.hasUtterances)
|
||||
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
|
||||
|
||||
+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, runtimeNode.musicVolume)
|
||||
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>
|
||||
|
||||
+85
-10
@@ -13,7 +13,7 @@ async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
export function AdminPanel() {
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
|
||||
const [tab, setTab] = useState<'npcs' | 'mysteries'>('npcs')
|
||||
const [tab, setTab] = useState<'npcs' | 'mysteries' | 'assets'>('npcs')
|
||||
const [npcs, setNpcs] = useState<Npc[]>([])
|
||||
const [mysteries, setMysteries] = useState<Mystery[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
@@ -29,12 +29,13 @@ export function AdminPanel() {
|
||||
setNpcs(list)
|
||||
setSelectedId(current => selectKey ? (list.find(npc => npc.key === selectKey)?.id ?? current) : (current && list.some(npc => npc.id === current) ? current : list[0]?.id ?? null))
|
||||
}, [])
|
||||
const reloadMysteries = useCallback(() => json<Mystery[]>('/api/admin/mysteries').then(setMysteries).catch(() => {}), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return
|
||||
reloadNpcs().catch(error => setStatus(String(error.message || error)))
|
||||
json<Mystery[]>('/api/admin/mysteries').then(setMysteries).catch(() => {})
|
||||
}, [isAdmin, reloadNpcs])
|
||||
void reloadMysteries()
|
||||
}, [isAdmin, reloadNpcs, reloadMysteries])
|
||||
|
||||
if (isAdmin === null) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY · ADMIN</p><small>AUTHENTICATING…</small></div>
|
||||
if (!isAdmin) return <div className="boot"><div className="seal">GU</div><p>ADMINISTRATOR ACCESS REQUIRED</p><small><a className="admin-link" href="/">← RETURN TO TERMINAL</a></small></div>
|
||||
@@ -46,6 +47,7 @@ export function AdminPanel() {
|
||||
<nav className="admin-tabs">
|
||||
<button className={tab === 'npcs' ? 'active' : ''} onClick={() => setTab('npcs')}>NPCS</button>
|
||||
<button className={tab === 'mysteries' ? 'active' : ''} onClick={() => setTab('mysteries')}>MYSTERIES</button>
|
||||
<button className={tab === 'assets' ? 'active' : ''} onClick={() => setTab('assets')}>ASSETS</button>
|
||||
</nav>
|
||||
<a className="admin-link" href="/">← TERMINAL</a>
|
||||
</header>
|
||||
@@ -65,22 +67,95 @@ export function AdminPanel() {
|
||||
</div>}
|
||||
|
||||
{tab === 'mysteries' && (editingMystery
|
||||
? <MysteryGraphEditor mysteryId={editingMystery.id} title={editingMystery.title} onClose={() => setEditingMystery(null)} setStatus={setStatus} />
|
||||
? <MysteryGraphEditor mysteryId={editingMystery.id} title={editingMystery.title} onClose={() => { setEditingMystery(null); void reloadMysteries() }} setStatus={setStatus} />
|
||||
: <div className="admin-body">
|
||||
<div className="mystery-list">
|
||||
{mysteries.length === 0 && <p className="admin-empty">No mysteries authored yet.</p>}
|
||||
{mysteries.map(mystery => <button key={mystery.id} className="mystery-row" onClick={() => setEditingMystery({ id: mystery.id, title: mystery.title })}>
|
||||
<strong>{mystery.title}</strong>
|
||||
<span>{mystery.slug} · {mystery.nodes} node{mystery.nodes === 1 ? '' : 's'} · edit graph →</span>
|
||||
</button>)}
|
||||
<p className="admin-note">Click a mystery to open its story-flow graph editor.</p>
|
||||
<div className="mystery-list-head"><span>MYSTERIES</span><button onClick={() => newMystery(setStatus, reloadMysteries, setEditingMystery)}>+ NEW</button></div>
|
||||
{mysteries.length === 0 && <p className="admin-empty">No mysteries yet — create one to begin.</p>}
|
||||
{mysteries.map(mystery => <div key={mystery.id} className="mystery-row" onClick={() => setEditingMystery({ id: mystery.id, title: mystery.title })}>
|
||||
<div className="mystery-row-main"><strong>{mystery.title}</strong><span>{mystery.slug} · {mystery.nodes} node{mystery.nodes === 1 ? '' : 's'} · edit graph →</span></div>
|
||||
<button className="mystery-del" title="Delete mystery" onClick={event => { event.stopPropagation(); void deleteMystery(mystery, setStatus, reloadMysteries) }}>×</button>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{tab === 'assets' && <AssetStore setStatus={setStatus} />}
|
||||
|
||||
<footer className="admin-foot">{status || 'READY'}</footer>
|
||||
</div>
|
||||
}
|
||||
|
||||
function slugify(value: string) { return value.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') }
|
||||
function formatSize(bytes: number) { return bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(0)} KB` : `${(bytes / 1048576).toFixed(1)} MB` }
|
||||
|
||||
async function newMystery(setStatus: (m: string) => void, reload: () => Promise<void>, open: (m: { id: string; title: string }) => void) {
|
||||
const title = window.prompt('Mystery title (e.g. The Glass Harbour Diversion)')?.trim()
|
||||
if (!title) return
|
||||
const slug = window.prompt('URL slug', slugify(title))?.trim()
|
||||
if (!slug) return
|
||||
try {
|
||||
await json('/api/mysteries?edit=1', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slug, title }) })
|
||||
await reload()
|
||||
const created = (await json<Mystery[]>('/api/admin/mysteries')).find(m => m.slug === slugify(slug))
|
||||
if (created) open({ id: created.id, title: created.title })
|
||||
setStatus(`Created ${title}`)
|
||||
} catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
async function deleteMystery(mystery: Mystery, setStatus: (m: string) => void, reload: () => Promise<void>) {
|
||||
if (!window.confirm(`Delete mystery “${mystery.title}”? This removes its whole story graph.`)) return
|
||||
try { await json(`/api/admin/mysteries/${mystery.id}`, { method: 'DELETE' }); await reload(); setStatus(`Deleted ${mystery.title}`) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
|
||||
type Asset = { id: string; originalName: string; mimeType: string; byteSize: number; url: string }
|
||||
function AssetStore({ setStatus }: { setStatus: (m: string) => void }) {
|
||||
const [assets, setAssets] = useState<Asset[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const reload = useCallback(() => json<Asset[]>('/api/admin/assets').then(setAssets).catch(error => setStatus(String((error as Error).message || error))), [setStatus])
|
||||
useEffect(() => { void reload() }, [reload])
|
||||
|
||||
const upload = async (files: FileList) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
for (const file of Array.from(files)) { const form = new FormData(); form.append('file', file); await json('/api/admin/assets', { method: 'POST', body: form }) }
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
await reload(); setStatus(`Uploaded ${files.length} file${files.length === 1 ? '' : 's'}`)
|
||||
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
|
||||
}
|
||||
const remove = async (asset: Asset) => {
|
||||
if (!window.confirm(`Delete ${asset.originalName}?`)) return
|
||||
try { await json(`/api/admin/assets/${asset.id}`, { method: 'DELETE' }); await reload(); setStatus('Deleted') }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
const copy = (text: string) => { void navigator.clipboard?.writeText(text); setStatus(`Copied ${text}`) }
|
||||
|
||||
return <div className="admin-body">
|
||||
<div className="asset-store">
|
||||
<div className="mystery-list-head"><span>ASSET LIBRARY · images · audio · pdf</span>
|
||||
<label className="asset-upload-btn">{busy ? 'UPLOADING…' : '+ UPLOAD'}
|
||||
<input ref={fileRef} type="file" accept="image/*,audio/*,application/pdf" multiple onChange={event => { if (event.target.files?.length) void upload(event.target.files) }} />
|
||||
</label>
|
||||
</div>
|
||||
{assets.length === 0 && <p className="admin-empty">No assets yet. Upload images, audio, or PDFs.</p>}
|
||||
<div className="asset-grid">
|
||||
{assets.map(asset => <figure key={asset.id} className="asset-card">
|
||||
<div className="asset-thumb">{asset.mimeType.startsWith('image/')
|
||||
? <img src={asset.url} alt="" />
|
||||
: <span className="asset-icon">{asset.mimeType.startsWith('audio/') ? '♪' : asset.mimeType === 'application/pdf' ? 'PDF' : 'FILE'}</span>}</div>
|
||||
<figcaption title={asset.originalName}>{asset.originalName}</figcaption>
|
||||
<small>{formatSize(asset.byteSize)}</small>
|
||||
<div className="asset-actions">
|
||||
<button onClick={() => copy(asset.id)} title="Copy asset id">id</button>
|
||||
<button onClick={() => copy(asset.url)} title="Copy URL">url</button>
|
||||
<button className="asset-del" onClick={() => remove(asset)} title="Delete (blocked if in use)">×</button>
|
||||
</div>
|
||||
</figure>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
async function createNpc(setStatus: (message: string) => void, reload: (key?: string) => Promise<void>) {
|
||||
const name = window.prompt('NPC display name (e.g. Prof. Almira Vetch)')?.trim()
|
||||
if (!name) return
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// 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'
|
||||
let baseVolume = 0.5 // authored scene volume (0-1); mute overrides to 0
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let noiseBuffer: AudioBuffer | null = null
|
||||
function noise(context: AudioContext) {
|
||||
if (!noiseBuffer) {
|
||||
const length = Math.floor(context.sampleRate * 0.05)
|
||||
noiseBuffer = context.createBuffer(1, length, context.sampleRate)
|
||||
const data = noiseBuffer.getChannelData(0)
|
||||
for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1
|
||||
}
|
||||
return noiseBuffer
|
||||
}
|
||||
|
||||
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(baseVolume) }).catch(() => { pending = true })
|
||||
}
|
||||
|
||||
export const audio = {
|
||||
// A null url inherits whatever is already playing; a new url crossfades to it.
|
||||
// The same url with a new volume just adjusts the level (no restart).
|
||||
setMusic(url: string | null, volume?: number) {
|
||||
if (!music) return
|
||||
if (volume !== undefined) baseVolume = Math.max(0, Math.min(1, volume))
|
||||
if (url === currentUrl) { if (volume !== undefined && currentUrl && !muted) fade(baseVolume); 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) { if (music && music.paused) startMusic(); else fade(baseVolume) }
|
||||
return muted
|
||||
},
|
||||
isMuted() { return muted },
|
||||
sfx(kind: Sfx) {
|
||||
if (muted) return
|
||||
const context = audioContext()
|
||||
if (!context) return
|
||||
// Called from a click, so we can unlock the context right here if it's suspended.
|
||||
if (context.state === 'suspended') void context.resume()
|
||||
const osc = context.createOscillator(), gain = context.createGain()
|
||||
osc.type = 'triangle'
|
||||
const now = context.currentTime + 0.01
|
||||
const base = kind === 'choice' ? 500 : kind === 'sting' ? 260 : 420
|
||||
osc.frequency.setValueAtTime(base, now)
|
||||
if (kind === 'choice') osc.frequency.exponentialRampToValueAtTime(base * 1.5, now + 0.09) // a little up-chirp
|
||||
gain.gain.setValueAtTime(0.0001, now)
|
||||
gain.gain.exponentialRampToValueAtTime(0.16, now + 0.012)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.16)
|
||||
osc.connect(gain).connect(context.destination)
|
||||
osc.start(now); osc.stop(now + 0.18)
|
||||
},
|
||||
// A dry typewriter key-clack, for the text-reveal clatter. Short filtered noise
|
||||
// burst with slight pitch jitter so successive keys differ.
|
||||
type() {
|
||||
if (muted) return
|
||||
const context = audioContext()
|
||||
if (!context) return
|
||||
if (context.state === 'suspended') void context.resume()
|
||||
const src = context.createBufferSource(); src.buffer = noise(context)
|
||||
const filter = context.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 1500 + Math.random() * 900; filter.Q.value = 0.9
|
||||
const gain = context.createGain()
|
||||
const now = context.currentTime
|
||||
gain.gain.setValueAtTime(0.06, now)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.028)
|
||||
src.connect(filter).connect(gain).connect(context.destination)
|
||||
src.start(now); src.stop(now + 0.04)
|
||||
},
|
||||
// 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())
|
||||
+9
-2
@@ -1,6 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { StrictMode, Suspense, lazy } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>)
|
||||
// three.js is lazy-loaded so the board never pays for it up front.
|
||||
const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview })))
|
||||
|
||||
// Visual spike: /?phone=1 renders the handset standalone, isolated from the board.
|
||||
const root = new URLSearchParams(window.location.search).has('phone')
|
||||
? <Suspense fallback={null}><PhonePreview /></Suspense>
|
||||
: <App />
|
||||
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
|
||||
|
||||
+31
-6
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { UtteranceCanvas } from './utteranceCanvas'
|
||||
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; musicVolume: number; 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 }
|
||||
|
||||
@@ -25,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)
|
||||
@@ -36,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()
|
||||
@@ -139,12 +146,16 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
|
||||
</div>)}
|
||||
</div>
|
||||
</div>)}
|
||||
{selected && (selected.nodeType === 'dialogue' || selected.hasUtterances) &&
|
||||
<div className="graph-node-preview" style={{ left: selected.xpos + NODE_W + 28, top: selected.ypos }} onClick={event => event.stopPropagation()}>
|
||||
<DialoguePreview nodeId={selected.id} />
|
||||
</div>}
|
||||
</div>
|
||||
{graph.nodes.length === 0 && <div className="graph-empty">Empty graph — add a node to begin.</div>}
|
||||
</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)) } }}
|
||||
@@ -164,13 +175,14 @@ 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
|
||||
}) {
|
||||
const [label, setLabel] = useState(node.label)
|
||||
const [componentKey, setComponentKey] = useState(node.componentKey || '')
|
||||
const [volume, setVolume] = useState(node.musicVolume)
|
||||
const nodeName = (id: string | null) => id ? (graph.nodes.find(n => n.id === id)?.label || '—') : '— unwired —'
|
||||
const usesComponent = node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate'
|
||||
return <div className="inspector-body">
|
||||
@@ -181,9 +193,22 @@ function NodeInspector({ node, graph, templates, onPatch, onSetEntry, onDelete,
|
||||
<option value="">— choose —</option>
|
||||
{templates.map(t => <option key={t.versionId} value={t.versionId}>{t.name} (v{t.version})</option>)}
|
||||
</select></label>}
|
||||
{usesComponent && <label className="ins-field"><span>Component key</span><input value={componentKey} placeholder={node.nodeType === 'cutscene' ? 'glass-harbour-diversion' : 'det_gate_lvl_1'} onChange={e => setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} /></label>}
|
||||
{usesComponent && <label className="ins-field"><span>Component key{node.nodeType === 'cutscene' && componentKey && !CUTSCENE_COMPONENT_KEYS.includes(componentKey) && <b className="ins-warn"> · not registered</b>}</span>
|
||||
<input list={node.nodeType === 'cutscene' ? 'cutscene-components' : undefined} value={componentKey} placeholder={node.nodeType === 'cutscene' ? 'glass-harbour-diversion' : 'det_gate_lvl_1'} onChange={e => setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} />
|
||||
{node.nodeType === 'cutscene' && <datalist id="cutscene-components">{CUTSCENE_COMPONENT_KEYS.map(k => <option key={k} value={k} />)}</datalist>}
|
||||
</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>
|
||||
{node.musicAssetId && <label className="ins-field"><span>Volume {volume}%</span>
|
||||
<input type="range" min={0} max={100} value={volume} onChange={e => setVolume(Number(e.target.value))}
|
||||
onPointerUp={() => volume !== node.musicVolume && onPatch({ musicVolume: volume })} onBlur={() => volume !== node.musicVolume && onPatch({ musicVolume: volume })} />
|
||||
</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">
|
||||
|
||||
+40
-5
@@ -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; musicVolume?: number; 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 }
|
||||
|
||||
@@ -45,6 +46,7 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) =
|
||||
</div>
|
||||
)
|
||||
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
|
||||
export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
|
||||
|
||||
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
|
||||
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
|
||||
@@ -60,9 +62,11 @@ export function CutsceneHost({ componentKey, label, onComplete }: { componentKey
|
||||
|
||||
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
||||
// branch, follow a chosen option to the next line or out through its exit terminal.
|
||||
export function DialoguePlayer({ node, onExit }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void }) {
|
||||
export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; inline?: boolean; startId?: string | null }) {
|
||||
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||||
const [currentId, setCurrentId] = useState<string | null>(node.rootId)
|
||||
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
||||
// In preview, clicking an utterance card jumps the walk to that line.
|
||||
useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, node.rootId])
|
||||
const [charCount, setCharCount] = useState(0)
|
||||
const reduced = usePrefersReducedMotion()
|
||||
const current = currentId ? byId.get(currentId) ?? null : null
|
||||
@@ -80,7 +84,15 @@ export function DialoguePlayer({ node, onExit }: { node: { utterances: RuntimeUt
|
||||
return () => window.clearInterval(id)
|
||||
}, [currentId, fullText, reduced]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Typewriter clatter as characters are revealed (every other non-space char).
|
||||
useEffect(() => {
|
||||
if (inline || charCount === 0 || charCount > fullText.length) return
|
||||
const ch = fullText[charCount - 1]
|
||||
if (ch && ch !== ' ' && charCount % 2 === 0) audio.type()
|
||||
}, [charCount]) // 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)
|
||||
}
|
||||
@@ -90,18 +102,20 @@ export function DialoguePlayer({ node, onExit }: { node: { utterances: RuntimeUt
|
||||
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(() => {
|
||||
if (inline) return // preview advances by click only, so it never steals the editor's keys
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (!showChoices && (event.key === ' ' || event.key === 'Enter' || event.key === 'ArrowRight')) { event.preventDefault(); proceedRef.current() }
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [showChoices])
|
||||
}, [showChoices, inline])
|
||||
|
||||
if (!current) return null
|
||||
return <div className="dialogue" role="dialog" aria-label="Dialogue" onClick={() => { if (!showChoices) proceedRef.current() }}>
|
||||
return <div className={`dialogue${inline ? ' inline' : ''}`} role="dialog" aria-label="Dialogue" onClick={() => { if (!showChoices) proceedRef.current() }}>
|
||||
<div className="dialogue-portrait">{current.poseUrl && <img src={current.poseUrl} alt={current.speaker.name} />}</div>
|
||||
<div className="dialogue-scrim" aria-hidden />
|
||||
<div className="dialogue-box">
|
||||
@@ -115,3 +129,24 @@ export function DialoguePlayer({ node, onExit }: { node: { utterances: RuntimeUt
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// A live, scaled-down mini player for the editor — runs the real DialoguePlayer
|
||||
// against the same server resolver, so it reflects the current authored dialogue.
|
||||
export function DialoguePreview({ nodeId, startId, revision, onClose }: { nodeId: string; startId?: string | null; revision?: number; onClose?: () => void }) {
|
||||
const [tree, setTree] = useState<{ utterances: RuntimeUtterance[]; rootId: string | null } | null>(null)
|
||||
const [playKey, setPlayKey] = useState(0)
|
||||
useEffect(() => {
|
||||
fetch(`/api/admin/story-nodes/${nodeId}/dialogue`).then(response => response.ok ? response.json() : null).then(setTree).catch(() => setTree(null))
|
||||
}, [nodeId, revision])
|
||||
return <div className="dialogue-preview" onPointerDown={event => event.stopPropagation()}>
|
||||
<div className="dialogue-preview-bar"><span>preview</span>
|
||||
<button title="Restart" onClick={event => { event.stopPropagation(); setPlayKey(key => key + 1) }}>↻</button>
|
||||
{onClose && <button title="Close" onClick={event => { event.stopPropagation(); onClose() }}>×</button>}
|
||||
</div>
|
||||
<div className="dialogue-preview-stage">
|
||||
{tree?.rootId
|
||||
? <div className="dialogue-preview-scale"><DialoguePlayer key={playKey} inline node={tree} startId={startId} onExit={() => setPlayKey(key => key + 1)} /></div>
|
||||
: <div className="dialogue-preview-empty">no utterances yet</div>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
|
||||
// A diegetic 90s handset rendered as a small three.js scene inside a fixed 1:2
|
||||
// portrait stage. The 3D layer owns the blocky body, the flip-open animation, and
|
||||
// the pressable keys; the screen UI is real DOM positioned in PERCENT of the stage
|
||||
// (the ortho camera is head-on, so the screen face maps to a constant rectangle).
|
||||
//
|
||||
// SPIKE STATUS: the directory + flag checks below are local stubs. In the game the
|
||||
// phone is an always-available surface whose number->node directory and node-enable
|
||||
// flag requirements come from the story graph (see the "mobile" gate discussion).
|
||||
const SCREEN_RECT = { top: 9, left: 25, width: 50, height: 30 } // % of the stage
|
||||
|
||||
const CLOSED_ANGLE = 3.12 // hinge rotation.x when shut (~179°: lid folds over the keypad)
|
||||
const OPEN_ANGLE = 0 // lid stands up, coplanar with the keypad, facing camera
|
||||
|
||||
// ---- placeholder telephony audio (to be replaced by recorded assets) ----------
|
||||
let pctx: AudioContext | null = null
|
||||
function ac() {
|
||||
if (!pctx) { const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (AC) pctx = new AC() }
|
||||
if (pctx?.state === 'suspended') void pctx.resume()
|
||||
return pctx
|
||||
}
|
||||
function tone(freqs: number[], dur: number, when = 0, gain = 0.05) {
|
||||
const ctx = ac(); if (!ctx) return
|
||||
const t = ctx.currentTime + when
|
||||
const g = ctx.createGain()
|
||||
g.gain.setValueAtTime(0.0001, t)
|
||||
g.gain.exponentialRampToValueAtTime(gain, t + 0.01)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + dur)
|
||||
g.connect(ctx.destination)
|
||||
for (const f of freqs) { const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = f; o.connect(g); o.start(t); o.stop(t + dur + 0.02) }
|
||||
}
|
||||
const DTMF: Record<string, [number, number]> = {
|
||||
'1': [697, 1209], '2': [697, 1336], '3': [697, 1477], '4': [770, 1209], '5': [770, 1336], '6': [770, 1477],
|
||||
'7': [852, 1209], '8': [852, 1336], '9': [852, 1477], '*': [941, 1209], '0': [941, 1336], '#': [941, 1477],
|
||||
}
|
||||
const sfx = {
|
||||
key(k: string) { const d = DTMF[k]; if (d) tone(d, 0.12, 0, 0.06) },
|
||||
ring() { tone([440, 480], 0.9, 0, 0.04) },
|
||||
unobtainable() { tone([950], 0.28, 0, 0.06); tone([1400], 0.28, 0.33, 0.06); tone([1800], 0.28, 0.66, 0.06) }, // SIT-ish
|
||||
voicemail() { tone([1000], 0.5, 0, 0.05) },
|
||||
}
|
||||
|
||||
// ---- scene --------------------------------------------------------------------
|
||||
|
||||
type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] }
|
||||
|
||||
function buildPhone(): Built {
|
||||
const group = new THREE.Group()
|
||||
const keys: THREE.Mesh[] = []
|
||||
|
||||
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x181a1c, roughness: 0.85, metalness: 0.05, flatShading: true })
|
||||
const trimMat = new THREE.MeshStandardMaterial({ color: 0x101214, roughness: 0.9, flatShading: true })
|
||||
const glassMat = new THREE.MeshStandardMaterial({ color: 0x0a1206, emissive: 0x101d09, emissiveIntensity: 0.6, roughness: 0.4, flatShading: true })
|
||||
const keyMat = new THREE.MeshStandardMaterial({ color: 0x9fb23a, emissive: 0x8fa522, emissiveIntensity: 0.55, roughness: 0.55, flatShading: true })
|
||||
|
||||
const keypad = new THREE.Mesh(new THREE.BoxGeometry(0.66, 0.92, 0.16), bodyMat)
|
||||
keypad.position.set(0, -0.47, 0)
|
||||
keypad.castShadow = true
|
||||
keypad.receiveShadow = true
|
||||
group.add(keypad)
|
||||
|
||||
// Hinge at the top-FRONT edge (ahead of the ~0.145 button tops); the lid child
|
||||
// cancels the hinge z so OPEN (rotation 0) is coplanar and SCREEN_RECT holds.
|
||||
const hinge = new THREE.Group()
|
||||
hinge.position.set(0, 0, 0.10)
|
||||
group.add(hinge)
|
||||
|
||||
const lid = new THREE.Mesh(new THREE.BoxGeometry(0.66, 0.92, 0.10), bodyMat)
|
||||
lid.position.set(0, 0.47, -0.10)
|
||||
lid.castShadow = true
|
||||
lid.receiveShadow = true
|
||||
hinge.add(lid)
|
||||
|
||||
const bezel = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.66, 0.02), trimMat)
|
||||
bezel.position.set(0, 0.05, 0.052)
|
||||
lid.add(bezel)
|
||||
const glass = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.02), glassMat)
|
||||
glass.position.set(0, 0.05, 0.062)
|
||||
lid.add(glass)
|
||||
|
||||
// Chunky protruding keys (LOCAL to the keypad, whose body spans local y [-0.46,0.46]).
|
||||
const key = (w: number, h: number, x: number, y: number, id: string) => {
|
||||
const k = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.09), keyMat)
|
||||
k.position.set(x, y, 0.10)
|
||||
k.userData = { key: id, baseZ: 0.10 }
|
||||
k.castShadow = true
|
||||
keypad.add(k)
|
||||
keys.push(k)
|
||||
}
|
||||
key(0.16, 0.10, -0.20, 0.40, 'call') // left soft key
|
||||
key(0.16, 0.10, 0.20, 0.40, 'end') // right soft key
|
||||
key(0.20, 0.16, 0, 0.22, 'nav') // nav pad
|
||||
const rows = [0.04, -0.10, -0.24, -0.38]
|
||||
const cols = [-0.20, 0, 0.20]
|
||||
const digits = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['*', '0', '#']]
|
||||
rows.forEach((y, r) => cols.forEach((x, c) => key(0.15, 0.10, x, y, digits[r][c])))
|
||||
|
||||
// Hinge barrel: parented to the hinge group at its local origin, so it sits
|
||||
// EXACTLY on the rotation axis (the pivot passes through the cylinder centre).
|
||||
// A cylinder spinning about its own axis is invisible, so it stays put as the
|
||||
// lid swings. Pins stick out the sides to read as the pivot.
|
||||
const hingeMat = new THREE.MeshStandardMaterial({ color: 0x26292c, roughness: 0.5, metalness: 0.35, flatShading: true })
|
||||
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.055, 0.74, 12), hingeMat)
|
||||
barrel.rotation.z = Math.PI / 2 // lay the cylinder along X (the hinge axis)
|
||||
barrel.castShadow = true
|
||||
hinge.add(barrel)
|
||||
|
||||
// Chubby stub antenna on the top-right of the body, with a rounded cap.
|
||||
const antMat = new THREE.MeshStandardMaterial({ color: 0x2a2d30, roughness: 0.6, metalness: 0.2, flatShading: true })
|
||||
const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.042, 0.052, 0.22, 8), antMat)
|
||||
antenna.position.set(0.25, 0.10, -0.01)
|
||||
antenna.castShadow = true
|
||||
group.add(antenna)
|
||||
const tip = new THREE.Mesh(new THREE.SphereGeometry(0.055, 10, 8), antMat)
|
||||
tip.position.set(0.25, 0.24, -0.01)
|
||||
tip.castShadow = true
|
||||
group.add(tip)
|
||||
|
||||
return { group, hinge, keys }
|
||||
}
|
||||
|
||||
function PhoneDevice({ open, onKey }: { open: boolean; onKey: (k: string) => void }) {
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const targetRef = useRef(open ? 1 : 0)
|
||||
const onKeyRef = useRef(onKey)
|
||||
onKeyRef.current = onKey
|
||||
|
||||
// Open at once; on close, hold a beat so the screen fades out before the lid swings.
|
||||
useEffect(() => {
|
||||
if (open) { targetRef.current = 1; return }
|
||||
const t = setTimeout(() => { targetRef.current = 0 }, 170)
|
||||
return () => clearTimeout(t)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.shadowMap.enabled = true
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
||||
stage.appendChild(renderer.domElement)
|
||||
renderer.domElement.style.cssText = 'position:absolute;inset:0;width:100%;height:100%'
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.OrthographicCamera(-0.5, 0.5, 1, -1, 0.1, 100)
|
||||
camera.position.set(0, 0, 6)
|
||||
camera.lookAt(0, 0, 0)
|
||||
|
||||
scene.add(new THREE.AmbientLight(0x3a4030, 0.75))
|
||||
const keyLight = new THREE.DirectionalLight(0xfff4e0, 1.05)
|
||||
keyLight.position.set(1.4, 2.2, 2.4)
|
||||
keyLight.castShadow = true
|
||||
keyLight.shadow.mapSize.set(1024, 1024)
|
||||
const sc = keyLight.shadow.camera as THREE.OrthographicCamera
|
||||
sc.left = -1; sc.right = 1; sc.top = 1.3; sc.bottom = -1.3; sc.near = 0.1; sc.far = 12
|
||||
scene.add(keyLight)
|
||||
// Cool fill lifts the shadow side; warm rim from behind-top catches the top edges
|
||||
// so the closed clamshell reads as a 3D object rather than a flat slab.
|
||||
const fill = new THREE.DirectionalLight(0x8fa6c0, 0.4)
|
||||
fill.position.set(-1.8, 0.5, 2.0)
|
||||
scene.add(fill)
|
||||
const rim = new THREE.DirectionalLight(0xffd7a0, 0.55)
|
||||
rim.position.set(-0.3, 1.6, -2.6)
|
||||
scene.add(rim)
|
||||
const glow = new THREE.PointLight(0x9fd020, 0.5, 4)
|
||||
glow.position.set(0, -0.5, 0.9)
|
||||
scene.add(glow)
|
||||
|
||||
const { group, hinge, keys } = buildPhone()
|
||||
scene.add(group)
|
||||
|
||||
// Pressable keys via raycasting on the canvas.
|
||||
const ray = new THREE.Raycaster()
|
||||
const ndc = new THREE.Vector2()
|
||||
const pressedAt = new Map<THREE.Mesh, number>()
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
if (targetRef.current < 0.5) return // only when open
|
||||
const r = renderer.domElement.getBoundingClientRect()
|
||||
ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1)
|
||||
ray.setFromCamera(ndc, camera)
|
||||
const hit = ray.intersectObjects(keys, false)[0]
|
||||
if (hit) { const m = hit.object as THREE.Mesh; pressedAt.set(m, performance.now()); onKeyRef.current(m.userData.key) }
|
||||
}
|
||||
renderer.domElement.addEventListener('pointerdown', onPointer)
|
||||
|
||||
const resize = () => { const w = stage.clientWidth, h = stage.clientHeight; if (w && h) renderer.setSize(w, h, false) }
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
ro.observe(stage)
|
||||
|
||||
const R = 6
|
||||
let raf = 0
|
||||
let progress = 0
|
||||
const tick = () => {
|
||||
progress += (targetRef.current - progress) * 0.08
|
||||
hinge.rotation.x = CLOSED_ANGLE + (OPEN_ANGLE - CLOSED_ANGLE) * progress
|
||||
// Intro orbit: start angled (blocky form + seam visible) and rotate to exactly
|
||||
// head-on. Finishes by 80% open, so the DOM screen only ever appears aligned.
|
||||
const cam = Math.max(0, 1 - progress / 0.8)
|
||||
const az = 0.95 * cam, el = 0.28 * cam
|
||||
camera.position.set(Math.sin(az) * Math.cos(el) * R, Math.sin(el) * R, Math.cos(az) * Math.cos(el) * R)
|
||||
camera.lookAt(0, 0, 0)
|
||||
const now = performance.now()
|
||||
for (const k of keys) {
|
||||
const t0 = pressedAt.get(k)
|
||||
const base = k.userData.baseZ as number
|
||||
k.position.z = t0 ? base - 0.04 * Math.max(0, Math.sin(Math.min(1, (now - t0) / 130) * Math.PI)) : base
|
||||
}
|
||||
renderer.render(scene, camera)
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
tick()
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
renderer.domElement.removeEventListener('pointerdown', onPointer)
|
||||
renderer.dispose()
|
||||
renderer.domElement.remove()
|
||||
scene.traverse(obj => { if (obj instanceof THREE.Mesh) { obj.geometry.dispose(); (obj.material as THREE.Material).dispose() } })
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div ref={stageRef} className="phone-canvas" />
|
||||
}
|
||||
|
||||
// ---- directory (spike stub) ---------------------------------------------------
|
||||
// Real version: numbers are the phone node's terminals -> dialogue nodes; `requires`
|
||||
// are the target node's flag requirements, checked against the playthrough flags.
|
||||
type Contact = { name: string; requires: string[] }
|
||||
const DIRECTORY: Record<string, Contact> = {
|
||||
'55501': { name: 'Elias Board', requires: [] }, // always enabled -> connects
|
||||
'55502': { name: 'Voss Antiquities', requires: ['found_voss_number'] }, // enabled below -> connects
|
||||
'55503': { name: 'Preservation Soc.', requires: ['society_clearance'] }, // not enabled -> voicemail
|
||||
}
|
||||
const FLAGS = new Set<string>(['found_voss_number']) // toggle to demo connect vs. voicemail
|
||||
|
||||
type Mode = 'home' | 'dial' | 'calling' | 'unknown' | 'voicemail' | 'connected'
|
||||
|
||||
// ---- screen -------------------------------------------------------------------
|
||||
function PhoneScreen({ visible, mode, dialed, callee }: { visible: boolean; mode: Mode; dialed: string; callee: string }) {
|
||||
const hhmm = new Date().toTimeString().slice(0, 5)
|
||||
return <div className={`phone-screen${visible ? ' on' : ''}`}
|
||||
style={{ top: `${SCREEN_RECT.top}%`, left: `${SCREEN_RECT.left}%`, width: `${SCREEN_RECT.width}%`, height: `${SCREEN_RECT.height}%` }}>
|
||||
<div className="pscr-status"><span>▮▮▮</span><span>GU-NET</span><span>▚▚</span></div>
|
||||
{mode === 'home' && <>
|
||||
<div className="pscr-clock">{hhmm}</div>
|
||||
<div className="pscr-date">17 AUG</div>
|
||||
<div className="pscr-dir">55501 · 55502 · 55503</div>
|
||||
<div className="pscr-soft"><span>Menu</span><span>Names</span></div>
|
||||
</>}
|
||||
{mode === 'dial' && <>
|
||||
<div className="pscr-num">{dialed || '_'}</div>
|
||||
<div className="pscr-soft"><span>▸ Call</span><span>Clr ◂</span></div>
|
||||
</>}
|
||||
{mode === 'calling' && <><div className="pscr-big">CALLING<span className="pscr-dots" /></div><div className="pscr-num sm">{dialed}</div></>}
|
||||
{mode === 'unknown' && <><div className="pscr-big warn">NUMBER NOT</div><div className="pscr-big warn">IN SERVICE</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||
{mode === 'voicemail' && <><div className="pscr-big">VOICEMAIL</div><div className="pscr-callee">{callee}</div><div className="pscr-line">leave a message…</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||
{mode === 'connected' && <><div className="pscr-big ok">CONNECTED</div><div className="pscr-callee">{callee}</div><div className="pscr-line">[dialogue plays here]</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export function PhonePreview() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [screenOn, setScreenOn] = useState(false)
|
||||
const [mode, setMode] = useState<Mode>('home')
|
||||
const [dialed, setDialed] = useState('')
|
||||
const [callee, setCallee] = useState('')
|
||||
const callTimer = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { const t = setTimeout(() => setScreenOn(true), 560); return () => clearTimeout(t) }
|
||||
setScreenOn(false); setMode('home'); setDialed('')
|
||||
}, [open])
|
||||
|
||||
const resolveCall = (num: string) => {
|
||||
sfx.ring()
|
||||
setMode('calling')
|
||||
const contact = DIRECTORY[num]
|
||||
window.clearTimeout(callTimer.current)
|
||||
callTimer.current = window.setTimeout(() => {
|
||||
if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
|
||||
setCallee(contact.name)
|
||||
const enabled = contact.requires.every(f => FLAGS.has(f))
|
||||
if (enabled) setMode('connected')
|
||||
else { sfx.voicemail(); setMode('voicemail') }
|
||||
}, 950)
|
||||
}
|
||||
|
||||
const press = (k: string) => {
|
||||
// In a result screen, any key hangs up back to the dialer.
|
||||
if (mode === 'calling' || mode === 'unknown' || mode === 'voicemail' || mode === 'connected') {
|
||||
window.clearTimeout(callTimer.current); setMode(dialed ? 'dial' : 'home'); if (k === 'end') setDialed(''); return
|
||||
}
|
||||
if (k in DTMF) { sfx.key(k); setDialed(d => (d + k).slice(0, 14)); setMode('dial'); return }
|
||||
if (k === 'call' && dialed) { resolveCall(dialed); return }
|
||||
if (k === 'end') { setDialed(d => d.slice(0, -1)); if (dialed.length <= 1) setMode('home') }
|
||||
}
|
||||
|
||||
// Hardware keyboard convenience: digits, Enter = call, Backspace = delete.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key >= '0' && e.key <= '9') press(e.key)
|
||||
else if (e.key === '*' || e.key === '#') press(e.key)
|
||||
else if (e.key === 'Enter') press('call')
|
||||
else if (e.key === 'Backspace') { e.preventDefault(); press('end') }
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}) // re-bind each render so `press` closes over fresh state
|
||||
|
||||
return <div className="phone-backdrop">
|
||||
<div className="phone-stage">
|
||||
<PhoneDevice open={open} onKey={press} />
|
||||
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
||||
</div>
|
||||
<button className="phone-open-btn" onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
||||
<p className="phone-hint">spike — dial <code>55501</code> connects · <code>55503</code> voicemail · anything else unobtainable</p>
|
||||
</div>
|
||||
}
|
||||
@@ -548,3 +548,83 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
/* Utterance canvas vertical flow: input on top, output on the bottom */
|
||||
.uinput { top: -6px; bottom: auto; left: 50%; right: auto; transform: translateX(-50%); }
|
||||
.uport.flow { bottom: -7px; top: auto; left: 50%; right: auto; transform: translateX(-50%); }
|
||||
|
||||
/* Admin: mystery list actions + asset library */
|
||||
.mystery-list-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 0 10px; font: 600 9px IBM Plex Mono; letter-spacing: .18em; color: #78958d; }
|
||||
.mystery-list-head button, .asset-upload-btn { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 9px IBM Plex Mono; padding: 6px 12px; cursor: pointer; }
|
||||
.mystery-list-head button:hover, .asset-upload-btn:hover { background: #1c463a; }
|
||||
.asset-upload-btn { display: inline-flex; align-items: center; letter-spacing: .1em; }
|
||||
.asset-upload-btn input { display: none; }
|
||||
.mystery-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 12px 6px; border: 0; border-bottom: 1px solid #1c352e; background: none; text-align: left; cursor: pointer; }
|
||||
.mystery-row:hover { background: #12312a; }
|
||||
.mystery-row-main { flex: 1; display: grid; gap: 3px; min-width: 0; }
|
||||
.mystery-del { flex: 0 0 auto; background: none; border: 1px solid #3c5a52; color: #9bb0a9; width: 26px; height: 26px; font-size: 15px; cursor: pointer; }
|
||||
.mystery-del:hover { border-color: #d78a7f; color: #e5b3ab; background: #4a221d; }
|
||||
.ins-warn { color: #d78a7f; font-weight: 600; }
|
||||
.asset-store { padding: 0 24px 24px; overflow: auto; }
|
||||
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
|
||||
.asset-card { margin: 0; border: 1px solid #35544c; background: #0e2a24; display: flex; flex-direction: column; }
|
||||
.asset-thumb { aspect-ratio: 4 / 3; display: grid; place-items: center; overflow: hidden; background: #0a211d; }
|
||||
.asset-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.asset-icon { font: 600 20px IBM Plex Mono; color: #7f9a92; letter-spacing: .06em; }
|
||||
.asset-card figcaption { padding: 7px 9px 2px; font: 10px IBM Plex Mono; color: #cfe0d9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.asset-card small { padding: 0 9px 6px; font: 8px IBM Plex Mono; color: #7f9a92; }
|
||||
.asset-actions { display: flex; gap: 4px; padding: 0 8px 8px; margin-top: auto; }
|
||||
.asset-actions button { flex: 1; background: #143229; border: 1px solid #3c5a52; color: #9bb0a9; font: 8px IBM Plex Mono; padding: 5px; cursor: pointer; }
|
||||
.asset-actions button:hover { background: #1c463a; color: #e4e9e4; }
|
||||
.asset-actions .asset-del { flex: 0 0 26px; }
|
||||
.asset-actions .asset-del:hover { border-color: #d78a7f; color: #e5b3ab; background: #4a221d; }
|
||||
|
||||
/* Live dialogue preview (mini player, real DialoguePlayer scaled down) */
|
||||
.dialogue.inline { position: absolute; inset: 0; width: 100%; height: 100%; animation: none; }
|
||||
.dialogue-preview { width: 340px; background: #0a211d; border: 1px solid #40655b; box-shadow: 4px 6px 0 #04110e88; }
|
||||
.dialogue-preview-bar { display: flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; background: #0e2a24; border-bottom: 1px solid #24413a; font: 8px IBM Plex Mono; letter-spacing: .16em; color: #86a199; text-transform: uppercase; }
|
||||
.dialogue-preview-bar span { margin-right: auto; }
|
||||
.dialogue-preview-bar button { background: none; border: 0; color: #9bb0a9; cursor: pointer; font-size: 12px; line-height: 1; padding: 0 2px; }
|
||||
.dialogue-preview-bar button:hover { color: #e7b57e; }
|
||||
.dialogue-preview-stage { position: relative; width: 340px; height: 191px; overflow: hidden; cursor: pointer; background: #06140f; }
|
||||
.dialogue-preview-scale { position: absolute; top: 0; left: 0; width: 1020px; height: 573px; transform: scale(0.33333); transform-origin: top left; }
|
||||
.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; }
|
||||
|
||||
/* === 90s handset spike (src/phone.tsx) ============================ */
|
||||
.phone-backdrop { position: fixed; inset: 0; z-index: 500; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
|
||||
background: radial-gradient(120% 90% at 50% 30%, #10201c 0%, #060b0a 70%, #020403 100%); }
|
||||
/* Fixed 1:2 portrait stage. The 3D scene and the DOM screen both live here, so
|
||||
percentages resolve against the same box at every size. */
|
||||
.phone-stage { position: relative; height: min(88vh, 720px); aspect-ratio: 1 / 2; max-width: 92vw; }
|
||||
.phone-canvas { position: absolute; inset: 0; }
|
||||
/* The interactive screen, positioned in % of the stage over the 3D glass. */
|
||||
.phone-screen { position: absolute; box-sizing: border-box; padding: 4% 6%; overflow: hidden;
|
||||
background: linear-gradient(180deg, #12200b, #0a1607); color: #cdea6a; font-family: "DejaVu Sans Mono", ui-monospace, monospace;
|
||||
text-shadow: 0 0 6px #7fa02988; opacity: 0; transition: opacity .1s ease; pointer-events: none;
|
||||
box-shadow: inset 0 0 18px #0006, inset 0 0 0 1px #2c3d16; }
|
||||
.phone-screen.on { opacity: 1; pointer-events: auto; transition: opacity .28s ease .1s; }
|
||||
.phone-screen::after { content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||
background: repeating-linear-gradient(180deg, #0000 0 2px, #00000022 2px 3px); mix-blend-mode: multiply; }
|
||||
.pscr-status { display: flex; justify-content: space-between; font-size: 9px; letter-spacing: .5px; opacity: .85; }
|
||||
.pscr-clock { text-align: center; font-size: 34px; font-weight: 700; margin-top: 14%; letter-spacing: 2px; }
|
||||
.pscr-date { text-align: center; font-size: 11px; opacity: .8; letter-spacing: 3px; }
|
||||
.pscr-soft { position: absolute; left: 6%; right: 6%; bottom: 4%; display: flex; justify-content: space-between; font-size: 10px; opacity: .9; }
|
||||
.pscr-dir { text-align: center; font-size: 9px; opacity: .55; margin-top: 8%; letter-spacing: 1px; }
|
||||
.pscr-num { text-align: center; font-size: 22px; letter-spacing: 3px; margin-top: 16%; word-break: break-all; }
|
||||
.pscr-num.sm { font-size: 13px; margin-top: 4%; opacity: .8; }
|
||||
.pscr-big { text-align: center; font-size: 17px; letter-spacing: 1px; margin-top: 12%; font-weight: 700; }
|
||||
.pscr-big.warn { color: #e88a4a; text-shadow: 0 0 6px #e88a4a66; }
|
||||
.pscr-big.ok { color: #8fe86a; }
|
||||
.pscr-big:nth-of-type(3) { margin-top: 2%; }
|
||||
.pscr-callee { text-align: center; font-size: 12px; margin-top: 6%; opacity: .95; }
|
||||
.pscr-line { text-align: center; font-size: 10px; opacity: .7; margin-top: 3%; }
|
||||
.pscr-dots::after { content: "…"; animation: pscr-blink 1s steps(1) infinite; }
|
||||
@keyframes pscr-blink { 50% { opacity: .2; } }
|
||||
.phone-open-btn { border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace;
|
||||
letter-spacing: 2px; padding: 8px 22px; cursor: pointer; }
|
||||
.phone-open-btn:hover { border-color: #cdea6a; }
|
||||
.phone-hint { color: #5f7b73; font-family: ui-monospace, monospace; font-size: 11px; margin: 0; }
|
||||
.phone-hint code { color: #8fae4a; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
|
||||
import { DialoguePreview } from './narrative'
|
||||
|
||||
type Utterer = 'npc' | 'player'
|
||||
type Utterance = {
|
||||
@@ -34,8 +35,9 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat
|
||||
const cardRefs = useRef(new Map<string, HTMLDivElement>())
|
||||
const [heights, setHeights] = useState<Record<string, number>>({})
|
||||
|
||||
const [revision, setRevision] = useState(0)
|
||||
const reload = useCallback(async () => {
|
||||
try { setUtterances(await api<Utterance[]>(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')) }
|
||||
try { setUtterances(await api<Utterance[]>(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')); setRevision(r => r + 1) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}, [nodeId, setStatus])
|
||||
useEffect(() => { void reload(); api<Npc[]>('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {}) }, [reload])
|
||||
@@ -205,6 +207,9 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat
|
||||
</div>)}
|
||||
</div>
|
||||
{utterances.length === 0 && <div className="graph-empty">No utterances yet — add an NPC line or player choice.</div>}
|
||||
<div className="utterance-preview-dock" onPointerDown={event => event.stopPropagation()}>
|
||||
<DialoguePreview nodeId={nodeId} revision={revision} startId={selected ? (selected.utterer === 'player' ? selected.parentUtteranceId : selected.id) : null} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && <aside className="graph-inspector">
|
||||
|
||||
Reference in New Issue
Block a user