diff --git a/.agents/skills/migrate-vite-cloudflare-to-void b/.agents/skills/migrate-vite-cloudflare-to-void deleted file mode 120000 index f8b082b..0000000 --- a/.agents/skills/migrate-vite-cloudflare-to-void +++ /dev/null @@ -1 +0,0 @@ -../../node_modules/void/skills/migrate-vite-cloudflare-to-void \ No newline at end of file diff --git a/.agents/skills/void b/.agents/skills/void deleted file mode 120000 index 7b19a47..0000000 --- a/.agents/skills/void +++ /dev/null @@ -1 +0,0 @@ -../../node_modules/void/skills/void \ No newline at end of file diff --git a/.env.example b/.env.example index 851dd4d..7a33991 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,18 @@ # ── ElevenLabs voices + sound effects (optional) ──────────────────────────── -# Add a key to give the narrator and characters real voices and mood SFX. -# Leave blank to keep the game on procedural ambience only (still works). +# The shipped game already plays pre-rendered files in public/audio/. +# A key is only needed to regenerate those files (`npm run audio`) or to +# synthesize missing lines through the dev server. # -# Requests are routed through the Vite dev/preview proxy (/eleven), which injects -# this key server-side — so there is NO CORS issue and the key is NOT shipped in -# the client bundle. Either of these names works: +# Put the key in .env.local (gitignored) under this name: ELEVENLABS_API_KEY= -# VITE_ELEVENLABS_API_KEY= +# +# Keep the key off any VITE_ name. Vite inlines VITE_ variables into client +# code wherever they are read. The dev server reads ELEVENLABS_API_KEY and +# calls ElevenLabs from the `/api/eleven` route. -# Optional: TTS model (default: eleven_turbo_v2_5 — low latency, English). -# Use eleven_multilingual_v2 for higher quality at the cost of latency. -VITE_ELEVENLABS_MODEL=eleven_turbo_v2_5 +# Optional: TTS model used by the dev proxy and by `npm run audio`. +# The committed clips were rendered with eleven_v3. +VITE_ELEVENLABS_MODEL=eleven_v3 # Optional: audio output format (default: mp3_44100_128). VITE_ELEVENLABS_OUTPUT=mp3_44100_128 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8f80344 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run build + env: + STATIC_BUILD: "1" diff --git a/.gitignore b/.gitignore index fba111c..9be1f24 100644 --- a/.gitignore +++ b/.gitignore @@ -2,20 +2,32 @@ node_modules/ dist/ test-results/ playwright-report/ +coverage/ +*.log +logs/ +recordings/ +*.mp4 +*.mov +*.webm .DS_Store +.idea/ +*.swp +*.tsbuildinfo -# Local env files (may contain API keys); keep the template tracked +# Local secrets. The tracked template is .env.example. .env -.env.local -.env.*.local +.env.* +!.env.example + +# Machine-local agent notes +HERMES.md +CLAUDE.md +.agents/ -# Large working-copy duplicate of public/assets/map.jpg +# Large working-copy duplicate of public/assets/map.webp /Map.jpg -# Generated audio is committed so it doesn't need regenerating. -# (Run `npm run audio` only to (re)create or update it.) +# Generated by `void prepare` and local tooling .void .wrangler -*.tsbuildinfo .vercel -.env* diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index e69640b..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,18 +0,0 @@ - -## Void - -This project uses [Void](https://void.cloud) — a fullstack Vite plugin + deployment platform for Cloudflare. `voidPlugin()` in `vite.config.ts` gives you file-based API routing on Hono (`routes/`), Inertia-inspired server-rendered pages with co-located loaders/actions (`pages/` + `@void/vue` or `@void/react`), auto-provisioned D1/KV/R2 bindings, first-class Drizzle ORM integration (schema in `db/schema.ts` -> `void/db` Drizzle instance -> typed routes -> typed fetch client), built-in auth, queues, cron jobs, edge caching (ISR), and one-command deploys via `npx void deploy`. For first-time setup, prefer `npx void init`; in an empty directory, install `void` first and let the interactive flow scaffold the starter with Vite+ by default, add the matching framework adapter, configure project files, handle auth, and link or create the deploy project before the first deploy. In an existing app, `void init` configures Void in place by adding missing Vite scripts and creating or patching `vite.config.*` with `voidPlugin()`. Use `void` and `@void/*` package names in source code and package manifests. - -Database: define Drizzle tables in `db/schema.ts`, import `db` from `void/db` and tables from `@schema`. Use `void db push` for prototyping, `void db generate` for production migrations. `drizzle-orm` and `drizzle-kit` ship with void (no extra install). Migrations live in `db/migrations/`. - -Env: declare every env key in `env.ts` at the project root via `defineEnv({ KEY: string(), ... })` from `void/env`. Read values via `import { env } from "void/env"`. Schema validation runs at dev start (warns) and on `void deploy` (hard error on missing prod secrets). Use `VITE_*` prefix for keys that should be exposed to client code. - -CI/editor prep: run `void prepare` to generate `.void/routes.d.ts`, `.void/db.d.ts`, `.void/queues.d.ts`, `.void/env.d.ts`, and `.void/tsconfig.json` without booting Vite. Run it after `npm install` in CI or a fresh clone before typechecking; `vite dev` and `vite build` regenerate these during normal workflows. - -Rewrites and redirects: declare static rules in `void.json` under `routing.redirects` / `routing.rewrites` / `routing.fallbacks`, or in a `public/_redirects` file. For dynamic rewrites, call `c.rewrite(path)` in a `defineMiddleware`. - -Logs: surface app-level errors that should show up under `void project logs --level error` via `import { logger } from "void/log"` and `logger.error(msg, fields?)` (also `.warn` / `.info`). Anything caught and only persisted to your own DB is invisible to Cloudflare Tail; route it through `logger.*` or `console.*` so the platform can see it. - -Full docs are in `node_modules/void/docs/`. If you have the `void` skill available, use it for a complete API reference covering project structure, routing, pages mode, database, auth, typed fetch, KV, storage, queues, cron jobs, CLI, configuration, and deployment. - - diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a555ef5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +MIT License + +Copyright (c) 2026 Adam + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +The MIT license covers the source code in this repository. + +It does not cover the story prose (including `story-source/`), the +illustrations in `public/assets/`, or the audio in `public/audio/`. Those +assets keep the separate terms described in the README and in +`public/audio/CREDITS.md`. diff --git a/README.md b/README.md index eb4e893..cf7427f 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,68 @@ # The Underwater City of Athel -An interactive, cinematic "choose your own story" set in the underwater city of -Athel. The hand-drawn cross-section map is the stage: the camera pans and zooms -between locations, characters speak on the map, narration is typed out, scenes -fade through black, and your choices funnel Mira toward one of three endings. +A cinematic choose-your-own-adventure through a sealed city under the sea. The cross-section map is the stage: the camera travels between districts, the cast speaks on the glass, and Mira's choices lead to one of three endings. -## Run +**Voices and sound: [elevenlabs.io](https://elevenlabs.io)** -```bash -npm install -npm run dev # start the dev server -npm run build # typecheck + production build -npm test # unit tests (vitest) -npm run e2e # end-to-end tests (Playwright) -npm run lint -``` +Play it: **[underwater-city-story.vercel.app](https://underwater-city-story.vercel.app)** + +![Title screen over the city map](docs/screenshots/title.jpg) -## Deployment targets +![Torin speaks at the market stall, with the city map behind the line](docs/screenshots/scene.jpg) -Vercel is the static production target. Its repository configuration runs the -same typechecked `npm run build` command used locally and serves `dist/` as a -single-page app. Vite-hashed assets and content-addressed voice files are cached -immutably; HTML and `public/audio/manifest.json` are revalidated. +![The world map of Athel, opened from inside the story](docs/screenshots/map.jpg) -Cloudflare remains supported through Void for anyone who needs the server routes -or D1-backed demo API. The `Deploy to Cloudflare with Void` workflow is explicit -about that target and runs only from `main` or by manual dispatch. +## How it was made -## Voices & sound (ElevenLabs, optional) +The story follows Mira, a student in the Tank, after she finds proof that the poisoned-surface story her city teaches is a lie. The prose began as the drafts in [`story-source/`](story-source). The playable version turns that prose into a scene graph: optional detours rejoin the main path, and the climax branches for real. -The narrator and each character can be voiced with ElevenLabs text-to-speech, -and an optional sound-effect "sting" plays when the scene mood changes. +The map and the character sheet are original illustrations made for Athel, including AI-generated art by the author. Dialogue, mood beds, and sound effects were pre-rendered with ElevenLabs and committed under `public/audio/`, so the public site plays locally and does not call the API. -1. Copy `.env.example` to `.env.local`. -2. Set `ELEVENLABS_API_KEY` to your ElevenLabs key. -3. (Optional) Override the model with `VITE_ELEVENLABS_MODEL` (default - `eleven_turbo_v2_5` — low latency, English; use `eleven_multilingual_v2` - for higher quality at the cost of speed) or the per-character voices with - `VITE_ELEVENLABS_VOICE_*`. -4. Restart `npm run dev`. Use the top-right control cluster: - - **Ambience** — procedural underwater soundscape that retunes per mood - (works with no key); also triggers the ElevenLabs mood stingers when a key - is present. - - **Voices** — ElevenLabs narration; the on-screen text paces itself to the - spoken line and the speaking portrait pulses. +## Tech stack -Without a key the game runs normally (procedural ambience only) and the Voices -toggle is inert. +- [React 19](https://react.dev) and TypeScript, bundled with [Vite](https://vite.dev) +- [Framer Motion](https://www.framer.com/motion/) for the camera, travel, and fades +- Vitest and Testing Library for unit tests; Playwright for the ending paths +- A static production build on [Vercel](https://vercel.com) +- [Void](https://void.cloud) for an optional dev-time `/api/eleven` route that keeps an ElevenLabs key on the server -Requests are routed through a same-origin Vite proxy (`/eleven`) that injects -the key server-side — this avoids browser CORS errors and keeps the key out of -the client bundle. The proxy is active under `npm run dev` and `npm run preview`. +## Quick start + +```bash +npm install +npm run dev # http://127.0.0.1:5173 +npm test # unit tests +npm run lint +npm run build # void prepare, typecheck, production bundle +npm run e2e # Playwright (installs a browser on first run) +``` -### Pre-generate audio (recommended) +`npm run build` writes `dist/`. Set `STATIC_BUILD=1` to skip the Void plugin and emit the same static site Vercel serves. Vercel sets that mode itself. -For instant, reliable playback (no runtime API calls, no CORS, no latency), -pre-render every line and mood SFX to `public/audio/` once: +Copy `.env.example` to `.env.local` only if you want to regenerate audio. The key name is `ELEVENLABS_API_KEY`. Leave it unset to play the committed clips. The `/api/eleven` route is for that local workflow. The public site is the static build, which never receives the key. ```bash -npm run audio +npm run audio # re-render missing voice, music, and effect files ``` -This reads your key, writes `public/audio/voice/*.mp3`, `public/audio/sfx/*.mp3`, -and `public/audio/manifest.json`, then the app plays those local files (falling -back to the live proxy for anything not pre-generated). Re-running only fetches -what's new. `public/audio/` is git-ignored. +## Playing -> On the **free ElevenLabs plan**, only default voices work via the API (library -> voices return `402`). The defaults shipped here are free-tier voices. Override -> any of them with `VITE_ELEVENLABS_VOICE_*` if you have a paid plan. +Each scene offers choices. Some collect clues; the climax splits into different endings. Four hidden traits — evidence, trust, risk, and collective — decide the finale: -> **Security note:** `VITE_` variables are bundled into the client, so the key -> is visible to anyone who loads the app. That is acceptable for a local/demo -> build. For a public deployment, proxy the ElevenLabs requests through a small -> backend that keeps the key server-side. +- **Hopeful** — proof, allies, and a public that can hear it. +- **Bittersweet** — the truth spreads faster than anyone can hold it. +- **Dark** — an escape with too little proof and no one beside her. -## How the branching works +The map overlay opens the whole cross-section. The atlas keeps collected items, the cast, and achievements. An in-progress run is saved in the browser and can be continued from the title screen. -- Each scene offers choices. Some are optional detours (collect items, build - trust) that rejoin the main path; the **climax** opens genuinely different - branches (run to Torin / record more / get caught). -- Every choice can adjust four hidden traits — **evidence, trust, risk, - collective** — and can add items to the **Atlas**. -- The ending is resolved from your accumulated traits: - - **Hopeful** — strong proof, allies, and public support with low recklessness. - - **Bittersweet** — the truth spreads, but faster than it can be organised. - - **Dark** — an isolated escape without enough proof or allies. +## Credits -## Overlays +- Story, illustrations, and code by [Adam](https://github.com/Programcode). +- Cinzel, EB Garamond, and Inter are loaded from Google Fonts. Each is licensed under the [SIL Open Font License](https://openfontlicense.org/). +- Voice, music beds, and sound effects: ElevenLabs. Details and the free-plan limits are in [`public/audio/CREDITS.md`](public/audio/CREDITS.md). -- **Map** — the full cross-section world map; click any brass marker to read its - lore, related scenes, and collectibles. "Return to the story" closes it. -- **Atlas** — your foldout journal of collected items and charted places, plus - character bios (Cast) and Achievements (persisted across runs). +## Licence -## Saving & progress +Source code is [MIT](LICENSE), copyright 2026 Adam. -- The current run autosaves; the title screen offers **Continue** to resume it. -- Reaching an ending records it and unlocks achievements (kept across runs), and - the finale shows **the path you walked** and your trait scores. +The MIT license stops at the code. The story prose in `story-source/` and `src/data/`, the illustrations in `public/assets/`, and the audio in `public/audio/` stay under the terms in the credits above. The prose and illustrations are the author's, all rights reserved, and are included so the project can be read and run. diff --git a/db/migrations/20260607071511_brainy_maggott.sql b/db/migrations/20260607071511_brainy_maggott.sql deleted file mode 100644 index c019b94..0000000 --- a/db/migrations/20260607071511_brainy_maggott.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE `posts` ( - `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `title` text NOT NULL, - `body` text NOT NULL, - `created_at` text DEFAULT (datetime('now')) NOT NULL -); diff --git a/db/migrations/meta/20260607071511_snapshot.json b/db/migrations/meta/20260607071511_snapshot.json deleted file mode 100644 index dd011a5..0000000 --- a/db/migrations/meta/20260607071511_snapshot.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "5bfb98d4-7e2c-4ffb-abc3-af2540dca1be", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "posts": { - "name": "posts", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "body": { - "name": "body", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(datetime('now'))" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json deleted file mode 100644 index 91f30fd..0000000 --- a/db/migrations/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1780816511100, - "tag": "20260607071511_brainy_maggott", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/db/schema.ts b/db/schema.ts deleted file mode 100644 index 144476a..0000000 --- a/db/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { sql } from "void/db"; -import { integer, sqliteTable, text } from "void/schema-d1"; - -export const posts = sqliteTable("posts", { - id: integer("id").primaryKey({ autoIncrement: true }), - title: text("title").notNull(), - body: text("body").notNull(), - createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), -}); diff --git a/docs/screenshots/map.jpg b/docs/screenshots/map.jpg new file mode 100644 index 0000000..55ad5ab Binary files /dev/null and b/docs/screenshots/map.jpg differ diff --git a/docs/screenshots/scene.jpg b/docs/screenshots/scene.jpg new file mode 100644 index 0000000..11dcfa6 Binary files /dev/null and b/docs/screenshots/scene.jpg differ diff --git a/docs/screenshots/title.jpg b/docs/screenshots/title.jpg new file mode 100644 index 0000000..01d8762 Binary files /dev/null and b/docs/screenshots/title.jpg differ diff --git a/docs/superpowers/plans/2026-06-05-underwater-city-interactive-story.md b/docs/superpowers/plans/2026-06-05-underwater-city-interactive-story.md deleted file mode 100644 index d7a8edd..0000000 --- a/docs/superpowers/plans/2026-06-05-underwater-city-interactive-story.md +++ /dev/null @@ -1,1077 +0,0 @@ -# Underwater City Interactive Story Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a polished interactive web app for the underwater city story, combining an old-map-style clickable map with a branching choice game that reaches one of three endings. - -**Architecture:** This is a new frontend-only React + Vite app. The story, lore, endings, and map hotspots should be modeled as typed data, with focused UI components for the map explorer, lore drawer, story engine, and ending reveal. The app should use the provided `Map.jpg` as the main visual artifact and the RTF source files as canonical story material. - -**Tech Stack:** React, TypeScript, Vite, CSS modules or plain scoped CSS, Vitest, Testing Library, Playwright, `framer-motion` for deliberate animation, `lucide-react` for simple UI icons. - ---- - -## Source Material Summary - -Current folder: `/Users/adam/Documents/EnglishProject` - -Existing source files: -- `/Users/adam/Documents/EnglishProject/Map.jpg`: 5632 x 3072 old illustrated map titled "The Underwater City of Athel - Map & Guide -". -- `/Users/adam/Documents/EnglishProject/Story until climax.rtf`: shared story path from Mira's morning routine through the discovery of radiation reports, Sara's disappearance, and Mira being caught at the door. -- `/Users/adam/Documents/EnglishProject/Hopeful Ending.rtf`: Mira escapes, works with Torin, collects proof, broadcasts the evidence, citizens reach the surface, and society rebuilds freely. -- `/Users/adam/Documents/EnglishProject/Dark Ending.rtf`: Mira is captured, threatened by Veyr, tries to escape with Ocean, gets out alone, and billions remain trapped. -- `/Users/adam/Documents/EnglishProject/Bittersweet Ending.rtf`: Mira escapes the room, Sara returns with a warning, papers leak, society collapses into chaos, some survivors leave while others stay. -- `/Users/adam/Documents/EnglishProject/Info.rtf`: premise, ending summaries, and character descriptions. -- `/Users/adam/Documents/EnglishProject/Tank Info.rtf`: level-by-level worldbuilding. - -Content consistency decisions for implementation: -- Use `President Veyr`, not `Chancellor Veyr`, because most drafts use President. -- Use `Sara`, not `Sarah`, because the main story and bittersweet ending use Sara. -- Use `Torin`, not `Kael`, because the actual ending drafts use Torin. -- Use `Tank` in UI copy when referring to the society, and `Silo` only in story excerpts that already use it. -- Keep the three endings intact in meaning, but lightly edit sentence-level grammar during data extraction so on-screen text reads polished. - -## Product Shape - -The first screen should be the usable experience, not a marketing landing page. - -Primary layout: -- Left/center: full, zoomable map canvas using `Map.jpg`. -- Right: an aged-paper story panel with tabs for `Story`, `Map Lore`, and `Journal`. -- Bottom or side rail: story progress and current inventory/traits. -- Map hotspots pulse subtly and reveal lore cards, short animations, and story clues. -- The story game advances through scene cards and choices. - -Required interaction: -- Click map locations to pan/zoom, animate a parchment marker, and reveal lore. -- Choices advance through a shared story until the climax. -- After the climax, choices influence hidden counters and route to one of: - - Hopeful ending - - Dark ending - - Bittersweet ending -- Ending screen shows why that ending happened, which choices mattered, and offers restart. - -## File Structure - -Create these files: -- `/Users/adam/Documents/EnglishProject/package.json`: scripts and dependencies. -- `/Users/adam/Documents/EnglishProject/index.html`: Vite HTML entry. -- `/Users/adam/Documents/EnglishProject/tsconfig.json`: TypeScript config. -- `/Users/adam/Documents/EnglishProject/tsconfig.node.json`: Vite config typing. -- `/Users/adam/Documents/EnglishProject/vite.config.ts`: React/Vitest config. -- `/Users/adam/Documents/EnglishProject/playwright.config.ts`: browser e2e config. -- `/Users/adam/Documents/EnglishProject/public/assets/map.jpg`: copied from `Map.jpg`. -- `/Users/adam/Documents/EnglishProject/src/main.tsx`: React mount. -- `/Users/adam/Documents/EnglishProject/src/App.tsx`: top-level composition. -- `/Users/adam/Documents/EnglishProject/src/types.ts`: shared story/map types. -- `/Users/adam/Documents/EnglishProject/src/data/mapHotspots.ts`: hotspot coordinates and lore. -- `/Users/adam/Documents/EnglishProject/src/data/storyGraph.ts`: scenes, choices, scores, and ending routing. -- `/Users/adam/Documents/EnglishProject/src/data/sourceCopy.ts`: polished story and ending copy adapted from RTF files. -- `/Users/adam/Documents/EnglishProject/src/components/AppShell.tsx`: responsive app frame. -- `/Users/adam/Documents/EnglishProject/src/components/MapExplorer.tsx`: map viewport, zoom/pan, hotspots. -- `/Users/adam/Documents/EnglishProject/src/components/LorePanel.tsx`: active hotspot detail panel. -- `/Users/adam/Documents/EnglishProject/src/components/StoryGame.tsx`: story scene renderer and state machine. -- `/Users/adam/Documents/EnglishProject/src/components/ChoiceButton.tsx`: accessible choice control. -- `/Users/adam/Documents/EnglishProject/src/components/EndingPanel.tsx`: final ending reveal and restart. -- `/Users/adam/Documents/EnglishProject/src/components/Journal.tsx`: discovered clues and route history. -- `/Users/adam/Documents/EnglishProject/src/styles/tokens.css`: palette, typography, spacing, motion variables. -- `/Users/adam/Documents/EnglishProject/src/styles/global.css`: base layout, parchment/map styling, responsive rules. -- `/Users/adam/Documents/EnglishProject/src/test/storyGraph.test.ts`: story route unit tests. -- `/Users/adam/Documents/EnglishProject/src/test/mapHotspots.test.ts`: hotspot data validation. -- `/Users/adam/Documents/EnglishProject/tests/e2e/story.spec.ts`: Playwright user-flow tests. -- `/Users/adam/Documents/EnglishProject/tests/e2e/map.spec.ts`: Playwright map interaction tests. - -No backend is needed. - -## Data Model - -Use this type shape in `/Users/adam/Documents/EnglishProject/src/types.ts`: - -```ts -export type EndingId = 'hopeful' | 'dark' | 'bittersweet'; - -export type TraitKey = 'evidence' | 'trust' | 'risk' | 'collective'; - -export type StoryChoice = { - id: string; - label: string; - description: string; - nextSceneId: string; - effects?: Partial>; - requiresDiscoveredHotspot?: string; -}; - -export type StoryScene = { - id: string; - title: string; - locationHotspotId?: string; - paragraphs: string[]; - choices: StoryChoice[]; - isClimax?: boolean; -}; - -export type Ending = { - id: EndingId; - title: string; - tone: string; - summary: string; - paragraphs: string[]; -}; - -export type StoryState = { - currentSceneId: string; - traits: Record; - visitedSceneIds: string[]; - discoveredHotspotIds: string[]; - selectedChoiceIds: string[]; - endingId: EndingId | null; -}; - -export type MapHotspot = { - id: string; - title: string; - subtitle: string; - xPercent: number; - yPercent: number; - level: 'top' | 'upper' | 'market' | 'middle' | 'lower' | 'engine' | 'surface'; - storySceneIds: string[]; - lore: string[]; - animation: 'sun-glint' | 'water-ripple' | 'steam-pulse' | 'portrait-watch' | 'blackout' | 'broadcast'; -}; -``` - -Ending route rule in `/Users/adam/Documents/EnglishProject/src/data/storyGraph.ts`: - -```ts -export function resolveEnding(traits: Record): EndingId { - if (traits.evidence >= 3 && traits.trust >= 2 && traits.collective >= 2 && traits.risk <= 2) { - return 'hopeful'; - } - - if (traits.evidence >= 2 && traits.collective >= 2) { - return 'bittersweet'; - } - - return 'dark'; -} -``` - -This keeps the early story mostly shared, then makes late choices matter: -- Hopeful: enough evidence, enough trust in Torin/Ocean, collective exposure, lower recklessness. -- Bittersweet: truth gets out, but with insufficient control/preparation, producing social collapse. -- Dark: low evidence or isolated/risky choices lead to capture and solitary escape. - -## Map Hotspots - -Define these initial hotspots against the 5632 x 3072 map using percentage coordinates: - -```ts -export const mapHotspots: MapHotspot[] = [ - { - id: 'surface-dome', - title: 'The Glass Dome', - subtitle: 'Sunlight, water, and the forbidden surface above.', - xPercent: 50, - yPercent: 13, - level: 'surface', - storySceneIds: ['rumors-of-veyr', 'surface-truth'], - lore: [ - 'The top of Athel receives the most sunlight, which makes it feel almost unreal to lower-level residents.', - 'The government teaches that the air above is toxic, but the clear water and living sea creatures hint at a different truth.' - ], - animation: 'sun-glint' - }, - { - id: 'top-level', - title: 'Top Level', - subtitle: 'Private gardens and the president\'s protected world.', - xPercent: 52, - yPercent: 23, - level: 'top', - storySceneIds: ['veyr-portrait', 'platform-ascent'], - lore: [ - 'Only the wealthiest and most important citizens live here.', - 'The final ascent platforms are hidden near this level, where ordinary citizens are not meant to go.' - ], - animation: 'portrait-watch' - }, - { - id: 'upper-residences', - title: 'Upper Residences', - subtitle: 'Officials, privilege, and polished obedience.', - xPercent: 34, - yPercent: 34, - level: 'upper', - storySceneIds: ['school-morning'], - lore: [ - 'Higher-class citizens and officials live in cleaner, brighter quarters.', - 'The president\'s portraits appear in classrooms and public rooms, turning loyalty into a daily ritual.' - ], - animation: 'portrait-watch' - }, - { - id: 'archimedes-school', - title: 'Archimedes School', - subtitle: 'Where Mira learns the official history.', - xPercent: 60, - yPercent: 40, - level: 'middle', - storySceneIds: ['school-morning', 'sara-disappears'], - lore: [ - 'Mira and Ocean attend Archimedes because their marks let them study above their home level.', - 'The school teaches that the surface is lethal and that exile means death in seconds.' - ], - animation: 'blackout' - }, - { - id: 'market-levels', - title: 'Market Levels', - subtitle: 'Shops, rumor, and Torin\'s tuna sandwich.', - xPercent: 39, - yPercent: 43, - level: 'market', - storySceneIds: ['torins-shop'], - lore: [ - 'Torin runs a shop here when he is not working as a diver.', - 'For Mira, the market is normal life: breakfast, school rush, and the last place things felt simple.' - ], - animation: 'water-ripple' - }, - { - id: 'spiral-staircase', - title: 'Central Spiral Staircase', - subtitle: 'The long descent from sunlight to engines.', - xPercent: 50, - yPercent: 52, - level: 'middle', - storySceneIds: ['descent-to-engine', 'climax-door'], - lore: [ - 'Most residents use the central staircase to move between levels.', - 'The lower Mira walks, the darker and more humid the Tank becomes.' - ], - animation: 'blackout' - }, - { - id: 'industrial-core', - title: 'Industrial Core', - subtitle: 'Factories, heat, damp walls, and the engine rumble.', - xPercent: 38, - yPercent: 63, - level: 'lower', - storySceneIds: ['descent-to-engine'], - lore: [ - 'Lower-level workers live close to the machines that keep Athel alive.', - 'This area is loud during the day and almost lightless at night.' - ], - animation: 'steam-pulse' - }, - { - id: 'engine-room', - title: 'Engine Room', - subtitle: 'The wrong room, the confidential report, the first proof.', - xPercent: 61, - yPercent: 78, - level: 'engine', - storySceneIds: ['radiation-report', 'climax-door'], - lore: [ - 'Mira discovers reports showing radiation readings at zero.', - 'The engine room becomes the place where ordinary maintenance turns into rebellion.' - ], - animation: 'steam-pulse' - }, - { - id: 'communications-room', - title: 'Abandoned Communications Room', - subtitle: 'The hidden system that can broadcast the truth.', - xPercent: 70, - yPercent: 48, - level: 'middle', - storySceneIds: ['broadcast-plan', 'hopeful-ending'], - lore: [ - 'Torin believes the abandoned communications room can send proof through the Tank.', - 'In the hopeful path, this room turns evidence into public knowledge.' - ], - animation: 'broadcast' - } -]; -``` - -## Story Graph - -Scene order before climax: -1. `torins-shop`: Mira talks to Torin, hears of Veyr, rushes to school. -2. `school-morning`: Archimedes School, President Veyr portrait, anthem, blackout. -3. `descent-to-engine`: Mira descends the spiral staircase. -4. `radiation-report`: Mira enters the wrong room and finds zero-radiation reports. -5. `return-to-normal`: Mira tries to ignore what she saw. -6. `sara-disappears`: Sara is sent during a blackout and never returns. -7. `investigation-choice`: Mira decides who to trust and how to investigate. -8. `climax-door`: Mira listens to officials admit there has been no radiation. - -Post-climax routes: -- `caught-and-questioned`: high-risk path toward dark ending. -- `escape-to-torin`: trust/evidence path toward hopeful ending. -- `sara-warning`: leak/chaos path toward bittersweet ending. -- `broadcast-plan`: hopeful branch. -- `blackout-escape`: dark branch. -- `leak-and-collapse`: bittersweet branch. -- `ending`: calculated by `resolveEnding`. - -Example late choices: -- "Run to Torin and tell him everything" => `trust +1`, `collective +1`, next `escape-to-torin`. -- "Hide and record the president" => `evidence +2`, `risk +1`, next `broadcast-plan`. -- "Leak the reports anonymously" => `evidence +1`, `collective +2`, `risk +2`, next `leak-and-collapse`. -- "Protect Ocean by keeping her out of it" => `trust -1`, `collective -1`, next `blackout-escape`. -- "Use the blackout to escape alone" => `risk +2`, next `blackout-escape`. - -## Visual Direction - -Before implementation, generate or sketch one design concept for approval: -- Full-screen old cartographic interface. -- Sepia parchment background outside the map. -- The provided `Map.jpg` remains the centerpiece. -- UI chrome should look like aged labels, brass pins, ink annotations, torn paper panels, and subtle water-caustic movement. -- Avoid a modern marketing hero. -- Avoid decorative blobs, neon gradients, and generic game UI. -- Use readable serif headings and restrained sans-serif UI labels. - -Suggested CSS tokens: - -```css -:root { - --ink: #2f2117; - --ink-muted: #6e5945; - --paper: #ead8b8; - --paper-deep: #d0a56d; - --paper-light: #f7ead0; - --brass: #a97932; - --blue-glass: #2e8796; - --danger: #8c3328; - --hope: #3d7d67; - --shadow: rgba(47, 33, 23, 0.28); - --radius-sm: 6px; - --radius-md: 8px; - --font-display: "Cinzel", Georgia, serif; - --font-body: "Libre Baskerville", Georgia, serif; - --font-ui: Inter, system-ui, sans-serif; -} -``` - -Motion rules: -- Hotspots idle with low-intensity pulse. -- Clicking a hotspot pans/zooms the map and opens lore. -- Blackout scenes briefly dim the map and flicker the UI. -- Engine room and industrial core use steam pulse overlays. -- Broadcast scene shows circular signal lines from the communications hotspot. -- Ending reveal uses parchment unroll or ink-spread animation. -- Respect `prefers-reduced-motion`. - -## Task 0: Branch And Design Approval Gate - -**Files:** -- No code files modified. - -- [ ] **Step 1: Confirm execution branch** - -Ask: - -```text -This is a larger feature. Before implementation, should I create a branch like `codex/underwater-story-app`, and do you want subagent-driven execution task-by-task? -``` - -Expected: user confirms branch/execution mode. - -- [ ] **Step 2: Create branch if approved** - -Run: - -```bash -git switch -c codex/underwater-story-app -``` - -Expected: branch created locally. - -- [ ] **Step 3: Create visual concept** - -Use Image Gen or a design mockup pass with this brief: - -```text -Create a full-screen interactive web app concept for an old illustrated underwater city map story. The center is a large antique cutaway map of an underwater dome city. The right side has an aged parchment story panel with choose-your-own-story choices, clue journal, and ending progress. Hotspots are brass pins with ink labels. Style: old nautical map, sepia parchment, brass, ink, subtle blue glass water. No modern marketing hero, no neon gradients, no decorative blobs. The UI must be implementable as React controls, with readable text and clear button states. -``` - -Expected: one approved primary screen concept before coding. - -- [ ] **Step 4: Commit only the plan/design artifact if a file is created** - -Run: - -```bash -git status --short -git add docs/superpowers/plans/2026-06-05-underwater-city-interactive-story.md -git commit -m "docs: plan underwater city interactive story app" -``` - -Expected: plan commit created. Do not push. - -## Task 1: Scaffold React + Vite App - -**Files:** -- Create: `/Users/adam/Documents/EnglishProject/package.json` -- Create: `/Users/adam/Documents/EnglishProject/index.html` -- Create: `/Users/adam/Documents/EnglishProject/tsconfig.json` -- Create: `/Users/adam/Documents/EnglishProject/tsconfig.node.json` -- Create: `/Users/adam/Documents/EnglishProject/vite.config.ts` -- Create: `/Users/adam/Documents/EnglishProject/playwright.config.ts` -- Create: `/Users/adam/Documents/EnglishProject/src/main.tsx` -- Create: `/Users/adam/Documents/EnglishProject/src/App.tsx` - -- [ ] **Step 1: Create package metadata** - -Use this `package.json`: - -```json -{ - "name": "underwater-city-story", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite --host 127.0.0.1", - "build": "tsc -b && vite build", - "preview": "vite preview --host 127.0.0.1", - "test": "vitest run", - "test:watch": "vitest", - "e2e": "playwright test", - "lint": "eslint ." - }, - "dependencies": { - "@vitejs/plugin-react": "latest", - "framer-motion": "latest", - "lucide-react": "latest", - "vite": "latest", - "typescript": "latest", - "react": "latest", - "react-dom": "latest" - }, - "devDependencies": { - "@eslint/js": "latest", - "@playwright/test": "latest", - "@testing-library/jest-dom": "latest", - "@testing-library/react": "latest", - "@testing-library/user-event": "latest", - "@types/react": "latest", - "@types/react-dom": "latest", - "eslint": "latest", - "eslint-plugin-react-hooks": "latest", - "eslint-plugin-react-refresh": "latest", - "jsdom": "latest", - "vitest": "latest" - } -} -``` - -- [ ] **Step 2: Install dependencies** - -Run: - -```bash -npm install -``` - -Expected: `node_modules` and `package-lock.json` created. - -- [ ] **Step 3: Add Vite and TypeScript config** - -Use standard React + Vite TypeScript config with Vitest `environment: 'jsdom'`. - -- [ ] **Step 4: Add initial React mount** - -`src/main.tsx` should import `global.css`, render ``, and mount to `#root`. - -- [ ] **Step 5: Verify scaffold** - -Run: - -```bash -npm run build -``` - -Expected: TypeScript and Vite build pass. - -- [ ] **Step 6: Commit** - -Run: - -```bash -git add package.json package-lock.json index.html tsconfig.json tsconfig.node.json vite.config.ts playwright.config.ts src/main.tsx src/App.tsx -git commit -m "feat: scaffold underwater story app" -``` - -Expected: local commit created. Do not push. - -## Task 2: Add Typed Story And Map Data - -**Files:** -- Create: `/Users/adam/Documents/EnglishProject/src/types.ts` -- Create: `/Users/adam/Documents/EnglishProject/src/data/mapHotspots.ts` -- Create: `/Users/adam/Documents/EnglishProject/src/data/sourceCopy.ts` -- Create: `/Users/adam/Documents/EnglishProject/src/data/storyGraph.ts` -- Create: `/Users/adam/Documents/EnglishProject/src/test/storyGraph.test.ts` -- Create: `/Users/adam/Documents/EnglishProject/src/test/mapHotspots.test.ts` - -- [ ] **Step 1: Write story route tests** - -Test these cases: - -```ts -import { describe, expect, it } from 'vitest'; -import { resolveEnding } from '../data/storyGraph'; - -describe('resolveEnding', () => { - it('returns hopeful when Mira has proof, trust, collective support, and low recklessness', () => { - expect(resolveEnding({ evidence: 3, trust: 2, collective: 2, risk: 1 })).toBe('hopeful'); - }); - - it('returns bittersweet when truth spreads without enough preparation', () => { - expect(resolveEnding({ evidence: 2, trust: 0, collective: 2, risk: 4 })).toBe('bittersweet'); - }); - - it('returns dark when Mira lacks enough public proof or allies', () => { - expect(resolveEnding({ evidence: 1, trust: 0, collective: 0, risk: 3 })).toBe('dark'); - }); -}); -``` - -- [ ] **Step 2: Write map hotspot tests** - -Validate that every hotspot has: -- unique `id` -- `xPercent` between 0 and 100 -- `yPercent` between 0 and 100 -- at least one lore paragraph -- at least one linked story scene - -- [ ] **Step 3: Run tests and confirm failure** - -Run: - -```bash -npm test -``` - -Expected: tests fail because modules are not implemented yet. - -- [ ] **Step 4: Implement types and data** - -Add the types and hotspot list from this plan. Add `sourceCopy.ts` with polished excerpts from each RTF file: -- `introCopy` -- `climaxCopy` -- `hopefulEndingCopy` -- `darkEndingCopy` -- `bittersweetEndingCopy` - -Keep each on-screen paragraph short, ideally 1-3 sentences. - -- [ ] **Step 5: Implement story graph** - -Use deterministic scene ids, choices, and `resolveEnding`. Do not keep branching logic inside React components. - -- [ ] **Step 6: Run tests** - -Run: - -```bash -npm test -``` - -Expected: story and hotspot unit tests pass. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add src/types.ts src/data src/test -git commit -m "feat: model story graph and map lore" -``` - -Expected: local commit created. Do not push. - -## Task 3: Build The App Shell And Visual System - -**Files:** -- Create: `/Users/adam/Documents/EnglishProject/src/styles/tokens.css` -- Create: `/Users/adam/Documents/EnglishProject/src/styles/global.css` -- Create: `/Users/adam/Documents/EnglishProject/src/components/AppShell.tsx` -- Modify: `/Users/adam/Documents/EnglishProject/src/App.tsx` -- Modify: `/Users/adam/Documents/EnglishProject/src/main.tsx` - -- [ ] **Step 1: Add CSS tokens** - -Use the token set from the Visual Direction section. Add responsive variables: - -```css -:root { - color-scheme: light; - font-synthesis: none; - text-rendering: optimizeLegibility; -} - -@media (max-width: 900px) { - :root { - --app-panel-width: 100%; - } -} -``` - -- [ ] **Step 2: Add global layout** - -The layout must: -- fill the viewport -- put map and story panel side-by-side on desktop -- stack story below map on mobile -- avoid text overflow in buttons and panels -- provide visible focus outlines - -- [ ] **Step 3: Create AppShell** - -`AppShell` should accept `map`, `story`, and `journal` slots so map and story work stay separate. - -- [ ] **Step 4: Run build** - -Run: - -```bash -npm run build -``` - -Expected: build passes. - -- [ ] **Step 5: Commit** - -Run: - -```bash -git add src/styles src/components/AppShell.tsx src/App.tsx src/main.tsx -git commit -m "feat: add antique map app shell" -``` - -Expected: local commit created. Do not push. - -## Task 4: Implement Interactive Map Explorer - -**Files:** -- Create: `/Users/adam/Documents/EnglishProject/public/assets/map.jpg` -- Create: `/Users/adam/Documents/EnglishProject/src/components/MapExplorer.tsx` -- Create: `/Users/adam/Documents/EnglishProject/src/components/LorePanel.tsx` -- Modify: `/Users/adam/Documents/EnglishProject/src/App.tsx` -- Create: `/Users/adam/Documents/EnglishProject/tests/e2e/map.spec.ts` - -- [ ] **Step 1: Copy map asset** - -Run: - -```bash -mkdir -p public/assets -cp Map.jpg public/assets/map.jpg -``` - -Expected: app-safe asset path exists at `/assets/map.jpg`. - -- [ ] **Step 2: Write e2e map test** - -Test: -- page loads -- map image is visible -- clicking `Engine Room` opens lore -- clicking `Glass Dome` changes selected lore - -- [ ] **Step 3: Implement MapExplorer** - -Behavior: -- render the image with stable aspect ratio `5632 / 3072` -- overlay buttons at hotspot percentage coordinates -- selected hotspot gets active state -- click sets active hotspot and calls `onDiscoverHotspot(id)` -- use CSS transform for zoom/pan toward selected hotspot -- expose accessible labels like `Open lore for Engine Room` - -- [ ] **Step 4: Implement LorePanel** - -Behavior: -- show active hotspot title, subtitle, level, lore paragraphs -- show linked story scene labels if relevant -- empty state: "Select a brass pin on the map." - -- [ ] **Step 5: Add map animations** - -CSS classes keyed by `animation`: -- `sun-glint`: shimmer across dome -- `water-ripple`: subtle blue ring -- `steam-pulse`: low opacity steam movement -- `portrait-watch`: darker vignette pulse -- `blackout`: quick dim/flicker -- `broadcast`: expanding signal rings - -- [ ] **Step 6: Verify manually in browser** - -Run: - -```bash -npm run dev -``` - -Open local URL in the Browser plugin and click at least five hotspots. - -Expected: -- no map distortion -- hotspots align with map regions -- lore panel updates instantly -- keyboard tab can reach hotspot buttons - -- [ ] **Step 7: Run automated checks** - -Run: - -```bash -npm test -npm run e2e -npm run build -``` - -Expected: all pass. - -- [ ] **Step 8: Commit** - -Run: - -```bash -git add public/assets/map.jpg src/components/MapExplorer.tsx src/components/LorePanel.tsx src/App.tsx tests/e2e/map.spec.ts -git commit -m "feat: add interactive lore map" -``` - -Expected: local commit created. Do not push. - -## Task 5: Implement Choose-Your-Own-Story Game - -**Files:** -- Create: `/Users/adam/Documents/EnglishProject/src/components/StoryGame.tsx` -- Create: `/Users/adam/Documents/EnglishProject/src/components/ChoiceButton.tsx` -- Create: `/Users/adam/Documents/EnglishProject/src/components/EndingPanel.tsx` -- Modify: `/Users/adam/Documents/EnglishProject/src/App.tsx` -- Create: `/Users/adam/Documents/EnglishProject/tests/e2e/story.spec.ts` - -- [ ] **Step 1: Write e2e story tests** - -Test three paths: -- choices that raise evidence/trust/collective route to hopeful -- choices that leak truth with high risk route to bittersweet -- choices that isolate Mira route to dark - -- [ ] **Step 2: Implement StoryGame state** - -Use local React state: -- `currentSceneId` -- `traits` -- `visitedSceneIds` -- `selectedChoiceIds` -- `endingId` - -When a choice is clicked: -- apply trait effects -- append selected choice id -- move to next scene -- if next scene is terminal, call `resolveEnding` - -- [ ] **Step 3: Implement ChoiceButton** - -Button content: -- label -- one-sentence consequence hint -- optional disabled state if a required hotspot has not been discovered - -Accessibility: -- native `button` -- visible focus state -- no clipped text on mobile - -- [ ] **Step 4: Implement EndingPanel** - -Show: -- ending title -- tone -- adapted ending paragraphs -- "Why this happened" list based on final traits -- restart button - -- [ ] **Step 5: Connect story and map** - -When the story enters a scene with `locationHotspotId`, set the active map hotspot. When the user discovers a map hotspot, add it to `discoveredHotspotIds` for journal and optional choice unlocks. - -- [ ] **Step 6: Run checks** - -Run: - -```bash -npm test -npm run e2e -npm run build -``` - -Expected: all pass, and all three endings are reachable. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add src/components/StoryGame.tsx src/components/ChoiceButton.tsx src/components/EndingPanel.tsx src/App.tsx tests/e2e/story.spec.ts -git commit -m "feat: add branching story game" -``` - -Expected: local commit created. Do not push. - -## Task 6: Add Journal, Progress, And Lore Integration - -**Files:** -- Create: `/Users/adam/Documents/EnglishProject/src/components/Journal.tsx` -- Modify: `/Users/adam/Documents/EnglishProject/src/App.tsx` -- Modify: `/Users/adam/Documents/EnglishProject/src/components/AppShell.tsx` -- Modify: `/Users/adam/Documents/EnglishProject/src/styles/global.css` - -- [ ] **Step 1: Implement Journal** - -Journal sections: -- `Discovered Places` -- `Evidence` -- `People` -- `Route Taken` - -Evidence labels: -- "Zero-radiation report" -- "Sara's disappearance" -- "Officials' confession" -- "Surface footage" -- "Veyr's admission" - -- [ ] **Step 2: Add progress ribbon** - -Show current act: -- Act I: Routine -- Act II: Suspicion -- Act III: The Door -- Act IV: Consequences -- Ending - -- [ ] **Step 3: Add tab behavior** - -Tabs: -- `Story` -- `Map Lore` -- `Journal` - -On mobile, tabs should prevent a very tall permanent side panel. - -- [ ] **Step 4: Run checks** - -Run: - -```bash -npm test -npm run build -``` - -Expected: pass. - -- [ ] **Step 5: Commit** - -Run: - -```bash -git add src/components/Journal.tsx src/components/AppShell.tsx src/App.tsx src/styles/global.css -git commit -m "feat: connect journal and story progress" -``` - -Expected: local commit created. Do not push. - -## Task 7: Polish Visuals, Responsiveness, And Accessibility - -**Files:** -- Modify: `/Users/adam/Documents/EnglishProject/src/styles/tokens.css` -- Modify: `/Users/adam/Documents/EnglishProject/src/styles/global.css` -- Modify: `/Users/adam/Documents/EnglishProject/src/components/*.tsx` - -- [ ] **Step 1: Desktop visual pass** - -Check: -- map is dominant and not cropped awkwardly -- story panel looks like parchment, not a generic card -- controls use brass/ink styling -- no text overlaps -- endings feel visually distinct but still in the same system - -- [ ] **Step 2: Mobile visual pass** - -Use Browser or Playwright viewport around 390 x 844. - -Check: -- map remains inspectable -- panel tabs are reachable -- choice buttons wrap cleanly -- ending text is readable -- no horizontal overflow - -- [ ] **Step 3: Accessibility pass** - -Check: -- all hotspot buttons have accessible names -- all choice buttons are keyboard reachable -- focus state is visible -- reduced motion disables dramatic animations -- color contrast is readable on parchment backgrounds - -- [ ] **Step 4: Add final animation tuning** - -Keep animations subtle: -- no continuous heavy movement -- no motion that makes story text hard to read -- no map markers shifting layout - -- [ ] **Step 5: Run checks** - -Run: - -```bash -npm test -npm run e2e -npm run build -``` - -Expected: pass. - -- [ ] **Step 6: Commit** - -Run: - -```bash -git add src -git commit -m "polish: refine old map story experience" -``` - -Expected: local commit created. Do not push. - -## Task 8: Final Verification And Self-Review - -**Files:** -- No expected code changes unless verification finds issues. - -- [ ] **Step 1: Run full verification** - -Run: - -```bash -npm test -npm run e2e -npm run build -git diff --check -``` - -Expected: -- unit tests pass -- e2e tests pass -- production build passes -- no whitespace errors - -- [ ] **Step 2: Browser QA** - -Use Browser plugin against the local dev server. - -Required QA paths: -- click every map hotspot -- play to Hopeful ending -- restart -- play to Dark ending -- restart -- play to Bittersweet ending -- verify mobile viewport - -- [ ] **Step 3: Diff review** - -Run: - -```bash -git status --short -git diff --stat HEAD -git diff HEAD -- src package.json index.html vite.config.ts playwright.config.ts -``` - -Review for: -- accidental source RTF edits -- accidental `Map.jpg` mutation -- unused dependencies -- story inconsistencies -- unreachable scenes -- hardcoded layout values that break mobile - -- [ ] **Step 4: Fix any issues found** - -If issues are found, fix them in the relevant task area and rerun: - -```bash -npm test -npm run e2e -npm run build -``` - -Expected: all pass after fixes. - -- [ ] **Step 5: Final commit if fixes were made** - -Run: - -```bash -git add . -git commit -m "fix: address final story app verification issues" -``` - -Expected: only if there are verification fixes. Do not push. - -## Acceptance Criteria - -The implementation is complete when: -- The app starts with `npm run dev`. -- The map appears using the real `/Users/adam/Documents/EnglishProject/Map.jpg` content. -- At least nine map hotspots are clickable and reveal lore. -- Hotspots animate in ways tied to story locations. -- Story choices progress through the shared story to the climax. -- All three endings are reachable. -- Ending route depends on accumulated choices, not a single final button. -- The UI looks like an old nautical/parchment map interface. -- Desktop and mobile layouts are both usable. -- Keyboard navigation works for hotspots, tabs, choices, and restart. -- Tests cover route logic and hotspot data. -- Playwright covers map clicks and all three ending paths. -- `npm test`, `npm run e2e`, `npm run build`, and `git diff --check` pass. -- No push is performed unless the user explicitly asks. - -## Open Questions Before Implementation - -1. Should the final app use only the provided map image and CSS, or should we generate one additional UI concept/background texture with Image Gen? -2. Should the story text be lightly edited for grammar and consistency, or displayed exactly as written in the RTF files? -3. Should this be committed as one final feature commit after verification, or as the multiple task commits listed above? -4. Should the app title use the map title `The Underwater City of Athel`, or another title from the English project? - -## Recommended Execution Mode - -Use subagent-driven execution after branch creation: -- Agent 1: scaffold and data model. -- Agent 2: map explorer and hotspot interactions. -- Agent 3: story engine and endings. -- Agent 4: visual polish, accessibility, and QA. - -Main session responsibilities: -- approve data consistency decisions -- prevent overlapping edits -- run integrated verification -- perform final browser QA -- commit only verified coherent changes - diff --git a/docs/superpowers/plans/2026-06-07-handoff.md b/docs/superpowers/plans/2026-06-07-handoff.md deleted file mode 100644 index 1a6d5cd..0000000 --- a/docs/superpowers/plans/2026-06-07-handoff.md +++ /dev/null @@ -1,139 +0,0 @@ -# Handoff prompt — Athel interactive story: voices, scene art, living map - -> Paste this whole block to the implementing agent. It is self-contained: it -> states the project facts, the decisions already made, the plan documents to -> follow, the work order, the guardrails, and how to verify. Do not re-derive -> what is already stated here. - ---- - -## Your role - -You are implementing features for **Athel**, an interactive cinematic -visual-novel-style story (React + TypeScript + Vite, framer-motion) in -`/Users/adam/Documents/EnglishProject`. Work in small, independently shippable -steps. After each step, run the build and tests and keep the game fully playable. - -## Read these first - -- `docs/superpowers/plans/2026-06-07-scene-illustration-overlays.md` — **Plan 1** - (per-scene images drawn on top of the map; for an image-capable agent). -- `docs/superpowers/plans/2026-06-07-living-map-animation.md` — **Plan 2** - (animated characters that idle/talk/walk between places, camera travel, - per-place ambience, event SFX, and the audio backend swap). -- `docs/superpowers/plans/2026-06-05-underwater-city-interactive-story.md` — the - original project plan (background). - -Follow the plans as written; this handoff sets the order and the decisions. - -## Architecture facts you need (already verified — trust these) - -- Cinematic view: `src/components/CinematicStage.tsx` → `src/components/MapCamera.tsx`. - `MapCamera` renders `/assets/map.jpg` (5632×3072) inside `.map-camera__drift`, - then positions `Actor` portraits at percentage coordinates. Actors counter-scale - with the CSS var `--inv` (= `1 / zoom`) so they keep size at any camera zoom. -- A scene (`StoryScene` in `src/types.ts`) has an optional `locationHotspotId`. - `CinematicStage` resolves it via `getHotspot(...)` (`src/data/mapHotspots.ts`) - to a `Focus = {x,y}` percentage; the camera zooms/pans there using - `computeCameraTransform` in `src/lib/camera.ts`. `arrangeActors` lays out the - present characters around that focus. -- `CinematicStage` is **remounted per scene** (keyed by scene id in `App`), so any - cross-scene animation (e.g. walking between places) must live in `App` or a - wrapper that survives the scene swap. -- Characters: `src/data/characters.ts` (ids: `mira`, `ocean`, `torin`, `veyr`; - each has accent colour, sprite class, resting position). `getCharactersForScene` - maps scene id → present characters. -- Audio at runtime is **fully local and offline**: `useNarration` - (`src/hooks/useNarration.ts`) plays files from `public/audio/voice/` via - `public/audio/manifest.json`, keyed by `clipKey(speaker,text)` = `` `${speaker}|${text}` ``. - It only calls the live ElevenLabs proxy as a fallback when a clip is missing. -- The **offline generator** is `scripts/generate-audio.ts` (run: `npm run audio`). - It reads the API key from `.env.local`, collects every dialogue line, skips - clips that already exist on disk, fetches the rest, and writes the manifest. -- Ambience today is **procedural, per-mood only** (`AmbienceEngine` in - `src/lib/ambience.ts`) — NOT per place. SFX (`src/hooks/useSfx.ts`) plays one - mood sting; the six files in `public/audio/sfx/` are **identical placeholders** - (all 112,893 bytes) and must be replaced. -- Reduced motion is already threaded everywhere as a `reduced` boolean - (`useReducedMotion`). Every animation must honor it. - -## Decisions already made (do not relitigate) - -1. **Voices = ElevenLabs free tier.** The expressive flagship voices and models - are available on the free tier; paid only removes the monthly cap and adds - commercial rights / cloning. The full spoken corpus is ~8,400 characters, which - fits within the free 10k/month cap (worst case 1–2 months once narrator - paragraphs are included). The existing generator already supports stop/resume - across months (it skips existing clips). **No ToS circumvention** (no cracked - keys, reseller keys, or multi-account cap-dodging) — that is out of scope and - forbidden. -2. **Free-tier output is non-commercial and requires attributing ElevenLabs.** - Add an attribution line in the credits/README. If commercial use is ever - needed, swap the generator backend to a freely-licensed local model - (**Kokoro-82M**, Apache-2.0) — Plan 2 §7 describes the swap; keep `clipKey`, - the manifest shape, and `public/audio/voice/` identical so app code is untouched. -3. Both feature plans are **purely additive**: the game must remain fully playable - with zero scene art, zero animation, and zero audio files present. - -## Work order (each step builds, tests green, game playable) - -1. **Generate the voices (unblocks everything audio).** - - Confirm `.env.local` has `ELEVENLABS_API_KEY` (free account key). - - Set/confirm the per-speaker expressive voice ids in `.env.local` - (`VITE_ELEVENLABS_VOICE_NARRATOR`, `_MIRA`, `_OCEAN`, `_TORIN`, `_VEYR`) or - keep the defaults in `src/data/audioPrompts.ts`. - - Run `npm run audio`. If the monthly cap interrupts it, commit what generated - and note that a re-run next month completes it. Commit - `public/audio/voice/*` and `public/audio/manifest.json`. - - Add an ElevenLabs attribution line to `README.md` and a - `public/audio/CREDITS.md`. - -2. **Plan 2, Layer 1 — idle/talk/gesture animation** (no new assets). Procedural - CSS on `Actor`, driven by existing `voicing`/`speaking` state. Honor `reduced`. - -3. **Replace placeholder SFX + Plan 2, Layer 2** — per-place ambience - (`AmbienceEngine.setPlace(level)` procedural approach first) and named event - SFX (`sfxEvents.ts` + `useSfx.play`). Source replacement clips from CC0 - libraries (Freesound CC0, BBC Sound Effects); record provenance in - `public/audio/CREDITS.md`. - -4. **Plan 1 — scene illustration overlays.** Implement the code path first - (`sceneArt.ts` registry, `SceneArt.tsx`, wiring, preload, fallback) and verify - with one placeholder image. Then, as the image-capable agent, generate the art - set per Plan 1 §7–§8 (style guide + per-scene prompts) into - `public/assets/scenes/` and fill the registry with `alt` + `prompt` for each. - -5. **Plan 2, Layers 3–4 — walking between places + camera travel.** This is the - biggest change: add `choreography.ts`, host the cross-scene travel phase in - `App` (since `CinematicStage` remounts), animate actors old-focus → new-focus - with a footstep loop, route vertical moves through the spiral-staircase - waypoint, and make the camera follow. Keep the travel phase short (≤ ~1.5s) and - always skippable; `reduced` cuts straight to the next scene. - -## Guardrails - -- Additive only: never break the no-art / no-audio / reduced-motion fallbacks. -- Keep `clipKey`, `manifest.json` shape, and `public/audio/voice/` paths stable so - the audio backend stays swappable. -- Don't edit `src/data/storyGraph.ts` story logic for art/animation; use the new - side registries (`sceneArt.ts`, `choreography.ts`) keyed by scene id. -- Match existing conventions (the `--inv` counter-scale, framer-motion easing - `[0.22, 0.61, 0.21, 1]`, `localStorage`-persisted toggles like - `useAmbience`/`useNarration`). -- No ToS-violating audio shortcuts. No network calls that exfiltrate code/secrets. - -## Verify after every step - -- `npm run build` (typecheck + bundle) must pass. -- `npm test` (unit) must pass; add the tests specified in each plan's "Tests" - section (`sceneArt.test.ts`, `choreography.test.ts`, `sfxEvents.test.ts`). -- `npx playwright test` (e2e in `tests/e2e/`) must pass, including: game completes - start→ending with all audio/art absent, and reduced-motion advances instantly - with no travel hang. -- Clean up any temp files. Report what you verified and anything you could not. - -## First action - -Start with **Step 1** (generate voices). Before running `npm run audio`, report -whether `.env.local` already contains an `ELEVENLABS_API_KEY` and which voice ids -are configured, and ask the user to add the key if it is missing. diff --git a/docs/superpowers/plans/2026-06-07-living-map-animation.md b/docs/superpowers/plans/2026-06-07-living-map-animation.md deleted file mode 100644 index 8af7c4e..0000000 --- a/docs/superpowers/plans/2026-06-07-living-map-animation.md +++ /dev/null @@ -1,264 +0,0 @@ -# Plan 2 — The living map (animated characters, travel, ambience & SFX) - -**Date:** 2026-06-07 -**Goal:** Make the map feel alive. Characters idle, talk, gesture, and **walk -between places** when the story moves; the camera travels with them; each place -has its own **ambience bed**; and **event sound effects** (footsteps, doors, -alarms) fire on actions. Also documents the **subscription-free voice/SFX swap**. - ---- - -## 1. Current state - -- `MapCamera` (`src/components/MapCamera.tsx`) places `Actor` portraits at static - percentage coords from `arrangeActors(characters, focus)` in `src/lib/camera.ts`. - The camera does a single animated **cut** to the scene's focus (framer-motion - `animate={{ transform }}`). -- `Actor` (`src/components/Actor.tsx`) has `speaking` / `voicing` / `dimmed` - states → CSS pulse. No walk/idle/gesture; portrait is a static sprite - (`.character-portrait--` from `public/assets/characters.png`). -- Voicing state already exists per line (`voicing` boolean, `speaker`). -- Ambience is **procedural per-mood only** (`AmbienceEngine`, `src/lib/ambience.ts`), - not per place. SFX (`useSfx`) plays one mood "sting"; the six files in - `public/audio/sfx/` are identical 112,893-byte placeholders. - ---- - -## 2. Four layers of "alive" (build in this order) - -1. **Idle + talk life** (cheapest, no new art) — procedural CSS motion. -2. **Per-place ambience beds + event SFX** — audio. -3. **Walk between places** — actors tween along a path on scene change. -4. **Camera travel** — camera follows the walk instead of cutting. - -Each layer is independently shippable and gated by `prefers-reduced-motion`. - ---- - -## 3. Layer 1 — Idle & talk animation (no new assets) - -Add procedural CSS keyframes to the existing portrait, driven by state already -available in `Actor`: - -- **Idle:** gentle 3–4s vertical bob + slight rotate, randomized per character - via an `--idle-delay` set from the character id, so they don't bob in sync. -- **Talk:** while `voicing` is true, a faster "speaking" oscillation (subtle - scale/teeter on `.actor__portrait`) to read as talking; stop on line end. -- **Gesture:** add an optional `gesture` prop (`'point' | 'react' | 'nod'`) → - one-shot keyframe. Drive from choreography (§5). -- **Reduced motion:** all keyframes disabled (`reduced` already threaded in). - -Files: `src/styles/global.css` (keyframes + `.actor--idle/--talk/--gesture-*`), -`src/components/Actor.tsx` (apply classes from props). No data model change. - -Optional upgrade (ties to Plan 1's image agent): replace the single portrait -with a **2–4 frame sprite sheet** per character (idle / walk / talk) under -`public/assets/sprites/.webp`, swapped via `background-position` steps. Keep -procedural CSS as the zero-art fallback. - ---- - -## 4. Layer 2 — Per-place ambience + event SFX - -### 4a. Per-place ambience beds - -Today ambience is keyed by `SceneMood`. Add a **place dimension** layered on top -of the mood drone, so the Market sounds different from the Engine Room even at -the same mood. - -Two viable approaches: - -- **(A) Procedural (no files):** extend `AmbienceEngine` with a `setPlace(level)` - that adjusts an extra layer per `MapHotspot['level']` - (`surface|top|upper|market|middle|lower|engine`) — e.g. engine = louder - low-rumble + metallic resonance; market = mid murmur band; surface = bright - filtered water + occasional shimmer. Cheapest, fully offline, on-brand with the - existing engine. -- **(B) Looped samples (best texture):** add `public/audio/ambience/.mp3` - loops (CC0 sources, see §7), crossfaded on place change by a new - `useAmbienceBed` hook. Heavier but richer. - -Recommended: ship **(A)** first (no asset dependency), then optionally swap in -**(B)** behind the same `setPlace` API. - -Data: add an `ambience?: { bed: string; intensity?: number }` hint to -`MapHotspot` (optional), defaulting to the `level`-based profile. Drive from -`CinematicStage` which already resolves the scene's hotspot. - -### 4b. Event sound effects - -Replace the single mood-sting model with **named, positional events**. - -Create `src/data/sfxEvents.ts`: - -```ts -export type SfxEvent = - | 'footstep' | 'door-open' | 'door-heavy' | 'paper-rustle' - | 'alarm' | 'broadcast-static' | 'water-drip' | 'sonar-ping'; - -/** event → file under /audio/sfx/events (filled when assets exist). */ -export const sfxEventFiles: Partial> = {}; -``` - -- Extend `useSfx` (`src/hooks/useSfx.ts`) with `play(event: SfxEvent, opts?)` - alongside the existing `playMood`. Cache `Audio` per event; respect a volume - and an enabled flag (reuse the ambience toggle or add an SFX toggle). -- Triggers: - - `footstep` loop while a character walks (Layer 3) — start/stop with the tween. - - `door-heavy` on `climax-door`; `broadcast-static` on `broadcast-plan`; - `alarm` on `danger` mood / `blackout-escape`; `paper-rustle` on - `radiation-report`. Map these in `choreography.ts` (§5) as beat events. -- Files: `public/audio/sfx/events/.mp3`. Until present, `play()` no-ops - (same graceful-fallback contract as voices). - -**Note:** the current `public/audio/sfx/*.mp3` mood files are identical -placeholders — they must be regenerated/replaced (see §7). - ---- - -## 5. Layer 3 — Walking between places - -### Choreography data - -Create `src/data/choreography.ts` — per scene, where each present character -stands and what they do. Defaults derive from `arrangeActors` + hotspot focus; -only overrides are stored. - -```ts -export type Beat = { - characterId: string; - /** Target position; defaults to arrangeActors slot. */ - xPercent?: number; yPercent?: number; - action?: 'idle' | 'enter' | 'walk' | 'gesture-point' | 'gesture-react' | 'exit'; - /** Optional SFX fired when this beat starts. */ - sfx?: import('./sfxEvents').SfxEvent; -}; - -export type SceneChoreography = { - /** Optional travel: characters walk from prevHotspot → this scene's hotspot. */ - travelFrom?: string; // hotspot id - beats: Beat[]; -}; - -export const choreography: Record = {}; -``` - -### Movement on scene change - -- Today `CinematicStage` is remounted per scene (fresh state). To **show** a walk - between scenes, the transition must persist briefly across the change. -- Mechanism: when a choice is taken in `App`, before swapping the scene, compute - the **previous** hotspot focus and the **next** hotspot focus. Render a short - "travel" phase where the present characters tween (framer-motion `animate` - left/top, duration scaled by distance) from old → new arranged slots, with the - `walk` class active and a looping `footstep` SFX. When the tween ends, mount - the next scene's dialogue. -- Pathing: straight-line by default; for vertical moves through levels, route via - the `spiral-staircase` hotspot (50,52) as a waypoint so characters appear to - use the staircase. Keep a tiny `pathFor(fromLevel,toLevel)` helper in - `src/lib/camera.ts` returning waypoints. -- `arrangeActors` stays the resting layout; the tween just animates between two - `arrangeActors` results (old focus → new focus). - -### Reduced motion - -`reduced` → skip the travel phase entirely; cut to the new scene (today's -behaviour). The story must remain fully playable with travel disabled. - ---- - -## 6. Layer 4 — Camera travel - -- `MapCamera` currently animates `transform` to a single `focus`. Add a - `travel` mode: instead of one cut, interpolate `focus` from old → new (and zoom - out slightly mid-travel for an establishing feel, then back in) so the camera - *follows the walk* from §5. -- Implement by animating a `focus` motion value (framer-motion `useMotionValue` + - `animate`) and recomputing `computeCameraTransform(focus, zoom)` each frame, or - by tweening the transform string through a midpoint. Keep the existing single-cut - path as the `reduced`/default fallback. -- Sync: camera travel duration == walk tween duration so actors stay framed. - ---- - -## 7. Subscription-free voices & SFX (the audio question) - -The app already plays **local files** (`public/audio/voice/*` via -`manifest.json`) and only calls ElevenLabs as a fallback. So nothing in the app -changes — only the **offline generator** `scripts/generate-audio.ts`. - -### Voices — swap the backend in `generate-audio.ts` - -Keep `clipKey`, the manifest shape, and `public/audio/voice/` identical. Replace -the `tts(voiceId, text)` function's ElevenLabs `fetch` with a free engine: - -- **Kokoro-82M (recommended)** — Apache-2.0, high quality, local. Generate via a - small Python step (`kokoro` / `kokoro-onnx`) invoked from the script, or call a - local Kokoro server. Map each `Speaker` to a Kokoro voice name instead of an - ElevenLabs id (replace `DEFAULT_VOICES` ids with engine voice names, or add a - parallel map). -- **Piper** — MIT/MPL, very fast, fully offline; lower ceiling than Kokoro. -- **edge-tts** — free, no key, excellent Microsoft neural voices; requires being - online at generation time and uses an undocumented endpoint (ToS gray area) — - flag before relying on it. -- **Coqui XTTS-v2** — can clone a distinct voice per character, but weights are - **non-commercial** licensed — flag for public release. - -Add a `TTS_BACKEND` env switch in the script so the engine is swappable without -touching app code. Per-speaker voice map moves from ElevenLabs ids → engine voice -names. Re-run `npm run audio`; the manifest + files regenerate; the app is -unchanged. - -### SFX & ambience beds — free sources - -- The six mood files are placeholders and must be replaced. ElevenLabs - sound-generation is also subscription-gated; use **CC0 / royalty-free** sources - instead: Freesound (filter CC0), the BBC Sound Effects library (RemArc terms), - or generate procedurally via the existing `AmbienceEngine` approach. -- Drop event clips into `public/audio/sfx/events/` and ambience loops (if using - approach B) into `public/audio/ambience/`. Record provenance/license in a - `public/audio/CREDITS.md`. - ---- - -## 8. Implementation steps (ordered, each shippable) - -1. **Layer 1:** idle/talk/gesture CSS + `Actor` props. Run build + tests. -2. **Audio swap:** repoint `generate-audio.ts` at Kokoro/Piper; regenerate - voices; replace placeholder mood SFX with CC0 clips. -3. **Layer 2a:** `AmbienceEngine.setPlace(level)` + wire from `CinematicStage`. -4. **Layer 2b:** `sfxEvents.ts` + extend `useSfx.play(event)`; trigger on moods/ - key scenes. -5. **Layer 3:** `choreography.ts` + travel phase in `App`/`CinematicStage`; - footstep loop; staircase waypoint pathing. -6. **Layer 4:** camera travel mode in `MapCamera`. -7. Tests (§9). `npm run build`, `npm test`, Playwright e2e. - ---- - -## 9. Tests - -- `src/test/choreography.test.ts`: every key is a real scene id; every `Beat` - `characterId` appears in that scene's `getCharactersForScene`; coords in 0–100. -- `src/test/sfxEvents.test.ts`: referenced event names exist in `SfxEvent`. -- Ambience unit: `setPlace` is a no-op when unsupported / before `ensure`. -- E2E: with reduced-motion **on**, a choice still advances instantly (no travel - hang); with it **off**, the `.actor--walk` class appears during a transition. -- Guard: game completes start→ending with all audio files absent (fallbacks). - ---- - -## 10. Risks / notes - -- **Cross-scene persistence:** the travel phase is the main architectural change - — `CinematicStage` is remounted per scene today, so the walk must live in `App` - (or a wrapper) that survives the scene swap. Keep it short (≤ ~1.5s) and always - skippable. -- **Performance:** animate transforms/opacity only; cap concurrent footstep - audio; preload only the next place's ambience bed. -- **Accessibility:** every motion layer honors `reduced`; SFX/voice/ambience each - have independent toggles persisted in `localStorage` (pattern already used by - `useAmbience`/`useNarration`). -- **Synergy with Plan 1:** animated sprites and scene illustrations share the - image agent and the `--inv` counter-scale convention; a scene can use Plan 1 - static art *or* Plan 2 animated actors, chosen per scene. diff --git a/docs/superpowers/plans/2026-06-07-scene-illustration-overlays.md b/docs/superpowers/plans/2026-06-07-scene-illustration-overlays.md deleted file mode 100644 index 7c6b874..0000000 --- a/docs/superpowers/plans/2026-06-07-scene-illustration-overlays.md +++ /dev/null @@ -1,313 +0,0 @@ -# Plan 1 — Per-scene illustration overlays on the map - -**Date:** 2026-06-07 -**Goal:** Let a developing agent that can also generate images place a hand-drawn -illustration *on top of the map* for each scene, showing what is happening in -that beat (e.g. "Mira reads the radiation report in the engine room"). The -illustration is anchored to the scene's place on the map, fades in with the -camera move, and degrades gracefully to today's actor-only view when art is -missing. - -This plan is written so an image-capable agent can execute it end-to-end: -data model, file conventions, rendering component, the **art style guide and -per-scene prompt spec**, fallbacks, and tests. - ---- - -## 1. Current state (what we build on) - -- The cinematic view is `src/components/CinematicStage.tsx`, which renders - `MapCamera` (`src/components/MapCamera.tsx`). -- `MapCamera` shows `/assets/map.jpg` (5632×3072) inside a `.map-camera__drift` - wrapper, then positions `Actor` portraits at percentage coordinates and - applies mood/vignette/caustics/grain overlay layers. -- Each scene (`StoryScene` in `src/types.ts`) optionally has a - `locationHotspotId`; `CinematicStage` resolves it to a `Focus = {x,y}` - percentage via `getHotspot(...)` and the camera zooms/pans there. -- Scene mood (`SceneMood`) already drives tints. Scenes are keyed by `scene.id`, - and `CinematicStage` is remounted per scene (so per-scene art state is clean). - -**Implication:** an illustration is just another layer inside -`.map-camera__drift`, positioned at the same focal percentage as the actors, so -it pans and zooms *with* the map automatically. No camera math changes needed. - ---- - -## 2. Data model - -Keep story logic untouched. Add a **separate art registry** the image agent -owns, so regenerating art never risks editing `storyGraph.ts`. - -Create `src/data/sceneArt.ts`: - -```ts -import type { SceneMood } from '../types'; - -/** How the illustration is framed relative to the scene's focal point. */ -export type ArtFraming = - | 'vignette' // transparent-bg character moment, sits on the place (default) - | 'panel' // framed rectangular illustration "pinned" over the place - | 'fullbleed'; // large establishing art that covers most of the viewport - -export type SceneArt = { - /** Matches StoryScene.id (or EndingId for endings). */ - sceneId: string; - /** File under /assets/scenes (webp preferred). Omit while art is pending. */ - src?: string; - /** Optional 2× asset for high-DPI screens. */ - src2x?: string; - /** Anchor on the map in percent. Defaults to the scene's hotspot focus. */ - xPercent?: number; - yPercent?: number; - /** Rendered width as a % of the map width before camera zoom (default 26). */ - widthPercent?: number; - framing: ArtFraming; - /** Required for accessibility — describes the depicted action. */ - alt: string; - /** The exact prompt used to generate the image (provenance + regeneration). */ - prompt?: string; - /** Mood the art was drawn for, so we can detect drift if a scene's mood changes. */ - mood?: SceneMood; -}; - -/** sceneId → art. Missing entries fall back to the actor-only view. */ -export const sceneArt: Record = { - // populated by the image agent, one entry per scene id -}; - -export function getSceneArt(sceneId: string): SceneArt | undefined { - const art = sceneArt[sceneId]; - return art?.src ? art : undefined; // only "ready" art (has src) is returned -} -``` - -Do **not** add fields to `StoryScene`. The registry is matched by id, which -decouples art production from narrative edits. - ---- - -## 3. Asset conventions - -- Location: `public/assets/scenes/.webp` (and optional - `@2x.webp`). Endings: `public/assets/scenes/ending-.webp`. -- Format: **WebP or AVIF**, not PNG/JPG. The map alone is 12 MB; scene art must - stay light. Target ≤ 180 KB per `vignette`, ≤ 320 KB per `panel`/`fullbleed`. -- `vignette` framing → **transparent background** (the map shows through). -- `panel`/`fullbleed` → may be opaque but should carry a soft painted edge / no - hard rectangle that fights the illustrated map. -- Canonical render size: design vignettes at ~1024×1024, panels at ~1280×900. -- Every generated file must have a matching `sceneArt` entry including `alt` and - `prompt` (provenance so any agent can regenerate identically). - ---- - -## 4. Rendering - -New component `src/components/SceneArt.tsx`, rendered **inside** -`.map-camera__drift` in `MapCamera`, layered *above the map image and below the -actors* for `panel`/`fullbleed`, or *above actors* for an action `vignette` -(decide per scene; default below actors so characters stay readable). - -```tsx -import { AnimatePresence, motion } from 'framer-motion'; -import type { SceneArt as Art } from '../data/sceneArt'; - -type Props = { art: Art | undefined; focus: { x: number; y: number }; zoom: number; reduced: boolean }; - -export function SceneArt({ art, focus, zoom, reduced }: Props) { - return ( - - {art ? ( - { - event.currentTarget.style.display = 'none'; - }} - /> - ) : null} - - ); -} -``` - -Wire-up in `MapCamera.tsx`: -- Accept a `sceneId` (or the resolved `SceneArt`) prop from `CinematicStage`. -- Render `` - immediately after the `` (panel/fullbleed) — and, - for `vignette`, after the actor map so it overlays the characters. -- CSS in `src/styles/global.css`: `.scene-art` is `position:absolute; transform: translate(-50%,-50%) scale(var(--inv)); width: var(--w);` matching the existing `.actor` counter-scale pattern (`--inv`). Add a subtle drop shadow for vignettes. - -Pass `scene.id` from `CinematicStage` into `MapCamera` (it already computes -`focus` and `zoom`). - ---- - -## 5. Preloading (no jank on scene change) - -Mirror the existing `prefetchNext` voice pattern. In `CinematicStage`, when the -final dialogue line is reached or a choice is hovered, preload the art for each -`choice.nextSceneId`: - -```ts -function preloadSceneArt(sceneId: string) { - const art = getSceneArt(sceneId); - if (!art) return; - const img = new Image(); - img.src = `/assets/scenes/${art.src}`; -} -``` - -This keeps the fade-in instant when the next scene mounts. - ---- - -## 6. Fallback behaviour (must-have) - -- No `sceneArt` entry, or entry without `src` → `getSceneArt` returns - `undefined` → `SceneArt` renders nothing → today's actor-only view is shown. - **The feature is purely additive; the game must be fully playable with zero - art present.** -- Broken image URL → `onError` hides the element (set `display:none`) so a - missing file never shows a broken-image icon over the map. -- `prefers-reduced-motion` (already surfaced as `reduced`) → no scale, just a - fast opacity cut. - ---- - -## 7. Art style guide (for the image agent) - -The map (`public/assets/map.jpg`) is an **illustrated, aged cross-section** of -the underwater city of Athel. All scene art must look like it was drawn by the -same hand and could be pasted onto that map without clashing. - -**Visual identity** -- Medium: hand-painted / inked illustration with visible texture; *not* photoreal, *not* flat vector, *not* 3D render. -- Palette: muted, slightly desaturated; aged-paper warmth up top (Surface/Top - levels) shifting to cold teal-greens and rust in the depths (Engine). Match - the mood tints already used: `calm`, `tense`, `descent`, `secret`, `danger`, - `hope`. -- Lighting: soft caustic underwater light; god-rays near the Glass Dome; dim, - steam-lit glow in the Industrial Core / Engine Room. -- Linework: confident, storybook-illustration weight; soft painted edges (no - hard photographic cutouts), so `vignette` art melts onto the map. -- Mood-to-tone mapping: `calm`→still/warm; `tense`→tight framing, cooler; - `descent`→downward motion, shadow; `secret`→single light source, hush; - `danger`→high contrast, red accents; `hope`→light breaking through water. - -**Characters** (keep consistent with `src/data/characters.ts` accents) -- Mira — bright lower-level student, accent gold `#d9b15a`. -- Ocean — Mira's friend, teal `#5fb6c4`. -- Torin — market shopkeeper / hull diver, copper `#c98a4b`. -- Veyr — the president, magenta-rose `#b1556a`. -Reuse the same hair/silhouette/wardrobe across all scenes so the same person is -recognisable beat to beat. - -**Framing rules** -- `vignette`: 1–3 characters mid-action, transparent background, no scenery box. -- `panel`: a small framed "window" of the location's interior, pinned over the - place on the map. -- `fullbleed`: reserved for climax / endings only. - -**Prohibited:** modern logos, text/UI in the image, photoreal faces, anachronistic -tech, gore. Keep it PG and painterly. - ---- - -## 8. Per-scene prompt spec (deliverable from the image agent) - -For **every** scene id below, produce one illustration + a `sceneArt` entry with -`alt` and `prompt`. Derive the depicted action from the scene's `paragraphs` / -`dialogue` in `src/data/storyGraph.ts` and the place from `locationHotspotId`. -Scene ids and their places (from `mapHotspots.ts` / `characters.ts`): - -| sceneId | place | who | suggested beat | -|---|---|---|---| -| `school-morning` | Archimedes School | Mira, Ocean, Veyr (portrait) | classroom, president's portrait on the wall | -| `torins-shop` | Market Levels | Mira, Torin | market stall, tuna sandwich, hushed talk | -| `descent-to-engine` | Spiral Staircase | Mira | lone descent into darker, humid levels | -| `radiation-report` | Engine Room | Mira (+Veyr later) | Mira reading the report, readings at zero | -| `sara-disappears` | Archimedes School | Mira, Ocean | empty seat, unease | -| `city-hall-board` | Upper Residences | Mira, Veyr | announcement board, official lie | -| `climax-door` | Spiral Staircase / Engine | Mira, Veyr | the door, the confrontation (panel/fullbleed) | -| `escape-to-torin` | Comms Room / Market | Mira, Torin | fleeing to Torin | -| `broadcast-plan` | Comms Room | Mira, Torin | the broadcast rig, planning the truth | -| `blackout-escape` | Glass Dome | Mira, Ocean, Torin | blackout, the dome, the ascent | -| `caught-and-questioned` | Top Level | Mira, Veyr | interrogation | -| `leak-and-collapse` | Glass Dome | Mira, Veyr | the dark beat (fullbleed) | -| ...remaining scene ids in `storyScenes` | per `locationHotspotId` | per `getCharactersForScene` | — | -| endings `hopeful` / `dark` / `bittersweet` | per ending `locationHotspotId` | — | fullbleed finale art | - -(The agent should enumerate the authoritative list programmatically from -`storyScenes` + `endings` rather than trusting this table, then fill any gaps.) - -A reusable prompt template: - -> "Hand-painted storybook illustration, muted aged palette, soft caustic -> underwater light, in the style of an illustrated antique cross-section map. -> {action}. Characters: {character descriptions with accent colours}. Setting: -> {place}. Mood: {mood}. Transparent background, soft painted edges, no text, -> no UI, PG, painterly, not photoreal." - -Store the filled-in prompt in `sceneArt[sceneId].prompt`. - ---- - -## 9. Implementation steps (ordered) - -1. Add `src/data/sceneArt.ts` (registry + `getSceneArt`). -2. Add `src/components/SceneArt.tsx`. -3. Add `.scene-art*` styles to `src/styles/global.css` (mirror `.actor` counter-scale). -4. Thread `sceneId` from `CinematicStage` → `MapCamera`; render `SceneArt`. -5. Add `preloadSceneArt` and call it for `choice.nextSceneId` on hover / last line. -6. Image agent: generate art per §8 into `public/assets/scenes/`, fill registry. -7. Add tests (§10). Run `npm run build`, `npm test`, and the Playwright e2e. - -Steps 1–5 ship immediately and are testable with **placeholder art** (one -sample webp) before the full art set exists. - ---- - -## 10. Tests - -- `src/test/sceneArt.test.ts`: - - every key in `sceneArt` is a real scene/ending id (cross-check `storyScenes`, - `endings`); - - every entry with a `src` has a non-empty `alt`; - - `xPercent`/`yPercent`, when present, are within 0–100. -- Component test: `SceneArt` renders nothing for an unknown id, renders an `` - with the right `alt` for a known one, and hides on `error`. -- E2E (`tests/e2e/`): on a scene known to have art, assert the `.scene-art` image - is visible and within the camera frame; assert the game is still playable when - the registry is empty (fallback path). - ---- - -## 11. Risks / notes - -- **Weight:** enforce the per-file size budget; consider AVIF. Lazy/preload only - the next scene's art, never the whole set. -- **Readability:** if a `vignette` over actors hurts dialogue legibility, default - art to *below* the actor layer and reserve above-actor only for establishing beats. -- **Consistency drift:** store `prompt` + `mood` per entry so regeneration stays - on-model; add the mood-mismatch check to the test if scenes are re-tuned. -- This plan is **independent of Plan 2** (living map). Static art works with - static actors; if Plan 2 lands, `vignette` art can be swapped for / layered - with animated sprites scene by scene. diff --git a/env.ts b/env.ts index c9d09bc..47c8f93 100644 --- a/env.ts +++ b/env.ts @@ -6,7 +6,7 @@ import { defineEnv, string } from 'void/env'; export default defineEnv({ ELEVENLABS_API_KEY: string().optional(), VITE_ELEVENLABS_API_KEY: string().optional(), - VITE_ELEVENLABS_MODEL: string().default('eleven_multilingual_v2'), + VITE_ELEVENLABS_MODEL: string().default('eleven_v3'), VITE_ELEVENLABS_OUTPUT: string().default('mp3_44100_128'), VITE_ELEVENLABS_VOICE_MIRA: string().optional(), VITE_ELEVENLABS_VOICE_NARRATOR: string().optional(), diff --git a/humanizer.skill b/humanizer.skill deleted file mode 100644 index 962ea14..0000000 Binary files a/humanizer.skill and /dev/null differ diff --git a/index.html b/index.html index dc8c5e7..edc682a 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,9 @@ href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700;800&family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&family=Inter:wght@600;700;800;900&display=swap" rel="stylesheet" /> + + + The Underwater City of Athel — An Interactive Story diff --git a/package.json b/package.json index e584930..81f9e1f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", - "build": "tsc -b && vite build", + "build": "void prepare && tsc -b && vite build", "preview": "vite preview --host 127.0.0.1", "test": "vitest run", "test:watch": "vitest", diff --git a/public/audio/CREDITS.md b/public/audio/CREDITS.md new file mode 100644 index 0000000..9e32c63 --- /dev/null +++ b/public/audio/CREDITS.md @@ -0,0 +1,7 @@ +# Audio credits + +The voice lines, mood beds, and sound effects in this directory were generated with [ElevenLabs](https://elevenlabs.io): narration and character speech with the `eleven_v3` text-to-speech model, and the music and effects with the ElevenLabs sound-generation API. `manifest.json` records the render (`generatedAt`: 2026-06-09). + +Project notes describe this set as free-plan output. ElevenLabs does not grant a commercial license for audio made on the free plan or while signed out. Publishing that audio requires attribution: the title of the published work includes `elevenlabs.io` or `11.ai`. This repo shows that credit on the title screen and in the README. If these clips were made on the free plan, add the same credit to the public title (repository description or page title) before treating the release as fully attributed. + +Confirm the plan that produced these files before any commercial use. Audio generated during a paid ElevenLabs subscription can carry a commercial license under ElevenLabs' terms; free-plan audio cannot, and it is not relicensed by the MIT license on the source code. diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..f4f459d Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..2927084 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..52a6fab --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/routes/api/posts.ts b/routes/api/posts.ts deleted file mode 100644 index a764ab7..0000000 --- a/routes/api/posts.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineHandler } from "void"; -import { db } from "void/db"; -import { posts } from "@schema"; - -// GET /api/posts — returns all posts, fully typed from migration schema -export const GET = defineHandler(async () => { - return db.select().from(posts); -}); diff --git a/src/components/DialogueOverlay.tsx b/src/components/DialogueOverlay.tsx index 5161752..ba90322 100644 --- a/src/components/DialogueOverlay.tsx +++ b/src/components/DialogueOverlay.tsx @@ -40,7 +40,7 @@ export function DialogueOverlay({ speaker, children, showContinue, onAdvance }: ); return ( -
+
{showContinue ? (