Spaces:
Running
Running
File size: 6,548 Bytes
414dc55 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | // Game store: reducer, context, responsive mode, and localStorage-backed tweaks.
// Mirrors the prototype's app.jsx store, but the case comes from the server and
// suspicion/verdict are server-authoritative (the client only displays them).
import { createContext } from 'preact'
import type { ComponentChildren } from 'preact'
import { useCallback, useContext, useEffect, useReducer, useState } from 'preact/hooks'
import type { PublicCase } from './types'
export type Screen =
| 'title' | 'story' | 'briefing' | 'board' | 'interro' | 'evidence'
| 'flashback' | 'timeline' | 'notes' | 'accuse' | 'verdict' | 'share' | 'boot'
export interface Line {
role: 'det' | 'sus'
text: string
ev?: string
}
export interface AccuseState {
suspect: string | null
motive: string | null
evidence: string[]
}
export interface GameState {
screen: Screen
payload: Record<string, unknown>
suspicion: Record<string, number>
interrogations: Record<string, Line[]>
usedQ: Record<string, string[]>
usedEv: Record<string, string[]>
pinned: string[]
accuse: AccuseState
startedAt: number
}
type Action =
| { type: 'NAV'; screen: Screen; payload?: Record<string, unknown> }
| { type: 'SUSP_SET'; sid: string; value: number }
| { type: 'ADD_LINE'; sid: string; line: Line }
| { type: 'USEQ'; sid: string; qid: string }
| { type: 'USEEV'; sid: string; ev: string }
| { type: 'PIN'; ev: string }
| { type: 'ACCUSE'; field: keyof AccuseState; value: unknown }
function reducer(s: GameState, a: Action): GameState {
switch (a.type) {
case 'NAV':
return { ...s, screen: a.screen, payload: a.payload || {} }
case 'SUSP_SET':
return { ...s, suspicion: { ...s.suspicion, [a.sid]: Math.max(0, Math.min(100, Math.round(a.value))) } }
case 'ADD_LINE':
return { ...s, interrogations: { ...s.interrogations, [a.sid]: [...(s.interrogations[a.sid] || []), a.line] } }
case 'USEQ':
return { ...s, usedQ: { ...s.usedQ, [a.sid]: [...(s.usedQ[a.sid] || []), a.qid] } }
case 'USEEV':
return { ...s, usedEv: { ...s.usedEv, [a.sid]: [...(s.usedEv[a.sid] || []), a.ev] } }
case 'PIN':
return { ...s, pinned: s.pinned.includes(a.ev) ? s.pinned : [...s.pinned, a.ev] }
case 'ACCUSE':
return { ...s, accuse: { ...s.accuse, [a.field]: a.value } }
default:
return s
}
}
function initialState(c: PublicCase, screen: Screen = 'title'): GameState {
const suspicion: Record<string, number> = {}
c.suspects.forEach((s) => {
suspicion[s.id] = s.baselineSuspicion
})
return {
screen,
payload: {},
suspicion,
interrogations: {},
usedQ: {},
usedEv: {},
pinned: [],
accuse: { suspect: null, motive: null, evidence: [] },
startedAt: Date.now(),
}
}
// ---- tweaks ----
export interface Tweaks {
palette: 'sodium' | 'harbor' | 'violet'
fonts: 'crisp' | 'terminal' | 'stamp'
fx: 'low' | 'med' | 'high'
mood: 'night' | 'day'
pixelScale: number
typeSpeed: number
rain: boolean
}
export const TWEAK_DEFAULTS: Tweaks = {
palette: 'sodium', fonts: 'crisp', fx: 'med', mood: 'night',
pixelScale: 1, typeSpeed: 18, rain: true,
}
const TWEAK_KEY = 'cz-tweaks'
export function useTweaks(): [Tweaks, <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void] {
const [t, setT] = useState<Tweaks>(() => {
try {
const raw = localStorage.getItem(TWEAK_KEY)
return raw ? { ...TWEAK_DEFAULTS, ...JSON.parse(raw) } : TWEAK_DEFAULTS
} catch {
return TWEAK_DEFAULTS
}
})
const setTweak = useCallback(<K extends keyof Tweaks>(k: K, v: Tweaks[K]) => {
setT((prev) => {
const next = { ...prev, [k]: v }
try {
localStorage.setItem(TWEAK_KEY, JSON.stringify(next))
} catch {
/* ignore */
}
return next
})
}, [])
return [t, setTweak]
}
// ---- responsive mode ----
export type Device = 'auto' | 'desktop' | 'mobile'
export function useMode(device: Device): 'desktop' | 'mobile' {
const [w, setW] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200)
useEffect(() => {
const f = () => setW(window.innerWidth)
window.addEventListener('resize', f)
return () => window.removeEventListener('resize', f)
}, [])
if (device === 'desktop') return 'desktop'
if (device === 'mobile') return 'mobile'
return w < 820 ? 'mobile' : 'desktop'
}
// ---- context ----
export interface Game {
state: GameState & { tweaks: Tweaks }
dispatch: (a: Action) => void
nav: (screen: Screen, payload?: Record<string, unknown>) => void
mode: 'desktop' | 'mobile'
runStats: () => [string, string][]
case: PublicCase
runId: string
setTweak: <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void
newCase: () => void // fetch a fresh case from the server and start playing it
loadCase: (id: string) => void // load a specific case by ID and jump straight into it
}
const GameCtx = createContext<Game | null>(null)
export const useGame = (): Game => useContext(GameCtx)!
interface ProviderProps {
case: PublicCase
runId: string
mode: 'desktop' | 'mobile'
tweaks: Tweaks
setTweak: <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void
initialScreen?: Screen
newCase: () => void
loadCase: (id: string) => void
children: ComponentChildren
}
export function GameProvider({ case: c, runId, mode, tweaks, setTweak, initialScreen = 'title', newCase, loadCase, children }: ProviderProps) {
const [state, dispatch] = useReducer(reducer, c, (cc) => initialState(cc, initialScreen))
const nav = useCallback((screen: Screen, payload?: Record<string, unknown>) => {
dispatch({ type: 'NAV', screen, payload })
const stage = document.querySelector('.app__view')
if (stage) stage.scrollTop = 0
}, [])
const runStats = useCallback((): [string, string][] => {
const ms = Date.now() - state.startedAt
const mm = String(Math.floor(ms / 60000)).padStart(2, '0')
const ss = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')
const grilled = Object.values(state.interrogations).filter((x) => x.length > 1).length
return [
['TIME', `${mm}:${ss}`],
['GRILLED', `${grilled}/${c.suspects.length}`],
['EXHIBITS', `${state.pinned.length}/${c.evidence.length}`],
]
}, [state, c])
const game: Game = {
state: { ...state, tweaks },
dispatch,
nav,
mode,
runStats,
case: c,
runId,
setTweak,
newCase,
loadCase,
}
window.__game = game
return <GameCtx.Provider value={game}>{children}</GameCtx.Provider>
}
|