Adding better routing
This commit is contained in:
+2
-1
@@ -96,7 +96,8 @@ export function App() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
||||
|
||||
const deepLinkLevel = params.get('level')
|
||||
const pathLevel = window.location.pathname.match(/^\/level\/(.+)$/)
|
||||
const deepLinkLevel = params.get('level') || (pathLevel ? decodeURIComponent(pathLevel[1]) : null)
|
||||
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
||||
const openLevel = async (slug: string) => {
|
||||
const data = await loadLevelBySlug(slug, editQuery)
|
||||
|
||||
+8
-3
@@ -1,13 +1,18 @@
|
||||
import { StrictMode, Suspense, lazy } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import { Play } from './play'
|
||||
import './styles.css'
|
||||
|
||||
// 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')
|
||||
// Routing: the bare root is the game's front door (splash + campaign); /level/:id,
|
||||
// /admin, and the legacy ?level= deep link open the board; ?phone=1 is the spike.
|
||||
const path = window.location.pathname
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const isBoard = path.startsWith('/level/') || path === '/admin' || search.has('level')
|
||||
const root = search.has('phone')
|
||||
? <Suspense fallback={null}><PhonePreview /></Suspense>
|
||||
: <App />
|
||||
: isBoard ? <App /> : <Play />
|
||||
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } from './narrative'
|
||||
|
||||
// The game's front door and campaign runtime. Shows the splash when there's no
|
||||
// active playthrough; otherwise walks the story graph — cutscene and dialogue
|
||||
// nodes render here, and when the graph reaches a LEVEL node it hands off to the
|
||||
// board route (/level/<clone id>), which App renders.
|
||||
export function Play() {
|
||||
const [state, setState] = useState<PlaythroughState | null>(null)
|
||||
const [splash, setSplash] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [status, setStatus] = useState('')
|
||||
|
||||
const apply = useCallback((next: PlaythroughState | null) => {
|
||||
if (!next) { setSplash(true); return }
|
||||
if (next.node?.kind === 'level' && next.node.levelSlug) { window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`); return }
|
||||
if (!next.node) { setState(null); setSplash(true); setStatus('CASE CLOSED — begin another'); return }
|
||||
setState(next); setSplash(false)
|
||||
}, [])
|
||||
|
||||
const ensurePlaythroughId = async (): Promise<string | null> => {
|
||||
const cur = await fetch('/api/playthroughs/current')
|
||||
if (cur.ok && cur.status !== 204) return (await cur.json())?.playthrough?.id ?? null
|
||||
const made = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) })
|
||||
return made.ok ? (await made.json())?.playthrough?.id ?? null : null
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
// /node/:id — dev teleport to a specific story node (level nodes -> the board).
|
||||
const nodeMatch = window.location.pathname.match(/^\/node\/(.+)$/)
|
||||
if (nodeMatch) {
|
||||
const id = await ensurePlaythroughId()
|
||||
if (cancelled || !id) { if (!cancelled) setStatus('NO PLAYTHROUGH'); return }
|
||||
const res = await fetch(`/api/playthroughs/${id}/goto`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nodeId: decodeURIComponent(nodeMatch[1]) }) })
|
||||
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
|
||||
return
|
||||
}
|
||||
const res = await fetch('/api/playthroughs/current')
|
||||
if (cancelled) return
|
||||
if (res.status === 204) { setSplash(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return }
|
||||
res.ok ? apply(await res.json()) : setSplash(true)
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [apply])
|
||||
|
||||
const newGame = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) })
|
||||
if (res.ok) apply(await res.json())
|
||||
else setStatus('NO MYSTERY AVAILABLE')
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const advance = async (terminalKey?: string) => {
|
||||
if (!state) return
|
||||
const res = await fetch(`/api/playthroughs/${state.playthrough.id}/advance`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ terminalKey }),
|
||||
})
|
||||
if (res.ok) apply(await res.json())
|
||||
}
|
||||
|
||||
if (splash) return <SplashScreen hasResume={false} busy={busy} status={status} onNewGame={newGame} onResume={() => {}} />
|
||||
|
||||
const node = state?.node
|
||||
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
|
||||
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
|
||||
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }} onExit={terminalKey => { void advance(terminalKey) }} />
|
||||
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
||||
}
|
||||
Reference in New Issue
Block a user