File size: 6,019 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
// Root shell: loads the case from the server (or ?case=ID), then mounts the game.
import { useEffect, useState } from 'preact/hooks'

import { getCase, newCase } from './api'
import { RainFX, prefersReducedMotion } from './engine/pixel'
import { GameProvider, type Screen, useGame, useMode, useTweaks } from './store'
import type { PublicCase } from './types'
import { SCREENS } from './screens'
import { TitleScreen } from './screens/cold'
import { Assistant } from './ui/assistant'
import { unlockAudioOnce } from './ui/audio'
import { Btn } from './ui/components'

const RAIN_SCREENS = new Set(['title', 'interro', 'briefing', 'verdict', 'flashback', 'story'])

function ScreenHost() {
  const g = useGame()
  const Screen = SCREENS[g.state.screen] || TitleScreen
  const t = g.state.tweaks
  const showRain = t.rain && t.fx !== 'low' && !prefersReducedMotion() && RAIN_SCREENS.has(g.state.screen)
  return (
    <div class="app__stage">
      <div class="app__frame">
        <Screen key={g.state.screen} />
        <div class="fx-layer fx-scanlines" />
        <div class="fx-layer fx-vignette" />
        <div class="fx-layer fx-flicker" />
        {showRain && (
          <div class="fx-layer">
            <RainFX density={t.fx === 'high' ? 130 : 80} />
          </div>
        )}
      </div>
      <Assistant />
    </div>
  )
}

function Loading() {
  const showRain = !prefersReducedMotion()
  return (
    <div class="app__stage">
      <div class="app__frame" style={{ position: 'relative', background: 'var(--ink-0)' }}>
        {showRain && (
          <div class="fx-layer">
            <RainFX density={80} />
          </div>
        )}
        <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(80% 70% at 50% 45%, transparent 35%, rgba(8,11,16,.85) 100%)' }} />
        <div class="screen-center" style={{ position: 'relative', zIndex: 2 }}>
          <div class="col center" style={{ gap: 14, textAlign: 'center' }}>
            <div class="t-label" style={{ letterSpacing: '.34em', color: 'var(--amber-2)' }}>CASE ZERO</div>
            <div class="t-display" style={{ fontSize: 'clamp(18px,4vw,28px)', color: 'var(--bone-3)' }}>FORMING A CASE<span class="cursor" /></div>
            <div class="t-mono dim" style={{ fontSize: 'calc(15px*var(--mono-scale))', maxWidth: 320 }}>The city, the body, the lies — coming together from the night wire.</div>
          </div>
        </div>
        <div class="fx-layer fx-scanlines" />
        <div class="fx-layer fx-vignette" />
      </div>
    </div>
  )
}

function ErrorView({ msg, retry }: { msg: string; retry: () => void }) {
  return (
    <div class="app__stage">
      <div class="app__frame">
        <div class="screen-center">
          <div class="panel panel--ox col center" style={{ gap: 14, maxWidth: 420, textAlign: 'center', padding: 24 }}>
            <div class="t-display ox" style={{ fontSize: 14 }}>THE WIRE WENT DEAD</div>
            <div class="t-body" style={{ color: 'var(--bone-2)' }}>{msg}</div>
            <Btn variant="amber" onClick={retry}>Try again</Btn>
          </div>
        </div>
      </div>
    </div>
  )
}

export function Root() {
  const [data, setData] = useState<{ case: PublicCase; runId: string } | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [startScreen, setStartScreen] = useState<Screen>('title')
  const mode = useMode('auto')  // always responsive to the real viewport
  const [tweaks, setTweak] = useTweaks()

  useEffect(() => {
    const r = document.documentElement
    r.setAttribute('data-palette', tweaks.palette)
    r.setAttribute('data-fonts', tweaks.fonts)
    r.setAttribute('data-fx', tweaks.fx)
    r.setAttribute('data-mood', tweaks.mood)
    window.__pxScale = tweaks.pixelScale
  }, [tweaks])

  useEffect(unlockAudioOnce, []) // grant audio playback on first tap (mobile autoplay policy)

  const load = () => {
    setError(null)
    setData(null)
    setStartScreen('title')
    const params = new URLSearchParams(window.location.search)
    const cid = params.get('case')
    const req = cid ? getCase(cid) : newCase()
    req.then((r) => setData({ case: r.case, runId: r.runId })).catch((e) => setError(e instanceof Error ? e.message : String(e)))
  }
  useEffect(load, [])

  // "Begin New Case" - always fetch a FRESH case from the server (never replay the loaded one)
  // and start playing it. A shared ?case= is cleared so a refresh won't snap back to it.
  const beginNewCase = () => {
    setError(null)
    setData(null)
    setStartScreen('story')
    const u = new URL(window.location.href)
    if (u.searchParams.has('case')) {
      u.searchParams.delete('case')
      window.history.replaceState({}, '', u.pathname + u.search)
    }
    newCase()
      .then((r) => setData({ case: r.case, runId: r.runId }))
      .catch((e) => setError(e instanceof Error ? e.message : String(e)))
  }

  // "Enter Case ID" - load that exact case (fresh run) and jump straight into playing it.
  const loadCaseById = (id: string) => {
    const cid = id.trim()
    if (!cid) return
    setError(null)
    setData(null)
    setStartScreen('story')
    const u = new URL(window.location.href)
    u.searchParams.set('case', cid)
    window.history.replaceState({}, '', u.pathname + u.search)
    getCase(cid)
      .then((r) => setData({ case: r.case, runId: r.runId }))
      .catch((e) => setError(e instanceof Error ? e.message : String(e)))
  }

  if (error) {
    return (
      <div class="app" data-mode={mode}>
        <ErrorView msg={error} retry={load} />
      </div>
    )
  }
  if (!data) {
    return (
      <div class="app" data-mode={mode}>
        <Loading />
      </div>
    )
  }
  return (
    <div class="app" data-mode={mode}>
      <GameProvider key={data.runId} case={data.case} runId={data.runId} mode={mode} tweaks={tweaks} setTweak={setTweak} initialScreen={startScreen} newCase={beginNewCase} loadCase={loadCaseById}>
        <ScreenHost />
      </GameProvider>
    </div>
  )
}