Spaces:
Running
Running
UI overhaul: mobile corkboard, Connect the Dots, nav and overflow fixes
#3
by aliabdelwahab - opened
- COMPLIANCE.md +108 -105
- src/case_zero/api/case_adapter.py +297 -264
- web/src/engine/art.ts +0 -0
- web/src/engine/draw.ts +77 -72
- web/src/engine/pixel.tsx +253 -253
- web/src/screens/board-threads.tsx +125 -0
- web/src/screens/board.tsx +277 -149
- web/src/screens/cold.tsx +368 -363
- web/src/screens/index.ts +27 -25
- web/src/screens/interro.tsx +194 -194
- web/src/screens/suspects.tsx +47 -0
- web/src/store.tsx +191 -206
- web/src/styles/layout.css +358 -307
- web/src/styles/pixel.css +276 -273
- web/src/ui/components.tsx +407 -409
- web/src/ui/tweaks.tsx +0 -76
COMPLIANCE.md
CHANGED
|
@@ -1,105 +1,108 @@
|
|
| 1 |
-
# Case Zero - Hackathon Compliance
|
| 2 |
-
|
| 3 |
-
Built for the **Build Small Hackathon** ("Small models, big adventure").
|
| 4 |
-
|
| 5 |
-
Case Zero is a **Gradio application**: the whole app is one `gradio.Server` (Gradio 6
|
| 6 |
-
"Server mode" - a FastAPI subclass launched through Gradio, with Gradio API endpoints
|
| 7 |
-
registered via `@server.api`). It is deployed as a **Hugging Face Space** on **CPU** (no
|
| 8 |
-
GPU). It ships via the Docker SDK purely so llama.cpp compiles on a stable base image - the
|
| 9 |
-
app itself is Gradio, served end to end by `gradio.Server`.
|
| 10 |
-
|
| 11 |
-
## Core requirements
|
| 12 |
-
|
| 13 |
-
| Requirement | Status |
|
| 14 |
-
|---|---|
|
| 15 |
-
| Total model params <= 32B | ✓ ~1.6B (see budget below) |
|
| 16 |
-
| Built in Gradio | ✓ one `gradio.Server`, with `@server.api` endpoints (`new_case`, `interrogate`) |
|
| 17 |
-
| Hosted as a Hugging Face Space | ✓ `build-small-hackathon/case0` (Docker SDK, `app_port: 7860`) |
|
| 18 |
-
| Demo video | ☐ to record (warmup -> interrogate -> present evidence -> alibi cracks -> accuse -> verdict) |
|
| 19 |
-
| Social-media post | ☐ to post |
|
| 20 |
-
|
| 21 |
-
## Parameter budget (<= 32B total)
|
| 22 |
-
|
| 23 |
-
Every model is open-weights and self-run. **No third-party AI service is ever called.**
|
| 24 |
-
|
| 25 |
-
| Component | Model | Open? | Params | Runs |
|
| 26 |
-
|---|---|---|---|---|
|
| 27 |
-
| Reasoning + dialogue (the whole game) | Qwen2.5-1.5B-Instruct (Q4_K_M GGUF) | Apache-2.0 | **1.5B** | in-process llama.cpp on CPU |
|
| 28 |
-
| Suspect voices | Supertonic (ONNX) | open | ~0.1B | local ONNX Runtime (CPU) |
|
| 29 |
-
| Portraits / scenes / props | Procedural canvas - no model | n/a | 0B | client-side |
|
| 30 |
-
| Music + SFX | Pre-made / procedural audio - no model | n/a | 0B | playback only |
|
| 31 |
-
| Embeddings / vector RAG | none | n/a | 0B | - |
|
| 32 |
-
|
| 33 |
-
**Total runtime parameters: ~1.6B** - far under 32B (and under 4B, eligible for the
|
| 34 |
-
**Tiny Titan** special award).
|
| 35 |
-
|
| 36 |
-
## Merit badges
|
| 37 |
-
|
| 38 |
-
### Earned by the build (verifiable on the Space)
|
| 39 |
-
|
| 40 |
-
- **Off the Grid** - *"No cloud APIs. The whole thing runs on the model in front of you."*
|
| 41 |
-
The LLM is in-process llama.cpp; the voices are a local ONNX model; the pixel art is
|
| 42 |
-
rendered client-side on canvas; the music is a bundled CC-BY track. The open weights are
|
| 43 |
-
baked into the Docker image at build time, so the running container makes **no AI network
|
| 44 |
-
calls at all**. Proof: `python scripts/net_audit.py` runs a full playthrough under a
|
| 45 |
-
socket guard and asserts **zero non-loopback connections**. ✓
|
| 46 |
-
- **Llama Champion** - *"Your model runs through the llama.cpp runtime."* The LLM runs
|
| 47 |
-
through `llama-cpp-python` (in-process, on the CPU) - no server, no GPU, no remote
|
| 48 |
-
endpoint. ✓
|
| 49 |
-
- **Off-Brand** - *"A custom frontend that pushes past the default Gradio look."* The front
|
| 50 |
-
end is **not** stock Gradio. It is a hand-built **pixel-art noir SPA (Preact + Vite,
|
| 51 |
-
TypeScript)** -
|
| 52 |
-
Pixelify Sans fonts, beveled 9-slice panels, inventory-slot evidence cards
|
| 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 |
-
- [x]
|
| 103 |
-
- [x]
|
| 104 |
-
- [
|
| 105 |
-
- [
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Case Zero - Hackathon Compliance
|
| 2 |
+
|
| 3 |
+
Built for the **Build Small Hackathon** ("Small models, big adventure").
|
| 4 |
+
|
| 5 |
+
Case Zero is a **Gradio application**: the whole app is one `gradio.Server` (Gradio 6
|
| 6 |
+
"Server mode" - a FastAPI subclass launched through Gradio, with Gradio API endpoints
|
| 7 |
+
registered via `@server.api`). It is deployed as a **Hugging Face Space** on **CPU** (no
|
| 8 |
+
GPU). It ships via the Docker SDK purely so llama.cpp compiles on a stable base image - the
|
| 9 |
+
app itself is Gradio, served end to end by `gradio.Server`.
|
| 10 |
+
|
| 11 |
+
## Core requirements
|
| 12 |
+
|
| 13 |
+
| Requirement | Status |
|
| 14 |
+
|---|---|
|
| 15 |
+
| Total model params <= 32B | ✓ ~1.6B (see budget below) |
|
| 16 |
+
| Built in Gradio | ✓ one `gradio.Server`, with `@server.api` endpoints (`new_case`, `interrogate`) |
|
| 17 |
+
| Hosted as a Hugging Face Space | ✓ `build-small-hackathon/case0` (Docker SDK, `app_port: 7860`) |
|
| 18 |
+
| Demo video | ☐ to record (warmup -> interrogate -> present evidence -> alibi cracks -> accuse -> verdict) |
|
| 19 |
+
| Social-media post | ☐ to post |
|
| 20 |
+
|
| 21 |
+
## Parameter budget (<= 32B total)
|
| 22 |
+
|
| 23 |
+
Every model is open-weights and self-run. **No third-party AI service is ever called.**
|
| 24 |
+
|
| 25 |
+
| Component | Model | Open? | Params | Runs |
|
| 26 |
+
|---|---|---|---|---|
|
| 27 |
+
| Reasoning + dialogue (the whole game) | Qwen2.5-1.5B-Instruct (Q4_K_M GGUF) | Apache-2.0 | **1.5B** | in-process llama.cpp on CPU |
|
| 28 |
+
| Suspect voices | Supertonic (ONNX) | open | ~0.1B | local ONNX Runtime (CPU) |
|
| 29 |
+
| Portraits / scenes / props | Procedural canvas - no model | n/a | 0B | client-side |
|
| 30 |
+
| Music + SFX | Pre-made / procedural audio - no model | n/a | 0B | playback only |
|
| 31 |
+
| Embeddings / vector RAG | none | n/a | 0B | - |
|
| 32 |
+
|
| 33 |
+
**Total runtime parameters: ~1.6B** - far under 32B (and under 4B, eligible for the
|
| 34 |
+
**Tiny Titan** special award).
|
| 35 |
+
|
| 36 |
+
## Merit badges
|
| 37 |
+
|
| 38 |
+
### Earned by the build (verifiable on the Space)
|
| 39 |
+
|
| 40 |
+
- **Off the Grid** - *"No cloud APIs. The whole thing runs on the model in front of you."*
|
| 41 |
+
The LLM is in-process llama.cpp; the voices are a local ONNX model; the pixel art is
|
| 42 |
+
rendered client-side on canvas; the music is a bundled CC-BY track. The open weights are
|
| 43 |
+
baked into the Docker image at build time, so the running container makes **no AI network
|
| 44 |
+
calls at all**. Proof: `python scripts/net_audit.py` runs a full playthrough under a
|
| 45 |
+
socket guard and asserts **zero non-loopback connections**. ✓
|
| 46 |
+
- **Llama Champion** - *"Your model runs through the llama.cpp runtime."* The LLM runs
|
| 47 |
+
through `llama-cpp-python` (in-process, on the CPU) - no server, no GPU, no remote
|
| 48 |
+
endpoint. ✓
|
| 49 |
+
- **Off-Brand** - *"A custom frontend that pushes past the default Gradio look."* The front
|
| 50 |
+
end is **not** stock Gradio. It is a hand-built **pixel-art noir SPA (Preact + Vite,
|
| 51 |
+
TypeScript)** - 13 screens, a custom pixel design system (self-hosted Silkscreen /
|
| 52 |
+
Pixelify Sans fonts, beveled 9-slice panels, inventory-slot evidence cards with 23
|
| 53 |
+
procedural pixel icons keyword-matched to each exhibit, a ruled-paper dossier with
|
| 54 |
+
page-flips), a draggable corkboard with a red-yarn **"Connect the Dots"** mode (tie
|
| 55 |
+
evidence together with sagging SVG threads, on desktop and on the scrollable mobile
|
| 56 |
+
wall), a live interrogation stage with a voiced suspect, procedural canvas art and rain
|
| 57 |
+
FX, and a full client audio layer. The built bundle is served as static files by the
|
| 58 |
+
same `gradio.Server` that exposes the `/api` routes - one process, no separate frontend
|
| 59 |
+
host. ✓
|
| 60 |
+
|
| 61 |
+
### Targeted / in progress
|
| 62 |
+
|
| 63 |
+
- **Field Notes** - *"Write a blog post or report about your project."*
|
| 64 |
+
[`docs/FIELD_NOTES.md`](docs/FIELD_NOTES.md), shipped with the Space.
|
| 65 |
+
- **Sharing is Caring** - *"You shared your agent trace on the Hub for everyone to learn
|
| 66 |
+
from."* Real traces (the exact prompts + raw completions of a full case generation, and
|
| 67 |
+
a live interrogation playthrough with server-authoritative suspicion) are produced by
|
| 68 |
+
`scripts/export_traces.py` and published as a Hub dataset (linked from the README).
|
| 69 |
+
- **Well-Tuned** - *"Your app uses a fine-tuned model you've published on Hugging Face."*
|
| 70 |
+
Not claimed - the game runs on stock Qwen2.5-1.5B. Would require fine-tuning and
|
| 71 |
+
publishing a model; out of scope for this submission.
|
| 72 |
+
|
| 73 |
+
## Content scope
|
| 74 |
+
|
| 75 |
+
Cases span **homicide, theft, fraud, blackmail, arson, and missing-person** mysteries.
|
| 76 |
+
Generation is structurally constrained (case-file language, physical evidence, no graphic
|
| 77 |
+
description) and a deterministic scrubber sanitizes model output. Sexual violence is
|
| 78 |
+
deliberately **not** a case type, keeping the Space comfortably inside the
|
| 79 |
+
[HF Content Guidelines](https://huggingface.co/content-guidelines) with no NFAA gating.
|
| 80 |
+
|
| 81 |
+
## Zero cloud AI APIs
|
| 82 |
+
|
| 83 |
+
- **No OpenAI, Anthropic, Google, ElevenLabs, Higgsfield, Midjourney, or any other hosted
|
| 84 |
+
AI API is ever called** - not for text, not for voice, not for images.
|
| 85 |
+
- The LLM is the in-process llama.cpp runtime. The voices are a local ONNX model. The pixel
|
| 86 |
+
art is procedural canvas. The music is a bundled CC-BY track.
|
| 87 |
+
- The open Qwen GGUF and Supertonic ONNX are **baked into the Docker image at build time**,
|
| 88 |
+
so the running container makes no AI network calls. `scripts/net_audit.py` proves zero
|
| 89 |
+
non-loopback connections during a full playthrough.
|
| 90 |
+
|
| 91 |
+
## Anti-cheat / fairness (why the game is solvable and the win is earned)
|
| 92 |
+
|
| 93 |
+
- The sealed solution (killer, true motive, key evidence) is **never sent to the client**
|
| 94 |
+
pre-verdict; it is read only inside `/api/run/{runId}/accuse`. Verified by anti-leak tests.
|
| 95 |
+
- Suspicion, evidence reactions, and the verdict are **server-authoritative** - the client
|
| 96 |
+
only displays them.
|
| 97 |
+
- Suspects **never confess**: the win is registered only when the player accuses correctly,
|
| 98 |
+
so the outcome is immune to prose (a jailbroken "just tell me who did it" earns nothing).
|
| 99 |
+
|
| 100 |
+
## Submission checklist
|
| 101 |
+
|
| 102 |
+
- [x] Gradio app on a Hugging Face Space (CPU)
|
| 103 |
+
- [x] <= 32B total params (~1.6B)
|
| 104 |
+
- [x] Open-weights, self-run models only - zero cloud AI APIs
|
| 105 |
+
- [x] Custom (non-default) UI - pixel-art Preact SPA via `gradio.Server`
|
| 106 |
+
- [x] Off the Grid proof (`scripts/net_audit.py`)
|
| 107 |
+
- [ ] Short demo video
|
| 108 |
+
- [ ] Social-media post
|
src/case_zero/api/case_adapter.py
CHANGED
|
@@ -1,264 +1,297 @@
|
|
| 1 |
-
"""Project a generated ``CaseFile`` into the PUBLIC wire view (the same contract the
|
| 2 |
-
golden case uses). Deterministic: it synthesizes the display surface from the case's
|
| 3 |
-
own fields. Nothing from the sealed solution (culprit, true motive id, breaker chain)
|
| 4 |
-
is exposed; the true motive's *text* appears only as one option among the decoys.
|
| 5 |
-
|
| 6 |
-
Generated evidence renders as paper exhibits (reveal_text); richer per-type payloads
|
| 7 |
-
(threads, keycard tables, voicemail) and authored tags/quotes are a later enhancement.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
from __future__ import annotations
|
| 11 |
-
|
| 12 |
-
import re
|
| 13 |
-
|
| 14 |
-
from ..generator.crime_profiles import CrimeProfile, profile_for
|
| 15 |
-
from ..schemas.case import CaseFile
|
| 16 |
-
from ..schemas.clue import Clue
|
| 17 |
-
from .public_view import (
|
| 18 |
-
FlashbackAccount,
|
| 19 |
-
PublicCase,
|
| 20 |
-
PublicEvidence,
|
| 21 |
-
PublicFlashback,
|
| 22 |
-
PublicMotive,
|
| 23 |
-
PublicSuspect,
|
| 24 |
-
PublicVictim,
|
| 25 |
-
StoryBeat,
|
| 26 |
-
SuggestedQuestion,
|
| 27 |
-
TimelineBeat,
|
| 28 |
-
)
|
| 29 |
-
from .questions import FIXED_QUESTIONS
|
| 30 |
-
|
| 31 |
-
_SCENES = ("skyline", "atrium", "interro", "seawall", "mezzanine", "desk")
|
| 32 |
-
_WEATHER = (
|
| 33 |
-
"Rain, and a wind off the water.", "Fog thick enough to lean on.",
|
| 34 |
-
"A dry cold that gets into the joints.", "Sleet against every window.",
|
| 35 |
-
)
|
| 36 |
-
_GREETS = (
|
| 37 |
-
"Detective. Let's get this over with.",
|
| 38 |
-
"I already told the others everything I know.",
|
| 39 |
-
"Ask what you came to ask.",
|
| 40 |
-
"I have nothing to hide, if that's what you think.",
|
| 41 |
-
)
|
| 42 |
-
_APPARENT = (
|
| 43 |
-
"Was close enough to the victim to have a reason - and a chance.",
|
| 44 |
-
"Had more to lose tonight than they're letting on.",
|
| 45 |
-
"Their story has a seam in it, if you pull the right thread.",
|
| 46 |
-
"Stood to gain from a death no one saw coming.",
|
| 47 |
-
)
|
| 48 |
-
_DECOY_MOTIVES = (
|
| 49 |
-
"To bury a debt that was about to surface.",
|
| 50 |
-
"Jealousy that had festered for years.",
|
| 51 |
-
"To keep a buried secret buried.",
|
| 52 |
-
"To settle an old score, finally.",
|
| 53 |
-
"Fear of being exposed and ruined.",
|
| 54 |
-
)
|
| 55 |
-
# Keyword-matched icons so a letter looks like
|
| 56 |
-
# never a phone icon on a
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
(re.compile(r"
|
| 61 |
-
(re.compile(r"
|
| 62 |
-
(re.compile(r"
|
| 63 |
-
(re.compile(r"
|
| 64 |
-
|
| 65 |
-
(re.compile(r"
|
| 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 |
-
return tuple(
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Project a generated ``CaseFile`` into the PUBLIC wire view (the same contract the
|
| 2 |
+
golden case uses). Deterministic: it synthesizes the display surface from the case's
|
| 3 |
+
own fields. Nothing from the sealed solution (culprit, true motive id, breaker chain)
|
| 4 |
+
is exposed; the true motive's *text* appears only as one option among the decoys.
|
| 5 |
+
|
| 6 |
+
Generated evidence renders as paper exhibits (reveal_text); richer per-type payloads
|
| 7 |
+
(threads, keycard tables, voicemail) and authored tags/quotes are a later enhancement.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import re
|
| 13 |
+
|
| 14 |
+
from ..generator.crime_profiles import CrimeProfile, profile_for
|
| 15 |
+
from ..schemas.case import CaseFile
|
| 16 |
+
from ..schemas.clue import Clue
|
| 17 |
+
from .public_view import (
|
| 18 |
+
FlashbackAccount,
|
| 19 |
+
PublicCase,
|
| 20 |
+
PublicEvidence,
|
| 21 |
+
PublicFlashback,
|
| 22 |
+
PublicMotive,
|
| 23 |
+
PublicSuspect,
|
| 24 |
+
PublicVictim,
|
| 25 |
+
StoryBeat,
|
| 26 |
+
SuggestedQuestion,
|
| 27 |
+
TimelineBeat,
|
| 28 |
+
)
|
| 29 |
+
from .questions import FIXED_QUESTIONS
|
| 30 |
+
|
| 31 |
+
_SCENES = ("skyline", "atrium", "interro", "seawall", "mezzanine", "desk")
|
| 32 |
+
_WEATHER = (
|
| 33 |
+
"Rain, and a wind off the water.", "Fog thick enough to lean on.",
|
| 34 |
+
"A dry cold that gets into the joints.", "Sleet against every window.",
|
| 35 |
+
)
|
| 36 |
+
_GREETS = (
|
| 37 |
+
"Detective. Let's get this over with.",
|
| 38 |
+
"I already told the others everything I know.",
|
| 39 |
+
"Ask what you came to ask.",
|
| 40 |
+
"I have nothing to hide, if that's what you think.",
|
| 41 |
+
)
|
| 42 |
+
_APPARENT = (
|
| 43 |
+
"Was close enough to the victim to have a reason - and a chance.",
|
| 44 |
+
"Had more to lose tonight than they're letting on.",
|
| 45 |
+
"Their story has a seam in it, if you pull the right thread.",
|
| 46 |
+
"Stood to gain from a death no one saw coming.",
|
| 47 |
+
)
|
| 48 |
+
_DECOY_MOTIVES = (
|
| 49 |
+
"To bury a debt that was about to surface.",
|
| 50 |
+
"Jealousy that had festered for years.",
|
| 51 |
+
"To keep a buried secret buried.",
|
| 52 |
+
"To settle an old score, finally.",
|
| 53 |
+
"Fear of being exposed and ruined.",
|
| 54 |
+
)
|
| 55 |
+
# Keyword-matched icons so a letter looks like an envelope, a tumbler like a glass and a
|
| 56 |
+
# bloodied letter opener like a blade - never a phone icon on a fingerprint. First match
|
| 57 |
+
# wins, so the order is deliberate: specific objects (knife before "letter", footprints
|
| 58 |
+
# before "prints", keycard before "key") come before the generic paper/photo buckets.
|
| 59 |
+
_ICON_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
|
| 60 |
+
(re.compile(r"cctv|camera|footage|surveillance|still\b", re.I), "cctv"),
|
| 61 |
+
(re.compile(r"photo|polaroid|snapshot|portrait|negative|film\b", re.I), "photoEv"),
|
| 62 |
+
(re.compile(r"knife|blade|dagger|razor|scissors?|shears|opener|stilett?o", re.I), "knife"),
|
| 63 |
+
(re.compile(r"voicemail|recording|tape\b|audio|cylinder|dictaphone|phonograph", re.I), "voicemail"),
|
| 64 |
+
(re.compile(r"phone|telephone|message|\bwire\b|\bcall\b|\btext\b", re.I), "phone"),
|
| 65 |
+
(re.compile(r"keycard|key card|access|badge|\bpass\b|swipe", re.I), "keycard"),
|
| 66 |
+
(re.compile(r"\bkey\b|\bkeys\b|keychain|keyring|latchkey|\block\b|padlock", re.I), "key"),
|
| 67 |
+
(re.compile(r"footprint|bootprint|\bboot\b|shoe|\bsole\b|tread|scuff|heel", re.I), "bootprint"),
|
| 68 |
+
(re.compile(r"fingerprint|thumbprint|handprint|prints?\b", re.I), "fingerprint"),
|
| 69 |
+
(re.compile(r"tumbler|glass\b|goblet|wine|whisky|whiskey|brandy|champagne|cocktail", re.I), "tumbler"),
|
| 70 |
+
(re.compile(r"bottle|vial|flask|perfume|poison|decanter|tonic|arsenic|cyanide|sedative", re.I), "bottle"),
|
| 71 |
+
(re.compile(r"match(es|box|book)?\b|lighter|accelerant|gasoline|petrol|kerosene|"
|
| 72 |
+
r"\bfuel\b|scorch|charred|\bburn(t|ed)?\b|ember|\bash\b|flame", re.I), "flame"),
|
| 73 |
+
(re.compile(r"cigar|cigarette|tobacco|ashtray", re.I), "cigarette"),
|
| 74 |
+
(re.compile(r"clock|\bwatch\b|timepiece|hourglass|pendulum", re.I), "clock"),
|
| 75 |
+
(re.compile(r"cufflink|jewell?|diamond|brooch|necklace|pendant|earring|locket|"
|
| 76 |
+
r"bracelet|\bgem\b|\bring\b|tiara|pearl|sapphire|ruby|emerald", re.I), "jewel"),
|
| 77 |
+
(re.compile(r"thread|fib(er|re)|cloth\b|fabric|handkerchief|scarf|glove|wool|silk|"
|
| 78 |
+
r"snag|button\b|lace\b", re.I), "thread"),
|
| 79 |
+
(re.compile(r"\bpen\b|\bnib\b|quill|fountain pen|inkwell|\bink\b", re.I), "pen"),
|
| 80 |
+
(re.compile(r"letter\b|envelope|telegram|postcard|\bnote\b|correspondence|\bmemo\b", re.I), "envelope"),
|
| 81 |
+
(re.compile(r"\bbook\b|journal|diary|ledger|notebook|manuscript|novel", re.I), "book"),
|
| 82 |
+
(re.compile(r"\bcash\b|banknote|money|wallet|billfold|currency|\bcoins?\b|bribe|payout", re.I), "cash"),
|
| 83 |
+
(re.compile(r"magnif|\blens\b|loupe", re.I), "magnifier"),
|
| 84 |
+
(re.compile(r"receipt|ticket|\bstub\b|paper|document|contract|deed|cheque|\bcheck\b|"
|
| 85 |
+
r"\bbill\b|pawn|invoice|agreement|statement|certificate|affidavit|\bwill\b|"
|
| 86 |
+
r"\bfile\b|\bform\b|register", re.I), "receipt"),
|
| 87 |
+
(re.compile(r"\bmap\b|compass|route|itinerary|timetable|schedule", re.I), "compass"),
|
| 88 |
+
)
|
| 89 |
+
# No keyword hit: fall back to how the clue is FOUND, which is always known - forensics
|
| 90 |
+
# read as a print, documents as paper, searches as a scene photo, testimony as a voice.
|
| 91 |
+
_METHOD_ICONS = {
|
| 92 |
+
"forensic": "fingerprint",
|
| 93 |
+
"document": "receipt",
|
| 94 |
+
"search": "photoEv",
|
| 95 |
+
"interrogation": "voicemail",
|
| 96 |
+
}
|
| 97 |
+
_ICONS = ("photoEv", "receipt", "compass", "envelope", "fingerprint")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _icon_for(name: str, reveal: str, idx: int, method: str = "") -> str:
|
| 101 |
+
# The NAME names the object itself, so it decides first; the reveal text only breaks
|
| 102 |
+
# a tie when the name says nothing (it describes context and loves false positives -
|
| 103 |
+
# a fingerprint whose reveal mentions "the blade" is still a fingerprint).
|
| 104 |
+
for hay in (name, reveal):
|
| 105 |
+
for rule, icon in _ICON_RULES:
|
| 106 |
+
if rule.search(hay):
|
| 107 |
+
return icon
|
| 108 |
+
return _METHOD_ICONS.get(method, _ICONS[idx % len(_ICONS)])
|
| 109 |
+
# Noir city names so CITY reads as an actual city, not the venue (the venue goes in SCENE).
|
| 110 |
+
_CITIES = (
|
| 111 |
+
"Graymoor", "Blackport", "Ashmoor", "Harrowgate", "Duskwater", "Coldhaven",
|
| 112 |
+
"Ravenreach", "Mortlake", "Greyharbor", "Thornehaven", "Mistport", "Hollowmere",
|
| 113 |
+
"Saltmarsh", "Ironhaven", "Blackwater", "Fellgate",
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _hash(s: str) -> int:
|
| 118 |
+
h = 0
|
| 119 |
+
for ch in s:
|
| 120 |
+
h = (h * 31 + ord(ch)) & 0x7FFFFFFF
|
| 121 |
+
return h
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _clock(minute: int) -> str:
|
| 125 |
+
h = (minute // 60) % 24
|
| 126 |
+
m = minute % 60
|
| 127 |
+
suffix = "AM" if h < 12 else "PM"
|
| 128 |
+
h12 = h % 12 or 12
|
| 129 |
+
return f"{h12}:{m:02d} {suffix}"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _loc_name(case: CaseFile, loc_id: str) -> str:
|
| 133 |
+
for loc in case.setting.locations:
|
| 134 |
+
if loc.loc_id == loc_id:
|
| 135 |
+
return loc.name
|
| 136 |
+
return loc_id
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
|
| 140 |
+
s = case.suspects[idx]
|
| 141 |
+
seed = _hash(s.sus_id)
|
| 142 |
+
role_word = (s.role.split()[-1] if s.role else "").strip(".,").upper()
|
| 143 |
+
tag = f"THE {role_word}" if role_word else "PERSON OF INTEREST"
|
| 144 |
+
quote = (s.persona_summary.split(".")[0] + ".") if s.persona_summary else "I've nothing to hide."
|
| 145 |
+
gender = "female" if ((s.visual.gender if s.visual else "") or "").lower().startswith("f") else "male"
|
| 146 |
+
return PublicSuspect(
|
| 147 |
+
id=s.sus_id,
|
| 148 |
+
name=s.name,
|
| 149 |
+
role=s.role,
|
| 150 |
+
age=30 + (seed % 35),
|
| 151 |
+
sprite=s.sus_id,
|
| 152 |
+
gender=gender,
|
| 153 |
+
tag=tag[:22],
|
| 154 |
+
baseline_suspicion=25 + (seed % 16),
|
| 155 |
+
motive=_APPARENT[idx % len(_APPARENT)],
|
| 156 |
+
alibi=s.stated_alibi.claim_text,
|
| 157 |
+
quote=quote,
|
| 158 |
+
greet=_GREETS[idx % len(_GREETS)],
|
| 159 |
+
suggested_questions=tuple(SuggestedQuestion(id=q, q=text) for q, text in FIXED_QUESTIONS),
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
|
| 164 |
+
at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
|
| 165 |
+
time = _clock(at) if at is not None else _clock(case.setting.murder_window.start_min + 7 * (idx + 1))
|
| 166 |
+
return PublicEvidence(
|
| 167 |
+
id=clue.clue_id,
|
| 168 |
+
name=clue.name.upper(),
|
| 169 |
+
type=clue.discovery_method.value.upper(),
|
| 170 |
+
icon=_icon_for(clue.name, clue.reveal_text, idx, clue.discovery_method.value),
|
| 171 |
+
time=time,
|
| 172 |
+
found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
|
| 173 |
+
summary=clue.reveal_text,
|
| 174 |
+
detail=clue.reveal_text,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _timeline(case: CaseFile, profile: CrimeProfile) -> tuple[TimelineBeat, ...]:
|
| 179 |
+
beats: list[TimelineBeat] = []
|
| 180 |
+
w = case.setting.murder_window
|
| 181 |
+
beats.append(TimelineBeat(time=_clock(w.start_min), label="The evening is under way; everyone is in the house.", locked=True))
|
| 182 |
+
culprit = case.culprit.sus_id
|
| 183 |
+
for i, clue in enumerate(case.clues):
|
| 184 |
+
at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
|
| 185 |
+
t = at if at is not None else w.start_min + 7 * (i + 1)
|
| 186 |
+
conflict = clue.contradicts_alibi_of == culprit
|
| 187 |
+
beats.append(TimelineBeat(time=_clock(t), label=clue.reveal_text[:80], ev=clue.clue_id, conflict=conflict))
|
| 188 |
+
incident = profile.timeline_line.format(
|
| 189 |
+
name=case.victim.name, instrument=case.weapon.name,
|
| 190 |
+
room=_loc_name(case, case.victim.found_at_loc_id),
|
| 191 |
+
)
|
| 192 |
+
beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=incident, locked=True))
|
| 193 |
+
beats.sort(key=lambda b: b.time)
|
| 194 |
+
return tuple(beats)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _flashback(case: CaseFile) -> PublicFlashback:
|
| 198 |
+
culprit = next((s for s in case.suspects if s.sus_id == case.culprit.sus_id), case.suspects[0])
|
| 199 |
+
claimed = _loc_name(case, case.culprit.alibi_lie.claimed_loc_id)
|
| 200 |
+
crime = _loc_name(case, case.victim.found_at_loc_id)
|
| 201 |
+
breakers = [c for c in case.clues if c.contradicts_alibi_of == culprit.sus_id][:3]
|
| 202 |
+
return PublicFlashback(
|
| 203 |
+
title="TWO ACCOUNTS",
|
| 204 |
+
a=FlashbackAccount(
|
| 205 |
+
who=f"{culprit.name} SAYS",
|
| 206 |
+
scene=claimed,
|
| 207 |
+
lines=(culprit.stated_alibi.claim_text, f"I was in {claimed} the whole time.", "I never went near it."),
|
| 208 |
+
flags=(),
|
| 209 |
+
),
|
| 210 |
+
b=FlashbackAccount(
|
| 211 |
+
who="THE EVIDENCE SAYS",
|
| 212 |
+
scene=crime,
|
| 213 |
+
lines=tuple(c.reveal_text[:90] for c in breakers) or ("The evidence places someone at the scene.",),
|
| 214 |
+
flags=tuple(range(len(breakers))),
|
| 215 |
+
),
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _motives(case: CaseFile, seed: int) -> tuple[PublicMotive, ...]:
|
| 220 |
+
true_text = case.culprit.true_motive.summary
|
| 221 |
+
decoys = [d for d in _DECOY_MOTIVES if d.lower() != true_text.lower()]
|
| 222 |
+
chosen = [PublicMotive(id="M1", text=true_text)]
|
| 223 |
+
for i in range(3):
|
| 224 |
+
chosen.append(PublicMotive(id=f"MD{i + 1}", text=decoys[(seed + i) % len(decoys)]))
|
| 225 |
+
# stable shuffle by seed so the true option isn't always first
|
| 226 |
+
rot = seed % len(chosen)
|
| 227 |
+
return tuple(chosen[rot:] + chosen[:rot])
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _story_beats(case: CaseFile, profile: CrimeProfile, scene: str) -> tuple[StoryBeat, ...]:
|
| 231 |
+
"""Each beat's backdrop is the place its text describes: the city for the call, the
|
| 232 |
+
REAL crime scene for the incident, another room of the same building for the cast,
|
| 233 |
+
and the detective's desk for the hand-off."""
|
| 234 |
+
v = case.victim
|
| 235 |
+
crime_loc = case.victim.found_at_loc_id
|
| 236 |
+
building = case.setting.name
|
| 237 |
+
other_room = next(
|
| 238 |
+
(loc.name for loc in case.setting.locations if loc.loc_id != crime_loc),
|
| 239 |
+
_loc_name(case, crime_loc),
|
| 240 |
+
)
|
| 241 |
+
return (
|
| 242 |
+
StoryBeat(scene="skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing),
|
| 243 |
+
StoryBeat(scene=scene, kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"),
|
| 244 |
+
StoryBeat(scene=f"{building} — {other_room}", kicker="THOSE WHO STAYED", title="Persons of interest",
|
| 245 |
+
text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
|
| 246 |
+
StoryBeat(scene="desk", kicker="YOUR CASE NOW", title="Detective",
|
| 247 |
+
text="One of them is lying to your face. Find the crack in the account and follow it down."),
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def casefile_to_public(case: CaseFile) -> PublicCase:
|
| 252 |
+
seed = case.seed
|
| 253 |
+
profile = profile_for(case.crime_kind)
|
| 254 |
+
tod = case.victim.time_of_death.start_min
|
| 255 |
+
building = case.setting.name
|
| 256 |
+
room = _loc_name(case, case.victim.found_at_loc_id)
|
| 257 |
+
scene = f"{building} — {room}" # building + room; the painter keys off the room
|
| 258 |
+
city = _CITIES[seed % len(_CITIES)]
|
| 259 |
+
return PublicCase(
|
| 260 |
+
id=case.case_id,
|
| 261 |
+
city=city,
|
| 262 |
+
district=building,
|
| 263 |
+
title=case.title,
|
| 264 |
+
tagline="Detective, your presence is required.",
|
| 265 |
+
weather=_WEATHER[seed % len(_WEATHER)],
|
| 266 |
+
victim=PublicVictim(name=case.victim.name, role=case.victim.role, age=40 + (seed % 30), sprite="victim", bio=case.briefing),
|
| 267 |
+
scene=scene,
|
| 268 |
+
tod=_clock(tod),
|
| 269 |
+
found=f"{profile.found_verb} {scene} at {_clock(case.victim.found_at_min)}.",
|
| 270 |
+
cause=case.victim.cause_of_death,
|
| 271 |
+
kind=profile.kind.value,
|
| 272 |
+
kind_label=profile.kind_label,
|
| 273 |
+
division=profile.division,
|
| 274 |
+
victim_status=profile.victim_status,
|
| 275 |
+
tod_label=profile.tod_label,
|
| 276 |
+
verdict=profile.verdict,
|
| 277 |
+
facts=(
|
| 278 |
+
("CITY", city),
|
| 279 |
+
("VICTIM", f"{case.victim.name}"),
|
| 280 |
+
("SCENE", scene),
|
| 281 |
+
(profile.tod_label, _clock(tod)),
|
| 282 |
+
("WHAT HAPPENED", case.victim.cause_of_death),
|
| 283 |
+
("VERDICT", profile.verdict),
|
| 284 |
+
),
|
| 285 |
+
boot_lines=(
|
| 286 |
+
"The phone drags you up out of half a sleep.",
|
| 287 |
+
f"{case.setting.name}. {profile.boot_line}",
|
| 288 |
+
f"{case.victim.name} - {case.victim.role}.",
|
| 289 |
+
"They're holding the scene. It's yours now, detective.",
|
| 290 |
+
),
|
| 291 |
+
story_beats=_story_beats(case, profile, scene),
|
| 292 |
+
suspects=tuple(_suspect_public(case, i) for i in range(len(case.suspects))),
|
| 293 |
+
evidence=tuple(_evidence_public(case, c, i) for i, c in enumerate(case.clues)),
|
| 294 |
+
timeline=_timeline(case, profile),
|
| 295 |
+
flashback=_flashback(case),
|
| 296 |
+
motives=_motives(case, seed),
|
| 297 |
+
)
|
web/src/engine/art.ts
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/engine/draw.ts
CHANGED
|
@@ -1,72 +1,77 @@
|
|
| 1 |
-
// Low-level pixel drawing helpers, shared by the canvas components and the procedural
|
| 2 |
-
// art. Ported verbatim from the prototype's pixel.jsx so rendering stays pixel-accurate.
|
| 3 |
-
|
| 4 |
-
export type Pal = Record<string, string> | null | undefined
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Low-level pixel drawing helpers, shared by the canvas components and the procedural
|
| 2 |
+
// art. Ported verbatim from the prototype's pixel.jsx so rendering stays pixel-accurate.
|
| 3 |
+
|
| 4 |
+
export type Pal = Record<string, string> | null | undefined
|
| 5 |
+
|
| 6 |
+
/** One sprite row: a string of single-char palette keys, or an array of cell values
|
| 7 |
+
* (full hex colors / multi-char keys). Indexing works the same on both. */
|
| 8 |
+
export type PixelRow = string | string[]
|
| 9 |
+
export type PixelMap = PixelRow[]
|
| 10 |
+
|
| 11 |
+
/** Draw a pixel map: rows of cells, each cell a key in `pal` or a literal color. */
|
| 12 |
+
export function drawMap(
|
| 13 |
+
ctx: CanvasRenderingContext2D,
|
| 14 |
+
map: PixelMap,
|
| 15 |
+
pal: Pal,
|
| 16 |
+
px: number,
|
| 17 |
+
): void {
|
| 18 |
+
for (let y = 0; y < map.length; y++) {
|
| 19 |
+
const row = map[y]
|
| 20 |
+
for (let x = 0; x < row.length; x++) {
|
| 21 |
+
const c = row[x]
|
| 22 |
+
if (c === '.' || c === ' ' || c == null) continue
|
| 23 |
+
const col = pal ? pal[c] || c : c
|
| 24 |
+
if (!col || col === '.') continue
|
| 25 |
+
ctx.fillStyle = col
|
| 26 |
+
ctx.fillRect(x * px, y * px, px, px)
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/** Ordered 4x4 Bayer matrix for dithering. */
|
| 32 |
+
export const BAYER4 = [
|
| 33 |
+
[0, 8, 2, 10],
|
| 34 |
+
[12, 4, 14, 6],
|
| 35 |
+
[3, 11, 1, 9],
|
| 36 |
+
[15, 7, 13, 5],
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
/** Dither between two colors over a region; `t` in 0..1 is the `near` coverage. */
|
| 40 |
+
export function dither(
|
| 41 |
+
ctx: CanvasRenderingContext2D,
|
| 42 |
+
x0: number,
|
| 43 |
+
y0: number,
|
| 44 |
+
w: number,
|
| 45 |
+
h: number,
|
| 46 |
+
near: string,
|
| 47 |
+
far: string,
|
| 48 |
+
t: number,
|
| 49 |
+
): void {
|
| 50 |
+
for (let y = 0; y < h; y++) {
|
| 51 |
+
for (let x = 0; x < w; x++) {
|
| 52 |
+
const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16
|
| 53 |
+
ctx.fillStyle = thr < t ? near : far
|
| 54 |
+
ctx.fillRect(x0 + x, y0 + y, 1, 1)
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/** Vertical dither gradient from colTop to colBottom across height h. */
|
| 60 |
+
export function ditherGrad(
|
| 61 |
+
ctx: CanvasRenderingContext2D,
|
| 62 |
+
x0: number,
|
| 63 |
+
y0: number,
|
| 64 |
+
w: number,
|
| 65 |
+
h: number,
|
| 66 |
+
colTop: string,
|
| 67 |
+
colBottom: string,
|
| 68 |
+
): void {
|
| 69 |
+
for (let y = 0; y < h; y++) {
|
| 70 |
+
const t = 1 - y / h
|
| 71 |
+
for (let x = 0; x < w; x++) {
|
| 72 |
+
const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16
|
| 73 |
+
ctx.fillStyle = thr < t ? colTop : colBottom
|
| 74 |
+
ctx.fillRect(x0 + x, y0 + y, 1, 1)
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
}
|
web/src/engine/pixel.tsx
CHANGED
|
@@ -1,253 +1,253 @@
|
|
| 1 |
-
// Pixel engine — crisp nearest-neighbor canvas rendering. Ported from prototype/js/pixel.jsx.
|
| 2 |
-
import { useEffect, useRef, useState } from 'preact/hooks'
|
| 3 |
-
import type { JSX } from 'preact'
|
| 4 |
-
|
| 5 |
-
import { type Pal, drawMap } from './draw'
|
| 6 |
-
|
| 7 |
-
export type ScenePainter = (
|
| 8 |
-
ctx: CanvasRenderingContext2D,
|
| 9 |
-
w: number,
|
| 10 |
-
h: number,
|
| 11 |
-
t: number,
|
| 12 |
-
) => void
|
| 13 |
-
|
| 14 |
-
interface PixelCanvasProps {
|
| 15 |
-
frames?:
|
| 16 |
-
map?:
|
| 17 |
-
pal?: Pal
|
| 18 |
-
px?: number
|
| 19 |
-
fps?: number
|
| 20 |
-
className?: string
|
| 21 |
-
style?: JSX.CSSProperties
|
| 22 |
-
playing?: boolean
|
| 23 |
-
onClick?: (e: MouseEvent) => void
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
/** A sprite, optionally animated across frames. */
|
| 27 |
-
export function PixelCanvas({
|
| 28 |
-
frames,
|
| 29 |
-
map,
|
| 30 |
-
pal,
|
| 31 |
-
px = 4,
|
| 32 |
-
fps = 4,
|
| 33 |
-
className,
|
| 34 |
-
style,
|
| 35 |
-
playing = true,
|
| 36 |
-
onClick,
|
| 37 |
-
}: PixelCanvasProps) {
|
| 38 |
-
const ref = useRef<HTMLCanvasElement>(null)
|
| 39 |
-
const allFrames = frames || [map as
|
| 40 |
-
const cols = allFrames[0][0].length
|
| 41 |
-
const rows = allFrames[0].length
|
| 42 |
-
const [f, setF] = useState(0)
|
| 43 |
-
|
| 44 |
-
useEffect(() => {
|
| 45 |
-
if (!playing || allFrames.length < 2) return
|
| 46 |
-
const id = setInterval(() => setF((v) => (v + 1) % allFrames.length), 1000 / fps)
|
| 47 |
-
return () => clearInterval(id)
|
| 48 |
-
}, [playing, allFrames.length, fps])
|
| 49 |
-
|
| 50 |
-
useEffect(() => {
|
| 51 |
-
const cv = ref.current
|
| 52 |
-
if (!cv) return
|
| 53 |
-
const ctx = cv.getContext('2d')!
|
| 54 |
-
ctx.imageSmoothingEnabled = false
|
| 55 |
-
ctx.clearRect(0, 0, cv.width, cv.height)
|
| 56 |
-
drawMap(ctx, allFrames[f], pal, px)
|
| 57 |
-
}, [f, px, pal, allFrames])
|
| 58 |
-
|
| 59 |
-
return (
|
| 60 |
-
<canvas
|
| 61 |
-
ref={ref}
|
| 62 |
-
width={cols * px}
|
| 63 |
-
height={rows * px}
|
| 64 |
-
onClick={onClick}
|
| 65 |
-
class={className}
|
| 66 |
-
style={{ imageRendering: 'pixelated', display: 'block', ...style }}
|
| 67 |
-
/>
|
| 68 |
-
)
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
interface SceneCanvasProps {
|
| 72 |
-
paint: ScenePainter
|
| 73 |
-
w?: number
|
| 74 |
-
h?: number
|
| 75 |
-
className?: string
|
| 76 |
-
style?: JSX.CSSProperties
|
| 77 |
-
deps?: unknown[]
|
| 78 |
-
anim?: boolean
|
| 79 |
-
full?: boolean
|
| 80 |
-
rain?: boolean
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
/** Procedural painter at low internal res. Static scenes paint once to an offscreen
|
| 84 |
-
* buffer; anim scenes blit the buffer + a cheap rain overlay so we never re-dither
|
| 85 |
-
* the whole canvas every frame. `full` forces a true per-frame repaint. */
|
| 86 |
-
export function SceneCanvas({
|
| 87 |
-
paint,
|
| 88 |
-
w = 240,
|
| 89 |
-
h = 135,
|
| 90 |
-
className,
|
| 91 |
-
style,
|
| 92 |
-
deps = [],
|
| 93 |
-
anim = false,
|
| 94 |
-
full = false,
|
| 95 |
-
rain = true,
|
| 96 |
-
}: SceneCanvasProps) {
|
| 97 |
-
const ref = useRef<HTMLCanvasElement>(null)
|
| 98 |
-
const bufRef = useRef<HTMLCanvasElement | null>(null)
|
| 99 |
-
const tRef = useRef(0)
|
| 100 |
-
useEffect(() => {
|
| 101 |
-
const cv = ref.current
|
| 102 |
-
if (!cv) return
|
| 103 |
-
const ctx = cv.getContext('2d')!
|
| 104 |
-
ctx.imageSmoothingEnabled = false
|
| 105 |
-
|
| 106 |
-
const buf = document.createElement('canvas')
|
| 107 |
-
buf.width = w
|
| 108 |
-
buf.height = h
|
| 109 |
-
const bctx = buf.getContext('2d')!
|
| 110 |
-
bctx.imageSmoothingEnabled = false
|
| 111 |
-
paint(bctx, w, h, 0)
|
| 112 |
-
bufRef.current = buf
|
| 113 |
-
|
| 114 |
-
let raf = 0
|
| 115 |
-
// always paint a first frame synchronously (rAF is paused in background iframes)
|
| 116 |
-
ctx.clearRect(0, 0, w, h)
|
| 117 |
-
ctx.drawImage(buf, 0, 0)
|
| 118 |
-
if (full) {
|
| 119 |
-
let last = 0
|
| 120 |
-
const loop = (ts: number) => {
|
| 121 |
-
if (ts - last > 110) {
|
| 122 |
-
last = ts
|
| 123 |
-
tRef.current += 1
|
| 124 |
-
ctx.clearRect(0, 0, w, h)
|
| 125 |
-
paint(ctx, w, h, tRef.current)
|
| 126 |
-
}
|
| 127 |
-
raf = requestAnimationFrame(loop)
|
| 128 |
-
}
|
| 129 |
-
raf = requestAnimationFrame(loop)
|
| 130 |
-
} else if (anim && rain) {
|
| 131 |
-
let last = 0
|
| 132 |
-
const loop = (ts: number) => {
|
| 133 |
-
if (ts - last > 70) {
|
| 134 |
-
last = ts
|
| 135 |
-
tRef.current += 1
|
| 136 |
-
ctx.clearRect(0, 0, w, h)
|
| 137 |
-
ctx.drawImage(buf, 0, 0)
|
| 138 |
-
ctx.fillStyle = 'rgba(176,196,206,0.26)'
|
| 139 |
-
const t = tRef.current
|
| 140 |
-
for (let i = 0; i < 36; i++) {
|
| 141 |
-
const x = (i * 41 + t * 5) % w
|
| 142 |
-
const y = (i * 57 + t * 9) % h
|
| 143 |
-
ctx.fillRect(Math.floor(x), Math.floor(y), 1, 3)
|
| 144 |
-
}
|
| 145 |
-
}
|
| 146 |
-
raf = requestAnimationFrame(loop)
|
| 147 |
-
}
|
| 148 |
-
raf = requestAnimationFrame(loop)
|
| 149 |
-
} else {
|
| 150 |
-
ctx.clearRect(0, 0, w, h)
|
| 151 |
-
ctx.drawImage(buf, 0, 0)
|
| 152 |
-
}
|
| 153 |
-
return () => cancelAnimationFrame(raf)
|
| 154 |
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 155 |
-
}, deps)
|
| 156 |
-
return (
|
| 157 |
-
<canvas
|
| 158 |
-
ref={ref}
|
| 159 |
-
width={w}
|
| 160 |
-
height={h}
|
| 161 |
-
class={className}
|
| 162 |
-
style={{ imageRendering: 'pixelated', display: 'block', width: '100%', height: '100%', ...style }}
|
| 163 |
-
/>
|
| 164 |
-
)
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
/** Full-screen pixel rain on a canvas. */
|
| 168 |
-
export function RainFX({ density = 90 }: { density?: number }) {
|
| 169 |
-
const ref = useRef<HTMLCanvasElement>(null)
|
| 170 |
-
useEffect(() => {
|
| 171 |
-
const cv = ref.current
|
| 172 |
-
if (!cv) return
|
| 173 |
-
const ctx = cv.getContext('2d')!
|
| 174 |
-
let W = 0
|
| 175 |
-
let H = 0
|
| 176 |
-
let drops: { x: number; y: number; v: number; len: number }[] = []
|
| 177 |
-
let raf = 0
|
| 178 |
-
const resize = () => {
|
| 179 |
-
W = cv.width = Math.ceil(window.innerWidth / 3)
|
| 180 |
-
H = cv.height = Math.ceil(window.innerHeight / 3)
|
| 181 |
-
cv.style.width = window.innerWidth + 'px'
|
| 182 |
-
cv.style.height = window.innerHeight + 'px'
|
| 183 |
-
drops = Array.from({ length: density }, () => ({
|
| 184 |
-
x: Math.random() * W,
|
| 185 |
-
y: Math.random() * H,
|
| 186 |
-
v: 2 + Math.random() * 3,
|
| 187 |
-
len: 3 + Math.random() * 5,
|
| 188 |
-
}))
|
| 189 |
-
}
|
| 190 |
-
resize()
|
| 191 |
-
window.addEventListener('resize', resize)
|
| 192 |
-
let last = 0
|
| 193 |
-
const loop = (ts: number) => {
|
| 194 |
-
if (ts - last > 33) {
|
| 195 |
-
last = ts
|
| 196 |
-
ctx.clearRect(0, 0, W, H)
|
| 197 |
-
ctx.fillStyle = 'rgba(180,200,210,0.30)'
|
| 198 |
-
for (const d of drops) {
|
| 199 |
-
ctx.fillRect(Math.floor(d.x), Math.floor(d.y), 1, Math.floor(d.len))
|
| 200 |
-
d.y += d.v
|
| 201 |
-
d.x += 0.4
|
| 202 |
-
if (d.y > H) {
|
| 203 |
-
d.y = -d.len
|
| 204 |
-
d.x = Math.random() * W
|
| 205 |
-
}
|
| 206 |
-
}
|
| 207 |
-
}
|
| 208 |
-
raf = requestAnimationFrame(loop)
|
| 209 |
-
}
|
| 210 |
-
raf = requestAnimationFrame(loop)
|
| 211 |
-
return () => {
|
| 212 |
-
cancelAnimationFrame(raf)
|
| 213 |
-
window.removeEventListener('resize', resize)
|
| 214 |
-
}
|
| 215 |
-
}, [density])
|
| 216 |
-
return <canvas ref={ref} class="fx-rain" style={{ position: 'fixed', inset: 0, imageRendering: 'pixelated' }} />
|
| 217 |
-
}
|
| 218 |
-
|
| 219 |
-
/** True if the user asked the OS to reduce motion. */
|
| 220 |
-
export function prefersReducedMotion(): boolean {
|
| 221 |
-
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
| 222 |
-
}
|
| 223 |
-
|
| 224 |
-
/** Typewriter hook: returns [visibleText, done]. Honors prefers-reduced-motion (instant). */
|
| 225 |
-
export function useTypewriter(text: string, speed = 28, on = true): [string, boolean] {
|
| 226 |
-
if (prefersReducedMotion()) on = false
|
| 227 |
-
const [out, setOut] = useState(on ? '' : text)
|
| 228 |
-
const [done, setDone] = useState(!on)
|
| 229 |
-
useEffect(() => {
|
| 230 |
-
if (!on) {
|
| 231 |
-
setOut(text)
|
| 232 |
-
setDone(true)
|
| 233 |
-
return
|
| 234 |
-
}
|
| 235 |
-
setOut('')
|
| 236 |
-
setDone(false)
|
| 237 |
-
if (!text) {
|
| 238 |
-
setDone(true)
|
| 239 |
-
return
|
| 240 |
-
}
|
| 241 |
-
let i = 0
|
| 242 |
-
const id = setInterval(() => {
|
| 243 |
-
i++
|
| 244 |
-
setOut(text.slice(0, i))
|
| 245 |
-
if (i >= text.length) {
|
| 246 |
-
clearInterval(id)
|
| 247 |
-
setDone(true)
|
| 248 |
-
}
|
| 249 |
-
}, speed)
|
| 250 |
-
return () => clearInterval(id)
|
| 251 |
-
}, [text, speed, on])
|
| 252 |
-
return [out, done]
|
| 253 |
-
}
|
|
|
|
| 1 |
+
// Pixel engine — crisp nearest-neighbor canvas rendering. Ported from prototype/js/pixel.jsx.
|
| 2 |
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
| 3 |
+
import type { JSX } from 'preact'
|
| 4 |
+
|
| 5 |
+
import { type Pal, type PixelMap, drawMap } from './draw'
|
| 6 |
+
|
| 7 |
+
export type ScenePainter = (
|
| 8 |
+
ctx: CanvasRenderingContext2D,
|
| 9 |
+
w: number,
|
| 10 |
+
h: number,
|
| 11 |
+
t: number,
|
| 12 |
+
) => void
|
| 13 |
+
|
| 14 |
+
interface PixelCanvasProps {
|
| 15 |
+
frames?: PixelMap[]
|
| 16 |
+
map?: PixelMap
|
| 17 |
+
pal?: Pal
|
| 18 |
+
px?: number
|
| 19 |
+
fps?: number
|
| 20 |
+
className?: string
|
| 21 |
+
style?: JSX.CSSProperties
|
| 22 |
+
playing?: boolean
|
| 23 |
+
onClick?: (e: MouseEvent) => void
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/** A sprite, optionally animated across frames. */
|
| 27 |
+
export function PixelCanvas({
|
| 28 |
+
frames,
|
| 29 |
+
map,
|
| 30 |
+
pal,
|
| 31 |
+
px = 4,
|
| 32 |
+
fps = 4,
|
| 33 |
+
className,
|
| 34 |
+
style,
|
| 35 |
+
playing = true,
|
| 36 |
+
onClick,
|
| 37 |
+
}: PixelCanvasProps) {
|
| 38 |
+
const ref = useRef<HTMLCanvasElement>(null)
|
| 39 |
+
const allFrames = frames || [map as PixelMap]
|
| 40 |
+
const cols = allFrames[0][0].length
|
| 41 |
+
const rows = allFrames[0].length
|
| 42 |
+
const [f, setF] = useState(0)
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
if (!playing || allFrames.length < 2) return
|
| 46 |
+
const id = setInterval(() => setF((v) => (v + 1) % allFrames.length), 1000 / fps)
|
| 47 |
+
return () => clearInterval(id)
|
| 48 |
+
}, [playing, allFrames.length, fps])
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
const cv = ref.current
|
| 52 |
+
if (!cv) return
|
| 53 |
+
const ctx = cv.getContext('2d')!
|
| 54 |
+
ctx.imageSmoothingEnabled = false
|
| 55 |
+
ctx.clearRect(0, 0, cv.width, cv.height)
|
| 56 |
+
drawMap(ctx, allFrames[f], pal, px)
|
| 57 |
+
}, [f, px, pal, allFrames])
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<canvas
|
| 61 |
+
ref={ref}
|
| 62 |
+
width={cols * px}
|
| 63 |
+
height={rows * px}
|
| 64 |
+
onClick={onClick}
|
| 65 |
+
class={className}
|
| 66 |
+
style={{ imageRendering: 'pixelated', display: 'block', ...style }}
|
| 67 |
+
/>
|
| 68 |
+
)
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
interface SceneCanvasProps {
|
| 72 |
+
paint: ScenePainter
|
| 73 |
+
w?: number
|
| 74 |
+
h?: number
|
| 75 |
+
className?: string
|
| 76 |
+
style?: JSX.CSSProperties
|
| 77 |
+
deps?: unknown[]
|
| 78 |
+
anim?: boolean
|
| 79 |
+
full?: boolean
|
| 80 |
+
rain?: boolean
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/** Procedural painter at low internal res. Static scenes paint once to an offscreen
|
| 84 |
+
* buffer; anim scenes blit the buffer + a cheap rain overlay so we never re-dither
|
| 85 |
+
* the whole canvas every frame. `full` forces a true per-frame repaint. */
|
| 86 |
+
export function SceneCanvas({
|
| 87 |
+
paint,
|
| 88 |
+
w = 240,
|
| 89 |
+
h = 135,
|
| 90 |
+
className,
|
| 91 |
+
style,
|
| 92 |
+
deps = [],
|
| 93 |
+
anim = false,
|
| 94 |
+
full = false,
|
| 95 |
+
rain = true,
|
| 96 |
+
}: SceneCanvasProps) {
|
| 97 |
+
const ref = useRef<HTMLCanvasElement>(null)
|
| 98 |
+
const bufRef = useRef<HTMLCanvasElement | null>(null)
|
| 99 |
+
const tRef = useRef(0)
|
| 100 |
+
useEffect(() => {
|
| 101 |
+
const cv = ref.current
|
| 102 |
+
if (!cv) return
|
| 103 |
+
const ctx = cv.getContext('2d')!
|
| 104 |
+
ctx.imageSmoothingEnabled = false
|
| 105 |
+
|
| 106 |
+
const buf = document.createElement('canvas')
|
| 107 |
+
buf.width = w
|
| 108 |
+
buf.height = h
|
| 109 |
+
const bctx = buf.getContext('2d')!
|
| 110 |
+
bctx.imageSmoothingEnabled = false
|
| 111 |
+
paint(bctx, w, h, 0)
|
| 112 |
+
bufRef.current = buf
|
| 113 |
+
|
| 114 |
+
let raf = 0
|
| 115 |
+
// always paint a first frame synchronously (rAF is paused in background iframes)
|
| 116 |
+
ctx.clearRect(0, 0, w, h)
|
| 117 |
+
ctx.drawImage(buf, 0, 0)
|
| 118 |
+
if (full) {
|
| 119 |
+
let last = 0
|
| 120 |
+
const loop = (ts: number) => {
|
| 121 |
+
if (ts - last > 110) {
|
| 122 |
+
last = ts
|
| 123 |
+
tRef.current += 1
|
| 124 |
+
ctx.clearRect(0, 0, w, h)
|
| 125 |
+
paint(ctx, w, h, tRef.current)
|
| 126 |
+
}
|
| 127 |
+
raf = requestAnimationFrame(loop)
|
| 128 |
+
}
|
| 129 |
+
raf = requestAnimationFrame(loop)
|
| 130 |
+
} else if (anim && rain) {
|
| 131 |
+
let last = 0
|
| 132 |
+
const loop = (ts: number) => {
|
| 133 |
+
if (ts - last > 70) {
|
| 134 |
+
last = ts
|
| 135 |
+
tRef.current += 1
|
| 136 |
+
ctx.clearRect(0, 0, w, h)
|
| 137 |
+
ctx.drawImage(buf, 0, 0)
|
| 138 |
+
ctx.fillStyle = 'rgba(176,196,206,0.26)'
|
| 139 |
+
const t = tRef.current
|
| 140 |
+
for (let i = 0; i < 36; i++) {
|
| 141 |
+
const x = (i * 41 + t * 5) % w
|
| 142 |
+
const y = (i * 57 + t * 9) % h
|
| 143 |
+
ctx.fillRect(Math.floor(x), Math.floor(y), 1, 3)
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
raf = requestAnimationFrame(loop)
|
| 147 |
+
}
|
| 148 |
+
raf = requestAnimationFrame(loop)
|
| 149 |
+
} else {
|
| 150 |
+
ctx.clearRect(0, 0, w, h)
|
| 151 |
+
ctx.drawImage(buf, 0, 0)
|
| 152 |
+
}
|
| 153 |
+
return () => cancelAnimationFrame(raf)
|
| 154 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 155 |
+
}, deps)
|
| 156 |
+
return (
|
| 157 |
+
<canvas
|
| 158 |
+
ref={ref}
|
| 159 |
+
width={w}
|
| 160 |
+
height={h}
|
| 161 |
+
class={className}
|
| 162 |
+
style={{ imageRendering: 'pixelated', display: 'block', width: '100%', height: '100%', ...style }}
|
| 163 |
+
/>
|
| 164 |
+
)
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/** Full-screen pixel rain on a canvas. */
|
| 168 |
+
export function RainFX({ density = 90 }: { density?: number }) {
|
| 169 |
+
const ref = useRef<HTMLCanvasElement>(null)
|
| 170 |
+
useEffect(() => {
|
| 171 |
+
const cv = ref.current
|
| 172 |
+
if (!cv) return
|
| 173 |
+
const ctx = cv.getContext('2d')!
|
| 174 |
+
let W = 0
|
| 175 |
+
let H = 0
|
| 176 |
+
let drops: { x: number; y: number; v: number; len: number }[] = []
|
| 177 |
+
let raf = 0
|
| 178 |
+
const resize = () => {
|
| 179 |
+
W = cv.width = Math.ceil(window.innerWidth / 3)
|
| 180 |
+
H = cv.height = Math.ceil(window.innerHeight / 3)
|
| 181 |
+
cv.style.width = window.innerWidth + 'px'
|
| 182 |
+
cv.style.height = window.innerHeight + 'px'
|
| 183 |
+
drops = Array.from({ length: density }, () => ({
|
| 184 |
+
x: Math.random() * W,
|
| 185 |
+
y: Math.random() * H,
|
| 186 |
+
v: 2 + Math.random() * 3,
|
| 187 |
+
len: 3 + Math.random() * 5,
|
| 188 |
+
}))
|
| 189 |
+
}
|
| 190 |
+
resize()
|
| 191 |
+
window.addEventListener('resize', resize)
|
| 192 |
+
let last = 0
|
| 193 |
+
const loop = (ts: number) => {
|
| 194 |
+
if (ts - last > 33) {
|
| 195 |
+
last = ts
|
| 196 |
+
ctx.clearRect(0, 0, W, H)
|
| 197 |
+
ctx.fillStyle = 'rgba(180,200,210,0.30)'
|
| 198 |
+
for (const d of drops) {
|
| 199 |
+
ctx.fillRect(Math.floor(d.x), Math.floor(d.y), 1, Math.floor(d.len))
|
| 200 |
+
d.y += d.v
|
| 201 |
+
d.x += 0.4
|
| 202 |
+
if (d.y > H) {
|
| 203 |
+
d.y = -d.len
|
| 204 |
+
d.x = Math.random() * W
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
raf = requestAnimationFrame(loop)
|
| 209 |
+
}
|
| 210 |
+
raf = requestAnimationFrame(loop)
|
| 211 |
+
return () => {
|
| 212 |
+
cancelAnimationFrame(raf)
|
| 213 |
+
window.removeEventListener('resize', resize)
|
| 214 |
+
}
|
| 215 |
+
}, [density])
|
| 216 |
+
return <canvas ref={ref} class="fx-rain" style={{ position: 'fixed', inset: 0, imageRendering: 'pixelated' }} />
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
/** True if the user asked the OS to reduce motion. */
|
| 220 |
+
export function prefersReducedMotion(): boolean {
|
| 221 |
+
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/** Typewriter hook: returns [visibleText, done]. Honors prefers-reduced-motion (instant). */
|
| 225 |
+
export function useTypewriter(text: string, speed = 28, on = true): [string, boolean] {
|
| 226 |
+
if (prefersReducedMotion()) on = false
|
| 227 |
+
const [out, setOut] = useState(on ? '' : text)
|
| 228 |
+
const [done, setDone] = useState(!on)
|
| 229 |
+
useEffect(() => {
|
| 230 |
+
if (!on) {
|
| 231 |
+
setOut(text)
|
| 232 |
+
setDone(true)
|
| 233 |
+
return
|
| 234 |
+
}
|
| 235 |
+
setOut('')
|
| 236 |
+
setDone(false)
|
| 237 |
+
if (!text) {
|
| 238 |
+
setDone(true)
|
| 239 |
+
return
|
| 240 |
+
}
|
| 241 |
+
let i = 0
|
| 242 |
+
const id = setInterval(() => {
|
| 243 |
+
i++
|
| 244 |
+
setOut(text.slice(0, i))
|
| 245 |
+
if (i >= text.length) {
|
| 246 |
+
clearInterval(id)
|
| 247 |
+
setDone(true)
|
| 248 |
+
}
|
| 249 |
+
}, speed)
|
| 250 |
+
return () => clearInterval(id)
|
| 251 |
+
}, [text, speed, on])
|
| 252 |
+
return [out, done]
|
| 253 |
+
}
|
web/src/screens/board-threads.tsx
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Red-yarn threads for the investigation board's "Connect the Dots" mode: pair state
|
| 2 |
+
// persisted per case in localStorage, plus the SVG layer that draws the yarn. (Named
|
| 3 |
+
// ThreadPair, not Thread — Evidence.thread already means phone messages in types.ts.)
|
| 4 |
+
import { useState } from 'preact/hooks'
|
| 5 |
+
|
| 6 |
+
export type ThreadPair = [string, string]
|
| 7 |
+
|
| 8 |
+
const keyFor = (caseId: string) => `cz-threads-${caseId}`
|
| 9 |
+
export const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`)
|
| 10 |
+
const canon = (a: string, b: string): ThreadPair => (a < b ? [a, b] : [b, a])
|
| 11 |
+
|
| 12 |
+
function loadPairs(caseId: string, valid: Set<string>): ThreadPair[] {
|
| 13 |
+
try {
|
| 14 |
+
const raw = localStorage.getItem(keyFor(caseId))
|
| 15 |
+
if (!raw) return []
|
| 16 |
+
const data = JSON.parse(raw) as { v?: number; pairs?: unknown }
|
| 17 |
+
if (data?.v !== 1 || !Array.isArray(data.pairs)) return []
|
| 18 |
+
const seen = new Set<string>()
|
| 19 |
+
const out: ThreadPair[] = []
|
| 20 |
+
for (const p of data.pairs) {
|
| 21 |
+
if (!Array.isArray(p) || typeof p[0] !== 'string' || typeof p[1] !== 'string') continue
|
| 22 |
+
const [a, b] = canon(p[0], p[1])
|
| 23 |
+
if (a === b || !valid.has(a) || !valid.has(b) || seen.has(pairKey(a, b))) continue
|
| 24 |
+
seen.add(pairKey(a, b))
|
| 25 |
+
out.push([a, b])
|
| 26 |
+
}
|
| 27 |
+
return out
|
| 28 |
+
} catch {
|
| 29 |
+
return []
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function savePairs(caseId: string, pairs: ThreadPair[]): void {
|
| 34 |
+
try {
|
| 35 |
+
localStorage.setItem(keyFor(caseId), JSON.stringify({ v: 1, pairs }))
|
| 36 |
+
} catch {
|
| 37 |
+
/* ignore */
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export interface Threads {
|
| 42 |
+
pairs: ThreadPair[]
|
| 43 |
+
/** Add the pair — or remove it if it already exists. */
|
| 44 |
+
toggle: (a: string, b: string) => 'added' | 'removed'
|
| 45 |
+
remove: (a: string, b: string) => void
|
| 46 |
+
clear: () => void
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/** Thread pairs for one case, restored from and written through to localStorage so the
|
| 50 |
+
* yarn survives screen switches (the board unmounts on nav) and full reloads. */
|
| 51 |
+
export function useThreads(caseId: string, validIds: string[]): Threads {
|
| 52 |
+
const [pairs, setPairs] = useState<ThreadPair[]>(() => loadPairs(caseId, new Set(validIds)))
|
| 53 |
+
const commit = (next: ThreadPair[]) => {
|
| 54 |
+
savePairs(caseId, next)
|
| 55 |
+
setPairs(next)
|
| 56 |
+
}
|
| 57 |
+
return {
|
| 58 |
+
pairs,
|
| 59 |
+
toggle: (a, b) => {
|
| 60 |
+
const k = pairKey(a, b)
|
| 61 |
+
const without = pairs.filter(([x, y]) => pairKey(x, y) !== k)
|
| 62 |
+
const removed = without.length < pairs.length
|
| 63 |
+
commit(removed ? without : [...pairs, canon(a, b)])
|
| 64 |
+
return removed ? 'removed' : 'added'
|
| 65 |
+
},
|
| 66 |
+
remove: (a, b) => {
|
| 67 |
+
const k = pairKey(a, b)
|
| 68 |
+
commit(pairs.filter(([x, y]) => pairKey(x, y) !== k))
|
| 69 |
+
},
|
| 70 |
+
clear: () => commit([]),
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/** Yarn path between two pushpins: a quadratic curve with a little gravity sag. */
|
| 75 |
+
export function threadD(x1: number, y1: number, x2: number, y2: number): string {
|
| 76 |
+
const dist = Math.hypot(x2 - x1, y2 - y1)
|
| 77 |
+
const sag = Math.min(48, 12 + dist * 0.14)
|
| 78 |
+
return `M ${x1} ${y1} Q ${(x1 + x2) / 2} ${(y1 + y2) / 2 + sag} ${x2} ${y2}`
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
interface ThreadLayerProps {
|
| 82 |
+
pairs: ThreadPair[]
|
| 83 |
+
/** Pushpin position for a card id, in board coordinates (null until laid out). */
|
| 84 |
+
anchor: (id: string) => { x: number; y: number } | null
|
| 85 |
+
w: number
|
| 86 |
+
h: number
|
| 87 |
+
/** Connect mode: threads grow an invisible fat hit-stroke so a tap can cut them. */
|
| 88 |
+
connect: boolean
|
| 89 |
+
/** Live dashed thread from the armed pin to the pointer (desktop juice). */
|
| 90 |
+
ghost?: { from: { x: number; y: number }; to: { x: number; y: number } } | null
|
| 91 |
+
onRemove: (a: string, b: string) => void
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export function ThreadLayer({ pairs, anchor, w, h, connect, ghost, onRemove }: ThreadLayerProps) {
|
| 95 |
+
return (
|
| 96 |
+
<svg class="thread-layer" viewBox={`0 0 ${w} ${h}`} width={w} height={h} aria-hidden="true">
|
| 97 |
+
{pairs.map(([a, b]) => {
|
| 98 |
+
const pa = anchor(a)
|
| 99 |
+
const pb = anchor(b)
|
| 100 |
+
if (!pa || !pb) return null
|
| 101 |
+
const d = threadD(pa.x, pa.y, pb.x, pb.y)
|
| 102 |
+
return (
|
| 103 |
+
// Keyed by pair so the draw-in animation runs once on creation, not on drags.
|
| 104 |
+
<g key={pairKey(a, b)}>
|
| 105 |
+
<path class="thread thread--under" d={d} pathLength={1} />
|
| 106 |
+
<path class="thread thread--draw" d={d} pathLength={1} />
|
| 107 |
+
{connect && (
|
| 108 |
+
<path
|
| 109 |
+
class="thread-hit"
|
| 110 |
+
d={d}
|
| 111 |
+
onClick={(e) => {
|
| 112 |
+
e.stopPropagation()
|
| 113 |
+
onRemove(a, b)
|
| 114 |
+
}}
|
| 115 |
+
/>
|
| 116 |
+
)}
|
| 117 |
+
<circle class="thread-knot" cx={pa.x} cy={pa.y} r={2.5} />
|
| 118 |
+
<circle class="thread-knot" cx={pb.x} cy={pb.y} r={2.5} />
|
| 119 |
+
</g>
|
| 120 |
+
)
|
| 121 |
+
})}
|
| 122 |
+
{ghost && <path class="thread thread--ghost" d={threadD(ghost.from.x, ghost.from.y, ghost.to.x, ghost.to.y)} />}
|
| 123 |
+
</svg>
|
| 124 |
+
)
|
| 125 |
+
}
|
web/src/screens/board.tsx
CHANGED
|
@@ -1,19 +1,26 @@
|
|
| 1 |
-
// Investigation board — corkboard with draggable evidence cards
|
| 2 |
-
//
|
| 3 |
-
//
|
|
|
|
| 4 |
import { useEffect, useRef, useState } from 'preact/hooks'
|
| 5 |
|
| 6 |
import { useGame } from '../store'
|
| 7 |
-
import type { PublicCase
|
| 8 |
-
import {
|
| 9 |
-
|
| 10 |
-
} from '.
|
| 11 |
|
| 12 |
-
const CARD_W = 150
|
| 13 |
const CARD_H = 92
|
|
|
|
|
|
|
| 14 |
const SPOTS: [number, number][] = [
|
| 15 |
[0.1, 0.3], [0.7, 0.16], [0.42, 0.44], [0.74, 0.52], [0.12, 0.72], [0.52, 0.78], [0.3, 0.28], [0.85, 0.36],
|
| 16 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
interface Node {
|
| 19 |
id: string
|
|
@@ -21,10 +28,11 @@ interface Node {
|
|
| 21 |
x: number
|
| 22 |
y: number
|
| 23 |
}
|
| 24 |
-
function boardNodes(c: PublicCase): Node[] {
|
| 25 |
-
const nodes: Node[] = [{ id: 'victim', kind: 'victim', x: 0.44, y: 0.05 }]
|
|
|
|
| 26 |
c.evidence.forEach((e, i) => {
|
| 27 |
-
const [x, y] =
|
| 28 |
nodes.push({ id: e.id, kind: 'evidence', x, y })
|
| 29 |
})
|
| 30 |
return nodes
|
|
@@ -32,16 +40,67 @@ function boardNodes(c: PublicCase): Node[] {
|
|
| 32 |
|
| 33 |
export function Board() {
|
| 34 |
const g = useGame()
|
| 35 |
-
if (g.mode === 'mobile') return <BoardMobile />
|
| 36 |
const c = g.case
|
| 37 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const ref = useRef<HTMLDivElement>(null)
|
| 39 |
-
const [size, setSize] = useState({ w:
|
| 40 |
const [pos, setPos] = useState<Record<string, { x: number; y: number }>>({})
|
| 41 |
const [selEv, setSelEv] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
| 42 |
const drag = useRef<{ id: string; dx: number; dy: number } | null>(null)
|
| 43 |
const dragCleanup = useRef<(() => void) | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
useEffect(() => {
|
| 46 |
const m = () => {
|
| 47 |
if (ref.current) {
|
|
@@ -52,32 +111,40 @@ export function Board() {
|
|
| 52 |
m()
|
| 53 |
window.addEventListener('resize', m)
|
| 54 |
return () => window.removeEventListener('resize', m)
|
| 55 |
-
}, [])
|
|
|
|
|
|
|
| 56 |
useEffect(() => {
|
| 57 |
if (size.w < 40) return
|
| 58 |
setPos((p) => {
|
| 59 |
if (Object.keys(p).length) return p
|
| 60 |
const np: Record<string, { x: number; y: number }> = {}
|
| 61 |
NODES.forEach((n) => {
|
| 62 |
-
np[n.id] = { x: n.x * (size.w -
|
| 63 |
})
|
| 64 |
return np
|
| 65 |
})
|
| 66 |
-
}, [size])
|
| 67 |
useEffect(() => () => dragCleanup.current?.(), [])
|
| 68 |
|
| 69 |
const onDown = (id: string, e: MouseEvent | TouchEvent) => {
|
|
|
|
| 70 |
e.preventDefault()
|
| 71 |
-
if (drag.current) return
|
| 72 |
const pt = 'touches' in e ? e.touches[0] : e
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
| 74 |
const move = (ev: MouseEvent | TouchEvent) => {
|
| 75 |
const d = drag.current
|
| 76 |
-
if (!d) return
|
|
|
|
| 77 |
window.__justDragged = true
|
|
|
|
| 78 |
const p = 'touches' in ev ? ev.touches[0] : ev
|
| 79 |
-
const nx = Math.max(0, Math.min(size.w -
|
| 80 |
-
const ny = Math.max(0, Math.min(size.h - CARD_H, p.clientY - d.dy))
|
| 81 |
setPos((o) => ({ ...o, [d.id]: { x: nx, y: ny } }))
|
| 82 |
}
|
| 83 |
const up = () => {
|
|
@@ -97,57 +164,153 @@ export function Board() {
|
|
| 97 |
window.addEventListener('touchend', up)
|
| 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 |
</div>
|
| 140 |
-
)
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
</div>
|
| 145 |
-
<
|
| 146 |
</div>
|
| 147 |
)
|
| 148 |
}
|
| 149 |
|
| 150 |
-
function WallItem({ x, y, rot, pin = 'amber', children, w }: { x: string; y: string; rot: number; pin?: string; children: preact.ComponentChildren; w?: number }) {
|
| 151 |
return (
|
| 152 |
<div class="wall-item" style={{ left: x, top: y, transform: `rotate(${rot}deg)`, width: w }}>
|
| 153 |
<span class={'pushpin pushpin--' + pin} />
|
|
@@ -156,11 +319,11 @@ function WallItem({ x, y, rot, pin = 'amber', children, w }: { x: string; y: str
|
|
| 156 |
)
|
| 157 |
}
|
| 158 |
|
| 159 |
-
function BoardDecor({ c }: { c: PublicCase }) {
|
| 160 |
const tod = c.facts.find(([k]) => k === 'TIME OF DEATH')?.[1] || c.tod
|
| 161 |
return (
|
| 162 |
<div style={{ position: 'absolute', inset: 0, zIndex: 2, pointerEvents: 'none' }}>
|
| 163 |
-
<WallItem x=
|
| 164 |
<div class="wall-clip">
|
| 165 |
<div style={{ fontFamily: 'var(--f-display)', fontSize: 9, lineHeight: 1.25, color: '#231f16' }}>{c.city} UNVEILS<br />ITS NEW CROWN</div>
|
| 166 |
<div style={{ height: 2, background: '#231f1633', margin: '5px 0' }} />
|
|
@@ -168,48 +331,56 @@ function BoardDecor({ c }: { c: PublicCase }) {
|
|
| 168 |
<div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#4a4234', lineHeight: 1.2 }}>{c.victim.name} found dead the night the city came to celebrate.</div>
|
| 169 |
</div>
|
| 170 |
</WallItem>
|
| 171 |
-
|
| 172 |
-
<
|
| 173 |
-
<div
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
| 178 |
<div class="wall-note scrawl" style={{ fontSize: 15 }}>{tod} —<br /><b>pushed?</b></div>
|
| 179 |
</WallItem>
|
| 180 |
-
|
| 181 |
-
<
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
| 184 |
<div class="wall-note scrawl" style={{ fontSize: 15 }}>MOTIVE = ?</div>
|
| 185 |
</WallItem>
|
| 186 |
-
|
| 187 |
-
<
|
| 188 |
-
<div class="
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
<div class="wall-photo">
|
| 207 |
<div style={{ background: '#0d1117', height: 72 }}><Scene name="mezzanine" w={126} h={72} style={{ width: '100%', height: '100%' }} /></div>
|
| 208 |
<div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 3 }}>SCENE — RAIL</div>
|
| 209 |
</div>
|
| 210 |
</WallItem>
|
| 211 |
-
<div class="wall-item" style={{ left: '44%', top: '70%', width: 64, height: 64, border: '5px solid rgba(60,40,20,.35)', borderRadius: '50%', boxShadow: 'inset 0 0 8px rgba(60,40,20,.25)' }} />
|
| 212 |
-
<div class="wall-item" style={{ left: '-6%', top: '88%', width: '46%', transform: 'rotate(-7deg)', zIndex: 8 }}>
|
| 213 |
<div style={{ background: 'var(--amber-2)', color: 'var(--ink-0)', padding: '5px 0', boxShadow: '0 3px 0 rgba(0,0,0,.4)', fontFamily: 'var(--f-display)', fontSize: 10, letterSpacing: '.3em', textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden' }}>
|
| 214 |
▪ POLICE LINE ▪ DO NOT CROSS ▪ POLICE LINE ▪
|
| 215 |
</div>
|
|
@@ -243,8 +414,8 @@ function SuspectRail() {
|
|
| 243 |
</div>
|
| 244 |
<div class="col grow" style={{ gap: 4, minWidth: 0 }}>
|
| 245 |
<div class="between" style={{ gap: 6 }}>
|
| 246 |
-
<span class="t-display nowrap" style={{ fontSize: 10, color: 'var(--bone-3)' }}>{s.name}</span>
|
| 247 |
-
<span class="t-mono" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: susp >= 65 ? 'var(--ox-3)' : 'var(--bone-1)' }}>{susp}%</span>
|
| 248 |
</div>
|
| 249 |
<div class="t-label">{s.tag}</div>
|
| 250 |
<SuspicionBar value={susp} compact label={null} />
|
|
@@ -266,13 +437,13 @@ function SuspectRail() {
|
|
| 266 |
)
|
| 267 |
}
|
| 268 |
|
| 269 |
-
function BoardNode({ node, selected, onSelect }: { node: Node; selected: boolean; onSelect: () => void }) {
|
| 270 |
const g = useGame()
|
| 271 |
const c = g.case
|
| 272 |
if (node.kind === 'evidence') {
|
| 273 |
const e = c.evidence.find((x) => x.id === node.id)!
|
| 274 |
return (
|
| 275 |
-
<div class="pin-note" style={{ width:
|
| 276 |
<div class="row" style={{ gap: 8, alignItems: 'center' }}>
|
| 277 |
<div style={{ background: 'var(--ink-1)', padding: 3 }}><EvIcon icon={e.icon} px={2} /></div>
|
| 278 |
<div class="col" style={{ gap: 2, minWidth: 0 }}>
|
|
@@ -284,7 +455,7 @@ function BoardNode({ node, selected, onSelect }: { node: Node; selected: boolean
|
|
| 284 |
)
|
| 285 |
}
|
| 286 |
return (
|
| 287 |
-
<Panel variant="ox" style={{ padding: 7, width:
|
| 288 |
<div class="row" style={{ gap: 8, alignItems: 'center' }}>
|
| 289 |
<div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 2, flexShrink: 0 }}>
|
| 290 |
<Portrait id={c.victim.sprite} px={3} />
|
|
@@ -297,46 +468,3 @@ function BoardNode({ node, selected, onSelect }: { node: Node; selected: boolean
|
|
| 297 |
</Panel>
|
| 298 |
)
|
| 299 |
}
|
| 300 |
-
|
| 301 |
-
function BoardMobile() {
|
| 302 |
-
const g = useGame()
|
| 303 |
-
const c = g.case
|
| 304 |
-
const [sel, setSel] = useState<string | null>(null)
|
| 305 |
-
return (
|
| 306 |
-
<div class="app__view">
|
| 307 |
-
<Hud title="THE BOARD" sub={c.id} right={<Btn sm variant="ghost" onClick={() => g.nav('accuse')}>Accuse</Btn>} />
|
| 308 |
-
<div class="screen-pad">
|
| 309 |
-
<div class="row between" style={{ marginBottom: 8 }}>
|
| 310 |
-
<span class="t-label" style={{ color: 'var(--amber-2)' }}>PERSONS OF INTEREST</span>
|
| 311 |
-
<span class="t-label dim">tap, then interrogate</span>
|
| 312 |
-
</div>
|
| 313 |
-
<div class="col" style={{ gap: 10 }}>
|
| 314 |
-
{c.suspects.map((s: Suspect) => {
|
| 315 |
-
const open = sel === s.id
|
| 316 |
-
return (
|
| 317 |
-
<div key={s.id} onClick={() => setSel(open ? null : s.id)}>
|
| 318 |
-
<SuspectCard s={s} active={open} />
|
| 319 |
-
{open && (
|
| 320 |
-
<div style={{ marginTop: -2 }}>
|
| 321 |
-
<Panel variant="amber" style={{ padding: 10, marginTop: 4 }}>
|
| 322 |
-
<div class="t-body" style={{ fontSize: 13, lineHeight: 1.4, marginBottom: 8 }}>{s.alibi}</div>
|
| 323 |
-
<Btn variant="ox" className="grow" style={{ width: '100%' }} onClick={(e) => { e.stopPropagation(); g.nav('interro', { suspect: s.id }) }}>▸ Interrogate {s.name.split(' ')[0]}</Btn>
|
| 324 |
-
</Panel>
|
| 325 |
-
</div>
|
| 326 |
-
)}
|
| 327 |
-
</div>
|
| 328 |
-
)
|
| 329 |
-
})}
|
| 330 |
-
</div>
|
| 331 |
-
<div class="row between" style={{ margin: '18px 0 8px' }}>
|
| 332 |
-
<span class="t-label">THE EVIDENCE WALL</span>
|
| 333 |
-
<span class="t-label dim">{c.evidence.length}</span>
|
| 334 |
-
</div>
|
| 335 |
-
<div class="col" style={{ gap: 8 }}>
|
| 336 |
-
{c.evidence.map((e) => <EvidenceCard key={e.id} e={e} onClick={() => g.nav('evidence', { focus: e.id })} />)}
|
| 337 |
-
</div>
|
| 338 |
-
</div>
|
| 339 |
-
<BottomNav />
|
| 340 |
-
</div>
|
| 341 |
-
)
|
| 342 |
-
}
|
|
|
|
| 1 |
+
// Investigation board — corkboard with draggable evidence cards, a red-yarn "Connect
|
| 2 |
+
// the Dots" mode, and a suspect rail on desktop. Mobile gets the same corkboard on a
|
| 3 |
+
// tall scrollable wall (suspects live in their own bottom-nav tab there). Node positions
|
| 4 |
+
// are derived from the case so any evidence count lays out cleanly.
|
| 5 |
import { useEffect, useRef, useState } from 'preact/hooks'
|
| 6 |
|
| 7 |
import { useGame } from '../store'
|
| 8 |
+
import type { PublicCase } from '../types'
|
| 9 |
+
import { playSfx } from '../ui/audio'
|
| 10 |
+
import { BottomNav, Btn, EvIcon, Hud, Panel, Portrait, Scene, SuspicionBar } from '../ui/components'
|
| 11 |
+
import { ThreadLayer, useThreads } from './board-threads'
|
| 12 |
|
|
|
|
| 13 |
const CARD_H = 92
|
| 14 |
+
const CARD_W = 150 // desktop
|
| 15 |
+
const CARD_W_M = 132 // mobile — two loose columns at 390px
|
| 16 |
const SPOTS: [number, number][] = [
|
| 17 |
[0.1, 0.3], [0.7, 0.16], [0.42, 0.44], [0.74, 0.52], [0.12, 0.72], [0.52, 0.78], [0.3, 0.28], [0.85, 0.36],
|
| 18 |
]
|
| 19 |
+
// Mobile wall: alternate two columns down a tall scrolling board.
|
| 20 |
+
const SPOTS_M: [number, number][] = [
|
| 21 |
+
[0.06, 0.1], [0.62, 0.17], [0.1, 0.27], [0.6, 0.35],
|
| 22 |
+
[0.08, 0.45], [0.58, 0.53], [0.12, 0.63], [0.6, 0.71],
|
| 23 |
+
]
|
| 24 |
|
| 25 |
interface Node {
|
| 26 |
id: string
|
|
|
|
| 28 |
x: number
|
| 29 |
y: number
|
| 30 |
}
|
| 31 |
+
function boardNodes(c: PublicCase, compact: boolean): Node[] {
|
| 32 |
+
const nodes: Node[] = [{ id: 'victim', kind: 'victim', x: compact ? 0.3 : 0.44, y: compact ? 0.025 : 0.05 }]
|
| 33 |
+
const spots = compact ? SPOTS_M : SPOTS
|
| 34 |
c.evidence.forEach((e, i) => {
|
| 35 |
+
const [x, y] = spots[i % spots.length]
|
| 36 |
nodes.push({ id: e.id, kind: 'evidence', x, y })
|
| 37 |
})
|
| 38 |
return nodes
|
|
|
|
| 40 |
|
| 41 |
export function Board() {
|
| 42 |
const g = useGame()
|
|
|
|
| 43 |
const c = g.case
|
| 44 |
+
const compact = g.mode === 'mobile'
|
| 45 |
+
const [connect, setConnect] = useState(false)
|
| 46 |
+
return (
|
| 47 |
+
<div class="app__view">
|
| 48 |
+
<Hud
|
| 49 |
+
title={compact ? 'THE BOARD' : 'INVESTIGATION BOARD'}
|
| 50 |
+
sub={connect ? 'CONNECT THE DOTS · TAP TWO PINS TO TIE A THREAD' : compact ? c.id : 'THE EVIDENCE WALL · DRAG TO ARRANGE'}
|
| 51 |
+
right={
|
| 52 |
+
compact ? (
|
| 53 |
+
<Btn sm variant="ghost" onClick={() => g.nav('accuse')}>Accuse</Btn>
|
| 54 |
+
) : (
|
| 55 |
+
<>
|
| 56 |
+
<Btn sm variant="ghost" onClick={() => g.nav('notes')}>Notes</Btn>
|
| 57 |
+
<Btn sm variant="ghost" onClick={() => g.nav('timeline')}>Timeline</Btn>
|
| 58 |
+
<Btn sm variant="ghost" onClick={() => g.nav('accuse')}>Accuse ▸</Btn>
|
| 59 |
+
</>
|
| 60 |
+
)
|
| 61 |
+
}
|
| 62 |
+
/>
|
| 63 |
+
{compact ? (
|
| 64 |
+
<Corkboard compact connect={connect} setConnect={setConnect} />
|
| 65 |
+
) : (
|
| 66 |
+
<div class="board-layout">
|
| 67 |
+
<Corkboard compact={false} connect={connect} setConnect={setConnect} />
|
| 68 |
+
<SuspectRail />
|
| 69 |
+
</div>
|
| 70 |
+
)}
|
| 71 |
+
<BottomNav />
|
| 72 |
+
</div>
|
| 73 |
+
)
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function Corkboard({ compact, connect, setConnect }: { compact: boolean; connect: boolean; setConnect: (v: boolean) => void }) {
|
| 77 |
+
const g = useGame()
|
| 78 |
+
const c = g.case
|
| 79 |
+
const NODES = boardNodes(c, compact)
|
| 80 |
+
const cardW = compact ? CARD_W_M : CARD_W
|
| 81 |
const ref = useRef<HTMLDivElement>(null)
|
| 82 |
+
const [size, setSize] = useState({ w: 0, h: 0 })
|
| 83 |
const [pos, setPos] = useState<Record<string, { x: number; y: number }>>({})
|
| 84 |
const [selEv, setSelEv] = useState<string | null>(null)
|
| 85 |
+
const [armed, setArmed] = useState<string | null>(null)
|
| 86 |
+
const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null)
|
| 87 |
+
const [confirmClear, setConfirmClear] = useState(false)
|
| 88 |
const drag = useRef<{ id: string; dx: number; dy: number } | null>(null)
|
| 89 |
const dragCleanup = useRef<(() => void) | null>(null)
|
| 90 |
+
const threads = useThreads(c.id, ['victim', ...c.evidence.map((e) => e.id)])
|
| 91 |
+
|
| 92 |
+
// Tall virtual wall on mobile so eight cards breathe at ~400px width.
|
| 93 |
+
const mobileBoardH = Math.max(900, 200 + c.evidence.length * 115)
|
| 94 |
|
| 95 |
+
// Mode flip: wipe layout (and the measurement that produced it) so cards re-seat
|
| 96 |
+
// on the new wall instead of stranding off-board.
|
| 97 |
+
useEffect(() => {
|
| 98 |
+
setPos({})
|
| 99 |
+
setSize({ w: 0, h: 0 })
|
| 100 |
+
setSelEv(null)
|
| 101 |
+
setArmed(null)
|
| 102 |
+
setGhost(null)
|
| 103 |
+
}, [compact])
|
| 104 |
useEffect(() => {
|
| 105 |
const m = () => {
|
| 106 |
if (ref.current) {
|
|
|
|
| 111 |
m()
|
| 112 |
window.addEventListener('resize', m)
|
| 113 |
return () => window.removeEventListener('resize', m)
|
| 114 |
+
}, [compact])
|
| 115 |
+
// Seat the cards once a real measurement exists (size starts at 0×0 so the default
|
| 116 |
+
// never leaks into the layout); manual arrangement survives later resizes.
|
| 117 |
useEffect(() => {
|
| 118 |
if (size.w < 40) return
|
| 119 |
setPos((p) => {
|
| 120 |
if (Object.keys(p).length) return p
|
| 121 |
const np: Record<string, { x: number; y: number }> = {}
|
| 122 |
NODES.forEach((n) => {
|
| 123 |
+
np[n.id] = { x: n.x * (size.w - cardW), y: n.y * (size.h - CARD_H) }
|
| 124 |
})
|
| 125 |
return np
|
| 126 |
})
|
| 127 |
+
}, [size, compact])
|
| 128 |
useEffect(() => () => dragCleanup.current?.(), [])
|
| 129 |
|
| 130 |
const onDown = (id: string, e: MouseEvent | TouchEvent) => {
|
| 131 |
+
if (connect) return // cards are pinned while connecting; let the tap become a click
|
| 132 |
e.preventDefault()
|
| 133 |
+
if (drag.current || !ref.current || !pos[id]) return
|
| 134 |
const pt = 'touches' in e ? e.touches[0] : e
|
| 135 |
+
// Board-local coordinates: the rect tracks container scroll, so dragging works
|
| 136 |
+
// anywhere down the mobile wall (clientX alone assumed an unscrolled board).
|
| 137 |
+
const r = ref.current.getBoundingClientRect()
|
| 138 |
+
drag.current = { id, dx: pt.clientX - r.left - pos[id].x, dy: pt.clientY - r.top - pos[id].y }
|
| 139 |
const move = (ev: MouseEvent | TouchEvent) => {
|
| 140 |
const d = drag.current
|
| 141 |
+
if (!d || !ref.current) return
|
| 142 |
+
if ('touches' in ev) ev.preventDefault() // block scroll only while a drag is live
|
| 143 |
window.__justDragged = true
|
| 144 |
+
const rr = ref.current.getBoundingClientRect()
|
| 145 |
const p = 'touches' in ev ? ev.touches[0] : ev
|
| 146 |
+
const nx = Math.max(0, Math.min(size.w - cardW, p.clientX - rr.left - d.dx))
|
| 147 |
+
const ny = Math.max(0, Math.min(size.h - CARD_H, p.clientY - rr.top - d.dy))
|
| 148 |
setPos((o) => ({ ...o, [d.id]: { x: nx, y: ny } }))
|
| 149 |
}
|
| 150 |
const up = () => {
|
|
|
|
| 164 |
window.addEventListener('touchend', up)
|
| 165 |
}
|
| 166 |
|
| 167 |
+
// Pushpin position for a card — where threads tie off.
|
| 168 |
+
const anchor = (id: string) => {
|
| 169 |
+
const p = pos[id]
|
| 170 |
+
return p ? { x: p.x + cardW / 2, y: p.y - 3 } : null
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
const tapCard = (id: string) => {
|
| 174 |
+
if (!connect) return
|
| 175 |
+
if (armed === null) {
|
| 176 |
+
setArmed(id)
|
| 177 |
+
return
|
| 178 |
+
}
|
| 179 |
+
if (armed === id) {
|
| 180 |
+
setArmed(null)
|
| 181 |
+
setGhost(null)
|
| 182 |
+
return
|
| 183 |
+
}
|
| 184 |
+
threads.toggle(armed, id)
|
| 185 |
+
playSfx('click')
|
| 186 |
+
setArmed(null)
|
| 187 |
+
setGhost(null)
|
| 188 |
+
}
|
| 189 |
+
const toggleConnect = () => {
|
| 190 |
+
setConnect(!connect)
|
| 191 |
+
setArmed(null)
|
| 192 |
+
setGhost(null)
|
| 193 |
+
setSelEv(null)
|
| 194 |
+
setConfirmClear(false)
|
| 195 |
+
}
|
| 196 |
+
const onBoardMove = (e: MouseEvent) => {
|
| 197 |
+
if (!connect || !armed || compact || !ref.current) return
|
| 198 |
+
const r = ref.current.getBoundingClientRect()
|
| 199 |
+
setGhost({ x: e.clientX - r.left, y: e.clientY - r.top })
|
| 200 |
+
}
|
| 201 |
+
const onBoardClick = (e: MouseEvent) => {
|
| 202 |
+
if (!connect || !armed) return
|
| 203 |
+
if ((e.target as HTMLElement).closest('.pin-card')) return // card taps handle themselves
|
| 204 |
+
setArmed(null)
|
| 205 |
+
setGhost(null)
|
| 206 |
+
}
|
| 207 |
+
const onClear = () => {
|
| 208 |
+
if (confirmClear) {
|
| 209 |
+
threads.clear()
|
| 210 |
+
setConfirmClear(false)
|
| 211 |
+
} else {
|
| 212 |
+
setConfirmClear(true)
|
| 213 |
+
setTimeout(() => setConfirmClear(false), 2500)
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
const armedAnchor = armed ? anchor(armed) : null
|
| 218 |
+
const board = (
|
| 219 |
+
<div
|
| 220 |
+
class={'board' + (compact ? ' board--mobile' : '') + (connect ? ' board--connect' : '')}
|
| 221 |
+
ref={ref}
|
| 222 |
+
style={compact ? { height: mobileBoardH } : undefined}
|
| 223 |
+
onMouseMove={onBoardMove}
|
| 224 |
+
onClick={onBoardClick}
|
| 225 |
+
>
|
| 226 |
+
<div class="board__felt" />
|
| 227 |
+
<BoardDecor c={c} compact={compact} />
|
| 228 |
+
<div class="board__frame" />
|
| 229 |
+
<ThreadLayer
|
| 230 |
+
pairs={threads.pairs}
|
| 231 |
+
anchor={anchor}
|
| 232 |
+
w={size.w}
|
| 233 |
+
h={size.h}
|
| 234 |
+
connect={connect}
|
| 235 |
+
ghost={connect && armedAnchor && ghost ? { from: armedAnchor, to: ghost } : null}
|
| 236 |
+
onRemove={(a, b) => {
|
| 237 |
+
threads.remove(a, b)
|
| 238 |
+
playSfx('click')
|
| 239 |
+
}}
|
| 240 |
/>
|
| 241 |
+
{NODES.map((n) => {
|
| 242 |
+
if (!pos[n.id]) return null
|
| 243 |
+
const p = pos[n.id]
|
| 244 |
+
const isEv = n.kind === 'evidence'
|
| 245 |
+
const open = selEv === n.id
|
| 246 |
+
const isArmed = armed === n.id
|
| 247 |
+
return (
|
| 248 |
+
<div
|
| 249 |
+
key={n.id}
|
| 250 |
+
class={'pin-card' + (open ? ' pin-card--sel' : '') + (isArmed ? ' pin-card--armed' : '')}
|
| 251 |
+
style={{ left: p.x, top: p.y, width: cardW, zIndex: open || isArmed ? 7 : 4 }}
|
| 252 |
+
>
|
| 253 |
+
<span
|
| 254 |
+
class="pin"
|
| 255 |
+
onMouseDown={(e) => onDown(n.id, e)}
|
| 256 |
+
onTouchStart={(e) => onDown(n.id, e)}
|
| 257 |
+
onClick={() => tapCard(n.id)}
|
| 258 |
+
title={connect ? 'Tie a thread' : 'Drag to move'}
|
| 259 |
+
/>
|
| 260 |
+
<div
|
| 261 |
+
class={'card-grip' + (connect ? ' card-grip--locked' : '')}
|
| 262 |
+
onMouseDown={(e) => onDown(n.id, e)}
|
| 263 |
+
onTouchStart={(e) => onDown(n.id, e)}
|
| 264 |
+
onClick={() => tapCard(n.id)}
|
| 265 |
+
title={connect ? 'Cards are pinned while connecting' : 'Drag to move'}
|
| 266 |
+
>
|
| 267 |
+
<span class="card-grip__dots">⠿⠿⠿</span>
|
| 268 |
+
<span class="card-grip__lbl">{connect ? 'PINNED' : 'MOVE'}</span>
|
| 269 |
+
</div>
|
| 270 |
+
<BoardNode
|
| 271 |
+
node={n}
|
| 272 |
+
cardW={cardW}
|
| 273 |
+
selected={open}
|
| 274 |
+
onSelect={() => {
|
| 275 |
+
if (connect) {
|
| 276 |
+
tapCard(n.id)
|
| 277 |
+
return
|
| 278 |
+
}
|
| 279 |
+
if (!window.__justDragged && isEv) setSelEv(open ? null : n.id)
|
| 280 |
+
}}
|
| 281 |
+
/>
|
| 282 |
+
{isEv && open && !connect && (
|
| 283 |
+
<div class="card-actions">
|
| 284 |
+
<Btn variant="amber" sm onClick={() => g.nav('evidence', { focus: n.id })}>Examine ▸</Btn>
|
| 285 |
</div>
|
| 286 |
+
)}
|
| 287 |
+
</div>
|
| 288 |
+
)
|
| 289 |
+
})}
|
| 290 |
+
</div>
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
// Toolbar first in the DOM: a static bar above the scrolling wall on mobile,
|
| 294 |
+
// a floating overlay (absolute, top-left) on desktop.
|
| 295 |
+
return (
|
| 296 |
+
<div class="board-wrap">
|
| 297 |
+
<div class="board__filters">
|
| 298 |
+
<span class="chip chip--amber">EVIDENCE WALL · {c.evidence.length}</span>
|
| 299 |
+
{threads.pairs.length > 0 && <span class="chip chip--ox">THREADS · {threads.pairs.length}</span>}
|
| 300 |
+
<Btn sm variant={connect ? 'ox' : 'ghost'} onClick={toggleConnect}>
|
| 301 |
+
{connect ? '▣ CONNECT: ON' : '◈ CONNECT THE DOTS'}
|
| 302 |
+
</Btn>
|
| 303 |
+
{connect && threads.pairs.length > 0 && (
|
| 304 |
+
<Btn sm variant="ghost" onClick={onClear}>{confirmClear ? 'SURE?' : 'CLEAR'}</Btn>
|
| 305 |
+
)}
|
| 306 |
+
{connect && <span class="chip">{armed ? 'PICK A SECOND CARD' : 'TAP TWO CARDS TO LINK'}</span>}
|
| 307 |
</div>
|
| 308 |
+
{compact ? <div class="board-scroll">{board}</div> : board}
|
| 309 |
</div>
|
| 310 |
)
|
| 311 |
}
|
| 312 |
|
| 313 |
+
function WallItem({ x, y, rot, pin = 'amber', children, w }: { x: string; y: string; rot: number; pin?: string; children: preact.ComponentChildren; w?: number | string }) {
|
| 314 |
return (
|
| 315 |
<div class="wall-item" style={{ left: x, top: y, transform: `rotate(${rot}deg)`, width: w }}>
|
| 316 |
<span class={'pushpin pushpin--' + pin} />
|
|
|
|
| 319 |
)
|
| 320 |
}
|
| 321 |
|
| 322 |
+
function BoardDecor({ c, compact }: { c: PublicCase; compact?: boolean }) {
|
| 323 |
const tod = c.facts.find(([k]) => k === 'TIME OF DEATH')?.[1] || c.tod
|
| 324 |
return (
|
| 325 |
<div style={{ position: 'absolute', inset: 0, zIndex: 2, pointerEvents: 'none' }}>
|
| 326 |
+
<WallItem x={compact ? '2%' : '0.5%'} y={compact ? '0.5%' : '3%'} rot={-3} pin="bone" w={compact ? 150 : 186}>
|
| 327 |
<div class="wall-clip">
|
| 328 |
<div style={{ fontFamily: 'var(--f-display)', fontSize: 9, lineHeight: 1.25, color: '#231f16' }}>{c.city} UNVEILS<br />ITS NEW CROWN</div>
|
| 329 |
<div style={{ height: 2, background: '#231f1633', margin: '5px 0' }} />
|
|
|
|
| 331 |
<div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#4a4234', lineHeight: 1.2 }}>{c.victim.name} found dead the night the city came to celebrate.</div>
|
| 332 |
</div>
|
| 333 |
</WallItem>
|
| 334 |
+
{!compact && (
|
| 335 |
+
<WallItem x="63%" y="1%" rot={3} pin="ox" w={160}>
|
| 336 |
+
<div class="wall-photo">
|
| 337 |
+
<div style={{ background: '#0d1117', height: 96 }}><Scene name="map" w={148} h={96} full style={{ width: '100%', height: '100%' }} /></div>
|
| 338 |
+
<div class="t-mono" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 4 }}>{c.district.split('·')[0]}</div>
|
| 339 |
+
</div>
|
| 340 |
+
</WallItem>
|
| 341 |
+
)}
|
| 342 |
+
<WallItem x={compact ? '58%' : '29%'} y={compact ? '8%' : '11%'} rot={4} pin="ox" w={compact ? 110 : 120}>
|
| 343 |
<div class="wall-note scrawl" style={{ fontSize: 15 }}>{tod} —<br /><b>pushed?</b></div>
|
| 344 |
</WallItem>
|
| 345 |
+
{!compact && (
|
| 346 |
+
<WallItem x="2%" y="52%" rot={-5} pin="amber" w={130}>
|
| 347 |
+
<div class="wall-note wall-note--bone scrawl" style={{ fontSize: 15 }}>WHO HELD<br />A KEY?</div>
|
| 348 |
+
</WallItem>
|
| 349 |
+
)}
|
| 350 |
+
<WallItem x={compact ? '8%' : '56%'} y={compact ? '78%' : '82%'} rot={-3} pin="amber" w={120}>
|
| 351 |
<div class="wall-note scrawl" style={{ fontSize: 15 }}>MOTIVE = ?</div>
|
| 352 |
</WallItem>
|
| 353 |
+
{!compact && (
|
| 354 |
+
<WallItem x="29%" y="60%" rot={2} pin="bone" w={150}>
|
| 355 |
+
<div class="wall-redact">
|
| 356 |
+
<div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150' }}>FORENSIC — SEALED</div>
|
| 357 |
+
<div class="bar-blk" style={{ width: '90%' }} />
|
| 358 |
+
<div class="bar-blk" style={{ width: '70%' }} />
|
| 359 |
+
<div class="bar-blk" style={{ width: '85%' }} />
|
| 360 |
+
</div>
|
| 361 |
+
</WallItem>
|
| 362 |
+
)}
|
| 363 |
+
{!compact && (
|
| 364 |
+
<WallItem x="83%" y="30%" rot={-4} pin="amber" w={150}>
|
| 365 |
+
<div class="wall-clip">
|
| 366 |
+
<div style={{ fontFamily: 'var(--f-display)', fontSize: 8, color: '#231f16', marginBottom: 5 }}>GUEST LIST</div>
|
| 367 |
+
{c.suspects.map((s) => (
|
| 368 |
+
<div key={s.id} class="t-mono" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#4a4234', display: 'flex', justifyContent: 'space-between' }}>
|
| 369 |
+
<span>{s.name.split(' ').slice(-1)[0]}, {s.name[0]}.</span>
|
| 370 |
+
<span style={{ color: '#8a3a2c' }}>✓</span>
|
| 371 |
+
</div>
|
| 372 |
+
))}
|
| 373 |
+
</div>
|
| 374 |
+
</WallItem>
|
| 375 |
+
)}
|
| 376 |
+
<WallItem x={compact ? '52%' : '84%'} y={compact ? '82%' : '55%'} rot={3} pin="ox" w={compact ? 130 : 138}>
|
| 377 |
<div class="wall-photo">
|
| 378 |
<div style={{ background: '#0d1117', height: 72 }}><Scene name="mezzanine" w={126} h={72} style={{ width: '100%', height: '100%' }} /></div>
|
| 379 |
<div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 3 }}>SCENE — RAIL</div>
|
| 380 |
</div>
|
| 381 |
</WallItem>
|
| 382 |
+
{!compact && <div class="wall-item" style={{ left: '44%', top: '70%', width: 64, height: 64, border: '5px solid rgba(60,40,20,.35)', borderRadius: '50%', boxShadow: 'inset 0 0 8px rgba(60,40,20,.25)' }} />}
|
| 383 |
+
<div class="wall-item" style={{ left: compact ? '2%' : '-6%', top: compact ? '94%' : '88%', width: compact ? '80%' : '46%', transform: 'rotate(-7deg)', zIndex: 8 }}>
|
| 384 |
<div style={{ background: 'var(--amber-2)', color: 'var(--ink-0)', padding: '5px 0', boxShadow: '0 3px 0 rgba(0,0,0,.4)', fontFamily: 'var(--f-display)', fontSize: 10, letterSpacing: '.3em', textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden' }}>
|
| 385 |
▪ POLICE LINE ▪ DO NOT CROSS ▪ POLICE LINE ▪
|
| 386 |
</div>
|
|
|
|
| 414 |
</div>
|
| 415 |
<div class="col grow" style={{ gap: 4, minWidth: 0 }}>
|
| 416 |
<div class="between" style={{ gap: 6 }}>
|
| 417 |
+
<span class="t-display nowrap" style={{ fontSize: 10, color: 'var(--bone-3)', flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.name}</span>
|
| 418 |
+
<span class="t-mono" style={{ flexShrink: 0, fontSize: 'calc(12px*var(--mono-scale))', color: susp >= 65 ? 'var(--ox-3)' : 'var(--bone-1)' }}>{susp}%</span>
|
| 419 |
</div>
|
| 420 |
<div class="t-label">{s.tag}</div>
|
| 421 |
<SuspicionBar value={susp} compact label={null} />
|
|
|
|
| 437 |
)
|
| 438 |
}
|
| 439 |
|
| 440 |
+
function BoardNode({ node, cardW, selected, onSelect }: { node: Node; cardW: number; selected: boolean; onSelect: () => void }) {
|
| 441 |
const g = useGame()
|
| 442 |
const c = g.case
|
| 443 |
if (node.kind === 'evidence') {
|
| 444 |
const e = c.evidence.find((x) => x.id === node.id)!
|
| 445 |
return (
|
| 446 |
+
<div class="pin-note" style={{ width: cardW, cursor: 'pointer', boxShadow: selected ? '0 0 0 3px var(--amber-2), 3px 4px 0 rgba(0,0,0,.4)' : undefined }} onClick={onSelect}>
|
| 447 |
<div class="row" style={{ gap: 8, alignItems: 'center' }}>
|
| 448 |
<div style={{ background: 'var(--ink-1)', padding: 3 }}><EvIcon icon={e.icon} px={2} /></div>
|
| 449 |
<div class="col" style={{ gap: 2, minWidth: 0 }}>
|
|
|
|
| 455 |
)
|
| 456 |
}
|
| 457 |
return (
|
| 458 |
+
<Panel variant="ox" style={{ padding: 7, width: cardW, cursor: 'pointer' }} onClick={onSelect}>
|
| 459 |
<div class="row" style={{ gap: 8, alignItems: 'center' }}>
|
| 460 |
<div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 2, flexShrink: 0 }}>
|
| 461 |
<Portrait id={c.victim.sprite} px={3} />
|
|
|
|
| 468 |
</Panel>
|
| 469 |
)
|
| 470 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
web/src/screens/cold.tsx
CHANGED
|
@@ -1,363 +1,368 @@
|
|
| 1 |
-
// Title · Story · Briefing (dossier) · Boot — the "cold" pre-investigation screens.
|
| 2 |
-
import { useEffect, useRef, useState } from 'preact/hooks'
|
| 3 |
-
import type { ComponentChildren } from 'preact'
|
| 4 |
-
|
| 5 |
-
import { useTypewriter } from '../engine/pixel'
|
| 6 |
-
import { useGame } from '../store'
|
| 7 |
-
import type { PublicCase } from '../types'
|
| 8 |
-
import { playSfx } from '../ui/audio'
|
| 9 |
-
import { BottomNav, Btn, Controls, ExhibitArt, HintButton, Hud, Panel, Portrait, Scene, Stamp } from '../ui/components'
|
| 10 |
-
|
| 11 |
-
export function TitleScreen() {
|
| 12 |
-
const g = useGame()
|
| 13 |
-
const c = g.case
|
| 14 |
-
const [enterId, setEnterId] = useState(false)
|
| 15 |
-
const [idVal, setIdVal] = useState(c.id)
|
| 16 |
-
const pull = () => {
|
| 17 |
-
const id = idVal.trim()
|
| 18 |
-
if (id) g.loadCase(id) // load that exact case (fresh run) and jump straight into it
|
| 19 |
-
}
|
| 20 |
-
return (
|
| 21 |
-
<div class="app__view" style={{ position: 'relative' }}>
|
| 22 |
-
<Scene name="skyline" w={320} h={200} anim cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
|
| 23 |
-
<div style={{ position: 'absolute', top: 12, right: 14, zIndex: 5 }}><Controls /></div>
|
| 24 |
-
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 75% at 50% 42%, transparent 45%, rgba(8,11,16,.45) 95%)' }} />
|
| 25 |
-
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(34% 56% at 50% 46%, rgba(8,11,16,.82) 30%, rgba(8,11,16,.5) 60%, transparent 80%)' }} />
|
| 26 |
-
<div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 26, padding: 24, textAlign: 'center' }}>
|
| 27 |
-
<div class="t-label" style={{ letterSpacing: '.34em', color: 'var(--amber-2)' }}>A PROCEDURAL
|
| 28 |
-
<h1 class="t-display" style={{ fontSize: 'clamp(34px,9vw,82px)', color: 'var(--bone-3)', lineHeight: 0.95, textShadow: '4px 4px 0 var(--ink-0)' }}>CASE<br />ZERO</h1>
|
| 29 |
-
<p class="t-body" style={{ maxWidth: 440, color: 'var(--bone-2)', fontSize: 16 }}>
|
| 30 |
-
Every case is generated. The city, the body, the lies. Solve one no one has ever seen.
|
| 31 |
-
</p>
|
| 32 |
-
<div class="t-mono amber" style={{ fontSize: 'calc(17px*var(--mono-scale))' }}>“{c.tagline}”</div>
|
| 33 |
-
{!enterId ? (
|
| 34 |
-
<div class="col" style={{ gap: 10, width: 'min(320px,80vw)', marginTop: 6 }}>
|
| 35 |
-
<Btn variant="amber" onClick={() => g.newCase()}>▸ Begin New Case</Btn>
|
| 36 |
-
<Btn variant="ghost" onClick={() => setEnterId(true)}>Enter Case ID</Btn>
|
| 37 |
-
<Btn variant="ghost" onClick={() => g.nav('board')}>Continue · {c.id}</Btn>
|
| 38 |
-
</div>
|
| 39 |
-
) : (
|
| 40 |
-
<Panel className="col" style={{ gap: 10, width: 'min(340px,86vw)', padding: 16 }}>
|
| 41 |
-
<span class="t-label">ENTER A CASE FILE NUMBER</span>
|
| 42 |
-
<input
|
| 43 |
-
class="pinput t-mono"
|
| 44 |
-
value={idVal}
|
| 45 |
-
onInput={(e) => setIdVal((e.target as HTMLInputElement).value)}
|
| 46 |
-
style={{ textAlign: 'center', fontSize: 'calc(17px*var(--mono-scale))' }}
|
| 47 |
-
/>
|
| 48 |
-
<div class="row" style={{ gap: 8 }}>
|
| 49 |
-
<Btn variant="ghost" sm onClick={() => setEnterId(false)}>Back</Btn>
|
| 50 |
-
<Btn variant="amber" className="grow" onClick={pull}>Pull File ▸</Btn>
|
| 51 |
-
</div>
|
| 52 |
-
</Panel>
|
| 53 |
-
)}
|
| 54 |
-
</div>
|
| 55 |
-
<div style={{ position: 'absolute', bottom: 12, left:
|
| 56 |
-
<
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
const
|
| 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 |
-
<div class="row" style={{ gap:
|
| 97 |
-
{
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
)}
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
const
|
| 117 |
-
const
|
| 118 |
-
const
|
| 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 |
-
<div class="
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
<div
|
| 224 |
-
<div class="
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
</
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
<
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
)
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
}
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
const
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
<
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
<
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Title · Story · Briefing (dossier) · Boot — the "cold" pre-investigation screens.
|
| 2 |
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
| 3 |
+
import type { ComponentChildren } from 'preact'
|
| 4 |
+
|
| 5 |
+
import { useTypewriter } from '../engine/pixel'
|
| 6 |
+
import { useGame } from '../store'
|
| 7 |
+
import type { PublicCase } from '../types'
|
| 8 |
+
import { playSfx } from '../ui/audio'
|
| 9 |
+
import { BottomNav, Btn, Controls, ExhibitArt, HintButton, Hud, Panel, Portrait, Scene, Stamp } from '../ui/components'
|
| 10 |
+
|
| 11 |
+
export function TitleScreen() {
|
| 12 |
+
const g = useGame()
|
| 13 |
+
const c = g.case
|
| 14 |
+
const [enterId, setEnterId] = useState(false)
|
| 15 |
+
const [idVal, setIdVal] = useState(c.id)
|
| 16 |
+
const pull = () => {
|
| 17 |
+
const id = idVal.trim()
|
| 18 |
+
if (id) g.loadCase(id) // load that exact case (fresh run) and jump straight into it
|
| 19 |
+
}
|
| 20 |
+
return (
|
| 21 |
+
<div class="app__view" style={{ position: 'relative' }}>
|
| 22 |
+
<Scene name="skyline" w={320} h={200} anim cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
|
| 23 |
+
<div style={{ position: 'absolute', top: 12, right: 14, zIndex: 5 }}><Controls /></div>
|
| 24 |
+
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 75% at 50% 42%, transparent 45%, rgba(8,11,16,.45) 95%)' }} />
|
| 25 |
+
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(34% 56% at 50% 46%, rgba(8,11,16,.82) 30%, rgba(8,11,16,.5) 60%, transparent 80%)' }} />
|
| 26 |
+
<div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 26, padding: 24, textAlign: 'center' }}>
|
| 27 |
+
<div class="t-label" style={{ letterSpacing: '.34em', color: 'var(--amber-2)' }}>A PROCEDURAL DETECTIVE MYSTERY</div>
|
| 28 |
+
<h1 class="t-display" style={{ fontSize: 'clamp(34px,9vw,82px)', color: 'var(--bone-3)', lineHeight: 0.95, textShadow: '4px 4px 0 var(--ink-0)' }}>CASE<br />ZERO</h1>
|
| 29 |
+
<p class="t-body" style={{ maxWidth: 440, color: 'var(--bone-2)', fontSize: 16 }}>
|
| 30 |
+
Every case is generated. The city, the body, the lies. Solve one no one has ever seen.
|
| 31 |
+
</p>
|
| 32 |
+
<div class="t-mono amber" style={{ fontSize: 'calc(17px*var(--mono-scale))' }}>“{c.tagline}”</div>
|
| 33 |
+
{!enterId ? (
|
| 34 |
+
<div class="col" style={{ gap: 10, width: 'min(320px,80vw)', marginTop: 6 }}>
|
| 35 |
+
<Btn variant="amber" onClick={() => g.newCase()}>▸ Begin New Case</Btn>
|
| 36 |
+
<Btn variant="ghost" onClick={() => setEnterId(true)}>Enter Case ID</Btn>
|
| 37 |
+
<Btn variant="ghost" onClick={() => g.nav('board')}>Continue · {c.id}</Btn>
|
| 38 |
+
</div>
|
| 39 |
+
) : (
|
| 40 |
+
<Panel className="col" style={{ gap: 10, width: 'min(340px,86vw)', padding: 16 }}>
|
| 41 |
+
<span class="t-label">ENTER A CASE FILE NUMBER</span>
|
| 42 |
+
<input
|
| 43 |
+
class="pinput t-mono"
|
| 44 |
+
value={idVal}
|
| 45 |
+
onInput={(e) => setIdVal((e.target as HTMLInputElement).value)}
|
| 46 |
+
style={{ textAlign: 'center', fontSize: 'calc(17px*var(--mono-scale))' }}
|
| 47 |
+
/>
|
| 48 |
+
<div class="row" style={{ gap: 8 }}>
|
| 49 |
+
<Btn variant="ghost" sm onClick={() => setEnterId(false)}>Back</Btn>
|
| 50 |
+
<Btn variant="amber" className="grow" onClick={pull}>Pull File ▸</Btn>
|
| 51 |
+
</div>
|
| 52 |
+
</Panel>
|
| 53 |
+
)}
|
| 54 |
+
</div>
|
| 55 |
+
<div style={{ position: 'absolute', bottom: 12, left: 12, right: 12, display: 'flex', justifyContent: 'center', zIndex: 2 }}>
|
| 56 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', columnGap: 8, rowGap: 3, alignItems: 'baseline', textAlign: 'center', maxWidth: 480, padding: '5px 12px', background: 'rgba(8,11,16,.6)', boxShadow: '0 0 0 2px var(--ink-3), 4px 4px 0 rgba(0,0,0,.35)' }}>
|
| 57 |
+
<span class="t-label nowrap" style={{ color: 'var(--amber-2)', letterSpacing: '.2em' }}>TONIGHT'S WIRE</span>
|
| 58 |
+
<span class="t-label nowrap">{c.city}</span>
|
| 59 |
+
<span class="t-label dim">·</span>
|
| 60 |
+
<span class="t-label">{c.weather}</span>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export function StoryScreen() {
|
| 68 |
+
const g = useGame()
|
| 69 |
+
const beats = g.case.storyBeats
|
| 70 |
+
const [i, setI] = useState(0)
|
| 71 |
+
const beat = beats[i]
|
| 72 |
+
const last = i === beats.length - 1
|
| 73 |
+
const [body, done] = useTypewriter(beat.text, (g.state.tweaks.typeSpeed || 18) + 2, true)
|
| 74 |
+
return (
|
| 75 |
+
<div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}>
|
| 76 |
+
<Scene name={beat.scene} w={320} h={200} cover deps={[i]} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.5 }} />
|
| 77 |
+
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(8,11,16,.7) 0%, rgba(8,11,16,.35) 38%, rgba(8,11,16,.82) 100%)' }} />
|
| 78 |
+
<div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
| 79 |
+
<div class="between" style={{ padding: '14px 18px' }}>
|
| 80 |
+
<span class="t-label" style={{ letterSpacing: '.2em', color: 'var(--amber-2)' }}>{beat.kicker}</span>
|
| 81 |
+
<div class="row" style={{ gap: 8 }}>
|
| 82 |
+
<HintButton />
|
| 83 |
+
<Btn sm variant="ghost" onClick={() => g.nav('briefing')}>Skip ▸</Btn>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
<div class="grow center" style={{ padding: '12px 22px' }}>
|
| 87 |
+
<div style={{ maxWidth: 680, width: '100%' }}>
|
| 88 |
+
<h1 class="t-display" style={{ fontSize: 'clamp(22px,4vw,34px)', color: 'var(--bone-3)', marginBottom: 18, lineHeight: 1.1, textShadow: '3px 3px 0 var(--ink-0)' }}>{beat.title}</h1>
|
| 89 |
+
<p class="t-body" style={{ fontSize: 'clamp(16px,2.2vw,20px)', lineHeight: 1.65, color: 'var(--bone-2)', minHeight: 130 }}>
|
| 90 |
+
{body}
|
| 91 |
+
{!done && <span class="cursor" />}
|
| 92 |
+
</p>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
<div class="between" style={{ padding: '16px 22px 22px', gap: 14 }}>
|
| 96 |
+
<div class="row" style={{ gap: 6 }}>
|
| 97 |
+
{beats.map((_, k) => (
|
| 98 |
+
<span key={k} onClick={() => setI(k)} style={{ width: 26, height: 6, cursor: 'pointer', background: k === i ? 'var(--amber-2)' : k < i ? 'var(--slate-2)' : 'var(--ink-3)', boxShadow: 'inset 0 0 0 1px var(--ink-0)' }} />
|
| 99 |
+
))}
|
| 100 |
+
</div>
|
| 101 |
+
<div class="row" style={{ gap: 10 }}>
|
| 102 |
+
{i > 0 && <Btn variant="ghost" onClick={() => setI(i - 1)}>◂ Back</Btn>}
|
| 103 |
+
{!last ? (
|
| 104 |
+
<Btn variant="amber" onClick={() => setI(i + 1)}>Next ▸</Btn>
|
| 105 |
+
) : (
|
| 106 |
+
<Btn variant="amber" style={{ padding: '13px 24px' }} onClick={() => g.nav('briefing')}>Open the Case File ▸▸</Btn>
|
| 107 |
+
)}
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
)
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
export function BootScreen() {
|
| 116 |
+
const g = useGame()
|
| 117 |
+
const boot = g.case.bootLines
|
| 118 |
+
const [lines, setLines] = useState<string[]>([])
|
| 119 |
+
const [cur, setCur] = useState('')
|
| 120 |
+
const [stamped, setStamped] = useState(false)
|
| 121 |
+
const [done, setDone] = useState(false)
|
| 122 |
+
const idx = useRef(0)
|
| 123 |
+
const ch = useRef(0)
|
| 124 |
+
|
| 125 |
+
useEffect(() => {
|
| 126 |
+
const speed = g.state.tweaks.typeSpeed || 16
|
| 127 |
+
let timer: ReturnType<typeof setTimeout>
|
| 128 |
+
const tick = () => {
|
| 129 |
+
const i = idx.current
|
| 130 |
+
if (i >= boot.length) {
|
| 131 |
+
setStamped(true)
|
| 132 |
+
setTimeout(() => setDone(true), 900)
|
| 133 |
+
return
|
| 134 |
+
}
|
| 135 |
+
const line = boot[i]
|
| 136 |
+
ch.current++
|
| 137 |
+
setCur(line.slice(0, ch.current))
|
| 138 |
+
if (ch.current >= line.length) {
|
| 139 |
+
setLines((l) => [...l, line])
|
| 140 |
+
setCur('')
|
| 141 |
+
ch.current = 0
|
| 142 |
+
idx.current++
|
| 143 |
+
timer = setTimeout(tick, 240)
|
| 144 |
+
} else {
|
| 145 |
+
timer = setTimeout(tick, speed)
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
timer = setTimeout(tick, 300)
|
| 149 |
+
return () => clearTimeout(timer)
|
| 150 |
+
}, [])
|
| 151 |
+
|
| 152 |
+
return (
|
| 153 |
+
<div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}>
|
| 154 |
+
<Scene name="seawall" w={320} h={200} cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.28 }} />
|
| 155 |
+
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 80% at 30% 40%, transparent 40%, rgba(8,11,16,.7) 100%)' }} />
|
| 156 |
+
<div class="screen-center" style={{ position: 'relative', zIndex: 2 }}>
|
| 157 |
+
<div class="maxw" style={{ maxWidth: 760, display: 'flex', flexDirection: 'column', gap: 22, alignItems: g.mode === 'mobile' ? 'stretch' : 'flex-start' }}>
|
| 158 |
+
<div class="row" style={{ gap: 10, alignItems: 'center' }}>
|
| 159 |
+
<span style={{ width: 9, height: 9, background: 'var(--ox-3)', boxShadow: '0 0 10px var(--ox-3)', animation: 'blink 1.4s steps(1) infinite' }} />
|
| 160 |
+
<span class="t-label" style={{ letterSpacing: '.22em', color: 'var(--amber-2)' }}>{g.case.city} · {g.case.district}</span>
|
| 161 |
+
</div>
|
| 162 |
+
<div class="t-mono" style={{ fontSize: 'calc(17px*var(--mono-scale))', lineHeight: 1.85, color: 'var(--bone-2)', minHeight: g.mode === 'mobile' ? 220 : 200 }}>
|
| 163 |
+
{lines.map((l, i) => (
|
| 164 |
+
<div key={i} style={{ opacity: i === lines.length - 1 && !cur ? 1 : 0.82 }}>{l}</div>
|
| 165 |
+
))}
|
| 166 |
+
{cur && <div class="bone">{cur}<span class="cursor" /></div>}
|
| 167 |
+
{lines.length === 0 && !cur && <span class="cursor" />}
|
| 168 |
+
</div>
|
| 169 |
+
<div class="col" style={{ gap: 18, alignItems: g.mode === 'mobile' ? 'stretch' : 'flex-start' }}>
|
| 170 |
+
<div style={{ position: 'relative', paddingTop: 16 }}>
|
| 171 |
+
<span class="t-label" style={{ position: 'absolute', top: 0, left: 0 }}>CASE FILE No.</span>
|
| 172 |
+
{stamped ? (
|
| 173 |
+
<Stamp slam style={{ fontSize: 'clamp(15px,3.5vw,22px)' }}>{g.case.id}</Stamp>
|
| 174 |
+
) : (
|
| 175 |
+
<span class="t-display dim" style={{ fontSize: 'clamp(15px,3.5vw,22px)', opacity: 0.25, letterSpacing: '.08em' }}>— — — — —</span>
|
| 176 |
+
)}
|
| 177 |
+
</div>
|
| 178 |
+
{done && <Btn variant="amber" style={{ fontSize: 12, padding: '15px 24px' }} onClick={() => g.nav('briefing')}>Take the Case ▸</Btn>}
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
)
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// ---- dossier ----
|
| 187 |
+
function PageHead({ tab, idx, c }: { tab: string; idx: string; c: PublicCase }) {
|
| 188 |
+
return (
|
| 189 |
+
<div class="between" style={{ borderBottom: '2px solid #211d1533', paddingBottom: 10, marginBottom: 16 }}>
|
| 190 |
+
<div class="col" style={{ gap: 4 }}>
|
| 191 |
+
<span class="dlabel">{c.city} CO. · {c.division || 'HOMICIDE DIVISION'}</span>
|
| 192 |
+
<span class="dh" style={{ fontSize: 13 }}>{tab}</span>
|
| 193 |
+
</div>
|
| 194 |
+
<span class="dtype" style={{ fontSize: 'calc(13px*var(--mono-scale))', color: '#6a6150' }}>FILE {c.id} · pg.{idx}</span>
|
| 195 |
+
</div>
|
| 196 |
+
)
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function dossierPages(c: PublicCase): { tab: string; render: () => ComponentChildren }[] {
|
| 200 |
+
return [
|
| 201 |
+
{
|
| 202 |
+
tab: 'COVER',
|
| 203 |
+
render: () => (
|
| 204 |
+
<div class="center" style={{ flexDirection: 'column', height: '100%', gap: 18, textAlign: 'center', position: 'relative' }}>
|
| 205 |
+
<span class="paperclip" />
|
| 206 |
+
<span class="dlabel" style={{ fontSize: 9, letterSpacing: '.3em' }}>{c.district}</span>
|
| 207 |
+
<div class="dlabel" style={{ color: '#6a6150', letterSpacing: '.2em' }}>{c.kindLabel || 'HOMICIDE'} — CASE FILE</div>
|
| 208 |
+
<h1 class="dh" style={{ fontSize: 'clamp(26px,5vw,44px)', lineHeight: 1.05 }}>{c.title}</h1>
|
| 209 |
+
<div style={{ margin: '6px 0' }}><span class="dstamp" style={{ fontSize: 'clamp(14px,3vw,20px)' }}>{c.id}</span></div>
|
| 210 |
+
<div class="dtype" style={{ color: '#4a4234' }}>{c.district}</div>
|
| 211 |
+
<div style={{ position: 'absolute', bottom: 6, right: 8 }}><span class="dstamp" style={{ fontSize: 11, borderColor: '#1a1610', color: '#1a1610', transform: 'rotate(4deg)' }}>CONFIDENTIAL</span></div>
|
| 212 |
+
<div class="dtype" style={{ color: '#6a6150', fontSize: 'calc(14px*var(--mono-scale))', marginTop: 8 }}>▸ flip the page →</div>
|
| 213 |
+
</div>
|
| 214 |
+
),
|
| 215 |
+
},
|
| 216 |
+
{
|
| 217 |
+
tab: 'THE VICTIM',
|
| 218 |
+
render: () => (
|
| 219 |
+
<div>
|
| 220 |
+
<PageHead tab="THE VICTIM" idx="01" c={c} />
|
| 221 |
+
<div class="row" style={{ gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
| 222 |
+
<div class="photo-paper" style={{ flexShrink: 0 }}>
|
| 223 |
+
<div style={{ background: '#0d1117', padding: 4 }}><Portrait id={c.victim.sprite} px={6} /></div>
|
| 224 |
+
<div class="dtype" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 4 }}>{c.victim.name.split(' ').slice(-1)[0]}</div>
|
| 225 |
+
</div>
|
| 226 |
+
<div class="col" style={{ gap: 8, flex: 1, minWidth: 200 }}>
|
| 227 |
+
<h2 class="dh" style={{ fontSize: 20 }}>{c.victim.name}</h2>
|
| 228 |
+
<div class="dtype" style={{ color: '#4a4234' }}>{c.victim.role}</div>
|
| 229 |
+
<div class="row" style={{ gap: 10, flexWrap: 'wrap' }}>
|
| 230 |
+
<span class="dstamp" style={{ fontSize: 11 }}>{c.victimStatus || 'DECEASED'}</span>
|
| 231 |
+
<span class="dtype" style={{ alignSelf: 'center' }}>{c.todLabel || 'T.O.D.'} {c.tod} · AGE {c.victim.age}</span>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
<hr style={{ border: 0, borderTop: '2px dashed #211d1533', margin: '16px 0' }} />
|
| 236 |
+
<p class="dtype">{c.victim.bio}</p>
|
| 237 |
+
</div>
|
| 238 |
+
),
|
| 239 |
+
},
|
| 240 |
+
{
|
| 241 |
+
tab: 'THE SCENE',
|
| 242 |
+
render: () => (
|
| 243 |
+
<div>
|
| 244 |
+
<PageHead tab="THE SCENE" idx="02" c={c} />
|
| 245 |
+
<div class="photo-paper" style={{ marginBottom: 16, transform: 'rotate(-1deg)' }}>
|
| 246 |
+
<div style={{ aspectRatio: '16/9', background: '#0d1117' }}>
|
| 247 |
+
<Scene name={c.scene} w={288} h={162} style={{ width: '100%', height: '100%' }} />
|
| 248 |
+
</div>
|
| 249 |
+
<div class="dtype" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#6a6150', marginTop: 4 }}>EXHIBIT A — {c.scene}</div>
|
| 250 |
+
</div>
|
| 251 |
+
<p class="dtype" style={{ marginBottom: 10 }}><b style={{ color: '#8a3a2c' }}>FOUND — </b>{c.found}</p>
|
| 252 |
+
<p class="dtype"><b style={{ color: '#8a3a2c' }}>CAUSE — </b>{c.cause}</p>
|
| 253 |
+
</div>
|
| 254 |
+
),
|
| 255 |
+
},
|
| 256 |
+
{
|
| 257 |
+
tab: 'THE EXHIBITS',
|
| 258 |
+
render: () => (
|
| 259 |
+
<div>
|
| 260 |
+
<PageHead tab="THE EXHIBITS" idx="03" c={c} />
|
| 261 |
+
<p class="dtype" style={{ marginBottom: 12, color: '#4a4234' }}>Photographed and logged at the scene. Examine each on the wall.</p>
|
| 262 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
| 263 |
+
{c.evidence.slice(0, 6).map((e, i) => (
|
| 264 |
+
<div key={e.id} class="photo-paper" style={{ transform: `rotate(${(i % 3) - 1}deg)` }}>
|
| 265 |
+
<div style={{ background: '#0d1117' }}>
|
| 266 |
+
<ExhibitArt e={e} style={{ width: '100%', height: 'auto' }} />
|
| 267 |
+
</div>
|
| 268 |
+
<div class="between" style={{ marginTop: 4, gap: 6 }}>
|
| 269 |
+
<span class="dtype" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.name}</span>
|
| 270 |
+
<span class="dtype" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#8a3a2c', flexShrink: 0 }}>{e.time}</span>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
))}
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
),
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
tab: 'KEY FACTS',
|
| 280 |
+
render: () => (
|
| 281 |
+
<div style={{ position: 'relative' }}>
|
| 282 |
+
<PageHead tab="KEY FACTS" idx="04" c={c} />
|
| 283 |
+
<div style={{ position: 'absolute', top: 60, right: 0 }}><span class="dstamp" style={{ fontSize: 13 }}>{c.kindLabel || 'HOMICIDE'}</span></div>
|
| 284 |
+
<div class="col" style={{ gap: 0 }}>
|
| 285 |
+
{c.facts.map(([k, v], i) => (
|
| 286 |
+
<div key={i} class="row" style={{ gap: 14, padding: '11px 0', borderBottom: '1px dotted #211d1540', alignItems: 'baseline' }}>
|
| 287 |
+
<span class="dlabel" style={{ color: '#6a6150', width: 116, flexShrink: 0 }}>{k}</span>
|
| 288 |
+
<span class="dtype" style={{ color: k === 'VERDICT' ? '#8a2a2a' : '#211d15' }}>{v}</span>
|
| 289 |
+
</div>
|
| 290 |
+
))}
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
),
|
| 294 |
+
},
|
| 295 |
+
{
|
| 296 |
+
tab: 'PERSONS OF INTEREST',
|
| 297 |
+
render: () => (
|
| 298 |
+
<div>
|
| 299 |
+
<PageHead tab="PERSONS OF INTEREST" idx="05" c={c} />
|
| 300 |
+
<p class="dtype" style={{ marginBottom: 14, color: '#4a4234' }}>Those who stayed when the others fled the sirens. Each had reason to be near.</p>
|
| 301 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
| 302 |
+
{c.suspects.map((s) => (
|
| 303 |
+
<div key={s.id} class="row" style={{ gap: 10, alignItems: 'center', background: '#00000010', padding: 8, boxShadow: 'inset 0 0 0 1px #211d1522' }}>
|
| 304 |
+
<div style={{ background: '#0d1117', padding: 3, flexShrink: 0 }}><Portrait id={s.sprite} px={3} gender={s.gender} /></div>
|
| 305 |
+
<div class="col" style={{ gap: 2, minWidth: 0 }}>
|
| 306 |
+
<span class="dh" style={{ fontSize: 10 }}>{s.name}</span>
|
| 307 |
+
<span class="dlabel" style={{ color: '#8a3a2c' }}>{s.tag}</span>
|
| 308 |
+
</div>
|
| 309 |
+
</div>
|
| 310 |
+
))}
|
| 311 |
+
</div>
|
| 312 |
+
<p class="dtype" style={{ marginTop: 16, color: '#8a3a2c', textAlign: 'center' }}>One of them is lying. Find the crack.</p>
|
| 313 |
+
</div>
|
| 314 |
+
),
|
| 315 |
+
},
|
| 316 |
+
]
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
export function BriefingScreen() {
|
| 320 |
+
const g = useGame()
|
| 321 |
+
const c = g.case
|
| 322 |
+
const PAGES = dossierPages(c)
|
| 323 |
+
const [page, setPage] = useState(0)
|
| 324 |
+
const [flip, setFlip] = useState<string | null>(null)
|
| 325 |
+
const go = (dir: number) => {
|
| 326 |
+
const next = page + dir
|
| 327 |
+
if (next < 0 || next >= PAGES.length) return
|
| 328 |
+
playSfx('page')
|
| 329 |
+
setFlip(dir > 0 ? 'fwd' : 'back')
|
| 330 |
+
setPage(next)
|
| 331 |
+
setTimeout(() => setFlip(null), 470)
|
| 332 |
+
}
|
| 333 |
+
const P = PAGES[page]
|
| 334 |
+
return (
|
| 335 |
+
<div class="app__view">
|
| 336 |
+
<Hud title={c.title} sub="CASE DOSSIER" />
|
| 337 |
+
<div class="dossier-stage">
|
| 338 |
+
<div class="dossier">
|
| 339 |
+
<div class="dossier__folder">
|
| 340 |
+
<div class="page-wrap">
|
| 341 |
+
<div class={'page ' + (page % 2 ? 'page--paper2 ' : '') + (flip ? 'page--flip-' + flip : '')} key={page}>
|
| 342 |
+
{P.render()}
|
| 343 |
+
<div class="page__curl" />
|
| 344 |
+
{page > 0 && <div class="page__edge page__edge--l" onClick={() => go(-1)} title="Previous page" />}
|
| 345 |
+
{page < PAGES.length - 1 && <div class="page__edge page__edge--r" onClick={() => go(1)} title="Next page" />}
|
| 346 |
+
</div>
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
<div class="dossier__nav">
|
| 350 |
+
<Btn sm variant="ghost" sfx={null} disabled={page === 0} onClick={() => go(-1)}>◂ Prev</Btn>
|
| 351 |
+
<div class="page-dots">
|
| 352 |
+
{PAGES.map((pg, i) => (
|
| 353 |
+
<span key={i} class={'page-dot' + (i === page ? ' page-dot--on' : '')} title={pg.tab}
|
| 354 |
+
onClick={() => { setFlip(i > page ? 'fwd' : 'back'); setPage(i); setTimeout(() => setFlip(null), 470) }} />
|
| 355 |
+
))}
|
| 356 |
+
</div>
|
| 357 |
+
{page < PAGES.length - 1 ? (
|
| 358 |
+
<Btn sm variant="amber" sfx={null} onClick={() => go(1)}>Next page ▸</Btn>
|
| 359 |
+
) : (
|
| 360 |
+
<Btn variant="amber" onClick={() => g.nav('board')}>Open the Wall ▸▸</Btn>
|
| 361 |
+
)}
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
<BottomNav />
|
| 366 |
+
</div>
|
| 367 |
+
)
|
| 368 |
+
}
|
web/src/screens/index.ts
CHANGED
|
@@ -1,25 +1,27 @@
|
|
| 1 |
-
import type { FunctionComponent } from 'preact'
|
| 2 |
-
|
| 3 |
-
import type { Screen } from '../store'
|
| 4 |
-
import { Board } from './board'
|
| 5 |
-
import { BootScreen, BriefingScreen, StoryScreen, TitleScreen } from './cold'
|
| 6 |
-
import { FlashbackScreen, NotesScreen, TimelineScreen } from './deduce'
|
| 7 |
-
import { AccusationScreen, ShareScreen, VerdictScreen } from './endgame'
|
| 8 |
-
import { EvidenceScreen } from './evidence'
|
| 9 |
-
import { Interrogation } from './interro'
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { FunctionComponent } from 'preact'
|
| 2 |
+
|
| 3 |
+
import type { Screen } from '../store'
|
| 4 |
+
import { Board } from './board'
|
| 5 |
+
import { BootScreen, BriefingScreen, StoryScreen, TitleScreen } from './cold'
|
| 6 |
+
import { FlashbackScreen, NotesScreen, TimelineScreen } from './deduce'
|
| 7 |
+
import { AccusationScreen, ShareScreen, VerdictScreen } from './endgame'
|
| 8 |
+
import { EvidenceScreen } from './evidence'
|
| 9 |
+
import { Interrogation } from './interro'
|
| 10 |
+
import { SuspectsScreen } from './suspects'
|
| 11 |
+
|
| 12 |
+
export const SCREENS: Record<Screen, FunctionComponent> = {
|
| 13 |
+
title: TitleScreen,
|
| 14 |
+
story: StoryScreen,
|
| 15 |
+
briefing: BriefingScreen,
|
| 16 |
+
boot: BootScreen,
|
| 17 |
+
board: Board,
|
| 18 |
+
suspects: SuspectsScreen,
|
| 19 |
+
interro: Interrogation,
|
| 20 |
+
evidence: EvidenceScreen,
|
| 21 |
+
flashback: FlashbackScreen,
|
| 22 |
+
timeline: TimelineScreen,
|
| 23 |
+
notes: NotesScreen,
|
| 24 |
+
accuse: AccusationScreen,
|
| 25 |
+
verdict: VerdictScreen,
|
| 26 |
+
share: ShareScreen,
|
| 27 |
+
}
|
web/src/screens/interro.tsx
CHANGED
|
@@ -1,194 +1,194 @@
|
|
| 1 |
-
// Interrogation room — questions, free-text, and present-evidence all go to the server,
|
| 2 |
-
// which returns the suspect's reply + the authoritative suspicion. The client only displays.
|
| 3 |
-
import { useEffect, useRef, useState } from 'preact/hooks'
|
| 4 |
-
|
| 5 |
-
import { interrogate } from '../api'
|
| 6 |
-
import { useGame } from '../store'
|
| 7 |
-
import type { Evidence, SuggestedQuestion } from '../types'
|
| 8 |
-
import { playSfx, prepareSpeak, stopSpeak } from '../ui/audio'
|
| 9 |
-
import { Btn, EvIcon, EvidenceCard, Hud, Panel, Portrait, Scene, SuspicionBar, TypeOnce } from '../ui/components'
|
| 10 |
-
|
| 11 |
-
interface Pending {
|
| 12 |
-
text: string
|
| 13 |
-
tag?: string | null
|
| 14 |
-
suspicion: number
|
| 15 |
-
speed?: number // typewriter ms/char, paced to the voice when one is available
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
export function Interrogation() {
|
| 19 |
-
const g = useGame()
|
| 20 |
-
const c = g.case
|
| 21 |
-
const sid = (g.state.payload.suspect as string) || c.suspects[0].id
|
| 22 |
-
const s = c.suspects.find((x) => x.id === sid)!
|
| 23 |
-
const transcript = g.state.interrogations[sid] || []
|
| 24 |
-
const susp = g.state.suspicion[sid]
|
| 25 |
-
const usedQ = g.state.usedQ[sid] || []
|
| 26 |
-
const usedEv = g.state.usedEv[sid] || []
|
| 27 |
-
const [pending, setPending] = useState<Pending | null>(null)
|
| 28 |
-
const [thinking, setThinking] = useState(false)
|
| 29 |
-
const [talking, setTalking] = useState(false)
|
| 30 |
-
const [tray, setTray] = useState(false)
|
| 31 |
-
const [input, setInput] = useState('')
|
| 32 |
-
const scroller = useRef<HTMLDivElement>(null)
|
| 33 |
-
const busy = thinking || !!pending
|
| 34 |
-
|
| 35 |
-
useEffect(() => () => stopSpeak(), []) // stop any voice when leaving the room
|
| 36 |
-
|
| 37 |
-
useEffect(() => {
|
| 38 |
-
if (!transcript.length) setPending({ text: s.greet, suspicion: susp })
|
| 39 |
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 40 |
-
}, [sid])
|
| 41 |
-
useEffect(() => {
|
| 42 |
-
if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
|
| 43 |
-
}, [transcript, pending, thinking])
|
| 44 |
-
|
| 45 |
-
const commit = () => {
|
| 46 |
-
if (!pending) return
|
| 47 |
-
g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'sus', text: pending.text } })
|
| 48 |
-
g.dispatch({ type: 'SUSP_SET', sid, value: pending.suspicion })
|
| 49 |
-
setPending(null)
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
const run = async (body: Parameters<typeof interrogate>[2], detLine: { text: string; ev?: string }) => {
|
| 53 |
-
if (busy) return
|
| 54 |
-
stopSpeak()
|
| 55 |
-
setTalking(false)
|
| 56 |
-
g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'det', text: detLine.text, ev: detLine.ev } })
|
| 57 |
-
setThinking(true)
|
| 58 |
-
const fallback = g.state.tweaks.typeSpeed || 22
|
| 59 |
-
try {
|
| 60 |
-
const r = await interrogate(g.runId, sid, body)
|
| 61 |
-
const tag = r.flags?.rattled ? 'RATTLED' : r.flags?.cornered ? 'CRACKING' : null
|
| 62 |
-
// Synthesize the voice during the "thinking" beat so text + speech start together.
|
| 63 |
-
const voice = await prepareSpeak(g.runId, sid, r.reply, () => setTalking(false))
|
| 64 |
-
// Pace the typewriter to the audio so words land in step with the spoken line.
|
| 65 |
-
const speed = voice ? Math.max(14, Math.min(90, Math.round(voice.durationMs / Math.max(1, r.reply.length)))) : fallback
|
| 66 |
-
setThinking(false)
|
| 67 |
-
setPending({ text: r.reply, suspicion: r.suspicion, tag, speed })
|
| 68 |
-
if (voice) {
|
| 69 |
-
setTalking(true)
|
| 70 |
-
voice.play()
|
| 71 |
-
}
|
| 72 |
-
} catch {
|
| 73 |
-
setThinking(false)
|
| 74 |
-
setPending({ text: '…I have nothing more to say to that.', suspicion: susp, speed: fallback })
|
| 75 |
-
}
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
const ask = (q: SuggestedQuestion) => {
|
| 79 |
-
if (busy) return
|
| 80 |
-
playSfx('select')
|
| 81 |
-
g.dispatch({ type: 'USEQ', sid, qid: q.id })
|
| 82 |
-
run({ questionId: q.id }, { text: q.q })
|
| 83 |
-
}
|
| 84 |
-
const present = (e: Evidence) => {
|
| 85 |
-
setTray(false)
|
| 86 |
-
if (busy) return
|
| 87 |
-
playSfx('present')
|
| 88 |
-
g.dispatch({ type: 'USEEV', sid, ev: e.id })
|
| 89 |
-
run({ presentEvidenceId: e.id }, { text: `Look at this. ${e.name}.`, ev: e.icon })
|
| 90 |
-
}
|
| 91 |
-
const sendFree = () => {
|
| 92 |
-
const txt = input.trim()
|
| 93 |
-
if (!txt || busy) return
|
| 94 |
-
setInput('')
|
| 95 |
-
run({ freeText: txt }, { text: txt })
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
const px = g.mode === 'mobile' ? 8 : 13
|
| 99 |
-
return (
|
| 100 |
-
<div class="app__view">
|
| 101 |
-
<Hud
|
| 102 |
-
title={s.name}
|
| 103 |
-
sub={s.role}
|
| 104 |
-
right={
|
| 105 |
-
<>
|
| 106 |
-
<div style={{ width: g.mode === 'mobile' ?
|
| 107 |
-
<Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>
|
| 108 |
-
</>
|
| 109 |
-
}
|
| 110 |
-
/>
|
| 111 |
-
<div class="interro" style={{ position: 'relative' }}>
|
| 112 |
-
<div class="interro__stage">
|
| 113 |
-
<Scene name="interro" w={220} h={380} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
|
| 114 |
-
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(55% 45% at 50% 58%, rgba(224,164,76,.12), transparent 72%)' }} />
|
| 115 |
-
<div class="interro__sprite" style={{ bottom: '7%' }}><Portrait id={sid} px={px} gender={s.gender} talking={talking} /></div>
|
| 116 |
-
<div style={{ position: 'absolute', top: 12, left: 12, zIndex: 4 }}>
|
| 117 |
-
<Panel style={{ padding: 8 }}>
|
| 118 |
-
<span class="t-label">{s.tag}</span>
|
| 119 |
-
<div class="t-mono dim" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>ALIBI · {s.alibi}</div>
|
| 120 |
-
</Panel>
|
| 121 |
-
</div>
|
| 122 |
-
</div>
|
| 123 |
-
|
| 124 |
-
<div class="interro__right">
|
| 125 |
-
<div class="transcript" ref={scroller}>
|
| 126 |
-
{transcript.map((l, i) => (
|
| 127 |
-
<div key={i} class={'tline tline--' + (l.role === 'det' ? 'det' : 'sus')}>
|
| 128 |
-
<div class="tline__who">{l.role === 'det' ? 'YOU' : s.name}</div>
|
| 129 |
-
<div class="tline__b">
|
| 130 |
-
{l.ev && (
|
| 131 |
-
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6, background: 'var(--ink-1)', padding: 2 }}>
|
| 132 |
-
<EvIcon icon={l.ev} px={2} />
|
| 133 |
-
</span>
|
| 134 |
-
)}
|
| 135 |
-
{l.text}
|
| 136 |
-
</div>
|
| 137 |
-
</div>
|
| 138 |
-
))}
|
| 139 |
-
{thinking && (
|
| 140 |
-
<div class="tline tline--sus">
|
| 141 |
-
<div class="tline__who">{s.name}</div>
|
| 142 |
-
<div class="tline__b"><span class="cursor" /></div>
|
| 143 |
-
</div>
|
| 144 |
-
)}
|
| 145 |
-
{pending && (
|
| 146 |
-
<div class="tline tline--sus">
|
| 147 |
-
<div class="tline__who">{s.name}{pending.tag && <span class="ox"> · {pending.tag}</span>}</div>
|
| 148 |
-
<div class="tline__b"><TypeOnce text={pending.text} speed={pending.speed ?? (g.state.tweaks.typeSpeed || 22)} onDone={commit} /></div>
|
| 149 |
-
</div>
|
| 150 |
-
)}
|
| 151 |
-
</div>
|
| 152 |
-
|
| 153 |
-
<div class="composer">
|
| 154 |
-
<div class="qsuggest">
|
| 155 |
-
{s.suggestedQuestions.map((q) => (
|
| 156 |
-
<button key={q.id} class="qchip" disabled={usedQ.includes(q.id) || busy} onClick={() => ask(q)}>
|
| 157 |
-
{usedQ.includes(q.id) ? '✓ ' : '? '}
|
| 158 |
-
{q.q}
|
| 159 |
-
</button>
|
| 160 |
-
))}
|
| 161 |
-
</div>
|
| 162 |
-
<div class="composer__input">
|
| 163 |
-
<input
|
| 164 |
-
class="pinput"
|
| 165 |
-
placeholder="Type your question…"
|
| 166 |
-
value={input}
|
| 167 |
-
onInput={(e) => setInput((e.target as HTMLInputElement).value)}
|
| 168 |
-
onKeyDown={(e) => e.key === 'Enter' && sendFree()}
|
| 169 |
-
/>
|
| 170 |
-
<Btn variant="amber" sm onClick={sendFree} disabled={busy}>Ask</Btn>
|
| 171 |
-
<Btn variant="ox" sm onClick={() => setTray(true)} disabled={busy}>Evidence ▴</Btn>
|
| 172 |
-
</div>
|
| 173 |
-
</div>
|
| 174 |
-
</div>
|
| 175 |
-
|
| 176 |
-
{tray && (
|
| 177 |
-
<div class="tray" onClick={() => setTray(false)}>
|
| 178 |
-
<div class="tray__sheet" onClick={(e) => e.stopPropagation()}>
|
| 179 |
-
<div class="between" style={{ marginBottom: 12 }}>
|
| 180 |
-
<span class="t-display amber" style={{ fontSize: 12 }}>PRESENT EVIDENCE</span>
|
| 181 |
-
<Btn sm variant="ghost" onClick={() => setTray(false)}>✕ Close</Btn>
|
| 182 |
-
</div>
|
| 183 |
-
<div class="tray__grid">
|
| 184 |
-
{c.evidence.map((e) => (
|
| 185 |
-
<EvidenceCard key={e.id} e={e} active={usedEv.includes(e.id)} onClick={() => present(e)} />
|
| 186 |
-
))}
|
| 187 |
-
</div>
|
| 188 |
-
</div>
|
| 189 |
-
</div>
|
| 190 |
-
)}
|
| 191 |
-
</div>
|
| 192 |
-
</div>
|
| 193 |
-
)
|
| 194 |
-
}
|
|
|
|
| 1 |
+
// Interrogation room — questions, free-text, and present-evidence all go to the server,
|
| 2 |
+
// which returns the suspect's reply + the authoritative suspicion. The client only displays.
|
| 3 |
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
| 4 |
+
|
| 5 |
+
import { interrogate } from '../api'
|
| 6 |
+
import { useGame } from '../store'
|
| 7 |
+
import type { Evidence, SuggestedQuestion } from '../types'
|
| 8 |
+
import { playSfx, prepareSpeak, stopSpeak } from '../ui/audio'
|
| 9 |
+
import { Btn, EvIcon, EvidenceCard, Hud, Panel, Portrait, Scene, SuspicionBar, TypeOnce } from '../ui/components'
|
| 10 |
+
|
| 11 |
+
interface Pending {
|
| 12 |
+
text: string
|
| 13 |
+
tag?: string | null
|
| 14 |
+
suspicion: number
|
| 15 |
+
speed?: number // typewriter ms/char, paced to the voice when one is available
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function Interrogation() {
|
| 19 |
+
const g = useGame()
|
| 20 |
+
const c = g.case
|
| 21 |
+
const sid = (g.state.payload.suspect as string) || c.suspects[0].id
|
| 22 |
+
const s = c.suspects.find((x) => x.id === sid)!
|
| 23 |
+
const transcript = g.state.interrogations[sid] || []
|
| 24 |
+
const susp = g.state.suspicion[sid]
|
| 25 |
+
const usedQ = g.state.usedQ[sid] || []
|
| 26 |
+
const usedEv = g.state.usedEv[sid] || []
|
| 27 |
+
const [pending, setPending] = useState<Pending | null>(null)
|
| 28 |
+
const [thinking, setThinking] = useState(false)
|
| 29 |
+
const [talking, setTalking] = useState(false)
|
| 30 |
+
const [tray, setTray] = useState(false)
|
| 31 |
+
const [input, setInput] = useState('')
|
| 32 |
+
const scroller = useRef<HTMLDivElement>(null)
|
| 33 |
+
const busy = thinking || !!pending
|
| 34 |
+
|
| 35 |
+
useEffect(() => () => stopSpeak(), []) // stop any voice when leaving the room
|
| 36 |
+
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
if (!transcript.length) setPending({ text: s.greet, suspicion: susp })
|
| 39 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 40 |
+
}, [sid])
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
|
| 43 |
+
}, [transcript, pending, thinking])
|
| 44 |
+
|
| 45 |
+
const commit = () => {
|
| 46 |
+
if (!pending) return
|
| 47 |
+
g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'sus', text: pending.text } })
|
| 48 |
+
g.dispatch({ type: 'SUSP_SET', sid, value: pending.suspicion })
|
| 49 |
+
setPending(null)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const run = async (body: Parameters<typeof interrogate>[2], detLine: { text: string; ev?: string }) => {
|
| 53 |
+
if (busy) return
|
| 54 |
+
stopSpeak()
|
| 55 |
+
setTalking(false)
|
| 56 |
+
g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'det', text: detLine.text, ev: detLine.ev } })
|
| 57 |
+
setThinking(true)
|
| 58 |
+
const fallback = g.state.tweaks.typeSpeed || 22
|
| 59 |
+
try {
|
| 60 |
+
const r = await interrogate(g.runId, sid, body)
|
| 61 |
+
const tag = r.flags?.rattled ? 'RATTLED' : r.flags?.cornered ? 'CRACKING' : null
|
| 62 |
+
// Synthesize the voice during the "thinking" beat so text + speech start together.
|
| 63 |
+
const voice = await prepareSpeak(g.runId, sid, r.reply, () => setTalking(false))
|
| 64 |
+
// Pace the typewriter to the audio so words land in step with the spoken line.
|
| 65 |
+
const speed = voice ? Math.max(14, Math.min(90, Math.round(voice.durationMs / Math.max(1, r.reply.length)))) : fallback
|
| 66 |
+
setThinking(false)
|
| 67 |
+
setPending({ text: r.reply, suspicion: r.suspicion, tag, speed })
|
| 68 |
+
if (voice) {
|
| 69 |
+
setTalking(true)
|
| 70 |
+
voice.play()
|
| 71 |
+
}
|
| 72 |
+
} catch {
|
| 73 |
+
setThinking(false)
|
| 74 |
+
setPending({ text: '…I have nothing more to say to that.', suspicion: susp, speed: fallback })
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const ask = (q: SuggestedQuestion) => {
|
| 79 |
+
if (busy) return
|
| 80 |
+
playSfx('select')
|
| 81 |
+
g.dispatch({ type: 'USEQ', sid, qid: q.id })
|
| 82 |
+
run({ questionId: q.id }, { text: q.q })
|
| 83 |
+
}
|
| 84 |
+
const present = (e: Evidence) => {
|
| 85 |
+
setTray(false)
|
| 86 |
+
if (busy) return
|
| 87 |
+
playSfx('present')
|
| 88 |
+
g.dispatch({ type: 'USEEV', sid, ev: e.id })
|
| 89 |
+
run({ presentEvidenceId: e.id }, { text: `Look at this. ${e.name}.`, ev: e.icon })
|
| 90 |
+
}
|
| 91 |
+
const sendFree = () => {
|
| 92 |
+
const txt = input.trim()
|
| 93 |
+
if (!txt || busy) return
|
| 94 |
+
setInput('')
|
| 95 |
+
run({ freeText: txt }, { text: txt })
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const px = g.mode === 'mobile' ? 8 : 13
|
| 99 |
+
return (
|
| 100 |
+
<div class="app__view">
|
| 101 |
+
<Hud
|
| 102 |
+
title={s.name}
|
| 103 |
+
sub={s.role}
|
| 104 |
+
right={
|
| 105 |
+
<>
|
| 106 |
+
<div style={{ width: g.mode === 'mobile' ? 96 : 180 }}><SuspicionBar value={susp} /></div>
|
| 107 |
+
<Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>
|
| 108 |
+
</>
|
| 109 |
+
}
|
| 110 |
+
/>
|
| 111 |
+
<div class="interro" style={{ position: 'relative' }}>
|
| 112 |
+
<div class="interro__stage">
|
| 113 |
+
<Scene name="interro" w={220} h={380} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
|
| 114 |
+
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(55% 45% at 50% 58%, rgba(224,164,76,.12), transparent 72%)' }} />
|
| 115 |
+
<div class="interro__sprite" style={{ bottom: '7%' }}><Portrait id={sid} px={px} gender={s.gender} talking={talking} /></div>
|
| 116 |
+
<div style={{ position: 'absolute', top: 12, left: 12, right: 12, zIndex: 4 }}>
|
| 117 |
+
<Panel style={{ padding: 8, width: 'fit-content', maxWidth: '100%' }}>
|
| 118 |
+
<span class="t-label">{s.tag}</span>
|
| 119 |
+
<div class="t-mono dim" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>ALIBI · {s.alibi}</div>
|
| 120 |
+
</Panel>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<div class="interro__right">
|
| 125 |
+
<div class="transcript" ref={scroller}>
|
| 126 |
+
{transcript.map((l, i) => (
|
| 127 |
+
<div key={i} class={'tline tline--' + (l.role === 'det' ? 'det' : 'sus')}>
|
| 128 |
+
<div class="tline__who">{l.role === 'det' ? 'YOU' : s.name}</div>
|
| 129 |
+
<div class="tline__b">
|
| 130 |
+
{l.ev && (
|
| 131 |
+
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6, background: 'var(--ink-1)', padding: 2 }}>
|
| 132 |
+
<EvIcon icon={l.ev} px={2} />
|
| 133 |
+
</span>
|
| 134 |
+
)}
|
| 135 |
+
{l.text}
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
))}
|
| 139 |
+
{thinking && (
|
| 140 |
+
<div class="tline tline--sus">
|
| 141 |
+
<div class="tline__who">{s.name}</div>
|
| 142 |
+
<div class="tline__b"><span class="cursor" /></div>
|
| 143 |
+
</div>
|
| 144 |
+
)}
|
| 145 |
+
{pending && (
|
| 146 |
+
<div class="tline tline--sus">
|
| 147 |
+
<div class="tline__who">{s.name}{pending.tag && <span class="ox"> · {pending.tag}</span>}</div>
|
| 148 |
+
<div class="tline__b"><TypeOnce text={pending.text} speed={pending.speed ?? (g.state.tweaks.typeSpeed || 22)} onDone={commit} /></div>
|
| 149 |
+
</div>
|
| 150 |
+
)}
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<div class="composer">
|
| 154 |
+
<div class="qsuggest">
|
| 155 |
+
{s.suggestedQuestions.map((q) => (
|
| 156 |
+
<button key={q.id} class="qchip" disabled={usedQ.includes(q.id) || busy} onClick={() => ask(q)}>
|
| 157 |
+
{usedQ.includes(q.id) ? '✓ ' : '? '}
|
| 158 |
+
{q.q}
|
| 159 |
+
</button>
|
| 160 |
+
))}
|
| 161 |
+
</div>
|
| 162 |
+
<div class="composer__input">
|
| 163 |
+
<input
|
| 164 |
+
class="pinput"
|
| 165 |
+
placeholder="Type your question…"
|
| 166 |
+
value={input}
|
| 167 |
+
onInput={(e) => setInput((e.target as HTMLInputElement).value)}
|
| 168 |
+
onKeyDown={(e) => e.key === 'Enter' && sendFree()}
|
| 169 |
+
/>
|
| 170 |
+
<Btn variant="amber" sm onClick={sendFree} disabled={busy}>Ask</Btn>
|
| 171 |
+
<Btn variant="ox" sm onClick={() => setTray(true)} disabled={busy}>Evidence ▴</Btn>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
{tray && (
|
| 177 |
+
<div class="tray" onClick={() => setTray(false)}>
|
| 178 |
+
<div class="tray__sheet" onClick={(e) => e.stopPropagation()}>
|
| 179 |
+
<div class="between" style={{ marginBottom: 12 }}>
|
| 180 |
+
<span class="t-display amber" style={{ fontSize: 12 }}>PRESENT EVIDENCE</span>
|
| 181 |
+
<Btn sm variant="ghost" onClick={() => setTray(false)}>✕ Close</Btn>
|
| 182 |
+
</div>
|
| 183 |
+
<div class="tray__grid">
|
| 184 |
+
{c.evidence.map((e) => (
|
| 185 |
+
<EvidenceCard key={e.id} e={e} active={usedEv.includes(e.id)} onClick={() => present(e)} />
|
| 186 |
+
))}
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
)}
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
)
|
| 194 |
+
}
|
web/src/screens/suspects.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Persons of interest — the mobile suspects list (own bottom-nav tab). Desktop shows
|
| 2 |
+
// the suspect rail on the board instead, so this screen falls through to the Board there.
|
| 3 |
+
import { useState } from 'preact/hooks'
|
| 4 |
+
|
| 5 |
+
import { useGame } from '../store'
|
| 6 |
+
import type { Suspect } from '../types'
|
| 7 |
+
import { BottomNav, Btn, Hud, Panel, SuspectCard } from '../ui/components'
|
| 8 |
+
import { Board } from './board'
|
| 9 |
+
|
| 10 |
+
export function SuspectsScreen() {
|
| 11 |
+
const g = useGame()
|
| 12 |
+
const [sel, setSel] = useState<string | null>(null) // before the early return: hook order survives a live resize
|
| 13 |
+
if (g.mode === 'desktop') return <Board />
|
| 14 |
+
const c = g.case
|
| 15 |
+
return (
|
| 16 |
+
<div class="app__view">
|
| 17 |
+
<Hud
|
| 18 |
+
title="PERSONS OF INTEREST"
|
| 19 |
+
sub={`${c.suspects.length} SUSPECTS · TAP, THEN INTERROGATE`}
|
| 20 |
+
right={<Btn sm variant="ghost" onClick={() => g.nav('accuse')}>Accuse</Btn>}
|
| 21 |
+
/>
|
| 22 |
+
<div class="screen-pad">
|
| 23 |
+
<div class="col" style={{ gap: 10 }}>
|
| 24 |
+
{c.suspects.map((s: Suspect) => {
|
| 25 |
+
const open = sel === s.id
|
| 26 |
+
return (
|
| 27 |
+
<div key={s.id} onClick={() => setSel(open ? null : s.id)}>
|
| 28 |
+
<SuspectCard s={s} active={open} />
|
| 29 |
+
{open && (
|
| 30 |
+
<div style={{ marginTop: -2 }}>
|
| 31 |
+
<Panel variant="amber" style={{ padding: 10, marginTop: 4 }}>
|
| 32 |
+
<div class="t-body" style={{ fontSize: 13, lineHeight: 1.4, marginBottom: 8 }}>{s.alibi}</div>
|
| 33 |
+
<Btn variant="ox" className="grow" style={{ width: '100%' }} onClick={(e) => { e.stopPropagation(); g.nav('interro', { suspect: s.id }) }}>
|
| 34 |
+
▸ Interrogate {s.name.split(' ')[0]}
|
| 35 |
+
</Btn>
|
| 36 |
+
</Panel>
|
| 37 |
+
</div>
|
| 38 |
+
)}
|
| 39 |
+
</div>
|
| 40 |
+
)
|
| 41 |
+
})}
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
<BottomNav />
|
| 45 |
+
</div>
|
| 46 |
+
)
|
| 47 |
+
}
|
web/src/store.tsx
CHANGED
|
@@ -1,206 +1,191 @@
|
|
| 1 |
-
// Game store: reducer, context, responsive mode, and localStorage-backed tweaks.
|
| 2 |
-
// Mirrors the prototype's app.jsx store, but the case comes from the server and
|
| 3 |
-
// suspicion/verdict are server-authoritative (the client only displays them).
|
| 4 |
-
import { createContext } from 'preact'
|
| 5 |
-
import type { ComponentChildren } from 'preact'
|
| 6 |
-
import { useCallback, useContext, useEffect, useReducer, useState } from 'preact/hooks'
|
| 7 |
-
|
| 8 |
-
import type { PublicCase } from './types'
|
| 9 |
-
|
| 10 |
-
export type Screen =
|
| 11 |
-
| 'title' | 'story' | 'briefing' | 'board' | 'interro' | 'evidence'
|
| 12 |
-
| 'flashback' | 'timeline' | 'notes' | 'accuse' | 'verdict' | 'share' | 'boot'
|
| 13 |
-
|
| 14 |
-
export interface Line {
|
| 15 |
-
role: 'det' | 'sus'
|
| 16 |
-
text: string
|
| 17 |
-
ev?: string
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
export interface AccuseState {
|
| 21 |
-
suspect: string | null
|
| 22 |
-
motive: string | null
|
| 23 |
-
evidence: string[]
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
export interface GameState {
|
| 27 |
-
screen: Screen
|
| 28 |
-
payload: Record<string, unknown>
|
| 29 |
-
suspicion: Record<string, number>
|
| 30 |
-
interrogations: Record<string, Line[]>
|
| 31 |
-
usedQ: Record<string, string[]>
|
| 32 |
-
usedEv: Record<string, string[]>
|
| 33 |
-
pinned: string[]
|
| 34 |
-
accuse: AccuseState
|
| 35 |
-
startedAt: number
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
type Action =
|
| 39 |
-
| { type: 'NAV'; screen: Screen; payload?: Record<string, unknown> }
|
| 40 |
-
| { type: 'SUSP_SET'; sid: string; value: number }
|
| 41 |
-
| { type: 'ADD_LINE'; sid: string; line: Line }
|
| 42 |
-
| { type: 'USEQ'; sid: string; qid: string }
|
| 43 |
-
| { type: 'USEEV'; sid: string; ev: string }
|
| 44 |
-
| { type: 'PIN'; ev: string }
|
| 45 |
-
| { type: 'ACCUSE'; field: keyof AccuseState; value: unknown }
|
| 46 |
-
|
| 47 |
-
function reducer(s: GameState, a: Action): GameState {
|
| 48 |
-
switch (a.type) {
|
| 49 |
-
case 'NAV':
|
| 50 |
-
return { ...s, screen: a.screen, payload: a.payload || {} }
|
| 51 |
-
case 'SUSP_SET':
|
| 52 |
-
return { ...s, suspicion: { ...s.suspicion, [a.sid]: Math.max(0, Math.min(100, Math.round(a.value))) } }
|
| 53 |
-
case 'ADD_LINE':
|
| 54 |
-
return { ...s, interrogations: { ...s.interrogations, [a.sid]: [...(s.interrogations[a.sid] || []), a.line] } }
|
| 55 |
-
case 'USEQ':
|
| 56 |
-
return { ...s, usedQ: { ...s.usedQ, [a.sid]: [...(s.usedQ[a.sid] || []), a.qid] } }
|
| 57 |
-
case 'USEEV':
|
| 58 |
-
return { ...s, usedEv: { ...s.usedEv, [a.sid]: [...(s.usedEv[a.sid] || []), a.ev] } }
|
| 59 |
-
case 'PIN':
|
| 60 |
-
return { ...s, pinned: s.pinned.includes(a.ev) ? s.pinned : [...s.pinned, a.ev] }
|
| 61 |
-
case 'ACCUSE':
|
| 62 |
-
return { ...s, accuse: { ...s.accuse, [a.field]: a.value } }
|
| 63 |
-
default:
|
| 64 |
-
return s
|
| 65 |
-
}
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
function initialState(c: PublicCase, screen: Screen = 'title'): GameState {
|
| 69 |
-
const suspicion: Record<string, number> = {}
|
| 70 |
-
c.suspects.forEach((s) => {
|
| 71 |
-
suspicion[s.id] = s.baselineSuspicion
|
| 72 |
-
})
|
| 73 |
-
return {
|
| 74 |
-
screen,
|
| 75 |
-
payload: {},
|
| 76 |
-
suspicion,
|
| 77 |
-
interrogations: {},
|
| 78 |
-
usedQ: {},
|
| 79 |
-
usedEv: {},
|
| 80 |
-
pinned: [],
|
| 81 |
-
accuse: { suspect: null, motive: null, evidence: [] },
|
| 82 |
-
startedAt: Date.now(),
|
| 83 |
-
}
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
// ---- tweaks ----
|
| 87 |
-
export interface Tweaks {
|
| 88 |
-
palette: 'sodium' | 'harbor' | 'violet'
|
| 89 |
-
fonts: 'crisp' | 'terminal' | 'stamp'
|
| 90 |
-
fx: 'low' | 'med' | 'high'
|
| 91 |
-
mood: 'night' | 'day'
|
| 92 |
-
pixelScale: number
|
| 93 |
-
typeSpeed: number
|
| 94 |
-
rain: boolean
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
export const TWEAK_DEFAULTS: Tweaks = {
|
| 98 |
-
palette: 'sodium', fonts: 'crisp', fx: 'med', mood: 'night',
|
| 99 |
-
pixelScale: 1, typeSpeed: 18, rain: true,
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
export function useTweaks(): [Tweaks, <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void] {
|
| 105 |
-
const [t, setT] = useState<Tweaks>(
|
| 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 |
-
mode: 'desktop' | 'mobile'
|
| 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 |
-
state: { ...state, tweaks },
|
| 193 |
-
dispatch,
|
| 194 |
-
nav,
|
| 195 |
-
mode,
|
| 196 |
-
runStats,
|
| 197 |
-
case: c,
|
| 198 |
-
runId,
|
| 199 |
-
setTweak,
|
| 200 |
-
newCase,
|
| 201 |
-
loadCase,
|
| 202 |
-
}
|
| 203 |
-
window.__game = game
|
| 204 |
-
|
| 205 |
-
return <GameCtx.Provider value={game}>{children}</GameCtx.Provider>
|
| 206 |
-
}
|
|
|
|
| 1 |
+
// Game store: reducer, context, responsive mode, and localStorage-backed tweaks.
|
| 2 |
+
// Mirrors the prototype's app.jsx store, but the case comes from the server and
|
| 3 |
+
// suspicion/verdict are server-authoritative (the client only displays them).
|
| 4 |
+
import { createContext } from 'preact'
|
| 5 |
+
import type { ComponentChildren } from 'preact'
|
| 6 |
+
import { useCallback, useContext, useEffect, useReducer, useState } from 'preact/hooks'
|
| 7 |
+
|
| 8 |
+
import type { PublicCase } from './types'
|
| 9 |
+
|
| 10 |
+
export type Screen =
|
| 11 |
+
| 'title' | 'story' | 'briefing' | 'board' | 'suspects' | 'interro' | 'evidence'
|
| 12 |
+
| 'flashback' | 'timeline' | 'notes' | 'accuse' | 'verdict' | 'share' | 'boot'
|
| 13 |
+
|
| 14 |
+
export interface Line {
|
| 15 |
+
role: 'det' | 'sus'
|
| 16 |
+
text: string
|
| 17 |
+
ev?: string
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export interface AccuseState {
|
| 21 |
+
suspect: string | null
|
| 22 |
+
motive: string | null
|
| 23 |
+
evidence: string[]
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export interface GameState {
|
| 27 |
+
screen: Screen
|
| 28 |
+
payload: Record<string, unknown>
|
| 29 |
+
suspicion: Record<string, number>
|
| 30 |
+
interrogations: Record<string, Line[]>
|
| 31 |
+
usedQ: Record<string, string[]>
|
| 32 |
+
usedEv: Record<string, string[]>
|
| 33 |
+
pinned: string[]
|
| 34 |
+
accuse: AccuseState
|
| 35 |
+
startedAt: number
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
type Action =
|
| 39 |
+
| { type: 'NAV'; screen: Screen; payload?: Record<string, unknown> }
|
| 40 |
+
| { type: 'SUSP_SET'; sid: string; value: number }
|
| 41 |
+
| { type: 'ADD_LINE'; sid: string; line: Line }
|
| 42 |
+
| { type: 'USEQ'; sid: string; qid: string }
|
| 43 |
+
| { type: 'USEEV'; sid: string; ev: string }
|
| 44 |
+
| { type: 'PIN'; ev: string }
|
| 45 |
+
| { type: 'ACCUSE'; field: keyof AccuseState; value: unknown }
|
| 46 |
+
|
| 47 |
+
function reducer(s: GameState, a: Action): GameState {
|
| 48 |
+
switch (a.type) {
|
| 49 |
+
case 'NAV':
|
| 50 |
+
return { ...s, screen: a.screen, payload: a.payload || {} }
|
| 51 |
+
case 'SUSP_SET':
|
| 52 |
+
return { ...s, suspicion: { ...s.suspicion, [a.sid]: Math.max(0, Math.min(100, Math.round(a.value))) } }
|
| 53 |
+
case 'ADD_LINE':
|
| 54 |
+
return { ...s, interrogations: { ...s.interrogations, [a.sid]: [...(s.interrogations[a.sid] || []), a.line] } }
|
| 55 |
+
case 'USEQ':
|
| 56 |
+
return { ...s, usedQ: { ...s.usedQ, [a.sid]: [...(s.usedQ[a.sid] || []), a.qid] } }
|
| 57 |
+
case 'USEEV':
|
| 58 |
+
return { ...s, usedEv: { ...s.usedEv, [a.sid]: [...(s.usedEv[a.sid] || []), a.ev] } }
|
| 59 |
+
case 'PIN':
|
| 60 |
+
return { ...s, pinned: s.pinned.includes(a.ev) ? s.pinned : [...s.pinned, a.ev] }
|
| 61 |
+
case 'ACCUSE':
|
| 62 |
+
return { ...s, accuse: { ...s.accuse, [a.field]: a.value } }
|
| 63 |
+
default:
|
| 64 |
+
return s
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function initialState(c: PublicCase, screen: Screen = 'title'): GameState {
|
| 69 |
+
const suspicion: Record<string, number> = {}
|
| 70 |
+
c.suspects.forEach((s) => {
|
| 71 |
+
suspicion[s.id] = s.baselineSuspicion
|
| 72 |
+
})
|
| 73 |
+
return {
|
| 74 |
+
screen,
|
| 75 |
+
payload: {},
|
| 76 |
+
suspicion,
|
| 77 |
+
interrogations: {},
|
| 78 |
+
usedQ: {},
|
| 79 |
+
usedEv: {},
|
| 80 |
+
pinned: [],
|
| 81 |
+
accuse: { suspect: null, motive: null, evidence: [] },
|
| 82 |
+
startedAt: Date.now(),
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// ---- tweaks ----
|
| 87 |
+
export interface Tweaks {
|
| 88 |
+
palette: 'sodium' | 'harbor' | 'violet'
|
| 89 |
+
fonts: 'crisp' | 'terminal' | 'stamp'
|
| 90 |
+
fx: 'low' | 'med' | 'high'
|
| 91 |
+
mood: 'night' | 'day'
|
| 92 |
+
pixelScale: number
|
| 93 |
+
typeSpeed: number
|
| 94 |
+
rain: boolean
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
export const TWEAK_DEFAULTS: Tweaks = {
|
| 98 |
+
palette: 'sodium', fonts: 'crisp', fx: 'med', mood: 'night',
|
| 99 |
+
pixelScale: 1, typeSpeed: 18, rain: true,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// In-memory only: the tweaks sheet UI is gone, so persisted values would be
|
| 103 |
+
// unrevertable. Defaults are deterministic; setTweak stays as a debug hook.
|
| 104 |
+
export function useTweaks(): [Tweaks, <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void] {
|
| 105 |
+
const [t, setT] = useState<Tweaks>(TWEAK_DEFAULTS)
|
| 106 |
+
const setTweak = useCallback(<K extends keyof Tweaks>(k: K, v: Tweaks[K]) => {
|
| 107 |
+
setT((prev) => ({ ...prev, [k]: v }))
|
| 108 |
+
}, [])
|
| 109 |
+
return [t, setTweak]
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// ---- responsive mode ----
|
| 113 |
+
export type Device = 'auto' | 'desktop' | 'mobile'
|
| 114 |
+
export function useMode(device: Device): 'desktop' | 'mobile' {
|
| 115 |
+
const [w, setW] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200)
|
| 116 |
+
useEffect(() => {
|
| 117 |
+
const f = () => setW(window.innerWidth)
|
| 118 |
+
window.addEventListener('resize', f)
|
| 119 |
+
return () => window.removeEventListener('resize', f)
|
| 120 |
+
}, [])
|
| 121 |
+
if (device === 'desktop') return 'desktop'
|
| 122 |
+
if (device === 'mobile') return 'mobile'
|
| 123 |
+
return w < 820 ? 'mobile' : 'desktop'
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// ---- context ----
|
| 127 |
+
export interface Game {
|
| 128 |
+
state: GameState & { tweaks: Tweaks }
|
| 129 |
+
dispatch: (a: Action) => void
|
| 130 |
+
nav: (screen: Screen, payload?: Record<string, unknown>) => void
|
| 131 |
+
mode: 'desktop' | 'mobile'
|
| 132 |
+
runStats: () => [string, string][]
|
| 133 |
+
case: PublicCase
|
| 134 |
+
runId: string
|
| 135 |
+
setTweak: <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void
|
| 136 |
+
newCase: () => void // fetch a fresh case from the server and start playing it
|
| 137 |
+
loadCase: (id: string) => void // load a specific case by ID and jump straight into it
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const GameCtx = createContext<Game | null>(null)
|
| 141 |
+
export const useGame = (): Game => useContext(GameCtx)!
|
| 142 |
+
|
| 143 |
+
interface ProviderProps {
|
| 144 |
+
case: PublicCase
|
| 145 |
+
runId: string
|
| 146 |
+
mode: 'desktop' | 'mobile'
|
| 147 |
+
tweaks: Tweaks
|
| 148 |
+
setTweak: <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void
|
| 149 |
+
initialScreen?: Screen
|
| 150 |
+
newCase: () => void
|
| 151 |
+
loadCase: (id: string) => void
|
| 152 |
+
children: ComponentChildren
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
export function GameProvider({ case: c, runId, mode, tweaks, setTweak, initialScreen = 'title', newCase, loadCase, children }: ProviderProps) {
|
| 156 |
+
const [state, dispatch] = useReducer(reducer, c, (cc) => initialState(cc, initialScreen))
|
| 157 |
+
|
| 158 |
+
const nav = useCallback((screen: Screen, payload?: Record<string, unknown>) => {
|
| 159 |
+
dispatch({ type: 'NAV', screen, payload })
|
| 160 |
+
const stage = document.querySelector('.app__view')
|
| 161 |
+
if (stage) stage.scrollTop = 0
|
| 162 |
+
}, [])
|
| 163 |
+
|
| 164 |
+
const runStats = useCallback((): [string, string][] => {
|
| 165 |
+
const ms = Date.now() - state.startedAt
|
| 166 |
+
const mm = String(Math.floor(ms / 60000)).padStart(2, '0')
|
| 167 |
+
const ss = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')
|
| 168 |
+
const grilled = Object.values(state.interrogations).filter((x) => x.length > 1).length
|
| 169 |
+
return [
|
| 170 |
+
['TIME', `${mm}:${ss}`],
|
| 171 |
+
['GRILLED', `${grilled}/${c.suspects.length}`],
|
| 172 |
+
['EXHIBITS', `${state.pinned.length}/${c.evidence.length}`],
|
| 173 |
+
]
|
| 174 |
+
}, [state, c])
|
| 175 |
+
|
| 176 |
+
const game: Game = {
|
| 177 |
+
state: { ...state, tweaks },
|
| 178 |
+
dispatch,
|
| 179 |
+
nav,
|
| 180 |
+
mode,
|
| 181 |
+
runStats,
|
| 182 |
+
case: c,
|
| 183 |
+
runId,
|
| 184 |
+
setTweak,
|
| 185 |
+
newCase,
|
| 186 |
+
loadCase,
|
| 187 |
+
}
|
| 188 |
+
window.__game = game
|
| 189 |
+
|
| 190 |
+
return <GameCtx.Provider value={game}>{children}</GameCtx.Provider>
|
| 191 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
web/src/styles/layout.css
CHANGED
|
@@ -1,307 +1,358 @@
|
|
| 1 |
-
/* ============================================================
|
| 2 |
-
LAYOUT & SCREEN CHROME (ported from prototype/css/layout.css)
|
| 3 |
-
============================================================ */
|
| 4 |
-
|
| 5 |
-
.app{ position:fixed; inset:0; display:flex; flex-direction:column; background:var(--ink-0); }
|
| 6 |
-
.app__view{ position:absolute; inset:0; display:flex; flex-direction:column; flex:1; min-height:0; }
|
| 7 |
-
|
| 8 |
-
/* device simulation: mobile = centered phone column */
|
| 9 |
-
.app[data-mode="mobile"] .app__frame{
|
| 10 |
-
width:min(430px,100vw); height:min(932px,100vh); margin:auto;
|
| 11 |
-
position:relative; box-shadow:0 0 0 6px var(--ink-2), 0 0 0 9px var(--ink-0), 0 0 60px -10px #000;
|
| 12 |
-
overflow:hidden; display:flex; flex-direction:column;
|
| 13 |
-
}
|
| 14 |
-
.app[data-mode="mobile"] .app__stage{ background:var(--ink-1); }
|
| 15 |
-
.app[data-mode="desktop"] .app__frame{ position:absolute; inset:0; display:flex; flex-direction:column; }
|
| 16 |
-
.app__stage{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; }
|
| 17 |
-
|
| 18 |
-
/* ---------- view toggle (top, outside content) ---------- */
|
| 19 |
-
.viewbar{
|
| 20 |
-
position:fixed; top:10px; right:14px; z-index:80;
|
| 21 |
-
display:flex; gap:6px; align-items:center;
|
| 22 |
-
}
|
| 23 |
-
.viewbar .seg{ display:flex; box-shadow:0 0 0 3px var(--ink-0); }
|
| 24 |
-
.viewbar .seg button{
|
| 25 |
-
font-family:var(--f-display); font-size:8px; letter-spacing:.08em;
|
| 26 |
-
padding:6px 9px; background:var(--ink-2); color:var(--bone-1); border:0;
|
| 27 |
-
}
|
| 28 |
-
.viewbar .seg button.on{ background:var(--amber-2); color:var(--ink-0); }
|
| 29 |
-
|
| 30 |
-
/* ============================================================
|
| 31 |
-
HUD (top chrome of in-game screens)
|
| 32 |
-
============================================================ */
|
| 33 |
-
.hud{
|
| 34 |
-
display:flex; align-items:center; justify-content:space-between; gap:12px;
|
| 35 |
-
padding:10px 14px; background:var(--ink-2);
|
| 36 |
-
box-shadow:inset 0 -3px 0 var(--ink-0), 0 3px 0 var(--ink-0);
|
| 37 |
-
z-index:20; flex-shrink:0;
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
}
|
| 44 |
-
.
|
| 45 |
-
.hud
|
| 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 |
-
background
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
.
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
.
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
.
|
| 199 |
-
.
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
.
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
.
|
| 210 |
-
.
|
| 211 |
-
.
|
| 212 |
-
.
|
| 213 |
-
.pin-card
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
.
|
| 219 |
-
.
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
.
|
| 223 |
-
.
|
| 224 |
-
.
|
| 225 |
-
.
|
| 226 |
-
.
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
.
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
.
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
.
|
| 242 |
-
.
|
| 243 |
-
|
| 244 |
-
.
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
.
|
| 257 |
-
|
| 258 |
-
.
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
.
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
.
|
| 268 |
-
.
|
| 269 |
-
.
|
| 270 |
-
.
|
| 271 |
-
.
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
.
|
| 277 |
-
.
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
.
|
| 283 |
-
.
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
.
|
| 289 |
-
.
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
LAYOUT & SCREEN CHROME (ported from prototype/css/layout.css)
|
| 3 |
+
============================================================ */
|
| 4 |
+
|
| 5 |
+
.app{ position:fixed; inset:0; display:flex; flex-direction:column; background:var(--ink-0); }
|
| 6 |
+
.app__view{ position:absolute; inset:0; display:flex; flex-direction:column; flex:1; min-height:0; }
|
| 7 |
+
|
| 8 |
+
/* device simulation: mobile = centered phone column */
|
| 9 |
+
.app[data-mode="mobile"] .app__frame{
|
| 10 |
+
width:min(430px,100vw); height:min(932px,100vh); margin:auto;
|
| 11 |
+
position:relative; box-shadow:0 0 0 6px var(--ink-2), 0 0 0 9px var(--ink-0), 0 0 60px -10px #000;
|
| 12 |
+
overflow:hidden; display:flex; flex-direction:column;
|
| 13 |
+
}
|
| 14 |
+
.app[data-mode="mobile"] .app__stage{ background:var(--ink-1); }
|
| 15 |
+
.app[data-mode="desktop"] .app__frame{ position:absolute; inset:0; display:flex; flex-direction:column; }
|
| 16 |
+
.app__stage{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; }
|
| 17 |
+
|
| 18 |
+
/* ---------- view toggle (top, outside content) ---------- */
|
| 19 |
+
.viewbar{
|
| 20 |
+
position:fixed; top:10px; right:14px; z-index:80;
|
| 21 |
+
display:flex; gap:6px; align-items:center;
|
| 22 |
+
}
|
| 23 |
+
.viewbar .seg{ display:flex; box-shadow:0 0 0 3px var(--ink-0); }
|
| 24 |
+
.viewbar .seg button{
|
| 25 |
+
font-family:var(--f-display); font-size:8px; letter-spacing:.08em;
|
| 26 |
+
padding:6px 9px; background:var(--ink-2); color:var(--bone-1); border:0;
|
| 27 |
+
}
|
| 28 |
+
.viewbar .seg button.on{ background:var(--amber-2); color:var(--ink-0); }
|
| 29 |
+
|
| 30 |
+
/* ============================================================
|
| 31 |
+
HUD (top chrome of in-game screens)
|
| 32 |
+
============================================================ */
|
| 33 |
+
.hud{
|
| 34 |
+
display:flex; align-items:center; justify-content:space-between; gap:12px;
|
| 35 |
+
padding:10px 14px; background:var(--ink-2);
|
| 36 |
+
box-shadow:inset 0 -3px 0 var(--ink-0), 0 3px 0 var(--ink-0);
|
| 37 |
+
z-index:20; flex-shrink:0;
|
| 38 |
+
}
|
| 39 |
+
/* Left cluster shrinks (title ellipsizes / clamps); right cluster never shrinks, so
|
| 40 |
+
buttons can't slide over the badge or title. On phones the right cluster wraps to
|
| 41 |
+
its own row instead of overlapping. */
|
| 42 |
+
.hud__left{ display:flex; align-items:center; gap:12px; min-width:0; flex:1 1 auto; }
|
| 43 |
+
.hud__right{ display:flex; align-items:center; gap:6px; flex-shrink:0; }
|
| 44 |
+
.hud__sub{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
| 45 |
+
[data-mode="mobile"] .hud{ flex-wrap:wrap; row-gap:7px; padding:8px 10px; }
|
| 46 |
+
[data-mode="mobile"] .hud__right{
|
| 47 |
+
margin-left:auto; max-width:100%; flex-wrap:wrap; justify-content:flex-end; row-gap:6px;
|
| 48 |
+
}
|
| 49 |
+
.hud-badge{
|
| 50 |
+
display:flex; flex-direction:column; align-items:flex-start; gap:1px;
|
| 51 |
+
background:var(--amber-2); border:0; padding:5px 9px; line-height:1.1; flex-shrink:0;
|
| 52 |
+
box-shadow:2px 2px 0 var(--ink-0), inset -2px -2px 0 rgba(0,0,0,.25);
|
| 53 |
+
}
|
| 54 |
+
.hud-badge span{ white-space:nowrap; }
|
| 55 |
+
.hud-badge:hover{ background:var(--amber-3); }
|
| 56 |
+
/* Title clamps to one line with an ellipsis on desktop (plenty of width there). */
|
| 57 |
+
.hud__title{
|
| 58 |
+
font-size:12px; color:var(--bone-3); line-height:1.15;
|
| 59 |
+
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
| 60 |
+
}
|
| 61 |
+
/* On narrow screens, wrap to two lines at a smaller size instead of cropping mid-word. */
|
| 62 |
+
[data-mode="mobile"] .hud__title{
|
| 63 |
+
font-size:10px; white-space:normal; overflow:hidden;
|
| 64 |
+
display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* ============================================================
|
| 68 |
+
BOTTOM NAV (mobile)
|
| 69 |
+
============================================================ */
|
| 70 |
+
.bottom-nav{ display:none; }
|
| 71 |
+
[data-mode="mobile"] .bottom-nav{
|
| 72 |
+
display:flex; align-items:stretch;
|
| 73 |
+
background:var(--ink-2); box-shadow:inset 0 3px 0 var(--ink-0);
|
| 74 |
+
padding:6px 4px 8px; flex-shrink:0; z-index:20;
|
| 75 |
+
}
|
| 76 |
+
.nav-btn{
|
| 77 |
+
flex:1 1 0; min-width:0; min-height:48px;
|
| 78 |
+
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px;
|
| 79 |
+
background:transparent; border:0; color:var(--bone-1); padding:3px 2px; position:relative;
|
| 80 |
+
}
|
| 81 |
+
.nav-btn__glyph{ width:30px; height:24px; display:flex; align-items:center; justify-content:center; }
|
| 82 |
+
.nav-btn--on .nav-btn__glyph{
|
| 83 |
+
background:var(--amber-2);
|
| 84 |
+
box-shadow:0 0 0 2px var(--ink-0), 2px 2px 0 var(--ink-0);
|
| 85 |
+
}
|
| 86 |
+
.nav-btn__lbl{ font-size:9px; letter-spacing:.04em; white-space:nowrap; }
|
| 87 |
+
.nav-btn--on .nav-btn__lbl{ color:var(--amber-2); }
|
| 88 |
+
@media (max-width:359px){ .nav-btn__lbl{ font-size:8px; letter-spacing:.02em; } }
|
| 89 |
+
|
| 90 |
+
/* ============================================================
|
| 91 |
+
"DEVELOPS IN" reveal — scanline/dither wipe
|
| 92 |
+
============================================================ */
|
| 93 |
+
.develop-veil{ position:absolute; inset:0; background:var(--ink-1); z-index:3;
|
| 94 |
+
background-image:repeating-linear-gradient(0deg, rgba(0,0,0,.5) 0 2px, transparent 2px 4px); }
|
| 95 |
+
.develop-veil--anim{ animation:develop .7s steps(10) forwards; }
|
| 96 |
+
@keyframes develop{
|
| 97 |
+
0%{ clip-path:inset(0 0 0 0); opacity:1; }
|
| 98 |
+
99%{ clip-path:inset(0 0 100% 0); opacity:1; }
|
| 99 |
+
100%{ clip-path:inset(0 0 100% 0); opacity:0; }
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/* ============================================================
|
| 103 |
+
DOSSIER — physical case file you flip through
|
| 104 |
+
============================================================ */
|
| 105 |
+
.dossier-stage{ flex:1; display:flex; align-items:center; justify-content:center; padding:20px; perspective:2200px; overflow:hidden; }
|
| 106 |
+
.dossier{ width:min(820px,96vw); max-height:100%; display:flex; flex-direction:column; gap:14px; }
|
| 107 |
+
.dossier__folder{
|
| 108 |
+
position:relative; background:linear-gradient(160deg,#2a2417,#1c1810);
|
| 109 |
+
box-shadow:0 0 0 3px var(--ink-0), 0 18px 40px -12px #000, inset 0 2px 0 rgba(224,164,76,.12);
|
| 110 |
+
padding:14px;
|
| 111 |
+
}
|
| 112 |
+
.dossier__folder::before{
|
| 113 |
+
content:''; position:absolute; top:-14px; left:30px; width:120px; height:16px;
|
| 114 |
+
background:linear-gradient(160deg,#2a2417,#1c1810); box-shadow:0 0 0 3px var(--ink-0);
|
| 115 |
+
clip-path:polygon(8% 0,92% 0,100% 100%,0 100%);
|
| 116 |
+
}
|
| 117 |
+
.page-wrap{ position:relative; height:min(560px,68vh); transform-style:preserve-3d; }
|
| 118 |
+
.page{
|
| 119 |
+
position:absolute; inset:0; background:#e7e0cc; color:#211d15;
|
| 120 |
+
padding:26px 30px; overflow:auto;
|
| 121 |
+
box-shadow:inset 0 0 0 1px #b9b09433, 0 4px 0 rgba(0,0,0,.35), 6px 8px 18px -6px rgba(0,0,0,.5);
|
| 122 |
+
background-image:repeating-linear-gradient(0deg, transparent 0 27px, rgba(33,29,21,.06) 27px 28px);
|
| 123 |
+
font-family:var(--f-mono);
|
| 124 |
+
}
|
| 125 |
+
.page--paper2{ background:#ece6d4; }
|
| 126 |
+
.page--flip-fwd{ animation:flipFwd .46s cubic-bezier(.2,.6,.2,1) both; transform-origin:left center; }
|
| 127 |
+
.page--flip-back{ animation:flipBack .46s cubic-bezier(.2,.6,.2,1) both; transform-origin:right center; }
|
| 128 |
+
@keyframes flipFwd{ from{ transform:rotateY(82deg); opacity:.2; } to{ transform:rotateY(0); opacity:1; } }
|
| 129 |
+
@keyframes flipBack{ from{ transform:rotateY(-82deg); opacity:.2; } to{ transform:rotateY(0); opacity:1; } }
|
| 130 |
+
.page__edge{ position:absolute; top:0; bottom:0; width:34px; cursor:pointer; z-index:5; }
|
| 131 |
+
.page__edge--r{ right:0; background:linear-gradient(270deg, rgba(0,0,0,.14), transparent); }
|
| 132 |
+
.page__edge--l{ left:0; background:linear-gradient(90deg, rgba(0,0,0,.14), transparent); }
|
| 133 |
+
.page__edge:hover{ filter:brightness(1.1); }
|
| 134 |
+
.page__curl{ position:absolute; bottom:0; right:0; width:0; height:0;
|
| 135 |
+
border-style:solid; border-width:0 0 26px 26px; border-color:transparent transparent #cfc6ad transparent;
|
| 136 |
+
box-shadow:-2px -2px 4px rgba(0,0,0,.2); pointer-events:none; }
|
| 137 |
+
.dh{ font-family:var(--f-display); text-transform:uppercase; color:#1a1610; letter-spacing:.02em; }
|
| 138 |
+
.dlabel{ font-family:var(--f-display); font-size:8px; letter-spacing:.18em; color:#8a3a2c; text-transform:uppercase; }
|
| 139 |
+
.dtype{ font-family:var(--f-mono); font-size:calc(17px*var(--mono-scale)); line-height:1.55; color:#2a251b; }
|
| 140 |
+
.dstamp{ font-family:var(--f-display); text-transform:uppercase; color:#8a2a2a;
|
| 141 |
+
border:3px solid #8a2a2a; padding:5px 10px; letter-spacing:.05em; transform:rotate(-7deg);
|
| 142 |
+
opacity:.82; box-shadow:0 0 0 1px #8a2a2a33; display:inline-block; }
|
| 143 |
+
.paperclip{ position:absolute; top:-8px; left:18px; width:14px; height:34px;
|
| 144 |
+
border:3px solid #9aa0a6; border-radius:8px; border-bottom-color:transparent; transform:rotate(8deg); }
|
| 145 |
+
.photo-paper{ background:#fff; padding:6px 6px 22px; box-shadow:3px 5px 0 rgba(0,0,0,.3); transform:rotate(-2deg); position:relative; }
|
| 146 |
+
.dossier__nav{ display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
| 147 |
+
.page-dots{ display:flex; gap:6px; }
|
| 148 |
+
.page-dot{ width:24px; height:6px; background:#3a3324; box-shadow:inset 0 0 0 1px var(--ink-0); cursor:pointer; }
|
| 149 |
+
.page-dot--on{ background:var(--amber-2); }
|
| 150 |
+
[data-mode="mobile"] .page{ padding:20px 18px; }
|
| 151 |
+
[data-mode="mobile"] .page-wrap{ height:62vh; }
|
| 152 |
+
/* let the dossier nav wrap so long labels ("Open the Wall") never crop on narrow phones */
|
| 153 |
+
[data-mode="mobile"] .dossier__nav{ flex-wrap:wrap; row-gap:8px; justify-content:center; }
|
| 154 |
+
[data-mode="mobile"] .dossier__nav .pbtn{ flex-shrink:0; }
|
| 155 |
+
|
| 156 |
+
/* ============================================================
|
| 157 |
+
ASSISTANT — partner-on-the-wire hint dock
|
| 158 |
+
============================================================ */
|
| 159 |
+
.assistant{ position:fixed; left:14px; bottom:14px; z-index:70; display:flex; flex-direction:column; align-items:flex-start; gap:10px; }
|
| 160 |
+
[data-mode="mobile"] .assistant{ bottom:78px; left:10px; right:10px; }
|
| 161 |
+
.assistant__panel{
|
| 162 |
+
width:min(330px,92vw); background:var(--ink-2); padding:12px;
|
| 163 |
+
box-shadow:0 0 0 3px var(--ink-0), inset 0 0 0 2px var(--amber-1), 0 0 26px -8px var(--glow);
|
| 164 |
+
animation:slideup .2s steps(5);
|
| 165 |
+
}
|
| 166 |
+
.assistant__x{ background:transparent; border:0; color:var(--bone-1); font-family:var(--f-mono); font-size:16px; line-height:1; padding:2px 4px; }
|
| 167 |
+
.assistant__x:hover{ color:var(--ox-3); }
|
| 168 |
+
|
| 169 |
+
.hint-btn{
|
| 170 |
+
display:inline-flex; align-items:center; gap:6px;
|
| 171 |
+
font-family:var(--f-display); font-size:9px; letter-spacing:.1em;
|
| 172 |
+
color:var(--ink-0); background:var(--amber-2); border:0; padding:8px 11px;
|
| 173 |
+
box-shadow:2px 2px 0 var(--ink-0), inset -2px -2px 0 rgba(0,0,0,.25);
|
| 174 |
+
}
|
| 175 |
+
.hint-btn:hover{ background:var(--amber-3); }
|
| 176 |
+
.hint-btn__dot{ width:6px; height:6px; background:var(--ink-0); animation:blink 1.3s steps(1) infinite; }
|
| 177 |
+
|
| 178 |
+
/* ============================================================
|
| 179 |
+
INVESTIGATION BOARD — corkboard
|
| 180 |
+
============================================================ */
|
| 181 |
+
.board{ position:relative; flex:1; overflow:hidden;
|
| 182 |
+
background:
|
| 183 |
+
radial-gradient(rgba(0,0,0,.22) 1px, transparent 1px),
|
| 184 |
+
radial-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
|
| 185 |
+
repeating-linear-gradient(26deg, rgba(0,0,0,.05) 0 2px, transparent 2px 5px),
|
| 186 |
+
linear-gradient(160deg, #5a4326, #3a2c18 55%, #2a2012);
|
| 187 |
+
background-size:5px 5px, 7px 7px, 9px 9px, 100% 100%;
|
| 188 |
+
background-position:0 0, 3px 4px, 0 0, 0 0;
|
| 189 |
+
}
|
| 190 |
+
.board__felt{ position:absolute; inset:0; pointer-events:none;
|
| 191 |
+
background:
|
| 192 |
+
radial-gradient(70% 50% at 50% 0%, rgba(245,208,138,.16), transparent 60%),
|
| 193 |
+
radial-gradient(120% 90% at 50% 45%, transparent 45%, rgba(0,0,0,.5) 100%);
|
| 194 |
+
}
|
| 195 |
+
.board__frame{ position:absolute; inset:6px; pointer-events:none; z-index:9;
|
| 196 |
+
box-shadow:inset 0 0 0 5px #241a0e, inset 0 0 0 8px #120d07, inset 0 0 40px 6px rgba(0,0,0,.5); }
|
| 197 |
+
|
| 198 |
+
.wall-item{ position:absolute; z-index:2; }
|
| 199 |
+
.pushpin{ position:absolute; top:-7px; left:50%; transform:translateX(-50%); width:11px; height:11px; z-index:3;
|
| 200 |
+
background:var(--amber-2); box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--amber-1); }
|
| 201 |
+
.pushpin--ox{ background:var(--ox-3); box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--ox-1); }
|
| 202 |
+
.pushpin--bone{ background:var(--bone-2); box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--bone-1); }
|
| 203 |
+
.wall-photo{ background:#efe9d6; padding:6px 6px 16px; box-shadow:4px 6px 0 rgba(0,0,0,.45); }
|
| 204 |
+
.wall-clip{ background:#e3ddc8; color:#231f16; padding:9px 11px; box-shadow:4px 6px 0 rgba(0,0,0,.45);
|
| 205 |
+
background-image:repeating-linear-gradient(0deg, transparent 0 9px, rgba(0,0,0,.05) 9px 10px); }
|
| 206 |
+
.wall-note{ background:var(--amber-2); color:#2a1f0c; padding:11px 12px; box-shadow:4px 6px 0 rgba(0,0,0,.4);
|
| 207 |
+
font-family:var(--f-body); }
|
| 208 |
+
.wall-note--bone{ background:#e7e0cc; color:#231f16; }
|
| 209 |
+
.wall-redact{ background:#1a1610; padding:10px; box-shadow:4px 6px 0 rgba(0,0,0,.45); }
|
| 210 |
+
.wall-redact .bar-blk{ height:7px; background:#0a0805; margin:4px 0; }
|
| 211 |
+
.scrawl{ font-family:var(--f-body); line-height:1.2; }
|
| 212 |
+
.pin-card{ position:absolute; cursor:default; user-select:none; z-index:4; transition:transform .05s; }
|
| 213 |
+
.pin-card .pin{
|
| 214 |
+
position:absolute; top:-9px; left:50%; transform:translateX(-50%);
|
| 215 |
+
width:12px; height:12px; background:var(--ox-3); z-index:6; cursor:grab;
|
| 216 |
+
box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--ox-1);
|
| 217 |
+
}
|
| 218 |
+
.pin-card .pin:active{ cursor:grabbing; }
|
| 219 |
+
.card-grip{ display:flex; align-items:center; justify-content:space-between; gap:6px;
|
| 220 |
+
background:var(--ink-3); color:var(--bone-1); padding:2px 6px; cursor:grab;
|
| 221 |
+
box-shadow:inset 0 0 0 2px var(--ink-0); }
|
| 222 |
+
.card-grip:active{ cursor:grabbing; background:var(--slate-1); }
|
| 223 |
+
.card-grip__dots{ font-size:11px; line-height:1; letter-spacing:-1px; color:var(--amber-2); }
|
| 224 |
+
.card-grip__lbl{ font-family:var(--f-display); font-size:7px; letter-spacing:.12em; }
|
| 225 |
+
.card-actions{ margin-top:6px; display:flex; justify-content:center; }
|
| 226 |
+
.pin-card--sel{ filter:drop-shadow(0 0 14px rgba(224,164,76,.35)); }
|
| 227 |
+
.pin-note{ background:var(--bone-2); color:var(--ink-1); padding:8px 10px;
|
| 228 |
+
box-shadow:3px 4px 0 rgba(0,0,0,.4); font-family:var(--f-body); }
|
| 229 |
+
.board__filters{
|
| 230 |
+
position:absolute; top:12px; left:12px; right:12px; z-index:8;
|
| 231 |
+
display:flex; gap:6px; flex-wrap:wrap; align-items:center; pointer-events:none;
|
| 232 |
+
}
|
| 233 |
+
.board__filters > *{ pointer-events:auto; }
|
| 234 |
+
|
| 235 |
+
.board-layout{ flex:1; display:flex; min-height:0; }
|
| 236 |
+
.board-layout .board{ flex:1; }
|
| 237 |
+
|
| 238 |
+
/* board-wrap anchors the floating toolbar; board-scroll is the mobile pan viewport
|
| 239 |
+
over a tall virtual wall. Drags start only on the pin/grip handles, so touches on
|
| 240 |
+
the felt fall through to native scroll. */
|
| 241 |
+
.board-wrap{ position:relative; flex:1; display:flex; min-height:0; }
|
| 242 |
+
.board-scroll{ flex:1; overflow-y:auto; overflow-x:hidden;
|
| 243 |
+
overscroll-behavior:contain; -webkit-overflow-scrolling:touch; }
|
| 244 |
+
.board--mobile{ flex:none; width:100%; }
|
| 245 |
+
/* mobile: toolbar becomes a static bar above the scrolling wall (an overlay would
|
| 246 |
+
cover the topmost cards), so the wrap stacks vertically */
|
| 247 |
+
[data-mode="mobile"] .board-wrap{ flex-direction:column; }
|
| 248 |
+
[data-mode="mobile"] .board__filters{
|
| 249 |
+
position:static; padding:8px 10px; background:var(--ink-2);
|
| 250 |
+
box-shadow:inset 0 -3px 0 var(--ink-0); flex-shrink:0;
|
| 251 |
+
}
|
| 252 |
+
.pin-card .pin, .card-grip{ touch-action:none; }
|
| 253 |
+
|
| 254 |
+
/* ---------- connect-the-dots red yarn ---------- */
|
| 255 |
+
.thread-layer{ position:absolute; inset:0; z-index:5; pointer-events:none; }
|
| 256 |
+
.thread{ fill:none; stroke:var(--thread); stroke-width:2.5; stroke-linecap:round; }
|
| 257 |
+
.thread--under{ stroke:var(--thread-dk); stroke-width:4.5; opacity:.55; transform:translateY(2px); }
|
| 258 |
+
.thread--draw{ stroke-dasharray:1; stroke-dashoffset:1; animation:threadDraw .25s steps(8) forwards; }
|
| 259 |
+
@keyframes threadDraw{ to{ stroke-dashoffset:0; } }
|
| 260 |
+
.thread--ghost{ stroke-dasharray:5 4; opacity:.75; }
|
| 261 |
+
.thread-hit{ fill:none; stroke:#000; stroke-opacity:0; stroke-width:14; pointer-events:stroke; cursor:pointer; }
|
| 262 |
+
.thread-knot{ fill:var(--thread-dk); stroke:var(--ink-0); stroke-width:1.5; }
|
| 263 |
+
.pin-card--armed{ outline:3px solid var(--thread); outline-offset:2px;
|
| 264 |
+
filter:drop-shadow(0 0 12px rgba(194,59,59,.5)); }
|
| 265 |
+
.pin-card--armed .pin{ animation:pinPulse .7s steps(2) infinite; }
|
| 266 |
+
@keyframes pinPulse{ 50%{ transform:translateX(-50%) scale(1.6); } }
|
| 267 |
+
.board--connect .pin{ cursor:pointer; }
|
| 268 |
+
.board--connect .pin-note{ cursor:pointer; }
|
| 269 |
+
.card-grip--locked{ cursor:pointer; opacity:.55; }
|
| 270 |
+
.card-grip--locked:active{ background:var(--ink-3); }
|
| 271 |
+
.suspect-rail{ width:316px; flex-shrink:0; background:var(--ink-2); box-shadow:inset 4px 0 0 var(--ink-0);
|
| 272 |
+
display:flex; flex-direction:column; min-height:0; }
|
| 273 |
+
.suspect-rail__head{ padding:14px 14px 10px; box-shadow:inset 0 -3px 0 var(--ink-0); }
|
| 274 |
+
.suspect-rail__list{ flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:10px; }
|
| 275 |
+
.rail-card{ cursor:pointer; }
|
| 276 |
+
.rail-card__reveal{ overflow:hidden; max-height:0; transition:max-height .25s steps(6); }
|
| 277 |
+
.rail-card__reveal--open{ max-height:200px; }
|
| 278 |
+
|
| 279 |
+
/* ============================================================
|
| 280 |
+
INTERROGATION
|
| 281 |
+
============================================================ */
|
| 282 |
+
.interro{ flex:1; display:grid; grid-template-columns:minmax(300px,440px) 1fr; gap:0; overflow:hidden; }
|
| 283 |
+
.interro__stage{ position:relative; overflow:hidden; background:var(--ink-0); display:flex; flex-direction:column; }
|
| 284 |
+
.interro__sprite{ position:absolute; left:50%; bottom:0; transform:translateX(-50%); z-index:2; }
|
| 285 |
+
.interro__right{ display:flex; flex-direction:column; min-width:0; background:var(--ink-1);
|
| 286 |
+
box-shadow:inset 4px 0 0 var(--ink-0); }
|
| 287 |
+
.transcript{ flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:12px; }
|
| 288 |
+
.tline{ max-width:88%; }
|
| 289 |
+
.tline--det{ align-self:flex-end; }
|
| 290 |
+
.tline--det .tline__b{ background:var(--slate-1); color:var(--bone-3); box-shadow:inset -2px -2px 0 rgba(0,0,0,.4); }
|
| 291 |
+
.tline--sus .tline__b{ background:var(--ink-3); color:var(--bone-2); box-shadow:inset 0 0 0 2px var(--ink-0); }
|
| 292 |
+
.tline__b{ padding:10px 12px; font-family:var(--f-body); font-size:15px; line-height:1.45; }
|
| 293 |
+
.tline__who{ font-family:var(--f-display); font-size:8px; letter-spacing:.1em; color:var(--bone-1); margin-bottom:4px; }
|
| 294 |
+
.tline--det .tline__who{ text-align:right; color:var(--amber-2); }
|
| 295 |
+
.composer{ flex-shrink:0; padding:12px; background:var(--ink-2); box-shadow:inset 0 3px 0 var(--ink-0); }
|
| 296 |
+
.qsuggest{ display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px; }
|
| 297 |
+
.qchip{ font-family:var(--f-body); font-size:13px; line-height:1.25; padding:7px 10px; background:var(--ink-1);
|
| 298 |
+
color:var(--bone-2); border:0; box-shadow:inset 0 0 0 2px var(--slate-1); text-align:left; flex:1 1 auto; min-width:0; }
|
| 299 |
+
.qchip:hover{ box-shadow:inset 0 0 0 2px var(--amber-2); color:var(--bone-3); }
|
| 300 |
+
.qchip:disabled{ opacity:.35; }
|
| 301 |
+
.composer__input{ display:flex; gap:8px; }
|
| 302 |
+
.pinput{ flex:1; background:var(--ink-1); border:0; color:var(--bone-3); padding:11px 12px;
|
| 303 |
+
font-size:15px; box-shadow:inset 0 0 0 2px var(--ink-0), inset 2px 2px 0 rgba(0,0,0,.4); outline:none; }
|
| 304 |
+
.pinput::placeholder{ color:var(--bone-1); }
|
| 305 |
+
.pinput:focus{ box-shadow:inset 0 0 0 2px var(--amber-1); }
|
| 306 |
+
|
| 307 |
+
.tray{ position:absolute; inset:0; z-index:30; background:rgba(8,11,16,.82);
|
| 308 |
+
display:flex; flex-direction:column; justify-content:flex-end; }
|
| 309 |
+
.tray__sheet{ background:var(--ink-2); box-shadow:0 -4px 0 var(--ink-0), inset 0 4px 0 var(--amber-1);
|
| 310 |
+
padding:16px; max-height:70%; overflow-y:auto; animation:slideup .25s steps(6); }
|
| 311 |
+
@keyframes slideup{ from{ transform:translateY(100%);} to{ transform:translateY(0);} }
|
| 312 |
+
.tray__grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:10px; }
|
| 313 |
+
|
| 314 |
+
/* ============================================================
|
| 315 |
+
GENERIC SCREEN SCAFFOLD
|
| 316 |
+
============================================================ */
|
| 317 |
+
.screen-pad{ flex:1; overflow-y:auto; padding:22px; }
|
| 318 |
+
.screen-center{ flex:1; display:flex; align-items:center; justify-content:center; padding:22px; overflow-y:auto; }
|
| 319 |
+
.maxw{ width:100%; max-width:1180px; margin:0 auto; }
|
| 320 |
+
.grid-2{ display:grid; grid-template-columns:1fr 1fr; gap:18px; }
|
| 321 |
+
.grid-3{ display:grid; grid-template-columns:repeat(3,1fr); gap:14px; }
|
| 322 |
+
.grid-4{ display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
|
| 323 |
+
|
| 324 |
+
/* ============================================================
|
| 325 |
+
TIMELINE
|
| 326 |
+
============================================================ */
|
| 327 |
+
.tl-track{ position:relative; padding:30px 10px; }
|
| 328 |
+
.tl-line{ position:absolute; left:0; right:0; top:50%; height:4px;
|
| 329 |
+
background:repeating-linear-gradient(90deg,var(--slate-1) 0 6px, transparent 6px 12px); }
|
| 330 |
+
.tl-stop{ position:relative; z-index:2; display:flex; flex-direction:column; align-items:center; gap:6px; }
|
| 331 |
+
.tl-node{ width:14px; height:14px; background:var(--slate-2); box-shadow:0 0 0 3px var(--ink-0); }
|
| 332 |
+
.tl-node--lock{ background:var(--amber-2); }
|
| 333 |
+
.tl-node--conflict{ background:var(--ox-3); }
|
| 334 |
+
.tl-slot{ min-height:62px; }
|
| 335 |
+
|
| 336 |
+
/* ============================================================
|
| 337 |
+
FLASHBACK compare
|
| 338 |
+
============================================================ */
|
| 339 |
+
.flash-grid{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }
|
| 340 |
+
.flash-flag{ box-shadow:inset 0 0 0 2px var(--ox-2); }
|
| 341 |
+
|
| 342 |
+
/* ============================================================
|
| 343 |
+
RESPONSIVE
|
| 344 |
+
============================================================ */
|
| 345 |
+
[data-mode="mobile"] .interro{ grid-template-columns:1fr; grid-template-rows:auto 1fr; }
|
| 346 |
+
[data-mode="mobile"] .interro__stage{ height:32vh; min-height:200px; }
|
| 347 |
+
[data-mode="mobile"] .grid-2,[data-mode="mobile"] .grid-3,[data-mode="mobile"] .grid-4,[data-mode="mobile"] .flash-grid{ grid-template-columns:1fr; }
|
| 348 |
+
[data-mode="mobile"] .screen-pad{ padding:14px; }
|
| 349 |
+
[data-mode="mobile"] .hud{ padding:9px 11px; }
|
| 350 |
+
|
| 351 |
+
@media (max-width:760px){
|
| 352 |
+
.app[data-mode="auto"] .interro{ grid-template-columns:1fr; grid-template-rows:auto 1fr; }
|
| 353 |
+
.app[data-mode="auto"] .grid-2,.app[data-mode="auto"] .grid-3,.app[data-mode="auto"] .grid-4{ grid-template-columns:1fr; }
|
| 354 |
+
.app[data-mode="auto"] .hud__title{
|
| 355 |
+
font-size:10px; white-space:normal; overflow:hidden;
|
| 356 |
+
display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2;
|
| 357 |
+
}
|
| 358 |
+
}
|
web/src/styles/pixel.css
CHANGED
|
@@ -1,273 +1,276 @@
|
|
| 1 |
-
/* ============================================================
|
| 2 |
-
CASE ZERO — pixel-art noir design system
|
| 3 |
-
Palette: blue-black shadows · desaturated teal/slate mids ·
|
| 4 |
-
warm sodium-amber (single light source) · oxblood (danger) ·
|
| 5 |
-
bone-white (key text)
|
| 6 |
-
Fonts are self-hosted via @fontsource (imported in main.tsx) — no network at runtime.
|
| 7 |
-
============================================================ */
|
| 8 |
-
|
| 9 |
-
/* ---------- palette variants ---------- */
|
| 10 |
-
:root,
|
| 11 |
-
[data-palette="sodium"]{
|
| 12 |
-
--ink-0:#080b10; --ink-1:#0d1117; --ink-2:#11202a; --ink-3:#1b2d38;
|
| 13 |
-
--slate-1:#2d4a52; --slate-2:#3a6b6b; --slate-3:#5d8a8a;
|
| 14 |
-
--amber-1:#b9772f; --amber-2:#e0a44c; --amber-3:#f5d08a;
|
| 15 |
-
--ox-1:#5e1c1c; --ox-2:#8a2a2a; --ox-3:#c23b3b;
|
| 16 |
-
--bone-1:#9a937e; --bone-2:#e0d9c4; --bone-3:#f5f1e6;
|
| 17 |
-
--glow:#e0a44c;
|
| 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 |
-
.t-
|
| 86 |
-
.t-
|
| 87 |
-
|
| 88 |
-
.
|
| 89 |
-
.
|
| 90 |
-
|
| 91 |
-
.
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
0 0 0
|
| 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 |
-
.pbtn--
|
| 152 |
-
.pbtn--
|
| 153 |
-
|
| 154 |
-
.pbtn--
|
| 155 |
-
.pbtn
|
| 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 |
-
|
| 208 |
-
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
[data-fx="low"]
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
*
|
| 232 |
-
*::-webkit-scrollbar
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
.
|
| 270 |
-
.
|
| 271 |
-
.
|
| 272 |
-
.
|
| 273 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
CASE ZERO — pixel-art noir design system
|
| 3 |
+
Palette: blue-black shadows · desaturated teal/slate mids ·
|
| 4 |
+
warm sodium-amber (single light source) · oxblood (danger) ·
|
| 5 |
+
bone-white (key text)
|
| 6 |
+
Fonts are self-hosted via @fontsource (imported in main.tsx) — no network at runtime.
|
| 7 |
+
============================================================ */
|
| 8 |
+
|
| 9 |
+
/* ---------- palette variants ---------- */
|
| 10 |
+
:root,
|
| 11 |
+
[data-palette="sodium"]{
|
| 12 |
+
--ink-0:#080b10; --ink-1:#0d1117; --ink-2:#11202a; --ink-3:#1b2d38;
|
| 13 |
+
--slate-1:#2d4a52; --slate-2:#3a6b6b; --slate-3:#5d8a8a;
|
| 14 |
+
--amber-1:#b9772f; --amber-2:#e0a44c; --amber-3:#f5d08a;
|
| 15 |
+
--ox-1:#5e1c1c; --ox-2:#8a2a2a; --ox-3:#c23b3b;
|
| 16 |
+
--bone-1:#9a937e; --bone-2:#e0d9c4; --bone-3:#f5f1e6;
|
| 17 |
+
--glow:#e0a44c;
|
| 18 |
+
--thread:#d24034; --thread-dk:#5e1c1c;
|
| 19 |
+
}
|
| 20 |
+
[data-palette="harbor"]{
|
| 21 |
+
--ink-0:#060a0f; --ink-1:#0a0e14; --ink-2:#0f1d26; --ink-3:#16242e;
|
| 22 |
+
--slate-1:#2c5151; --slate-2:#3a6b6b; --slate-3:#62989a;
|
| 23 |
+
--amber-1:#c2842f; --amber-2:#f0b860; --amber-3:#ffd98f;
|
| 24 |
+
--ox-1:#661f1f; --ox-2:#a33636; --ox-3:#d24a4a;
|
| 25 |
+
--bone-1:#a39d88; --bone-2:#eae3cf; --bone-3:#f7f3e8;
|
| 26 |
+
--glow:#f0b860;
|
| 27 |
+
--thread:#e04a3c; --thread-dk:#661f1f;
|
| 28 |
+
}
|
| 29 |
+
[data-palette="violet"]{
|
| 30 |
+
--ink-0:#08060e; --ink-1:#0e0c14; --ink-2:#171425; --ink-3:#221d33;
|
| 31 |
+
--slate-1:#33344e; --slate-2:#46506b; --slate-3:#6b7396;
|
| 32 |
+
--amber-1:#b4802f; --amber-2:#d99a3c; --amber-3:#f0c071;
|
| 33 |
+
--ox-1:#5e1d2a; --ox-2:#922d3a; --ox-3:#c4485a;
|
| 34 |
+
--bone-1:#9690a0; --bone-2:#e6e0d4; --bone-3:#f4f0ea;
|
| 35 |
+
--glow:#d99a3c;
|
| 36 |
+
--thread:#d24458; --thread-dk:#5e1d2a;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/* ---------- light precinct mood (daytime / bright bullpen) ---------- */
|
| 40 |
+
[data-mood="day"]{
|
| 41 |
+
--ink-0:#cdc6b2; --ink-1:#d8d2bf; --ink-2:#c6c0ac; --ink-3:#b8b29c;
|
| 42 |
+
--slate-1:#8a9a96; --slate-2:#6f8a88; --slate-3:#577070;
|
| 43 |
+
--bone-1:#5a5544; --bone-2:#2a2820; --bone-3:#15140f;
|
| 44 |
+
--glow:#b9772f;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/* ---------- font pairing variants ---------- */
|
| 48 |
+
:root,
|
| 49 |
+
[data-fonts="crisp"]{
|
| 50 |
+
--f-display:'Silkscreen', monospace;
|
| 51 |
+
--f-mono:'VT323', monospace;
|
| 52 |
+
--f-body:'Pixelify Sans', monospace;
|
| 53 |
+
--mono-scale:1.25;
|
| 54 |
+
}
|
| 55 |
+
[data-fonts="terminal"]{
|
| 56 |
+
--f-display:'VT323', monospace;
|
| 57 |
+
--f-mono:'VT323', monospace;
|
| 58 |
+
--f-body:'VT323', monospace;
|
| 59 |
+
--mono-scale:1.35;
|
| 60 |
+
}
|
| 61 |
+
[data-fonts="stamp"]{
|
| 62 |
+
--f-display:'Silkscreen', monospace;
|
| 63 |
+
--f-mono:'Silkscreen', monospace;
|
| 64 |
+
--f-body:'Pixelify Sans', monospace;
|
| 65 |
+
--mono-scale:1.0;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/* ---------- reset ---------- */
|
| 69 |
+
*{ box-sizing:border-box; margin:0; padding:0; }
|
| 70 |
+
html,body{ height:100%; }
|
| 71 |
+
body{
|
| 72 |
+
background:var(--ink-0);
|
| 73 |
+
color:var(--bone-2);
|
| 74 |
+
font-family:var(--f-body);
|
| 75 |
+
-webkit-font-smoothing:none;
|
| 76 |
+
font-smooth:never;
|
| 77 |
+
overflow:hidden;
|
| 78 |
+
}
|
| 79 |
+
img,canvas{ image-rendering:pixelated; image-rendering:crisp-edges; }
|
| 80 |
+
button{ font-family:inherit; color:inherit; cursor:pointer; }
|
| 81 |
+
input,textarea{ font-family:var(--f-body); }
|
| 82 |
+
::selection{ background:var(--amber-2); color:var(--ink-0); }
|
| 83 |
+
|
| 84 |
+
/* ---------- type scale ---------- */
|
| 85 |
+
.t-display{ font-family:var(--f-display); letter-spacing:.04em; line-height:1.05; text-transform:uppercase; }
|
| 86 |
+
.t-mono{ font-family:var(--f-mono); letter-spacing:.02em; }
|
| 87 |
+
.t-mono-lg{ font-family:var(--f-mono); font-size:calc(1.5rem * var(--mono-scale)); letter-spacing:.04em; }
|
| 88 |
+
.t-label{ font-family:var(--f-display); font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--bone-1); }
|
| 89 |
+
.t-body{ font-family:var(--f-body); font-size:17px; line-height:1.5; }
|
| 90 |
+
|
| 91 |
+
.amber{ color:var(--amber-2); }
|
| 92 |
+
.ox{ color:var(--ox-3); }
|
| 93 |
+
.bone{ color:var(--bone-3); }
|
| 94 |
+
.dim{ color:var(--bone-1); }
|
| 95 |
+
|
| 96 |
+
/* ============================================================
|
| 97 |
+
9-SLICE PIXEL PANELS — beveled, no border-radius ever
|
| 98 |
+
============================================================ */
|
| 99 |
+
.panel{
|
| 100 |
+
position:relative;
|
| 101 |
+
background:var(--ink-2);
|
| 102 |
+
border:3px solid var(--ink-0);
|
| 103 |
+
box-shadow:
|
| 104 |
+
inset 0 0 0 3px var(--ink-3),
|
| 105 |
+
inset 3px 3px 0 3px rgba(93,138,138,.12),
|
| 106 |
+
inset -3px -3px 0 3px rgba(0,0,0,.45),
|
| 107 |
+
0 0 0 1px var(--ink-0);
|
| 108 |
+
padding:18px;
|
| 109 |
+
}
|
| 110 |
+
.panel--raised{ background:var(--ink-3); }
|
| 111 |
+
.panel--inset{
|
| 112 |
+
background:var(--ink-1);
|
| 113 |
+
box-shadow:
|
| 114 |
+
inset 0 0 0 3px var(--ink-0),
|
| 115 |
+
inset 3px 3px 0 3px rgba(0,0,0,.5),
|
| 116 |
+
inset -3px -3px 0 3px rgba(93,138,138,.06);
|
| 117 |
+
}
|
| 118 |
+
.panel--amber{ border-color:var(--amber-1);
|
| 119 |
+
box-shadow:inset 0 0 0 2px var(--amber-1), 0 0 24px -6px var(--glow); }
|
| 120 |
+
.panel--ox{ border-color:var(--ox-1);
|
| 121 |
+
box-shadow:inset 0 0 0 2px var(--ox-1), 0 0 22px -8px var(--ox-3); }
|
| 122 |
+
|
| 123 |
+
.panel__tab{
|
| 124 |
+
position:absolute; top:-11px; left:14px;
|
| 125 |
+
background:var(--amber-2); color:var(--ink-0);
|
| 126 |
+
font-family:var(--f-display); font-size:9px; letter-spacing:.12em;
|
| 127 |
+
padding:4px 8px; text-transform:uppercase;
|
| 128 |
+
box-shadow:2px 2px 0 var(--ink-0);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
/* ---------- pixel buttons ---------- */
|
| 132 |
+
.pbtn{
|
| 133 |
+
font-family:var(--f-display); font-size:11px; letter-spacing:.08em;
|
| 134 |
+
text-transform:uppercase; white-space:nowrap;
|
| 135 |
+
color:var(--bone-3); background:var(--slate-1);
|
| 136 |
+
border:0; padding:13px 18px;
|
| 137 |
+
box-shadow:
|
| 138 |
+
inset -3px -3px 0 0 rgba(0,0,0,.45),
|
| 139 |
+
inset 3px 3px 0 0 rgba(255,255,255,.12),
|
| 140 |
+
0 0 0 3px var(--ink-0);
|
| 141 |
+
transition:transform .04s steps(1);
|
| 142 |
+
position:relative;
|
| 143 |
+
}
|
| 144 |
+
.pbtn:hover{ background:var(--slate-2); color:var(--ink-0); }
|
| 145 |
+
.pbtn:active{
|
| 146 |
+
transform:translate(2px,2px);
|
| 147 |
+
box-shadow:
|
| 148 |
+
inset 3px 3px 0 0 rgba(0,0,0,.4),
|
| 149 |
+
0 0 0 3px var(--ink-0);
|
| 150 |
+
}
|
| 151 |
+
.pbtn--amber{ background:var(--amber-2); color:var(--ink-0); }
|
| 152 |
+
.pbtn--amber:hover{ background:var(--amber-3); }
|
| 153 |
+
.pbtn--ox{ background:var(--ox-2); color:var(--bone-3); }
|
| 154 |
+
.pbtn--ox:hover{ background:var(--ox-3); }
|
| 155 |
+
.pbtn--ghost{ background:rgba(13,17,23,.66); color:var(--bone-1);
|
| 156 |
+
box-shadow:inset 0 0 0 2px var(--slate-1); }
|
| 157 |
+
.pbtn--ghost:hover{ color:var(--bone-3); background:rgba(13,17,23,.85); box-shadow:inset 0 0 0 2px var(--slate-3); }
|
| 158 |
+
.pbtn:disabled{ opacity:.4; cursor:not-allowed; }
|
| 159 |
+
.pbtn--sm{ font-size:9px; padding:8px 11px; }
|
| 160 |
+
|
| 161 |
+
/* ---------- chips / tags ---------- */
|
| 162 |
+
.chip{
|
| 163 |
+
display:inline-flex; align-items:center; gap:6px; white-space:nowrap;
|
| 164 |
+
font-family:var(--f-mono); font-size:calc(15px * var(--mono-scale));
|
| 165 |
+
line-height:1; padding:4px 8px;
|
| 166 |
+
background:var(--ink-1); color:var(--bone-1);
|
| 167 |
+
box-shadow:inset 0 0 0 2px var(--slate-1);
|
| 168 |
+
}
|
| 169 |
+
.chip--amber{ color:var(--amber-2); box-shadow:inset 0 0 0 2px var(--amber-1); }
|
| 170 |
+
.chip--ox{ color:var(--ox-3); box-shadow:inset 0 0 0 2px var(--ox-1); }
|
| 171 |
+
|
| 172 |
+
/* ============================================================
|
| 173 |
+
SUSPICION / COMPOSURE BAR
|
| 174 |
+
============================================================ */
|
| 175 |
+
.bar{
|
| 176 |
+
height:16px; background:var(--ink-1);
|
| 177 |
+
box-shadow:inset 0 0 0 2px var(--ink-0), inset 2px 2px 0 2px rgba(0,0,0,.5);
|
| 178 |
+
position:relative; overflow:hidden;
|
| 179 |
+
}
|
| 180 |
+
.bar__fill{
|
| 181 |
+
height:100%;
|
| 182 |
+
background:
|
| 183 |
+
repeating-linear-gradient(90deg, transparent 0 6px, rgba(0,0,0,.18) 6px 8px),
|
| 184 |
+
linear-gradient(var(--ox-3), var(--ox-2));
|
| 185 |
+
transition:width .5s steps(12);
|
| 186 |
+
box-shadow:inset 0 2px 0 rgba(255,255,255,.18);
|
| 187 |
+
}
|
| 188 |
+
.bar__fill--calm{ background:
|
| 189 |
+
repeating-linear-gradient(90deg, transparent 0 6px, rgba(0,0,0,.18) 6px 8px),
|
| 190 |
+
linear-gradient(var(--slate-3), var(--slate-1)); }
|
| 191 |
+
|
| 192 |
+
/* ============================================================
|
| 193 |
+
ATMOSPHERE — scanlines, vignette, lamp glow, rain
|
| 194 |
+
============================================================ */
|
| 195 |
+
.fx-layer{ position:fixed; inset:0; pointer-events:none; z-index:60; }
|
| 196 |
+
|
| 197 |
+
.fx-scanlines{
|
| 198 |
+
background:repeating-linear-gradient(
|
| 199 |
+
to bottom, rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px,
|
| 200 |
+
rgba(0,0,0,.16) 3px, rgba(0,0,0,.16) 3px);
|
| 201 |
+
mix-blend-mode:multiply;
|
| 202 |
+
opacity:var(--fx-scan, .6);
|
| 203 |
+
}
|
| 204 |
+
.fx-vignette{
|
| 205 |
+
background:radial-gradient(120% 90% at 50% 38%,
|
| 206 |
+
transparent 40%, rgba(0,0,0,.4) 78%, rgba(0,0,0,.72) 100%);
|
| 207 |
+
opacity:var(--fx-vig, 1);
|
| 208 |
+
}
|
| 209 |
+
.fx-flicker{ animation:flicker 6s steps(1) infinite; background:var(--glow); opacity:0; mix-blend-mode:overlay; }
|
| 210 |
+
@keyframes flicker{
|
| 211 |
+
0%,97%,100%{ opacity:0; } 97.5%{ opacity:.05; } 98%{ opacity:.02; } 98.5%{ opacity:.06; }
|
| 212 |
+
}
|
| 213 |
+
[data-fx="low"]{ --fx-scan:.25; --fx-vig:.6; }
|
| 214 |
+
[data-fx="med"]{ --fx-scan:.55; --fx-vig:1; }
|
| 215 |
+
[data-fx="high"]{ --fx-scan:.85; --fx-vig:1.25; }
|
| 216 |
+
[data-fx="low"] .fx-rain, [data-fx="low"] .fx-flicker{ display:none; }
|
| 217 |
+
|
| 218 |
+
.fx-rain{ opacity:var(--fx-rain,.5); }
|
| 219 |
+
|
| 220 |
+
@media (prefers-reduced-motion: reduce){
|
| 221 |
+
.fx-flicker{ animation:none; }
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/* ---------- blinking cursor ---------- */
|
| 225 |
+
.cursor::after{
|
| 226 |
+
content:'▮'; color:var(--amber-2);
|
| 227 |
+
animation:blink 1s steps(1) infinite; margin-left:2px;
|
| 228 |
+
}
|
| 229 |
+
@keyframes blink{ 50%{ opacity:0; } }
|
| 230 |
+
|
| 231 |
+
/* ---------- scrollbars ---------- */
|
| 232 |
+
*::-webkit-scrollbar{ width:10px; height:10px; }
|
| 233 |
+
*::-webkit-scrollbar-track{ background:var(--ink-1); }
|
| 234 |
+
*::-webkit-scrollbar-thumb{ background:var(--slate-1); border:2px solid var(--ink-1); }
|
| 235 |
+
*::-webkit-scrollbar-thumb:hover{ background:var(--slate-2); }
|
| 236 |
+
|
| 237 |
+
/* ---------- pixel divider ---------- */
|
| 238 |
+
.hr-pixel{
|
| 239 |
+
height:3px; background:repeating-linear-gradient(90deg,
|
| 240 |
+
var(--slate-1) 0 4px, transparent 4px 8px);
|
| 241 |
+
border:0;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
/* ---------- dithered fill helper ---------- */
|
| 245 |
+
.dither-amber{
|
| 246 |
+
background-image:
|
| 247 |
+
radial-gradient(rgba(224,164,76,.5) 1px, transparent 1px);
|
| 248 |
+
background-size:4px 4px;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/* ---------- stamp ---------- */
|
| 252 |
+
.stamp{
|
| 253 |
+
font-family:var(--f-display); text-transform:uppercase;
|
| 254 |
+
color:var(--ox-3);
|
| 255 |
+
border:4px solid var(--ox-3);
|
| 256 |
+
padding:8px 14px; letter-spacing:.06em;
|
| 257 |
+
box-shadow:0 0 0 2px var(--ink-0);
|
| 258 |
+
transform:rotate(-6deg);
|
| 259 |
+
mix-blend-mode:screen;
|
| 260 |
+
}
|
| 261 |
+
.stamp--slam{ animation:slam .45s cubic-bezier(.2,1.4,.3,1) both; }
|
| 262 |
+
@keyframes slam{
|
| 263 |
+
0%{ transform:rotate(-6deg) scale(3); opacity:0; }
|
| 264 |
+
60%{ transform:rotate(-6deg) scale(.92); opacity:1; }
|
| 265 |
+
100%{ transform:rotate(-6deg) scale(1); opacity:1; }
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/* utility */
|
| 269 |
+
.row{ display:flex; gap:12px; }
|
| 270 |
+
.col{ display:flex; flex-direction:column; gap:12px; }
|
| 271 |
+
.center{ display:flex; align-items:center; justify-content:center; }
|
| 272 |
+
.between{ display:flex; align-items:center; justify-content:space-between; }
|
| 273 |
+
.wrap{ flex-wrap:wrap; }
|
| 274 |
+
.grow{ flex:1; }
|
| 275 |
+
.scroll-y{ overflow-y:auto; }
|
| 276 |
+
.nowrap{ white-space:nowrap; }
|
web/src/ui/components.tsx
CHANGED
|
@@ -1,409 +1,407 @@
|
|
| 1 |
-
// UI kit — reusable pixel components. Ported from prototype/js/ui.jsx, wired to the
|
| 2 |
-
// store + procedural art modules instead of globals.
|
| 3 |
-
import type { ComponentChildren, JSX } from 'preact'
|
| 4 |
-
import { useEffect, useState } from 'preact/hooks'
|
| 5 |
-
|
| 6 |
-
import { EV_ICONS, IPAL, bodyFor, exhibitPainter, portraitFor, sceneFor } from '../engine/art'
|
| 7 |
-
import { PixelCanvas, SceneCanvas, useTypewriter } from '../engine/pixel'
|
| 8 |
-
import { useGame } from '../store'
|
| 9 |
-
import type { Evidence, Suspect } from '../types'
|
| 10 |
-
import { type Sfx, musicIsPlaying, playSfx, toggleMusic } from './audio'
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
let
|
| 28 |
-
let
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
clearTimeout(
|
| 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 |
-
const
|
| 74 |
-
const
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
if (
|
| 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 |
-
const
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
const
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
<span class="t-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
const
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
<
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
<div class="t-
|
| 224 |
-
|
| 225 |
-
</div>
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
<
|
| 254 |
-
|
| 255 |
-
<div class="
|
| 256 |
-
|
| 257 |
-
<
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
</
|
| 262 |
-
{develop &&
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
<
|
| 278 |
-
|
| 279 |
-
<
|
| 280 |
-
|
| 281 |
-
{
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
const [
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
{
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
// -
|
| 316 |
-
//
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
const
|
| 320 |
-
const
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
>
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
{icon('♪', musicOn ? 'Music: on' : 'Music: off', musicOn, () => setMusicOn(toggleMusic()))}
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
}
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
{ id: '
|
| 369 |
-
{ id: '
|
| 370 |
-
{ id: '
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
{icon === '
|
| 381 |
-
{icon === '
|
| 382 |
-
{icon === '
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
)
|
| 409 |
-
}
|
|
|
|
| 1 |
+
// UI kit — reusable pixel components. Ported from prototype/js/ui.jsx, wired to the
|
| 2 |
+
// store + procedural art modules instead of globals.
|
| 3 |
+
import type { ComponentChildren, JSX } from 'preact'
|
| 4 |
+
import { useEffect, useState } from 'preact/hooks'
|
| 5 |
+
|
| 6 |
+
import { EV_ICONS, IPAL, bodyFor, exhibitPainter, portraitFor, sceneFor } from '../engine/art'
|
| 7 |
+
import { PixelCanvas, SceneCanvas, useTypewriter } from '../engine/pixel'
|
| 8 |
+
import { useGame } from '../store'
|
| 9 |
+
import type { Evidence, Suspect } from '../types'
|
| 10 |
+
import { type Sfx, musicIsPlaying, playSfx, toggleMusic } from './audio'
|
| 11 |
+
|
| 12 |
+
const pxScaled = (px: number, floor = 2) =>
|
| 13 |
+
Math.max(floor, Math.round(px * (window.__pxScale || 1)))
|
| 14 |
+
|
| 15 |
+
// What the culprit is called in this kind of case ("name the killer / thief / arsonist").
|
| 16 |
+
const PERP: Record<string, string> = {
|
| 17 |
+
homicide: 'killer', theft: 'thief', fraud: 'fraudster',
|
| 18 |
+
blackmail: 'blackmailer', arson: 'arsonist', missing_person: 'abductor',
|
| 19 |
+
}
|
| 20 |
+
export const perpNoun = (kind?: string): string => PERP[kind || 'homicide'] || 'culprit'
|
| 21 |
+
|
| 22 |
+
// ---- sprite wrappers ----
|
| 23 |
+
export function useBlink(): boolean {
|
| 24 |
+
const [b, setB] = useState(false)
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
let t1: ReturnType<typeof setTimeout>
|
| 27 |
+
let t2: ReturnType<typeof setTimeout>
|
| 28 |
+
let alive = true
|
| 29 |
+
const loop = () => {
|
| 30 |
+
const wait = 2400 + Math.random() * 3200
|
| 31 |
+
t1 = setTimeout(() => {
|
| 32 |
+
if (!alive) return
|
| 33 |
+
setB(true)
|
| 34 |
+
t2 = setTimeout(() => {
|
| 35 |
+
if (alive) {
|
| 36 |
+
setB(false)
|
| 37 |
+
loop()
|
| 38 |
+
}
|
| 39 |
+
}, 120)
|
| 40 |
+
}, wait)
|
| 41 |
+
}
|
| 42 |
+
loop()
|
| 43 |
+
return () => {
|
| 44 |
+
alive = false
|
| 45 |
+
clearTimeout(t1)
|
| 46 |
+
clearTimeout(t2)
|
| 47 |
+
}
|
| 48 |
+
}, [])
|
| 49 |
+
return b
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
interface SpriteProps {
|
| 53 |
+
id: string
|
| 54 |
+
px?: number
|
| 55 |
+
style?: JSX.CSSProperties
|
| 56 |
+
className?: string
|
| 57 |
+
}
|
| 58 |
+
// Toggle the mouth open/closed at a speech-like cadence while the suspect's voice plays.
|
| 59 |
+
function useMouth(active: boolean): boolean {
|
| 60 |
+
const [open, setOpen] = useState(false)
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
if (!active) {
|
| 63 |
+
setOpen(false)
|
| 64 |
+
return
|
| 65 |
+
}
|
| 66 |
+
const id = setInterval(() => setOpen((o) => !o), 135)
|
| 67 |
+
return () => clearInterval(id)
|
| 68 |
+
}, [active])
|
| 69 |
+
return open
|
| 70 |
+
}
|
| 71 |
+
export function Portrait({ id, px = 6, blink = true, talking = false, style, className, gender }: SpriteProps & { blink?: boolean; talking?: boolean; gender?: string }) {
|
| 72 |
+
const p = portraitFor(id, gender)
|
| 73 |
+
const b = useBlink()
|
| 74 |
+
const mouth = useMouth(talking)
|
| 75 |
+
let frame = p.frames[0]
|
| 76 |
+
if (talking && p.frames[2]) frame = mouth ? p.frames[2] : p.frames[0]
|
| 77 |
+
else if (blink && b) frame = p.frames[1]
|
| 78 |
+
return <PixelCanvas map={frame} pal={p.pal} px={pxScaled(px)} style={style} className={className} />
|
| 79 |
+
}
|
| 80 |
+
export function Body({ id, px = 6, playing = true, style, className, gender }: SpriteProps & { playing?: boolean; gender?: string }) {
|
| 81 |
+
const p = bodyFor(id, gender)
|
| 82 |
+
return <PixelCanvas frames={p.frames} pal={p.pal} px={pxScaled(px)} fps={1.1} playing={playing} style={style} className={className} />
|
| 83 |
+
}
|
| 84 |
+
export function EvIcon({ icon, px = 4, style }: { icon: string; px?: number; style?: JSX.CSSProperties }) {
|
| 85 |
+
const m = EV_ICONS[icon] || EV_ICONS.photoEv
|
| 86 |
+
return <PixelCanvas map={m} pal={IPAL} px={pxScaled(px, 1)} style={style} />
|
| 87 |
+
}
|
| 88 |
+
interface SceneProps {
|
| 89 |
+
name: string
|
| 90 |
+
w?: number
|
| 91 |
+
h?: number
|
| 92 |
+
anim?: boolean
|
| 93 |
+
full?: boolean
|
| 94 |
+
cover?: boolean
|
| 95 |
+
style?: JSX.CSSProperties
|
| 96 |
+
className?: string
|
| 97 |
+
deps?: unknown[]
|
| 98 |
+
}
|
| 99 |
+
export function Scene({ name, w = 240, h = 135, anim = false, full = false, cover = false, style, className, deps = [] }: SceneProps) {
|
| 100 |
+
const st = cover ? { objectFit: 'cover' as const, ...style } : style
|
| 101 |
+
return (
|
| 102 |
+
<SceneCanvas
|
| 103 |
+
paint={sceneFor(name)}
|
| 104 |
+
w={w}
|
| 105 |
+
h={h}
|
| 106 |
+
anim={anim}
|
| 107 |
+
full={full}
|
| 108 |
+
style={st}
|
| 109 |
+
className={className}
|
| 110 |
+
deps={[name, anim, full, ...deps]}
|
| 111 |
+
/>
|
| 112 |
+
)
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
interface ExhibitArtProps {
|
| 116 |
+
e: Pick<Evidence, 'id' | 'name' | 'summary'>
|
| 117 |
+
w?: number
|
| 118 |
+
h?: number
|
| 119 |
+
style?: JSX.CSSProperties
|
| 120 |
+
className?: string
|
| 121 |
+
}
|
| 122 |
+
/** Procedural "evidence photo" of one exhibit - the object itself, on the forensic
|
| 123 |
+
* table, classified from the exhibit's own words and seeded by its id. */
|
| 124 |
+
export function ExhibitArt({ e, w = 96, h = 72, style, className }: ExhibitArtProps) {
|
| 125 |
+
return (
|
| 126 |
+
<SceneCanvas
|
| 127 |
+
paint={exhibitPainter(e.name, e.summary || '', e.id)}
|
| 128 |
+
w={w}
|
| 129 |
+
h={h}
|
| 130 |
+
style={style}
|
| 131 |
+
className={className}
|
| 132 |
+
deps={[e.id, e.name]}
|
| 133 |
+
/>
|
| 134 |
+
)
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// ---- primitives ----
|
| 138 |
+
interface PanelProps {
|
| 139 |
+
tab?: string
|
| 140 |
+
variant?: 'amber' | 'ox' | 'raised' | 'inset'
|
| 141 |
+
className?: string
|
| 142 |
+
style?: JSX.CSSProperties
|
| 143 |
+
children?: ComponentChildren
|
| 144 |
+
onClick?: (e: MouseEvent) => void
|
| 145 |
+
}
|
| 146 |
+
export function Panel({ tab, variant, className = '', style, children, onClick }: PanelProps) {
|
| 147 |
+
const v = variant ? ` panel--${variant}` : ''
|
| 148 |
+
return (
|
| 149 |
+
<div class={`panel${v} ${className}`} style={style} onClick={onClick}>
|
| 150 |
+
{tab && <span class="panel__tab">{tab}</span>}
|
| 151 |
+
{children}
|
| 152 |
+
</div>
|
| 153 |
+
)
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
type BtnProps = {
|
| 157 |
+
variant?: 'amber' | 'ox' | 'ghost'
|
| 158 |
+
sm?: boolean
|
| 159 |
+
className?: string
|
| 160 |
+
children?: ComponentChildren
|
| 161 |
+
/** SFX on click: a specific cue, or null to silence. Defaults to "click". */
|
| 162 |
+
sfx?: Sfx | null
|
| 163 |
+
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>
|
| 164 |
+
export function Btn({ variant, sm, className = '', children, sfx, onClick, ...rest }: BtnProps) {
|
| 165 |
+
const v = variant ? ` pbtn--${variant}` : ''
|
| 166 |
+
const handle = (e: MouseEvent) => {
|
| 167 |
+
if (sfx !== null) playSfx(sfx || 'click')
|
| 168 |
+
;(onClick as ((ev: MouseEvent) => void) | undefined)?.(e)
|
| 169 |
+
}
|
| 170 |
+
return (
|
| 171 |
+
<button class={`pbtn${v}${sm ? ' pbtn--sm' : ''} ${className}`} onClick={handle} {...rest}>
|
| 172 |
+
{children}
|
| 173 |
+
</button>
|
| 174 |
+
)
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
export function Chip({ variant, children, style }: { variant?: 'amber' | 'ox'; children?: ComponentChildren; style?: JSX.CSSProperties }) {
|
| 178 |
+
const v = variant ? ` chip--${variant}` : ''
|
| 179 |
+
return <span class={`chip${v}`} style={style}>{children}</span>
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
export function Stamp({ children, slam, style }: { children?: ComponentChildren; slam?: boolean; style?: JSX.CSSProperties }) {
|
| 183 |
+
return <span class={`stamp${slam ? ' stamp--slam' : ''}`} style={style}>{children}</span>
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// ---- suspicion / composure bar ----
|
| 187 |
+
export function SuspicionBar({ value, label = 'SUSPICION', compact }: { value: number; label?: string | null; compact?: boolean }) {
|
| 188 |
+
const v = Math.max(0, Math.min(100, Math.round(value)))
|
| 189 |
+
const calm = v < 42
|
| 190 |
+
return (
|
| 191 |
+
<div class="col" style={{ gap: compact ? 3 : 5 }}>
|
| 192 |
+
{!compact && label && (
|
| 193 |
+
<div class="between" style={{ gap: 6 }}>
|
| 194 |
+
<span class="t-label" style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
| 195 |
+
<span class="t-mono" style={{ flexShrink: 0, color: calm ? 'var(--slate-3)' : 'var(--ox-3)', fontSize: 'calc(14px*var(--mono-scale))' }}>{v}%</span>
|
| 196 |
+
</div>
|
| 197 |
+
)}
|
| 198 |
+
<div class="bar" style={compact ? { height: 10 } : undefined}>
|
| 199 |
+
<div class={'bar__fill' + (calm ? ' bar__fill--calm' : '')} style={{ width: v + '%' }} />
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
)
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// ---- suspect card ----
|
| 206 |
+
export function SuspectCard({ s, onClick, active, mini }: { s: Suspect; onClick?: (e: MouseEvent) => void; active?: boolean; mini?: boolean }) {
|
| 207 |
+
const g = useGame()
|
| 208 |
+
const susp = g.state.suspicion[s.id]
|
| 209 |
+
return (
|
| 210 |
+
<Panel
|
| 211 |
+
className="suspect-card"
|
| 212 |
+
variant={active ? 'amber' : undefined}
|
| 213 |
+
style={{ padding: mini ? 8 : 12, cursor: onClick ? 'pointer' : 'default', minWidth: mini ? 0 : 168 }}
|
| 214 |
+
onClick={onClick}
|
| 215 |
+
>
|
| 216 |
+
<div class="row" style={{ gap: 10, alignItems: 'flex-start' }}>
|
| 217 |
+
<div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 3, flexShrink: 0 }}>
|
| 218 |
+
<Portrait id={s.sprite} px={mini ? 3 : 4} gender={s.gender} />
|
| 219 |
+
</div>
|
| 220 |
+
<div class="col grow" style={{ gap: 5, minWidth: 0 }}>
|
| 221 |
+
<div>
|
| 222 |
+
<div class="t-display" style={{ fontSize: mini ? 9 : 11, color: 'var(--bone-3)' }}>{s.name}</div>
|
| 223 |
+
<div class="t-label" style={{ marginTop: 3 }}>{s.tag}</div>
|
| 224 |
+
</div>
|
| 225 |
+
{!mini && <div class="t-body dim" style={{ fontSize: 12, lineHeight: 1.35 }}>{s.role}</div>}
|
| 226 |
+
<SuspicionBar value={susp} compact />
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
</Panel>
|
| 230 |
+
)
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
// ---- evidence card with "develops in" reveal ----
|
| 234 |
+
export function EvidenceCard({ e, onClick, active, develop, small }: { e: Evidence; onClick?: (ev: MouseEvent) => void; active?: boolean; develop?: boolean; small?: boolean }) {
|
| 235 |
+
const [revealed, setRevealed] = useState(!develop)
|
| 236 |
+
useEffect(() => {
|
| 237 |
+
if (develop) {
|
| 238 |
+
const t = setTimeout(() => setRevealed(true), 60)
|
| 239 |
+
return () => clearTimeout(t)
|
| 240 |
+
}
|
| 241 |
+
}, [develop])
|
| 242 |
+
return (
|
| 243 |
+
<Panel
|
| 244 |
+
variant={active ? 'amber' : undefined}
|
| 245 |
+
className="ev-card"
|
| 246 |
+
style={{ padding: small ? 8 : 11, cursor: onClick ? 'pointer' : 'default', position: 'relative', overflow: 'hidden' }}
|
| 247 |
+
onClick={onClick}
|
| 248 |
+
>
|
| 249 |
+
<div class="row" style={{ gap: 10, alignItems: 'center' }}>
|
| 250 |
+
<div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 4, flexShrink: 0 }}>
|
| 251 |
+
<EvIcon icon={e.icon} px={small ? 2 : 3} />
|
| 252 |
+
</div>
|
| 253 |
+
<div class="col grow" style={{ gap: 4, minWidth: 0 }}>
|
| 254 |
+
<div class="t-display" style={{ fontSize: small ? 8 : 10, color: 'var(--bone-3)' }}>{e.name}</div>
|
| 255 |
+
<div class="row" style={{ gap: 6, alignItems: 'center' }}>
|
| 256 |
+
<Chip variant="amber" style={{ fontSize: 'calc(12px*var(--mono-scale))', padding: '2px 5px' }}>{e.type}</Chip>
|
| 257 |
+
<span class="t-mono dim nowrap" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>{e.time}</span>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
{develop && !revealed && <span class="develop-veil" />}
|
| 262 |
+
{develop && <span class="develop-veil develop-veil--anim" />}
|
| 263 |
+
</Panel>
|
| 264 |
+
)
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// ---- dialogue / speech panel with typewriter ----
|
| 268 |
+
export function DialoguePanel({ who, text, speed = 26, onDone, instant, tag }: { who: string; text: string; speed?: number; onDone?: () => void; instant?: boolean; tag?: string | null }) {
|
| 269 |
+
const [out, done] = useTypewriter(text, speed, !instant)
|
| 270 |
+
useEffect(() => {
|
| 271 |
+
if (done && onDone) onDone()
|
| 272 |
+
}, [done])
|
| 273 |
+
return (
|
| 274 |
+
<Panel className="dialogue" style={{ padding: 16 }}>
|
| 275 |
+
<div class="between" style={{ marginBottom: 8 }}>
|
| 276 |
+
<span class="t-display amber" style={{ fontSize: 11 }}>{who}</span>
|
| 277 |
+
{tag && <Chip variant="ox">{tag}</Chip>}
|
| 278 |
+
</div>
|
| 279 |
+
<div class="t-body" style={{ minHeight: 56, color: 'var(--bone-2)' }}>
|
| 280 |
+
{out}
|
| 281 |
+
{!done && <span class="cursor" />}
|
| 282 |
+
</div>
|
| 283 |
+
</Panel>
|
| 284 |
+
)
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
// ---- type a string once, fire onDone ----
|
| 288 |
+
export function TypeOnce({ text, speed, onDone }: { text: string; speed: number; onDone?: () => void }) {
|
| 289 |
+
const [out, done] = useTypewriter(text, speed, true)
|
| 290 |
+
const [fired, setFired] = useState(false)
|
| 291 |
+
useEffect(() => {
|
| 292 |
+
if (done && !fired) {
|
| 293 |
+
setFired(true)
|
| 294 |
+
onDone?.()
|
| 295 |
+
}
|
| 296 |
+
}, [done])
|
| 297 |
+
return (
|
| 298 |
+
<>
|
| 299 |
+
{out}
|
| 300 |
+
{!done && <span class="cursor" />}
|
| 301 |
+
</>
|
| 302 |
+
)
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
// ---- HINT trigger button ----
|
| 306 |
+
export function HintButton() {
|
| 307 |
+
return (
|
| 308 |
+
<button class="hint-btn" onClick={() => window.dispatchEvent(new Event('toggle-hint'))} title="Ask your partner">
|
| 309 |
+
<span class="hint-btn__dot" /> HINT
|
| 310 |
+
</button>
|
| 311 |
+
)
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// ---- top HUD bar ----
|
| 315 |
+
// Navbar controls: optional Main-menu and Music toggle. Compact icon buttons so
|
| 316 |
+
// they sit gracefully in the HUD bar (and on mobile) instead of floating mid-screen.
|
| 317 |
+
export function Controls({ menu = false }: { menu?: boolean }) {
|
| 318 |
+
const g = useGame()
|
| 319 |
+
const [musicOn, setMusicOn] = useState(musicIsPlaying())
|
| 320 |
+
const icon = (label: string, title: string, on: boolean, onClick: () => void) => (
|
| 321 |
+
<button
|
| 322 |
+
class="hint-btn"
|
| 323 |
+
title={title}
|
| 324 |
+
aria-label={title}
|
| 325 |
+
onClick={onClick}
|
| 326 |
+
style={{ width: 30, height: 28, padding: 0, justifyContent: 'center', fontSize: '0.95rem', opacity: on ? 1 : 0.5 }}
|
| 327 |
+
>
|
| 328 |
+
{label}
|
| 329 |
+
</button>
|
| 330 |
+
)
|
| 331 |
+
return (
|
| 332 |
+
<div class="row" style={{ gap: 5, alignItems: 'center' }}>
|
| 333 |
+
{menu && (g.mode === 'mobile'
|
| 334 |
+
? icon('≡', 'Main menu', true, () => g.nav('title'))
|
| 335 |
+
: <Btn sm variant="ghost" onClick={() => g.nav('title')}>Menu</Btn>)}
|
| 336 |
+
{icon('♪', musicOn ? 'Music: on' : 'Music: off', musicOn, () => setMusicOn(toggleMusic()))}
|
| 337 |
+
</div>
|
| 338 |
+
)
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
export function Hud({ title, sub, right }: { title: string; sub?: string; right?: ComponentChildren }) {
|
| 342 |
+
const g = useGame()
|
| 343 |
+
return (
|
| 344 |
+
<div class="hud">
|
| 345 |
+
<div class="hud__left">
|
| 346 |
+
<button class="hud-badge" onClick={() => g.nav('board')} title="Investigation Board">
|
| 347 |
+
<span class="t-display" style={{ fontSize: 9, color: 'var(--ink-0)' }}>CASE</span>
|
| 348 |
+
<span class="t-mono" style={{ fontSize: 'calc(13px*var(--mono-scale))', color: 'var(--ink-0)' }}>{g.case.id}</span>
|
| 349 |
+
</button>
|
| 350 |
+
<div class="col" style={{ gap: 2, minWidth: 0 }}>
|
| 351 |
+
<div class="t-display hud__title">{title}</div>
|
| 352 |
+
{sub && <div class="t-label hud__sub">{sub}</div>}
|
| 353 |
+
</div>
|
| 354 |
+
</div>
|
| 355 |
+
<div class="hud__right">
|
| 356 |
+
{right}
|
| 357 |
+
<Controls menu />
|
| 358 |
+
<HintButton />
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
)
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
// ---- bottom nav (mobile) ----
|
| 365 |
+
const NAV_ITEMS = [
|
| 366 |
+
{ id: 'briefing', label: 'CASE', icon: 'file' },
|
| 367 |
+
{ id: 'board', label: 'BOARD', icon: 'board' },
|
| 368 |
+
{ id: 'suspects', label: 'SUSPECTS', icon: 'people' },
|
| 369 |
+
{ id: 'evidence', label: 'EVIDENCE', icon: 'box' },
|
| 370 |
+
{ id: 'timeline', label: 'TIME', icon: 'clock' },
|
| 371 |
+
] as const
|
| 372 |
+
|
| 373 |
+
function NavGlyph({ icon, on }: { icon: string; on: boolean }) {
|
| 374 |
+
const c = on ? 'var(--ink-0)' : 'var(--bone-1)'
|
| 375 |
+
const P = (d: JSX.CSSProperties) => <span style={{ position: 'absolute', ...d }} />
|
| 376 |
+
return (
|
| 377 |
+
<span style={{ position: 'relative', width: 18, height: 18, display: 'inline-block' }}>
|
| 378 |
+
{icon === 'file' && (<>{P({ left: 4, top: 2, width: 10, height: 14, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 6, top: 6, width: 6, height: 2, background: c })}{P({ left: 6, top: 10, width: 6, height: 2, background: c })}</>)}
|
| 379 |
+
{icon === 'board' && (<>{P({ left: 2, top: 3, width: 14, height: 12, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 6, top: 7, width: 3, height: 3, background: c })}{P({ left: 11, top: 9, width: 3, height: 3, background: c })}</>)}
|
| 380 |
+
{icon === 'people' && (<>{P({ left: 3, top: 3, width: 5, height: 5, background: c })}{P({ left: 3, top: 9, width: 7, height: 6, background: c })}{P({ left: 11, top: 4, width: 4, height: 4, background: c })}{P({ left: 10, top: 9, width: 6, height: 6, background: c })}</>)}
|
| 381 |
+
{icon === 'box' && (<>{P({ left: 3, top: 4, width: 12, height: 11, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 3, top: 4, width: 12, height: 2, background: c })}{P({ left: 8, top: 8, width: 2, height: 4, background: c })}</>)}
|
| 382 |
+
{icon === 'clock' && (<>{P({ left: 3, top: 3, width: 12, height: 12, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 8, top: 6, width: 2, height: 4, background: c })}{P({ left: 8, top: 9, width: 4, height: 2, background: c })}</>)}
|
| 383 |
+
</span>
|
| 384 |
+
)
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
export function BottomNav() {
|
| 388 |
+
const g = useGame()
|
| 389 |
+
const cur = g.state.screen
|
| 390 |
+
return (
|
| 391 |
+
<nav class="bottom-nav">
|
| 392 |
+
{NAV_ITEMS.map((it) => {
|
| 393 |
+
const on = cur === it.id || (it.id === 'suspects' && cur === 'interro')
|
| 394 |
+
return (
|
| 395 |
+
<button
|
| 396 |
+
key={it.id}
|
| 397 |
+
class={'nav-btn' + (on ? ' nav-btn--on' : '')}
|
| 398 |
+
onClick={() => g.nav(it.id)}
|
| 399 |
+
>
|
| 400 |
+
<span class="nav-btn__glyph"><NavGlyph icon={it.icon} on={on} /></span>
|
| 401 |
+
<span class="t-display nav-btn__lbl">{it.label}</span>
|
| 402 |
+
</button>
|
| 403 |
+
)
|
| 404 |
+
})}
|
| 405 |
+
</nav>
|
| 406 |
+
)
|
| 407 |
+
}
|
|
|
|
|
|
web/src/ui/tweaks.tsx
DELETED
|
@@ -1,76 +0,0 @@
|
|
| 1 |
-
// Slim in-app tweaks sheet (palette / FX / pixel scale / mood / fonts / typewriter / rain),
|
| 2 |
-
// styled in the noir system and persisted to localStorage via the store. Opened from the
|
| 3 |
-
// navbar gear (Controls). No host/sandbox protocol — production-local only.
|
| 4 |
-
import { useGame } from '../store'
|
| 5 |
-
import type { Tweaks } from '../store'
|
| 6 |
-
|
| 7 |
-
const PALETTES: [Tweaks['palette'], string[]][] = [
|
| 8 |
-
['sodium', ['#0d1117', '#2d4a52', '#e0a44c', '#8a2a2a']],
|
| 9 |
-
['harbor', ['#0a0e14', '#3a6b6b', '#f0b860', '#a33636']],
|
| 10 |
-
['violet', ['#0e0c14', '#46506b', '#d99a3c', '#922d3a']],
|
| 11 |
-
]
|
| 12 |
-
|
| 13 |
-
function Seg<T extends string>({ value, options, onChange }: { value: T; options: T[]; onChange: (v: T) => void }) {
|
| 14 |
-
return (
|
| 15 |
-
<div class="viewbar" style={{ position: 'static' }}>
|
| 16 |
-
<div class="seg">
|
| 17 |
-
{options.map((o) => (
|
| 18 |
-
<button key={o} class={value === o ? 'on' : ''} onClick={() => onChange(o)}>{o}</button>
|
| 19 |
-
))}
|
| 20 |
-
</div>
|
| 21 |
-
</div>
|
| 22 |
-
)
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
function Row({ label, children }: { label: string; children: preact.ComponentChildren }) {
|
| 26 |
-
return (
|
| 27 |
-
<div class="col" style={{ gap: 6 }}>
|
| 28 |
-
<span class="t-label">{label}</span>
|
| 29 |
-
{children}
|
| 30 |
-
</div>
|
| 31 |
-
)
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
// The settings sheet, opened from the navbar gear (Controls). Caller owns the open state.
|
| 35 |
-
export function TweaksSheet({ onClose }: { onClose: () => void }) {
|
| 36 |
-
const g = useGame()
|
| 37 |
-
const t = g.state.tweaks
|
| 38 |
-
|
| 39 |
-
return (
|
| 40 |
-
<div
|
| 41 |
-
class="panel panel--amber"
|
| 42 |
-
style={{ position: 'fixed', right: 14, top: 56, zIndex: 90, width: 248, maxHeight: '80vh', overflowY: 'auto' }}
|
| 43 |
-
>
|
| 44 |
-
<div class="between" style={{ marginBottom: 12 }}>
|
| 45 |
-
<span class="t-display amber" style={{ fontSize: 11 }}>TWEAKS</span>
|
| 46 |
-
<button class="assistant__x" onClick={onClose}>✕</button>
|
| 47 |
-
</div>
|
| 48 |
-
<div class="col" style={{ gap: 14 }}>
|
| 49 |
-
<Row label="Palette">
|
| 50 |
-
<div style={{ display: 'flex', gap: 8 }}>
|
| 51 |
-
{PALETTES.map(([id, cols]) => (
|
| 52 |
-
<button key={id} onClick={() => g.setTweak('palette', id)} style={{ flex: 1, border: 0, padding: 0, cursor: 'pointer', background: 'transparent' }} title={id}>
|
| 53 |
-
<div style={{ display: 'flex', height: 24, boxShadow: t.palette === id ? '0 0 0 2px var(--amber-2)' : '0 0 0 2px var(--ink-0)' }}>
|
| 54 |
-
{cols.map((c, i) => <span key={i} style={{ flex: 1, background: c }} />)}
|
| 55 |
-
</div>
|
| 56 |
-
</button>
|
| 57 |
-
))}
|
| 58 |
-
</div>
|
| 59 |
-
</Row>
|
| 60 |
-
<Row label="Precinct mood"><Seg value={t.mood} options={['night', 'day']} onChange={(v) => g.setTweak('mood', v)} /></Row>
|
| 61 |
-
<Row label="FX intensity"><Seg value={t.fx} options={['low', 'med', 'high']} onChange={(v) => g.setTweak('fx', v)} /></Row>
|
| 62 |
-
<Row label="Bitmap fonts"><Seg value={t.fonts} options={['crisp', 'terminal', 'stamp']} onChange={(v) => g.setTweak('fonts', v)} /></Row>
|
| 63 |
-
<Row label={`Pixel scale · ${t.pixelScale}x`}>
|
| 64 |
-
<input type="range" min={0.75} max={1.5} step={0.25} value={t.pixelScale} onInput={(e) => g.setTweak('pixelScale', Number((e.target as HTMLInputElement).value))} style={{ width: '100%' }} />
|
| 65 |
-
</Row>
|
| 66 |
-
<Row label={`Typewriter · ${t.typeSpeed}ms`}>
|
| 67 |
-
<input type="range" min={6} max={44} step={2} value={t.typeSpeed} onInput={(e) => g.setTweak('typeSpeed', Number((e.target as HTMLInputElement).value))} style={{ width: '100%' }} />
|
| 68 |
-
</Row>
|
| 69 |
-
<div class="between">
|
| 70 |
-
<span class="t-label">Pixel rain</span>
|
| 71 |
-
<button class={t.rain ? 'pbtn pbtn--amber pbtn--sm' : 'pbtn pbtn--ghost pbtn--sm'} onClick={() => g.setTweak('rain', !t.rain)}>{t.rain ? 'ON' : 'OFF'}</button>
|
| 72 |
-
</div>
|
| 73 |
-
</div>
|
| 74 |
-
</div>
|
| 75 |
-
)
|
| 76 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|