From eb20cec4755a9acd3e22a25bd655489771e89b52 Mon Sep 17 00:00:00 2001 From: Venice Agent Date: Sat, 21 Mar 2026 02:44:37 +0000 Subject: [PATCH 1/3] chore: add implementation plan Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 218 +++++++++++++++++++++++++++++++++++++++++++++++++++++ tasks.json | 26 +++++++ 2 files changed, 244 insertions(+) create mode 100644 PLAN.md create mode 100644 tasks.json diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..ac9ea34 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,218 @@ +# Quantum Runner — Implementation Plan + +## Overview + +Quantum Runner is a turn-based grid game where the player doesn't directly control a character. Instead, each turn generates N stochastic possible futures and the player selects which future to "collapse into" — accept the current proposal or pass to see the next one, with no going back. + +## Tech Stack + +| Layer | Choice | Rationale | +|-------|--------|-----------| +| Language | TypeScript (strict) | Type safety for game state, entity models | +| Build | Vite 6 (`vanilla-ts` template) | Fast HMR, zero-config TS, optimized production build | +| Rendering | HTML5 Canvas 2D | Direct pixel control for grid rendering; no DOM overhead | +| UI Controls | Plain HTML/CSS | Accept/Pass buttons, HUD — minimal DOM, no framework needed | +| Serving (prod) | Nginx alpine | Lightweight static file server for Docker deployment | +| Font | JetBrains Mono (via CDN) | Monospace with character, fits the terminal/quantum aesthetic | + +**Why no React/framework?** The game is 95% Canvas rendering. The only DOM elements are a few buttons and text displays. A framework adds bundle size and complexity with zero benefit here. + +**Why no Phaser?** Phaser is designed for real-time games with physics, sprite sheets, and animation systems. This is a turn-based game with simple geometric rendering. Raw Canvas API keeps the code simple and the bundle tiny. + +Sources: +- [Vite docs](https://vite.dev/guide/) — project scaffolding +- [Canvas game patterns](https://blog.harveydelaney.com/creating-a-game-using-html5-canvas-typescript-and-webpack/) — architecture inspiration +- [Grid game in TypeScript](https://medium.com/swlh/building-a-game-with-typescript-iii-drawing-grid-4-5-398af1dd638d) — grid drawing patterns + +## Design Direction: "Terminal Phosphor" + +Dark background (#0a0a0f), green/cyan grid lines evoking retro CRT monitors. The protagonist is a bright cyan glyph, enemies are amber/red, the goal is a pulsing green. Future overlays use semi-transparent versions of entity colors with dashed outlines. Monospace typography throughout (JetBrains Mono). Subtle scanline effect on the canvas for atmosphere. + +**Palette:** +- Background: `#0a0a0f` +- Grid lines: `#1a3a2a` (subtle dark green) +- Protagonist: `#00ffcc` (cyan) +- Enemy: `#ff6b35` (amber-orange) +- Goal: `#39ff14` (neon green) +- Danger/lose: `#ff1744` (red) +- Future overlay: 40% opacity versions with dashed borders +- UI text: `#b0ffb0` (soft green) +- Accent: `#ffeb3b` (yellow for highlights) + +## Architecture + +### File Structure + +``` +quantum-runner/ +├── src/ +│ ├── main.ts # Entry point — initialize canvas, start game +│ ├── types.ts # Shared type definitions +│ ├── config.ts # Game configuration constants +│ ├── state/ +│ │ ├── GameState.ts # Core game state model +│ │ ├── Grid.ts # Grid representation + pathfinding helpers +│ │ └── entities.ts # Entity types (Player, Enemy, Goal, Wall) +│ ├── engine/ +│ │ ├── FuturesEngine.ts # Stochastic futures generation +│ │ ├── EnemyAI.ts # Enemy movement AI (weighted random) +│ │ └── movement.ts # Movement distributions + sampling +│ ├── render/ +│ │ ├── Renderer.ts # Main canvas renderer +│ │ ├── GridRenderer.ts # Grid lines, walls, floor tiles +│ │ ├── EntityRenderer.ts# Player, enemies, goal rendering +│ │ └── FutureOverlay.ts # Semi-transparent future state overlay +│ ├── ui/ +│ │ ├── GameController.ts# State machine: title → play → win/lose +│ │ ├── TurnManager.ts # Future streaming + accept/pass logic +│ │ ├── HUD.ts # Score, turn count, futures remaining +│ │ └── screens.ts # Title screen, win/lose screen rendering +│ └── style.css # Minimal CSS for HTML elements +├── index.html # Canvas + UI container +├── Dockerfile # Multi-stage: build with Node, serve with Nginx +├── tsconfig.json # Strict TypeScript config +├── package.json +└── vite.config.ts +``` + +### Core Data Model + +```typescript +// Grid cell types +type CellType = 'floor' | 'wall' | 'goal'; + +// Entity on the grid +interface Entity { + type: 'player' | 'enemy'; + x: number; + y: number; + id: string; +} + +// Complete game state (immutable per turn) +interface GameState { + grid: CellType[][]; // 2D grid of cells + player: Entity; + enemies: Entity[]; + goalPos: { x: number; y: number }; + turn: number; + status: 'playing' | 'won' | 'lost'; +} + +// A proposed future state +interface Future { + state: GameState; + description: string; // Brief text describing what happens + quality: number; // -1 to 1, how good this future is (for variance) +} +``` + +### Futures Engine Design + +Each turn, the engine generates N futures (configurable, default 3) by: + +1. **Player movement**: Sample from adjacent walkable cells. Each direction has a base probability (biased slightly toward the goal via a softmax over Manhattan distance). Sometimes the player stays in place (inertia). + +2. **Enemy movement**: Each enemy uses weighted random movement biased toward the player (simple chase AI). Occasionally an enemy moves randomly or stays still, creating variance. + +3. **Quality variance**: Futures are sorted/shuffled to ensure meaningful spread. At least one future should be "good" (player moves toward goal, enemies move away) and at least one "bad" (player moves into danger). The engine explicitly ensures this by rejection-sampling or adjusting probabilities if the initial batch is too uniform. + +4. **Collision resolution**: If the player and enemy occupy the same cell in a future → player is caught → status becomes 'lost'. If the player reaches the goal cell → status becomes 'won'. + +### Turn Flow + +``` +Current State + │ + ▼ +Generate N Futures + │ + ▼ +Present Future 1 ──── [Accept] ──→ Apply State → Check Win/Lose → Next Turn + │ + [Pass] + │ + ▼ +Present Future 2 ──── [Accept] ──→ Apply State → Check Win/Lose → Next Turn + │ + [Pass] + │ + ▼ + ... + │ + ▼ +Present Future N ──── [Auto-Accept] ──→ Apply State → Check Win/Lose → Next Turn +``` + +### Game Controller State Machine + +``` +TITLE ──[Start]──→ PLAYING ──[Win]──→ WIN_SCREEN ──[Restart]──→ TITLE + │ + [Lose] + │ + ▼ + LOSE_SCREEN ──[Restart]──→ TITLE +``` + +### Level Design + +A single hardcoded level for the prototype: +- 12×10 grid +- Walls forming corridors and a few dead ends +- Player starts bottom-left area +- Goal in top-right area +- 2 enemies: one patrolling near the middle, one near the goal +- Multiple viable paths to create meaningful choices + +### Rendering Strategy + +1. **Base layer**: Grid floor tiles + walls (drawn once, cached to offscreen canvas) +2. **Entity layer**: Player, enemies, goal (redrawn each state change) +3. **Future overlay**: When presenting a future, draw translucent versions of entities at their proposed positions with dashed connecting lines from current positions. Use color-coded arrows (green = toward goal, red = toward enemy). +4. **UI overlay**: Canvas-rendered HUD (turn counter, futures remaining indicator) + +### Key Design Decisions + +1. **Immutable game states**: Each `GameState` is a new object. Futures reference new state objects. This makes undo/comparison trivial and prevents mutation bugs. + +2. **No animation between turns**: The turn-based nature means we snap between states. The future overlay provides the visual transition. This dramatically simplifies the renderer. + +3. **Configurable N**: The number of futures per turn is a constant in `config.ts`, easily adjustable from 2–5. + +4. **Keyboard + click support**: Accept = Enter or click button. Pass = Space or click button. Arrow keys could optionally highlight entities. + +### Dockerfile + +Multi-stage build: +```dockerfile +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +``` + +## Scope Assessment + +This is a **single-agent** task. The modules are tightly coupled: +- The renderer needs the state model and entity types +- The futures engine needs the state model and movement logic +- The game controller orchestrates the renderer, futures engine, and UI +- Integration testing requires the full pipeline + +Splitting into parallel agents would create more integration overhead than it saves. A single focused agent can build this incrementally: types → state → engine → renderer → controller → screens → Dockerfile. + +## Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| Futures too uniform / no meaningful choice | Engine explicitly ensures quality spread via rejection sampling | +| Game too easy/hard | Configurable enemy AI aggressiveness + number of futures | +| Canvas rendering performance | Turn-based = no frame loop; redraw only on state change | +| Level too simple | Corridors + dead ends create tension even in a small grid | diff --git a/tasks.json b/tasks.json new file mode 100644 index 0000000..6d57b4e --- /dev/null +++ b/tasks.json @@ -0,0 +1,26 @@ +{ + "mode": "single", + "quality": "full", + "claudeMd": "# Quantum Runner — Implementation Guide\n\nYou are building a complete browser-based turn-based grid game called Quantum Runner. The player doesn't directly control a character — instead, each turn generates N stochastic possible futures and the player selects which one to \"collapse into\" by accepting or passing.\n\n## Tech Stack\n\n- **Vite 6** with `vanilla-ts` template — scaffold with `npm create vite@latest . -- --template vanilla-ts` then clean up the default files\n- **TypeScript** (strict mode) — all game logic in `src/`\n- **HTML5 Canvas 2D API** — all game rendering on a single `` element\n- **Plain HTML/CSS** — for Accept/Pass buttons and HUD overlays\n- **No frameworks** (no React, no Phaser, no game engines)\n- **Nginx alpine** — for Docker production serving\n- **JetBrains Mono** font via Google Fonts CDN\n\n## Design Direction: Terminal Phosphor\n\nDark retro-CRT aesthetic. NOT generic web-app styling.\n\n- Background: `#0a0a0f` (near-black)\n- Grid lines: `#1a3a2a` (subtle dark green)\n- Protagonist: `#00ffcc` (bright cyan) — rendered as a diamond/chevron shape\n- Enemy: `#ff6b35` (amber-orange) — rendered as a jagged/spiky shape\n- Goal: `#39ff14` (neon green) — rendered as a pulsing circle/beacon\n- Danger/dead: `#ff1744` (red)\n- Future overlay: 40% opacity entity colors + dashed borders + directional arrows\n- UI text: `#b0ffb0` (soft phosphor green)\n- Accent: `#ffeb3b` (yellow for highlights/selected items)\n- Font: JetBrains Mono everywhere (load from Google Fonts)\n- Optional: subtle CSS scanline overlay on the canvas container for atmosphere\n\n## Architecture\n\nBuild in this order:\n\n### 1. Project Setup\n- Scaffold Vite vanilla-ts project in the repo root\n- Configure `tsconfig.json` with strict mode\n- Clean out default Vite template files (counter.ts, etc.)\n- Set up `index.html` with canvas element + UI container\n- Set up `src/style.css` with the dark theme, font import, layout\n\n### 2. Core Types & Config (`src/types.ts`, `src/config.ts`)\n```typescript\n// types.ts\nexport type CellType = 'floor' | 'wall' | 'goal';\n\nexport interface Position { x: number; y: number; }\n\nexport interface Entity {\n type: 'player' | 'enemy';\n id: string;\n pos: Position;\n}\n\nexport interface GameState {\n grid: CellType[][];\n player: Entity;\n enemies: Entity[];\n goalPos: Position;\n turn: number;\n status: 'playing' | 'won' | 'lost';\n}\n\nexport interface Future {\n state: GameState;\n description: string;\n quality: number; // -1 to 1\n}\n\nexport type GamePhase = 'title' | 'playing' | 'choosing' | 'won' | 'lost';\n```\n\n```typescript\n// config.ts\nexport const CONFIG = {\n GRID_COLS: 12,\n GRID_ROWS: 10,\n CELL_SIZE: 56,\n NUM_FUTURES: 3, // default futures per turn (2-5)\n MIN_FUTURES: 2,\n MAX_FUTURES: 5,\n ENEMY_CHASE_BIAS: 0.6, // probability enemy moves toward player\n PLAYER_GOAL_BIAS: 0.3, // slight bias toward goal in movement sampling\n} as const;\n```\n\n### 3. Grid & Level (`src/state/Grid.ts`, `src/state/level.ts`)\n- `Grid` class: wraps the 2D CellType array, provides `isWalkable(x,y)`, `getNeighbors(x,y)`, `manhattanDistance(a,b)`\n- `level.ts`: hardcoded level definition — a 12×10 grid with walls forming corridors. Player starts near (1,8), goal at (10,1). Two enemies at ~(5,4) and (8,2). Multiple viable paths. Design walls to create interesting corridor choices.\n\nLevel layout concept (W=wall, .=floor, G=goal, P=player start, E=enemy start):\n```\n. . W . . . . . . . G .\n. . W . . . . . E . . .\n. . W . . W W . . . . .\n. . . . . W . . . W W .\n. . . . E . . . . . . .\n. W W . . . W W . . . .\n. . . . . . . . . W . .\n. . W W W . . . . W . .\n. P . . . . W . . . . .\n. . . . . . W . . . . .\n```\nAdjust to ensure playability — multiple paths from P to G, enemies positioned to create tension.\n\n### 4. Futures Engine (`src/engine/FuturesEngine.ts`, `src/engine/movement.ts`)\n\n**movement.ts**: \n- `samplePlayerMove(state: GameState): Position` — returns a random adjacent walkable cell, biased toward the goal via softmax over negative Manhattan distance. Include \"stay in place\" as an option with low weight.\n- `sampleEnemyMove(enemy: Entity, state: GameState): Position` — returns a random adjacent walkable cell, biased toward the player (ENEMY_CHASE_BIAS chance of moving in the best direction, otherwise random). Include \"stay\" option.\n\n**FuturesEngine.ts**:\n- `generateFutures(state: GameState, n: number): Future[]` — generates n futures by:\n 1. For each future: sample a player move, sample each enemy move, create new GameState\n 2. Check collisions: player on enemy cell → lost, player on goal → won\n 3. Compute quality score: based on (new player-goal distance vs old) and (new enemy-player distances vs old)\n 4. Generate description string: \"You drift north. Enemy Alpha closes in.\" etc.\n 5. **Ensure variance**: if all futures have similar quality (spread < threshold), regenerate some with adjusted probabilities. At minimum, ensure at least one future moves player closer to goal and one has an enemy moving closer.\n\n### 5. Renderer (`src/render/`)\n\n**Renderer.ts**: Main class that holds the canvas context and coordinates sub-renderers.\n- `render(state: GameState, futureOverlay?: Future)` — clear canvas, draw grid, draw entities, optionally draw future overlay\n\n**GridRenderer.ts**: \n- Draw cell backgrounds (dark for floor, lighter block pattern for walls)\n- Draw subtle grid lines in `#1a3a2a`\n- Highlight the goal cell with a pulsing glow effect\n\n**EntityRenderer.ts**:\n- Player: cyan diamond shape with slight glow\n- Enemy: amber-orange spiky/angular shape\n- Goal: green pulsing beacon/ring\n- Use geometric shapes, not sprites — keep it clean and readable\n\n**FutureOverlay.ts**:\n- When a future is being previewed: draw translucent (40% opacity) versions of all entities at their FUTURE positions\n- Draw dashed lines from current position to future position for player and each enemy\n- Color-code the arrows: green if entity moves toward goal (player) or away from player (enemy = good), red/amber if dangerous\n- Draw a subtle highlight on the cells that change\n\n### 6. UI & Game Controller (`src/ui/`)\n\n**GameController.ts**: The central state machine.\n- Manages `GamePhase`: title → playing → choosing → won/lost\n- On 'playing': calls FuturesEngine to generate futures, transitions to 'choosing'\n- On 'choosing': TurnManager handles the streaming UI\n- On accept: applies the chosen future, checks win/lose, loops back to 'playing' or transitions to 'won'/'lost'\n\n**TurnManager.ts**: Handles the future-selection UX.\n- Holds the array of futures and current index\n- `showNextFuture()`: advances to next future, triggers overlay render\n- `acceptCurrent()`: returns the current future, resets\n- `passCurrent()`: if more futures, advance; if last future, auto-accept\n- Exposes `currentFuture`, `futuresRemaining`, `isLastFuture` for UI\n\n**HUD.ts**: Canvas-rendered heads-up display.\n- Turn counter (top-left)\n- \"Future X of N\" indicator (top-right)\n- Current future description text (bottom)\n- Accept/Pass button labels (drawn or HTML overlaid)\n\n**screens.ts**: Full-canvas screens for title, win, lose.\n- Title: \"QUANTUM RUNNER\" in large JetBrains Mono, \"Press Enter to Start\", brief rules summary\n- Win: \"REALITY COLLAPSED — YOU WIN\" + turn count + restart prompt\n- Lose: \"TIMELINE TERMINATED\" + what happened + restart prompt\n\n### 7. Main Entry Point (`src/main.ts`)\n- Get canvas element, set size\n- Initialize GameController, Renderer\n- Wire up keyboard events (Enter=accept/start, Space=pass, R=restart)\n- Wire up click events for Accept/Pass buttons\n- Start on title screen\n\n### 8. HTML & CSS (`index.html`, `src/style.css`)\n\n**index.html**:\n```html\n
\n \n
\n \n \n
\n
\n
\n```\n\n**style.css**: Dark background, centered game container, styled buttons matching the phosphor theme. Buttons should have a terminal/retro look — bordered, monospace text, glow on hover.\n\n### 9. Dockerfile\n\nMulti-stage build:\n```dockerfile\nFROM node:22-alpine AS build\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\nFROM nginx:alpine\nCOPY --from=build /app/dist /usr/share/nginx/html\nEXPOSE 80\nCMD [\"nginx\", \"-g\", \"daemon off;\"]\n```\n\n## Key Implementation Notes\n\n1. **Immutable states**: Always create new GameState objects for futures. Use spread/structuredClone. Never mutate the current state.\n\n2. **Deep clone for futures**: `JSON.parse(JSON.stringify(state))` or `structuredClone()` for creating future state copies. Prefer `structuredClone`.\n\n3. **Responsive canvas**: Set canvas width/height based on grid dimensions × cell size. Don't try to be responsive — fixed size is fine for a prototype. Center it in the viewport.\n\n4. **Event-driven rendering**: No `requestAnimationFrame` loop. Render on state change: when a new future is shown, when a future is accepted, when screens change. Only use rAF for the goal pulsing animation (a single ongoing animation).\n\n5. **Button state management**: Disable Accept/Pass buttons when not in 'choosing' phase. Hide them on title/win/lose screens. Show keyboard hints.\n\n6. **Quality spread algorithm**: When generating futures, compute the quality of each. If max-min quality spread < 0.3, discard the worst future and regenerate with biased probabilities. Cap retries at 3 to avoid infinite loops.\n\n7. **Description generation**: Keep it terse and atmospheric. \"You phase north. Sentinel Alpha advances.\" \"Drift west into the corridor. All clear.\" \"Hold position. Sentinel Beta flanks east.\"\n\n8. **Test the game is winnable**: With 3 futures per turn and the level layout above, the player should be able to win in ~15-25 turns by choosing good futures. If futures are too random, increase PLAYER_GOAL_BIAS.\n\n## Gotchas\n\n- Vite's `vanilla-ts` template includes a `counter.ts` and default `main.ts` + `style.css` — delete/replace these entirely\n- Canvas `font` must be set AFTER the font is loaded. Use `document.fonts.ready` before first render, or set a fallback.\n- Canvas text rendering: use `ctx.textAlign` and `ctx.textBaseline` for centering\n- `structuredClone` doesn't work with functions — game state should be plain data only\n- Nginx in Docker needs `daemon off;` in CMD or it exits immediately\n- The Vite build output goes to `dist/` by default — that's what Nginx should serve\n- Make sure `package.json` has `\"type\": \"module\"` (Vite default)\n\n## Acceptance Criteria Mapping\n\n1. Grid-based level with protagonist, goal, enemy → Level layout + EntityRenderer\n2. N configurable futures (2-5) → CONFIG.NUM_FUTURES + FuturesEngine\n3. Streaming future presentation (Accept/Pass) → TurnManager\n4. Passed futures not revisitable, last auto-applied → TurnManager logic\n5. Visual future overlay → FutureOverlay renderer\n6. Win/lose conditions → GameState.status + collision checks\n7. Meaningful variance in futures → Quality spread algorithm\n8. Restart after win/loss → GameController state machine\n9. Responsive core loop → Event-driven render + keyboard/click controls", + "subtasks": [], + "integration": null, + "verification": { + "buildCommand": "npm run build", + "runCommand": "npm run preview -- --host 0.0.0.0 --port 3000", + "readySignal": "Local:|localhost|127.0.0.1|ready|listening", + "appType": "web", + "port": 3000, + "checks": [ + "A dark-themed grid-based game level is visible in the browser with a retro terminal/phosphor aesthetic — cyan diamond (player), amber-orange shapes (enemies), and a green pulsing beacon (goal) are all rendered on the grid", + "A title screen displays 'QUANTUM RUNNER' with a start prompt before gameplay begins", + "During gameplay, a future overlay appears showing translucent/ghost versions of entities at proposed positions with dashed lines indicating movement directions", + "Two buttons labeled 'ACCEPT' and 'PASS' are visible and functional during the future-choosing phase — clicking Accept applies the shown future, clicking Pass advances to the next", + "A 'Future X of N' indicator shows which future is currently being previewed and how many remain", + "When all futures are passed without accepting, the last future is automatically applied (the game advances without requiring explicit accept on the final option)", + "When the player entity reaches the goal cell through accepted futures, a win screen appears with a restart option", + "When an enemy entity occupies the same cell as the player in an accepted future, a lose screen appears with a restart option", + "Pressing Enter/Space on win or lose screens (or clicking restart) returns to the title screen and a new game can be started", + "Different futures presented each turn show meaningfully different outcomes — some move the player closer to the goal while others are more dangerous, creating actual strategic choice" + ] + } +} From 64fdc65653697f8cb11d0a8ba0f02abdc62d222e Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 21 Mar 2026 03:11:43 +0000 Subject: [PATCH 2/3] feat(game): implement Quantum Runner core game loop Implement the full playable prototype with: - 12x10 grid-based level with corridors and dead ends - Stochastic futures engine generating N (configurable 2-5) possible next states - Streaming decision interface: Accept/Pass with keyboard and click support - Visual future overlay with ghost entities, arrows, and quality-colored highlights - Win condition (reach EXIT) and lose condition (caught by enemy) - Title screen, gameplay, win/lose screens with restart - Terminal phosphor visual theme with Canvas 2D rendering - Vite + TypeScript build toolchain and Dockerfile for deployment Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + Dockerfile | 10 + index.html | 27 + package-lock.json | 1119 ++++++++++++++++++++++++++++++++++ package.json | 15 + src/config.ts | 35 ++ src/engine/EnemyAI.ts | 19 + src/engine/FuturesEngine.ts | 111 ++++ src/engine/movement.ts | 74 +++ src/main.ts | 5 + src/render/EntityRenderer.ts | 80 +++ src/render/FutureOverlay.ts | 133 ++++ src/render/GridRenderer.ts | 48 ++ src/render/Renderer.ts | 74 +++ src/state/GameState.ts | 29 + src/state/Grid.ts | 55 ++ src/state/entities.ts | 12 + src/style.css | 108 ++++ src/types.ts | 32 + src/ui/GameController.ts | 173 ++++++ src/ui/HUD.ts | 38 ++ src/ui/TurnManager.ts | 56 ++ src/ui/screens.ts | 104 ++++ tsconfig.json | 21 + vite.config.ts | 7 + 25 files changed, 2387 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/config.ts create mode 100644 src/engine/EnemyAI.ts create mode 100644 src/engine/FuturesEngine.ts create mode 100644 src/engine/movement.ts create mode 100644 src/main.ts create mode 100644 src/render/EntityRenderer.ts create mode 100644 src/render/FutureOverlay.ts create mode 100644 src/render/GridRenderer.ts create mode 100644 src/render/Renderer.ts create mode 100644 src/state/GameState.ts create mode 100644 src/state/Grid.ts create mode 100644 src/state/entities.ts create mode 100644 src/style.css create mode 100644 src/types.ts create mode 100644 src/ui/GameController.ts create mode 100644 src/ui/HUD.ts create mode 100644 src/ui/TurnManager.ts create mode 100644 src/ui/screens.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d0d4b6f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/index.html b/index.html new file mode 100644 index 0000000..96a93c6 --- /dev/null +++ b/index.html @@ -0,0 +1,27 @@ + + + + + + Quantum Runner + + + + + +
+ +
+ Turn: 0 + Future: 0/0 +
+
+
+ + +
+ +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f808e12 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1119 @@ +{ + "name": "quantum-runner", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "quantum-runner", + "version": "1.0.0", + "devDependencies": { + "typescript": "~5.6.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..81d7501 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "quantum-runner", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.6.0", + "vite": "^6.0.0" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b5e1b74 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,35 @@ +export const CONFIG = { + // Grid + GRID_COLS: 12, + GRID_ROWS: 10, + CELL_SIZE: 52, + + // Futures + NUM_FUTURES: 3, + MIN_FUTURES: 2, + MAX_FUTURES: 5, + + // Enemy AI + ENEMY_CHASE_BIAS: 0.6, + ENEMY_RANDOM_MOVE_CHANCE: 0.25, + ENEMY_STAY_CHANCE: 0.15, + + // Player movement + PLAYER_GOAL_BIAS: 0.3, + PLAYER_STAY_CHANCE: 0.1, + + // Colors + COLOR_BG: '#0a0a0f', + COLOR_GRID: '#1a3a2a', + COLOR_PLAYER: '#00ffcc', + COLOR_ENEMY: '#ff6b35', + COLOR_GOAL: '#39ff14', + COLOR_WALL: '#2a2a3a', + COLOR_FLOOR: '#12121f', + COLOR_DANGER: '#ff1744', + COLOR_UI_TEXT: '#b0ffb0', + COLOR_ACCENT: '#ffeb3b', + COLOR_FUTURE_GOOD: 'rgba(0, 255, 204, 0.35)', + COLOR_FUTURE_BAD: 'rgba(255, 23, 68, 0.35)', + COLOR_FUTURE_NEUTRAL: 'rgba(255, 235, 59, 0.35)', +} as const; diff --git a/src/engine/EnemyAI.ts b/src/engine/EnemyAI.ts new file mode 100644 index 0000000..fdb9bee --- /dev/null +++ b/src/engine/EnemyAI.ts @@ -0,0 +1,19 @@ +import { Entity, CellType } from '../types'; +import { CONFIG } from '../config'; +import { sampleEnemyMove } from './movement'; + +export function moveEnemy( + enemy: Entity, + playerPos: { x: number; y: number }, + grid: CellType[][], +): Entity { + const newPos = sampleEnemyMove( + grid, + enemy, + playerPos, + CONFIG.ENEMY_CHASE_BIAS, + CONFIG.ENEMY_RANDOM_MOVE_CHANCE, + CONFIG.ENEMY_STAY_CHANCE, + ); + return { ...enemy, x: newPos.x, y: newPos.y }; +} diff --git a/src/engine/FuturesEngine.ts b/src/engine/FuturesEngine.ts new file mode 100644 index 0000000..6e8fbb5 --- /dev/null +++ b/src/engine/FuturesEngine.ts @@ -0,0 +1,111 @@ +import { GameState, Future } from '../types'; +import { CONFIG } from '../config'; +import { cloneState } from '../state/GameState'; +import { samplePlayerMove } from './movement'; +import { moveEnemy } from './EnemyAI'; +import { manhattanDistance } from '../state/Grid'; + +function generateOneFuture(state: GameState): Future { + const next = cloneState(state); + next.turn = state.turn + 1; + + // Move player + const playerMove = samplePlayerMove( + next.grid, + state.player, + state.goalPos, + CONFIG.PLAYER_GOAL_BIAS, + CONFIG.PLAYER_STAY_CHANCE, + ); + next.player.x = playerMove.x; + next.player.y = playerMove.y; + + // Move enemies + next.enemies = state.enemies.map(e => moveEnemy(e, playerMove, next.grid)); + + // Check win/lose + if (next.player.x === next.goalPos.x && next.player.y === next.goalPos.y) { + next.status = 'won'; + } else { + for (const enemy of next.enemies) { + if (enemy.x === next.player.x && enemy.y === next.player.y) { + next.status = 'lost'; + break; + } + } + } + + // Compute quality: based on distance to goal and proximity to enemies + const distToGoal = manhattanDistance(next.player, next.goalPos); + const prevDistToGoal = manhattanDistance(state.player, state.goalPos); + const goalImprovement = prevDistToGoal - distToGoal; + + const minEnemyDist = Math.min(...next.enemies.map(e => manhattanDistance(next.player, e))); + const prevMinEnemyDist = Math.min(...state.enemies.map(e => manhattanDistance(state.player, e))); + const enemyChange = minEnemyDist - prevMinEnemyDist; // positive = enemies farther + + let quality = 0; + if (next.status === 'won') quality = 1; + else if (next.status === 'lost') quality = -1; + else { + quality = (goalImprovement * 0.4 + enemyChange * 0.3) / 2; + quality = Math.max(-1, Math.min(1, quality)); + } + + // Description + let desc = ''; + if (next.status === 'won') { + desc = 'You reach the exit! Victory!'; + } else if (next.status === 'lost') { + desc = 'An enemy catches you! Captured!'; + } else { + const dirX = playerMove.x - state.player.x; + const dirY = playerMove.y - state.player.y; + const moveDir = + dirX === 0 && dirY === 0 ? 'stays in place' : + dirX > 0 ? 'moves right' : + dirX < 0 ? 'moves left' : + dirY < 0 ? 'moves up' : 'moves down'; + const dangerLevel = minEnemyDist <= 2 ? ' (danger nearby!)' : minEnemyDist <= 4 ? '' : ' (safe zone)'; + desc = `Observer ${moveDir}${dangerLevel}`; + } + + return { state: next, description: desc, quality }; +} + +export function generateFutures(state: GameState, count: number = CONFIG.NUM_FUTURES): Future[] { + const n = Math.max(CONFIG.MIN_FUTURES, Math.min(CONFIG.MAX_FUTURES, count)); + const futures: Future[] = []; + + // Generate candidates — oversample to ensure quality spread + const candidates: Future[] = []; + const attempts = n * 4; + for (let i = 0; i < attempts; i++) { + candidates.push(generateOneFuture(state)); + } + + // Sort by quality + candidates.sort((a, b) => b.quality - a.quality); + + // Pick with spread: best, worst, and fill from middle + if (candidates.length >= n) { + futures.push(candidates[0]!); // best + futures.push(candidates[candidates.length - 1]!); // worst + // Fill remaining from evenly spaced positions + const remaining = n - 2; + for (let i = 0; i < remaining; i++) { + const idx = Math.floor((i + 1) * (candidates.length / (remaining + 1))); + futures.push(candidates[idx]!); + } + } else { + futures.push(...candidates); + } + + // Shuffle so the player doesn't know best is always first + for (let i = futures.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [futures[i], futures[j]] = [futures[j]!, futures[i]!]; + } + + return futures; +} diff --git a/src/engine/movement.ts b/src/engine/movement.ts new file mode 100644 index 0000000..cb40680 --- /dev/null +++ b/src/engine/movement.ts @@ -0,0 +1,74 @@ +import { Position, CellType } from '../types'; +import { getWalkableNeighbors, manhattanDistance } from '../state/Grid'; + +/** + * Weighted random pick from an array of (item, weight) tuples. + */ +export function weightedPick(items: { item: T; weight: number }[]): T { + const total = items.reduce((s, i) => s + i.weight, 0); + let r = Math.random() * total; + for (const { item, weight } of items) { + r -= weight; + if (r <= 0) return item; + } + return items[items.length - 1]!.item; +} + +/** + * Sample a player move biased toward a target (goal). + * Also includes a chance to stay in place. + */ +export function samplePlayerMove( + grid: CellType[][], + current: Position, + target: Position, + goalBias: number, + stayChance: number, +): Position { + if (Math.random() < stayChance) return { ...current }; + + const neighbors = getWalkableNeighbors(grid, current); + if (neighbors.length === 0) return { ...current }; + + const currentDist = manhattanDistance(current, target); + const weighted = neighbors.map(n => { + const dist = manhattanDistance(n, target); + const improvement = currentDist - dist; // positive = closer to goal + const weight = improvement > 0 ? 1 + goalBias : improvement < 0 ? Math.max(0.1, 1 - goalBias) : 0.5; + return { item: n, weight }; + }); + + return weightedPick(weighted); +} + +/** + * Sample an enemy move biased toward chasing the player. + */ +export function sampleEnemyMove( + grid: CellType[][], + current: Position, + playerPos: Position, + chaseBias: number, + randomChance: number, + stayChance: number, +): Position { + if (Math.random() < stayChance) return { ...current }; + + const neighbors = getWalkableNeighbors(grid, current); + if (neighbors.length === 0) return { ...current }; + + // Occasionally move randomly + if (Math.random() < randomChance) { + return neighbors[Math.floor(Math.random() * neighbors.length)]!; + } + + const currentDist = manhattanDistance(current, playerPos); + const weighted = neighbors.map(n => { + const dist = manhattanDistance(n, playerPos); + const closer = currentDist - dist; + const weight = closer > 0 ? 1 + chaseBias : closer < 0 ? Math.max(0.1, 1 - chaseBias) : 0.5; + return { item: n, weight }; + }); + + return weightedPick(weighted); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..2609fdf --- /dev/null +++ b/src/main.ts @@ -0,0 +1,5 @@ +import './style.css'; +import { GameController } from './ui/GameController'; + +const canvas = document.getElementById('game-canvas') as HTMLCanvasElement; +new GameController(canvas); diff --git a/src/render/EntityRenderer.ts b/src/render/EntityRenderer.ts new file mode 100644 index 0000000..c021447 --- /dev/null +++ b/src/render/EntityRenderer.ts @@ -0,0 +1,80 @@ +import { Entity, Position } from '../types'; +import { CONFIG } from '../config'; + +export function renderPlayer(ctx: CanvasRenderingContext2D, player: Entity): void { + const { CELL_SIZE, COLOR_PLAYER } = CONFIG; + const cx = player.x * CELL_SIZE + CELL_SIZE / 2; + const cy = player.y * CELL_SIZE + CELL_SIZE / 2; + const r = CELL_SIZE * 0.35; + + // Glow + ctx.shadowColor = COLOR_PLAYER; + ctx.shadowBlur = 12; + + // Diamond shape for player (the "observer") + ctx.fillStyle = COLOR_PLAYER; + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.closePath(); + ctx.fill(); + + // Eye symbol in center + ctx.shadowBlur = 0; + ctx.fillStyle = CONFIG.COLOR_BG; + ctx.font = `bold ${CELL_SIZE * 0.3}px "JetBrains Mono", monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('◉', cx, cy); + + ctx.shadowBlur = 0; +} + +export function renderEnemy(ctx: CanvasRenderingContext2D, enemy: Entity): void { + const { CELL_SIZE, COLOR_ENEMY } = CONFIG; + const cx = enemy.x * CELL_SIZE + CELL_SIZE / 2; + const cy = enemy.y * CELL_SIZE + CELL_SIZE / 2; + const r = CELL_SIZE * 0.32; + + // Glow + ctx.shadowColor = COLOR_ENEMY; + ctx.shadowBlur = 8; + + // Triangle shape for enemies + ctx.fillStyle = COLOR_ENEMY; + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy + r * 0.8); + ctx.lineTo(cx - r, cy + r * 0.8); + ctx.closePath(); + ctx.fill(); + + // X symbol + ctx.shadowBlur = 0; + ctx.fillStyle = CONFIG.COLOR_BG; + ctx.font = `bold ${CELL_SIZE * 0.25}px "JetBrains Mono", monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('✕', cx, cy + 2); + + ctx.shadowBlur = 0; +} + +export function renderGoalMarker(ctx: CanvasRenderingContext2D, goal: Position): void { + const { CELL_SIZE, COLOR_GOAL } = CONFIG; + const cx = goal.x * CELL_SIZE + CELL_SIZE / 2; + const cy = goal.y * CELL_SIZE + CELL_SIZE / 2; + + // Pulsing ring + const time = Date.now() / 1000; + const pulse = 0.4 + 0.2 * Math.sin(time * 3); + ctx.globalAlpha = pulse; + ctx.strokeStyle = COLOR_GOAL; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, cy, CELL_SIZE * 0.4, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; +} diff --git a/src/render/FutureOverlay.ts b/src/render/FutureOverlay.ts new file mode 100644 index 0000000..e67df51 --- /dev/null +++ b/src/render/FutureOverlay.ts @@ -0,0 +1,133 @@ +import { GameState, Future } from '../types'; +import { CONFIG } from '../config'; + +export function renderFutureOverlay( + ctx: CanvasRenderingContext2D, + current: GameState, + future: Future, +): void { + const { CELL_SIZE } = CONFIG; + const next = future.state; + + // Determine overlay color based on quality + let overlayColor: string; + if (future.quality > 0.15) { + overlayColor = CONFIG.COLOR_FUTURE_GOOD; + } else if (future.quality < -0.15) { + overlayColor = CONFIG.COLOR_FUTURE_BAD; + } else { + overlayColor = CONFIG.COLOR_FUTURE_NEUTRAL; + } + + // Draw ghost player at future position + const fpx = next.player.x * CELL_SIZE + CELL_SIZE / 2; + const fpy = next.player.y * CELL_SIZE + CELL_SIZE / 2; + const cpx = current.player.x * CELL_SIZE + CELL_SIZE / 2; + const cpy = current.player.y * CELL_SIZE + CELL_SIZE / 2; + + // Arrow from current to future player position + if (next.player.x !== current.player.x || next.player.y !== current.player.y) { + ctx.strokeStyle = CONFIG.COLOR_PLAYER; + ctx.globalAlpha = 0.6; + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.beginPath(); + ctx.moveTo(cpx, cpy); + ctx.lineTo(fpx, fpy); + ctx.stroke(); + ctx.setLineDash([]); + + // Arrowhead + const angle = Math.atan2(fpy - cpy, fpx - cpx); + const headLen = 10; + ctx.beginPath(); + ctx.moveTo(fpx, fpy); + ctx.lineTo(fpx - headLen * Math.cos(angle - 0.4), fpy - headLen * Math.sin(angle - 0.4)); + ctx.lineTo(fpx - headLen * Math.cos(angle + 0.4), fpy - headLen * Math.sin(angle + 0.4)); + ctx.closePath(); + ctx.fillStyle = CONFIG.COLOR_PLAYER; + ctx.fill(); + ctx.globalAlpha = 1; + } + + // Ghost player diamond + ctx.globalAlpha = 0.45; + ctx.fillStyle = CONFIG.COLOR_PLAYER; + const pr = CELL_SIZE * 0.3; + ctx.beginPath(); + ctx.moveTo(fpx, fpy - pr); + ctx.lineTo(fpx + pr, fpy); + ctx.lineTo(fpx, fpy + pr); + ctx.lineTo(fpx - pr, fpy); + ctx.closePath(); + ctx.fill(); + + // Dashed outline + ctx.strokeStyle = CONFIG.COLOR_PLAYER; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(fpx, fpy - pr); + ctx.lineTo(fpx + pr, fpy); + ctx.lineTo(fpx, fpy + pr); + ctx.lineTo(fpx - pr, fpy); + ctx.closePath(); + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; + + // Ghost enemies + for (let i = 0; i < next.enemies.length; i++) { + const fe = next.enemies[i]!; + const ce = current.enemies[i]; + const fex = fe.x * CELL_SIZE + CELL_SIZE / 2; + const fey = fe.y * CELL_SIZE + CELL_SIZE / 2; + + // Arrow from current to future enemy + if (ce && (fe.x !== ce.x || fe.y !== ce.y)) { + const cex = ce.x * CELL_SIZE + CELL_SIZE / 2; + const cey = ce.y * CELL_SIZE + CELL_SIZE / 2; + ctx.strokeStyle = CONFIG.COLOR_ENEMY; + ctx.globalAlpha = 0.4; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(cex, cey); + ctx.lineTo(fex, fey); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Ghost enemy triangle + ctx.globalAlpha = 0.35; + ctx.fillStyle = CONFIG.COLOR_ENEMY; + const er = CELL_SIZE * 0.28; + ctx.beginPath(); + ctx.moveTo(fex, fey - er); + ctx.lineTo(fex + er, fey + er * 0.8); + ctx.lineTo(fex - er, fey + er * 0.8); + ctx.closePath(); + ctx.fill(); + + ctx.strokeStyle = CONFIG.COLOR_ENEMY; + ctx.lineWidth = 2; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(fex, fey - er); + ctx.lineTo(fex + er, fey + er * 0.8); + ctx.lineTo(fex - er, fey + er * 0.8); + ctx.closePath(); + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; + } + + // Highlight the future player cell with quality-based overlay + ctx.fillStyle = overlayColor; + ctx.fillRect( + next.player.x * CELL_SIZE + 2, + next.player.y * CELL_SIZE + 2, + CELL_SIZE - 4, + CELL_SIZE - 4, + ); +} diff --git a/src/render/GridRenderer.ts b/src/render/GridRenderer.ts new file mode 100644 index 0000000..874007f --- /dev/null +++ b/src/render/GridRenderer.ts @@ -0,0 +1,48 @@ +import { CellType } from '../types'; +import { CONFIG } from '../config'; + +export function renderGrid(ctx: CanvasRenderingContext2D, grid: CellType[][]): void { + const { CELL_SIZE, COLOR_FLOOR, COLOR_WALL, COLOR_GOAL, COLOR_GRID } = CONFIG; + + for (let y = 0; y < grid.length; y++) { + const row = grid[y]!; + for (let x = 0; x < row.length; x++) { + const cell = row[x]!; + const px = x * CELL_SIZE; + const py = y * CELL_SIZE; + + // Fill cell + if (cell === 'wall') { + ctx.fillStyle = COLOR_WALL; + } else if (cell === 'goal') { + ctx.fillStyle = COLOR_GOAL; + ctx.globalAlpha = 0.25; + } else { + ctx.fillStyle = COLOR_FLOOR; + } + ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE); + ctx.globalAlpha = 1; + + // Grid lines + ctx.strokeStyle = COLOR_GRID; + ctx.lineWidth = 1; + ctx.strokeRect(px, py, CELL_SIZE, CELL_SIZE); + + // Goal marker + if (cell === 'goal') { + ctx.fillStyle = CONFIG.COLOR_GOAL; + ctx.globalAlpha = 0.6; + const margin = CELL_SIZE * 0.2; + ctx.fillRect(px + margin, py + margin, CELL_SIZE - margin * 2, CELL_SIZE - margin * 2); + ctx.globalAlpha = 1; + + // Draw "EXIT" text + ctx.fillStyle = CONFIG.COLOR_GOAL; + ctx.font = `bold ${CELL_SIZE * 0.25}px "JetBrains Mono", monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('EXIT', px + CELL_SIZE / 2, py + CELL_SIZE / 2); + } + } + } +} diff --git a/src/render/Renderer.ts b/src/render/Renderer.ts new file mode 100644 index 0000000..5c6c939 --- /dev/null +++ b/src/render/Renderer.ts @@ -0,0 +1,74 @@ +import { GameState, Future } from '../types'; +import { CONFIG } from '../config'; +import { renderGrid } from './GridRenderer'; +import { renderPlayer, renderEnemy, renderGoalMarker } from './EntityRenderer'; +import { renderFutureOverlay } from './FutureOverlay'; + +export class Renderer { + private ctx: CanvasRenderingContext2D; + private canvas: HTMLCanvasElement; + private animFrameId: number | null = null; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d')!; + this.resize(); + } + + resize(): void { + this.canvas.width = CONFIG.GRID_COLS * CONFIG.CELL_SIZE; + this.canvas.height = CONFIG.GRID_ROWS * CONFIG.CELL_SIZE; + } + + renderState(state: GameState, activeFuture?: Future): void { + const ctx = this.ctx; + + // Clear + ctx.fillStyle = CONFIG.COLOR_BG; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + // Grid + renderGrid(ctx, state.grid); + + // Goal pulsing marker + renderGoalMarker(ctx, state.goalPos); + + // Entities + for (const enemy of state.enemies) { + renderEnemy(ctx, enemy); + } + renderPlayer(ctx, state.player); + + // Future overlay + if (activeFuture) { + renderFutureOverlay(ctx, state, activeFuture); + } + + // Scanline effect + this.renderScanlines(); + } + + private renderScanlines(): void { + const ctx = this.ctx; + ctx.fillStyle = 'rgba(0, 0, 0, 0.04)'; + for (let y = 0; y < this.canvas.height; y += 3) { + ctx.fillRect(0, y, this.canvas.width, 1); + } + } + + startPulseAnimation(state: GameState, activeFuture?: Future): void { + this.stopAnimation(); + const animate = () => { + this.renderState(state, activeFuture); + this.animFrameId = requestAnimationFrame(animate); + }; + animate(); + } + + stopAnimation(): void { + if (this.animFrameId !== null) { + cancelAnimationFrame(this.animFrameId); + this.animFrameId = null; + } + } +} diff --git a/src/state/GameState.ts b/src/state/GameState.ts new file mode 100644 index 0000000..60cbc59 --- /dev/null +++ b/src/state/GameState.ts @@ -0,0 +1,29 @@ +import { GameState, Entity } from '../types'; +import { createGrid, getGoalPosition } from './Grid'; +import { createPlayer, createEnemies } from './entities'; + +export function createInitialState(): GameState { + return { + grid: createGrid(), + player: createPlayer(), + enemies: createEnemies(), + goalPos: getGoalPosition(), + turn: 0, + status: 'playing', + }; +} + +export function cloneState(state: GameState): GameState { + return { + grid: state.grid.map(row => [...row]), + player: { ...state.player }, + enemies: state.enemies.map(e => ({ ...e })), + goalPos: { ...state.goalPos }, + turn: state.turn, + status: state.status, + }; +} + +export function cloneEntity(entity: Entity): Entity { + return { ...entity }; +} diff --git a/src/state/Grid.ts b/src/state/Grid.ts new file mode 100644 index 0000000..21eecb0 --- /dev/null +++ b/src/state/Grid.ts @@ -0,0 +1,55 @@ +import { CellType, Position } from '../types'; +import { CONFIG } from '../config'; + +const W: CellType = 'wall'; +const F: CellType = 'floor'; +const G: CellType = 'goal'; + +// 12x10 grid — corridors with dead ends for interesting choices +// Row 0 = top, Row 9 = bottom +const LEVEL: CellType[][] = [ + [W, W, W, W, W, W, W, W, W, W, W, W], + [W, F, F, F, W, F, F, F, F, F, G, W], + [W, F, W, F, W, F, W, W, W, F, F, W], + [W, F, W, F, F, F, F, F, W, F, W, W], + [W, F, W, W, W, F, W, F, F, F, F, W], + [W, F, F, F, F, F, W, F, W, W, F, W], + [W, W, W, F, W, W, W, F, F, F, F, W], + [W, F, F, F, F, F, F, F, W, F, W, W], + [W, F, W, F, W, F, W, F, F, F, F, W], + [W, W, W, W, W, W, W, W, W, W, W, W], +]; + +export function createGrid(): CellType[][] { + return LEVEL.map(row => [...row]); +} + +export function getGoalPosition(): Position { + for (let y = 0; y < LEVEL.length; y++) { + const row = LEVEL[y]!; + for (let x = 0; x < row.length; x++) { + if (row[x] === 'goal') return { x, y }; + } + } + return { x: 10, y: 1 }; // fallback +} + +export function isWalkable(grid: CellType[][], x: number, y: number): boolean { + if (x < 0 || y < 0 || y >= CONFIG.GRID_ROWS || x >= CONFIG.GRID_COLS) return false; + const cell = grid[y]?.[x]; + return cell === 'floor' || cell === 'goal'; +} + +export function getWalkableNeighbors(grid: CellType[][], pos: Position): Position[] { + const dirs = [ + { x: pos.x, y: pos.y - 1 }, + { x: pos.x, y: pos.y + 1 }, + { x: pos.x - 1, y: pos.y }, + { x: pos.x + 1, y: pos.y }, + ]; + return dirs.filter(p => isWalkable(grid, p.x, p.y)); +} + +export function manhattanDistance(a: Position, b: Position): number { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} diff --git a/src/state/entities.ts b/src/state/entities.ts new file mode 100644 index 0000000..80fbe6e --- /dev/null +++ b/src/state/entities.ts @@ -0,0 +1,12 @@ +import { Entity } from '../types'; + +export function createPlayer(): Entity { + return { type: 'player', x: 1, y: 8, id: 'player' }; +} + +export function createEnemies(): Entity[] { + return [ + { type: 'enemy', x: 5, y: 5, id: 'enemy-1' }, + { type: 'enemy', x: 9, y: 2, id: 'enemy-2' }, + ]; +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..bfdfd75 --- /dev/null +++ b/src/style.css @@ -0,0 +1,108 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: #0a0a0f; + color: #b0ffb0; + font-family: 'JetBrains Mono', monospace; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + overflow: hidden; +} + +#game-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +#game-canvas { + border: 2px solid #1a3a2a; + border-radius: 4px; + image-rendering: pixelated; +} + +#hud { + display: flex; + gap: 24px; + font-size: 14px; + color: #b0ffb0; +} + +#future-description { + font-size: 15px; + color: #ffeb3b; + min-height: 22px; + text-align: center; +} + +#controls { + display: flex; + gap: 16px; +} + +#controls.hidden { + visibility: hidden; +} + +.game-btn { + font-family: 'JetBrains Mono', monospace; + font-size: 15px; + font-weight: 700; + padding: 10px 28px; + border: 2px solid #1a3a2a; + border-radius: 4px; + background: #12121f; + color: #b0ffb0; + cursor: pointer; + transition: all 0.15s ease; +} + +.game-btn:hover { + background: #1a3a2a; + border-color: #00ffcc; + color: #00ffcc; +} + +.game-btn:active { + transform: scale(0.96); +} + +#btn-accept { + border-color: #39ff14; + color: #39ff14; +} + +#btn-accept:hover { + background: rgba(57, 255, 20, 0.15); +} + +#btn-pass { + border-color: #ff6b35; + color: #ff6b35; +} + +#btn-pass:hover { + background: rgba(255, 107, 53, 0.15); +} + +#btn-pass.forced { + border-color: #ff1744; + color: #ff1744; + animation: pulse-danger 0.8s ease-in-out infinite; +} + +@keyframes pulse-danger { + 0%, 100% { border-color: #ff1744; } + 50% { border-color: #ff6b35; } +} + +#screen-overlay.hidden { + display: none; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..1cd141b --- /dev/null +++ b/src/types.ts @@ -0,0 +1,32 @@ +export type CellType = 'floor' | 'wall' | 'goal'; + +export interface Position { + x: number; + y: number; +} + +export interface Entity { + type: 'player' | 'enemy'; + x: number; + y: number; + id: string; +} + +export interface GameState { + grid: CellType[][]; + player: Entity; + enemies: Entity[]; + goalPos: Position; + turn: number; + status: 'playing' | 'won' | 'lost'; +} + +export interface Future { + state: GameState; + description: string; + quality: number; // -1 to 1 +} + +export type GamePhase = 'title' | 'playing' | 'won' | 'lost'; + +export type Direction = 'up' | 'down' | 'left' | 'right' | 'stay'; diff --git a/src/ui/GameController.ts b/src/ui/GameController.ts new file mode 100644 index 0000000..319823f --- /dev/null +++ b/src/ui/GameController.ts @@ -0,0 +1,173 @@ +import { GameState, GamePhase, Future } from '../types'; +import { createInitialState } from '../state/GameState'; +import { Renderer } from '../render/Renderer'; +import { TurnManager } from './TurnManager'; +import { HUD } from './HUD'; +import { renderTitleScreen, renderWinScreen, renderLoseScreen } from './screens'; + +export class GameController { + private phase: GamePhase = 'title'; + private state: GameState; + private renderer: Renderer; + private turnManager: TurnManager; + private hud: HUD; + private canvas: HTMLCanvasElement; + private controls: HTMLElement; + private btnAccept: HTMLElement; + private btnPass: HTMLElement; + private animFrameId: number | null = null; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.renderer = new Renderer(canvas); + this.turnManager = new TurnManager(); + this.hud = new HUD(); + this.state = createInitialState(); + this.controls = document.getElementById('controls')!; + this.btnAccept = document.getElementById('btn-accept')!; + this.btnPass = document.getElementById('btn-pass')!; + + this.turnManager.setCallbacks( + (future, index, total) => this.onFuturePresented(future, index, total), + (newState) => this.onTurnResolved(newState), + ); + + this.setupInput(); + this.showTitle(); + } + + private setupInput(): void { + this.btnAccept.addEventListener('click', () => this.handleAccept()); + this.btnPass.addEventListener('click', () => this.handlePass()); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (this.phase === 'title' || this.phase === 'won' || this.phase === 'lost') { + this.startGame(); + } else if (this.phase === 'playing') { + this.handleAccept(); + } + } else if (e.key === ' ') { + e.preventDefault(); + if (this.phase === 'playing') { + this.handlePass(); + } + } + }); + + // Click on canvas for title/end screens + this.canvas.addEventListener('click', () => { + if (this.phase === 'title' || this.phase === 'won' || this.phase === 'lost') { + this.startGame(); + } + }); + } + + private showTitle(): void { + this.phase = 'title'; + this.controls.classList.add('hidden'); + this.hud.hide(); + this.startScreenAnimation(() => { + const ctx = this.canvas.getContext('2d')!; + renderTitleScreen(ctx, this.canvas.width, this.canvas.height); + }); + } + + private startGame(): void { + this.stopAnimation(); + this.phase = 'playing'; + this.state = createInitialState(); + this.controls.classList.remove('hidden'); + this.hud.show(); + this.hud.updateTurn(0); + this.renderer.renderState(this.state); + this.startTurn(); + } + + private startTurn(): void { + this.turnManager.startTurn(this.state); + } + + private onFuturePresented(future: Future, index: number, total: number): void { + this.hud.updateFutures(index, total); + this.hud.updateDescription(future.description); + + // Update pass button text + if (this.turnManager.isLastFuture()) { + this.btnPass.textContent = 'Forced [Space]'; + this.btnPass.classList.add('forced'); + } else { + this.btnPass.textContent = 'Pass [Space]'; + this.btnPass.classList.remove('forced'); + } + + // Animate the current state with future overlay + this.renderer.startPulseAnimation(this.state, future); + } + + private onTurnResolved(newState: GameState): void { + this.renderer.stopAnimation(); + this.state = newState; + this.hud.updateTurn(this.state.turn); + this.hud.clearDescription(); + this.renderer.renderState(this.state); + + if (this.state.status === 'won') { + this.showWin(); + } else if (this.state.status === 'lost') { + this.showLose(); + } else { + // Small delay before next turn for readability + setTimeout(() => this.startTurn(), 300); + } + } + + private handleAccept(): void { + if (this.phase !== 'playing') return; + this.turnManager.accept(); + } + + private handlePass(): void { + if (this.phase !== 'playing') return; + this.turnManager.pass(); + } + + private showWin(): void { + this.phase = 'won'; + this.controls.classList.add('hidden'); + this.startScreenAnimation(() => { + // First render the final game state + this.renderer.renderState(this.state); + const ctx = this.canvas.getContext('2d')!; + renderWinScreen(ctx, this.canvas.width, this.canvas.height, this.state.turn); + }); + } + + private showLose(): void { + this.phase = 'lost'; + this.controls.classList.add('hidden'); + this.startScreenAnimation(() => { + this.renderer.renderState(this.state); + const ctx = this.canvas.getContext('2d')!; + renderLoseScreen(ctx, this.canvas.width, this.canvas.height, this.state.turn); + }); + } + + private startScreenAnimation(renderFn: () => void): void { + this.stopAnimation(); + const animate = () => { + renderFn(); + this.animFrameId = requestAnimationFrame(animate); + }; + animate(); + } + + private stopAnimation(): void { + if (this.animFrameId !== null) { + cancelAnimationFrame(this.animFrameId); + this.animFrameId = null; + } + this.renderer.stopAnimation(); + } +} diff --git a/src/ui/HUD.ts b/src/ui/HUD.ts new file mode 100644 index 0000000..418cc07 --- /dev/null +++ b/src/ui/HUD.ts @@ -0,0 +1,38 @@ +export class HUD { + private turnEl: HTMLElement; + private futuresEl: HTMLElement; + private descEl: HTMLElement; + + constructor() { + this.turnEl = document.getElementById('turn-counter')!; + this.futuresEl = document.getElementById('futures-counter')!; + this.descEl = document.getElementById('future-description')!; + } + + updateTurn(turn: number): void { + this.turnEl.textContent = `Turn: ${turn}`; + } + + updateFutures(current: number, total: number): void { + this.futuresEl.textContent = `Future: ${current + 1}/${total}`; + } + + updateDescription(text: string): void { + this.descEl.textContent = text; + } + + clearDescription(): void { + this.descEl.textContent = ''; + } + + show(): void { + this.turnEl.style.display = ''; + this.futuresEl.style.display = ''; + } + + hide(): void { + this.turnEl.style.display = 'none'; + this.futuresEl.style.display = 'none'; + this.descEl.textContent = ''; + } +} diff --git a/src/ui/TurnManager.ts b/src/ui/TurnManager.ts new file mode 100644 index 0000000..59b90f1 --- /dev/null +++ b/src/ui/TurnManager.ts @@ -0,0 +1,56 @@ +import { GameState, Future } from '../types'; +import { generateFutures } from '../engine/FuturesEngine'; +import { CONFIG } from '../config'; + +export class TurnManager { + private futures: Future[] = []; + private currentIndex = 0; + private onFuturePresented: ((future: Future, index: number, total: number) => void) | null = null; + private onTurnResolved: ((state: GameState) => void) | null = null; + + setCallbacks( + onFuturePresented: (future: Future, index: number, total: number) => void, + onTurnResolved: (state: GameState) => void, + ): void { + this.onFuturePresented = onFuturePresented; + this.onTurnResolved = onTurnResolved; + } + + startTurn(state: GameState): void { + this.futures = generateFutures(state, CONFIG.NUM_FUTURES); + this.currentIndex = 0; + this.presentCurrent(); + } + + private presentCurrent(): void { + const future = this.futures[this.currentIndex]; + if (future && this.onFuturePresented) { + this.onFuturePresented(future, this.currentIndex, this.futures.length); + } + } + + accept(): void { + const future = this.futures[this.currentIndex]; + if (future && this.onTurnResolved) { + this.onTurnResolved(future.state); + } + } + + pass(): void { + if (this.currentIndex < this.futures.length - 1) { + this.currentIndex++; + this.presentCurrent(); + } else { + // Last future — auto-accept + this.accept(); + } + } + + getCurrentFuture(): Future | null { + return this.futures[this.currentIndex] ?? null; + } + + isLastFuture(): boolean { + return this.currentIndex === this.futures.length - 1; + } +} diff --git a/src/ui/screens.ts b/src/ui/screens.ts new file mode 100644 index 0000000..9437bb3 --- /dev/null +++ b/src/ui/screens.ts @@ -0,0 +1,104 @@ +import { CONFIG } from '../config'; + +export function renderTitleScreen(ctx: CanvasRenderingContext2D, width: number, height: number): void { + ctx.fillStyle = CONFIG.COLOR_BG; + ctx.fillRect(0, 0, width, height); + + // Scanlines + ctx.fillStyle = 'rgba(0, 0, 0, 0.04)'; + for (let y = 0; y < height; y += 3) { + ctx.fillRect(0, y, width, 1); + } + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Title + ctx.shadowColor = CONFIG.COLOR_PLAYER; + ctx.shadowBlur = 20; + ctx.fillStyle = CONFIG.COLOR_PLAYER; + ctx.font = 'bold 36px "JetBrains Mono", monospace'; + ctx.fillText('QUANTUM RUNNER', width / 2, height / 3); + ctx.shadowBlur = 0; + + // Subtitle + ctx.fillStyle = CONFIG.COLOR_UI_TEXT; + ctx.font = '16px "JetBrains Mono", monospace'; + ctx.fillText('Collapse the future. Reach the exit.', width / 2, height / 3 + 50); + + // Instructions + ctx.fillStyle = CONFIG.COLOR_ACCENT; + ctx.font = '14px "JetBrains Mono", monospace'; + const instructions = [ + 'You are the Observer ◉ — you don\'t move directly.', + 'Each turn, possible futures are revealed one by one.', + 'Accept a future or pass to see the next.', + 'If you pass all futures, the last one is forced.', + 'Reach the EXIT. Avoid the enemies ✕.', + ]; + instructions.forEach((line, i) => { + ctx.fillText(line, width / 2, height / 2 + 20 + i * 26); + }); + + // Start prompt + const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 500); + ctx.globalAlpha = pulse; + ctx.fillStyle = CONFIG.COLOR_GOAL; + ctx.font = 'bold 20px "JetBrains Mono", monospace'; + ctx.fillText('[ Press ENTER or Click to Start ]', width / 2, height - 60); + ctx.globalAlpha = 1; +} + +export function renderWinScreen(ctx: CanvasRenderingContext2D, width: number, height: number, turns: number): void { + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.fillRect(0, 0, width, height); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + ctx.shadowColor = CONFIG.COLOR_GOAL; + ctx.shadowBlur = 20; + ctx.fillStyle = CONFIG.COLOR_GOAL; + ctx.font = 'bold 40px "JetBrains Mono", monospace'; + ctx.fillText('COLLAPSED!', width / 2, height / 3); + ctx.shadowBlur = 0; + + ctx.fillStyle = CONFIG.COLOR_UI_TEXT; + ctx.font = '18px "JetBrains Mono", monospace'; + ctx.fillText('The observer reached the exit.', width / 2, height / 3 + 50); + ctx.fillText(`Turns taken: ${turns}`, width / 2, height / 3 + 80); + + const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 500); + ctx.globalAlpha = pulse; + ctx.fillStyle = CONFIG.COLOR_ACCENT; + ctx.font = 'bold 18px "JetBrains Mono", monospace'; + ctx.fillText('[ Press ENTER or Click to Restart ]', width / 2, height - 60); + ctx.globalAlpha = 1; +} + +export function renderLoseScreen(ctx: CanvasRenderingContext2D, width: number, height: number, turns: number): void { + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.fillRect(0, 0, width, height); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + ctx.shadowColor = CONFIG.COLOR_DANGER; + ctx.shadowBlur = 20; + ctx.fillStyle = CONFIG.COLOR_DANGER; + ctx.font = 'bold 40px "JetBrains Mono", monospace'; + ctx.fillText('DECOHERENCE', width / 2, height / 3); + ctx.shadowBlur = 0; + + ctx.fillStyle = CONFIG.COLOR_UI_TEXT; + ctx.font = '18px "JetBrains Mono", monospace'; + ctx.fillText('The observer was caught.', width / 2, height / 3 + 50); + ctx.fillText(`Survived ${turns} turns.`, width / 2, height / 3 + 80); + + const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 500); + ctx.globalAlpha = pulse; + ctx.fillStyle = CONFIG.COLOR_ACCENT; + ctx.font = 'bold 18px "JetBrains Mono", monospace'; + ctx.fillText('[ Press ENTER or Click to Restart ]', width / 2, height - 60); + ctx.globalAlpha = 1; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c63d14e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..e59758d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + target: 'ES2022', + }, +}); From 27de4904130a979b8ec147d3647e3918ce306fc0 Mon Sep 17 00:00:00 2001 From: Venice Agent Date: Sat, 21 Mar 2026 03:12:52 +0000 Subject: [PATCH 3/3] chore: finalize --- PLAN.md | 218 ----------------------------------------------------- tasks.json | 26 ------- 2 files changed, 244 deletions(-) delete mode 100644 PLAN.md delete mode 100644 tasks.json diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index ac9ea34..0000000 --- a/PLAN.md +++ /dev/null @@ -1,218 +0,0 @@ -# Quantum Runner — Implementation Plan - -## Overview - -Quantum Runner is a turn-based grid game where the player doesn't directly control a character. Instead, each turn generates N stochastic possible futures and the player selects which future to "collapse into" — accept the current proposal or pass to see the next one, with no going back. - -## Tech Stack - -| Layer | Choice | Rationale | -|-------|--------|-----------| -| Language | TypeScript (strict) | Type safety for game state, entity models | -| Build | Vite 6 (`vanilla-ts` template) | Fast HMR, zero-config TS, optimized production build | -| Rendering | HTML5 Canvas 2D | Direct pixel control for grid rendering; no DOM overhead | -| UI Controls | Plain HTML/CSS | Accept/Pass buttons, HUD — minimal DOM, no framework needed | -| Serving (prod) | Nginx alpine | Lightweight static file server for Docker deployment | -| Font | JetBrains Mono (via CDN) | Monospace with character, fits the terminal/quantum aesthetic | - -**Why no React/framework?** The game is 95% Canvas rendering. The only DOM elements are a few buttons and text displays. A framework adds bundle size and complexity with zero benefit here. - -**Why no Phaser?** Phaser is designed for real-time games with physics, sprite sheets, and animation systems. This is a turn-based game with simple geometric rendering. Raw Canvas API keeps the code simple and the bundle tiny. - -Sources: -- [Vite docs](https://vite.dev/guide/) — project scaffolding -- [Canvas game patterns](https://blog.harveydelaney.com/creating-a-game-using-html5-canvas-typescript-and-webpack/) — architecture inspiration -- [Grid game in TypeScript](https://medium.com/swlh/building-a-game-with-typescript-iii-drawing-grid-4-5-398af1dd638d) — grid drawing patterns - -## Design Direction: "Terminal Phosphor" - -Dark background (#0a0a0f), green/cyan grid lines evoking retro CRT monitors. The protagonist is a bright cyan glyph, enemies are amber/red, the goal is a pulsing green. Future overlays use semi-transparent versions of entity colors with dashed outlines. Monospace typography throughout (JetBrains Mono). Subtle scanline effect on the canvas for atmosphere. - -**Palette:** -- Background: `#0a0a0f` -- Grid lines: `#1a3a2a` (subtle dark green) -- Protagonist: `#00ffcc` (cyan) -- Enemy: `#ff6b35` (amber-orange) -- Goal: `#39ff14` (neon green) -- Danger/lose: `#ff1744` (red) -- Future overlay: 40% opacity versions with dashed borders -- UI text: `#b0ffb0` (soft green) -- Accent: `#ffeb3b` (yellow for highlights) - -## Architecture - -### File Structure - -``` -quantum-runner/ -├── src/ -│ ├── main.ts # Entry point — initialize canvas, start game -│ ├── types.ts # Shared type definitions -│ ├── config.ts # Game configuration constants -│ ├── state/ -│ │ ├── GameState.ts # Core game state model -│ │ ├── Grid.ts # Grid representation + pathfinding helpers -│ │ └── entities.ts # Entity types (Player, Enemy, Goal, Wall) -│ ├── engine/ -│ │ ├── FuturesEngine.ts # Stochastic futures generation -│ │ ├── EnemyAI.ts # Enemy movement AI (weighted random) -│ │ └── movement.ts # Movement distributions + sampling -│ ├── render/ -│ │ ├── Renderer.ts # Main canvas renderer -│ │ ├── GridRenderer.ts # Grid lines, walls, floor tiles -│ │ ├── EntityRenderer.ts# Player, enemies, goal rendering -│ │ └── FutureOverlay.ts # Semi-transparent future state overlay -│ ├── ui/ -│ │ ├── GameController.ts# State machine: title → play → win/lose -│ │ ├── TurnManager.ts # Future streaming + accept/pass logic -│ │ ├── HUD.ts # Score, turn count, futures remaining -│ │ └── screens.ts # Title screen, win/lose screen rendering -│ └── style.css # Minimal CSS for HTML elements -├── index.html # Canvas + UI container -├── Dockerfile # Multi-stage: build with Node, serve with Nginx -├── tsconfig.json # Strict TypeScript config -├── package.json -└── vite.config.ts -``` - -### Core Data Model - -```typescript -// Grid cell types -type CellType = 'floor' | 'wall' | 'goal'; - -// Entity on the grid -interface Entity { - type: 'player' | 'enemy'; - x: number; - y: number; - id: string; -} - -// Complete game state (immutable per turn) -interface GameState { - grid: CellType[][]; // 2D grid of cells - player: Entity; - enemies: Entity[]; - goalPos: { x: number; y: number }; - turn: number; - status: 'playing' | 'won' | 'lost'; -} - -// A proposed future state -interface Future { - state: GameState; - description: string; // Brief text describing what happens - quality: number; // -1 to 1, how good this future is (for variance) -} -``` - -### Futures Engine Design - -Each turn, the engine generates N futures (configurable, default 3) by: - -1. **Player movement**: Sample from adjacent walkable cells. Each direction has a base probability (biased slightly toward the goal via a softmax over Manhattan distance). Sometimes the player stays in place (inertia). - -2. **Enemy movement**: Each enemy uses weighted random movement biased toward the player (simple chase AI). Occasionally an enemy moves randomly or stays still, creating variance. - -3. **Quality variance**: Futures are sorted/shuffled to ensure meaningful spread. At least one future should be "good" (player moves toward goal, enemies move away) and at least one "bad" (player moves into danger). The engine explicitly ensures this by rejection-sampling or adjusting probabilities if the initial batch is too uniform. - -4. **Collision resolution**: If the player and enemy occupy the same cell in a future → player is caught → status becomes 'lost'. If the player reaches the goal cell → status becomes 'won'. - -### Turn Flow - -``` -Current State - │ - ▼ -Generate N Futures - │ - ▼ -Present Future 1 ──── [Accept] ──→ Apply State → Check Win/Lose → Next Turn - │ - [Pass] - │ - ▼ -Present Future 2 ──── [Accept] ──→ Apply State → Check Win/Lose → Next Turn - │ - [Pass] - │ - ▼ - ... - │ - ▼ -Present Future N ──── [Auto-Accept] ──→ Apply State → Check Win/Lose → Next Turn -``` - -### Game Controller State Machine - -``` -TITLE ──[Start]──→ PLAYING ──[Win]──→ WIN_SCREEN ──[Restart]──→ TITLE - │ - [Lose] - │ - ▼ - LOSE_SCREEN ──[Restart]──→ TITLE -``` - -### Level Design - -A single hardcoded level for the prototype: -- 12×10 grid -- Walls forming corridors and a few dead ends -- Player starts bottom-left area -- Goal in top-right area -- 2 enemies: one patrolling near the middle, one near the goal -- Multiple viable paths to create meaningful choices - -### Rendering Strategy - -1. **Base layer**: Grid floor tiles + walls (drawn once, cached to offscreen canvas) -2. **Entity layer**: Player, enemies, goal (redrawn each state change) -3. **Future overlay**: When presenting a future, draw translucent versions of entities at their proposed positions with dashed connecting lines from current positions. Use color-coded arrows (green = toward goal, red = toward enemy). -4. **UI overlay**: Canvas-rendered HUD (turn counter, futures remaining indicator) - -### Key Design Decisions - -1. **Immutable game states**: Each `GameState` is a new object. Futures reference new state objects. This makes undo/comparison trivial and prevents mutation bugs. - -2. **No animation between turns**: The turn-based nature means we snap between states. The future overlay provides the visual transition. This dramatically simplifies the renderer. - -3. **Configurable N**: The number of futures per turn is a constant in `config.ts`, easily adjustable from 2–5. - -4. **Keyboard + click support**: Accept = Enter or click button. Pass = Space or click button. Arrow keys could optionally highlight entities. - -### Dockerfile - -Multi-stage build: -```dockerfile -FROM node:22-alpine AS build -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY . . -RUN npm run build - -FROM nginx:alpine -COPY --from=build /app/dist /usr/share/nginx/html -EXPOSE 80 -``` - -## Scope Assessment - -This is a **single-agent** task. The modules are tightly coupled: -- The renderer needs the state model and entity types -- The futures engine needs the state model and movement logic -- The game controller orchestrates the renderer, futures engine, and UI -- Integration testing requires the full pipeline - -Splitting into parallel agents would create more integration overhead than it saves. A single focused agent can build this incrementally: types → state → engine → renderer → controller → screens → Dockerfile. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Futures too uniform / no meaningful choice | Engine explicitly ensures quality spread via rejection sampling | -| Game too easy/hard | Configurable enemy AI aggressiveness + number of futures | -| Canvas rendering performance | Turn-based = no frame loop; redraw only on state change | -| Level too simple | Corridors + dead ends create tension even in a small grid | diff --git a/tasks.json b/tasks.json deleted file mode 100644 index 6d57b4e..0000000 --- a/tasks.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "mode": "single", - "quality": "full", - "claudeMd": "# Quantum Runner — Implementation Guide\n\nYou are building a complete browser-based turn-based grid game called Quantum Runner. The player doesn't directly control a character — instead, each turn generates N stochastic possible futures and the player selects which one to \"collapse into\" by accepting or passing.\n\n## Tech Stack\n\n- **Vite 6** with `vanilla-ts` template — scaffold with `npm create vite@latest . -- --template vanilla-ts` then clean up the default files\n- **TypeScript** (strict mode) — all game logic in `src/`\n- **HTML5 Canvas 2D API** — all game rendering on a single `` element\n- **Plain HTML/CSS** — for Accept/Pass buttons and HUD overlays\n- **No frameworks** (no React, no Phaser, no game engines)\n- **Nginx alpine** — for Docker production serving\n- **JetBrains Mono** font via Google Fonts CDN\n\n## Design Direction: Terminal Phosphor\n\nDark retro-CRT aesthetic. NOT generic web-app styling.\n\n- Background: `#0a0a0f` (near-black)\n- Grid lines: `#1a3a2a` (subtle dark green)\n- Protagonist: `#00ffcc` (bright cyan) — rendered as a diamond/chevron shape\n- Enemy: `#ff6b35` (amber-orange) — rendered as a jagged/spiky shape\n- Goal: `#39ff14` (neon green) — rendered as a pulsing circle/beacon\n- Danger/dead: `#ff1744` (red)\n- Future overlay: 40% opacity entity colors + dashed borders + directional arrows\n- UI text: `#b0ffb0` (soft phosphor green)\n- Accent: `#ffeb3b` (yellow for highlights/selected items)\n- Font: JetBrains Mono everywhere (load from Google Fonts)\n- Optional: subtle CSS scanline overlay on the canvas container for atmosphere\n\n## Architecture\n\nBuild in this order:\n\n### 1. Project Setup\n- Scaffold Vite vanilla-ts project in the repo root\n- Configure `tsconfig.json` with strict mode\n- Clean out default Vite template files (counter.ts, etc.)\n- Set up `index.html` with canvas element + UI container\n- Set up `src/style.css` with the dark theme, font import, layout\n\n### 2. Core Types & Config (`src/types.ts`, `src/config.ts`)\n```typescript\n// types.ts\nexport type CellType = 'floor' | 'wall' | 'goal';\n\nexport interface Position { x: number; y: number; }\n\nexport interface Entity {\n type: 'player' | 'enemy';\n id: string;\n pos: Position;\n}\n\nexport interface GameState {\n grid: CellType[][];\n player: Entity;\n enemies: Entity[];\n goalPos: Position;\n turn: number;\n status: 'playing' | 'won' | 'lost';\n}\n\nexport interface Future {\n state: GameState;\n description: string;\n quality: number; // -1 to 1\n}\n\nexport type GamePhase = 'title' | 'playing' | 'choosing' | 'won' | 'lost';\n```\n\n```typescript\n// config.ts\nexport const CONFIG = {\n GRID_COLS: 12,\n GRID_ROWS: 10,\n CELL_SIZE: 56,\n NUM_FUTURES: 3, // default futures per turn (2-5)\n MIN_FUTURES: 2,\n MAX_FUTURES: 5,\n ENEMY_CHASE_BIAS: 0.6, // probability enemy moves toward player\n PLAYER_GOAL_BIAS: 0.3, // slight bias toward goal in movement sampling\n} as const;\n```\n\n### 3. Grid & Level (`src/state/Grid.ts`, `src/state/level.ts`)\n- `Grid` class: wraps the 2D CellType array, provides `isWalkable(x,y)`, `getNeighbors(x,y)`, `manhattanDistance(a,b)`\n- `level.ts`: hardcoded level definition — a 12×10 grid with walls forming corridors. Player starts near (1,8), goal at (10,1). Two enemies at ~(5,4) and (8,2). Multiple viable paths. Design walls to create interesting corridor choices.\n\nLevel layout concept (W=wall, .=floor, G=goal, P=player start, E=enemy start):\n```\n. . W . . . . . . . G .\n. . W . . . . . E . . .\n. . W . . W W . . . . .\n. . . . . W . . . W W .\n. . . . E . . . . . . .\n. W W . . . W W . . . .\n. . . . . . . . . W . .\n. . W W W . . . . W . .\n. P . . . . W . . . . .\n. . . . . . W . . . . .\n```\nAdjust to ensure playability — multiple paths from P to G, enemies positioned to create tension.\n\n### 4. Futures Engine (`src/engine/FuturesEngine.ts`, `src/engine/movement.ts`)\n\n**movement.ts**: \n- `samplePlayerMove(state: GameState): Position` — returns a random adjacent walkable cell, biased toward the goal via softmax over negative Manhattan distance. Include \"stay in place\" as an option with low weight.\n- `sampleEnemyMove(enemy: Entity, state: GameState): Position` — returns a random adjacent walkable cell, biased toward the player (ENEMY_CHASE_BIAS chance of moving in the best direction, otherwise random). Include \"stay\" option.\n\n**FuturesEngine.ts**:\n- `generateFutures(state: GameState, n: number): Future[]` — generates n futures by:\n 1. For each future: sample a player move, sample each enemy move, create new GameState\n 2. Check collisions: player on enemy cell → lost, player on goal → won\n 3. Compute quality score: based on (new player-goal distance vs old) and (new enemy-player distances vs old)\n 4. Generate description string: \"You drift north. Enemy Alpha closes in.\" etc.\n 5. **Ensure variance**: if all futures have similar quality (spread < threshold), regenerate some with adjusted probabilities. At minimum, ensure at least one future moves player closer to goal and one has an enemy moving closer.\n\n### 5. Renderer (`src/render/`)\n\n**Renderer.ts**: Main class that holds the canvas context and coordinates sub-renderers.\n- `render(state: GameState, futureOverlay?: Future)` — clear canvas, draw grid, draw entities, optionally draw future overlay\n\n**GridRenderer.ts**: \n- Draw cell backgrounds (dark for floor, lighter block pattern for walls)\n- Draw subtle grid lines in `#1a3a2a`\n- Highlight the goal cell with a pulsing glow effect\n\n**EntityRenderer.ts**:\n- Player: cyan diamond shape with slight glow\n- Enemy: amber-orange spiky/angular shape\n- Goal: green pulsing beacon/ring\n- Use geometric shapes, not sprites — keep it clean and readable\n\n**FutureOverlay.ts**:\n- When a future is being previewed: draw translucent (40% opacity) versions of all entities at their FUTURE positions\n- Draw dashed lines from current position to future position for player and each enemy\n- Color-code the arrows: green if entity moves toward goal (player) or away from player (enemy = good), red/amber if dangerous\n- Draw a subtle highlight on the cells that change\n\n### 6. UI & Game Controller (`src/ui/`)\n\n**GameController.ts**: The central state machine.\n- Manages `GamePhase`: title → playing → choosing → won/lost\n- On 'playing': calls FuturesEngine to generate futures, transitions to 'choosing'\n- On 'choosing': TurnManager handles the streaming UI\n- On accept: applies the chosen future, checks win/lose, loops back to 'playing' or transitions to 'won'/'lost'\n\n**TurnManager.ts**: Handles the future-selection UX.\n- Holds the array of futures and current index\n- `showNextFuture()`: advances to next future, triggers overlay render\n- `acceptCurrent()`: returns the current future, resets\n- `passCurrent()`: if more futures, advance; if last future, auto-accept\n- Exposes `currentFuture`, `futuresRemaining`, `isLastFuture` for UI\n\n**HUD.ts**: Canvas-rendered heads-up display.\n- Turn counter (top-left)\n- \"Future X of N\" indicator (top-right)\n- Current future description text (bottom)\n- Accept/Pass button labels (drawn or HTML overlaid)\n\n**screens.ts**: Full-canvas screens for title, win, lose.\n- Title: \"QUANTUM RUNNER\" in large JetBrains Mono, \"Press Enter to Start\", brief rules summary\n- Win: \"REALITY COLLAPSED — YOU WIN\" + turn count + restart prompt\n- Lose: \"TIMELINE TERMINATED\" + what happened + restart prompt\n\n### 7. Main Entry Point (`src/main.ts`)\n- Get canvas element, set size\n- Initialize GameController, Renderer\n- Wire up keyboard events (Enter=accept/start, Space=pass, R=restart)\n- Wire up click events for Accept/Pass buttons\n- Start on title screen\n\n### 8. HTML & CSS (`index.html`, `src/style.css`)\n\n**index.html**:\n```html\n
\n \n
\n \n \n
\n
\n
\n```\n\n**style.css**: Dark background, centered game container, styled buttons matching the phosphor theme. Buttons should have a terminal/retro look — bordered, monospace text, glow on hover.\n\n### 9. Dockerfile\n\nMulti-stage build:\n```dockerfile\nFROM node:22-alpine AS build\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\nFROM nginx:alpine\nCOPY --from=build /app/dist /usr/share/nginx/html\nEXPOSE 80\nCMD [\"nginx\", \"-g\", \"daemon off;\"]\n```\n\n## Key Implementation Notes\n\n1. **Immutable states**: Always create new GameState objects for futures. Use spread/structuredClone. Never mutate the current state.\n\n2. **Deep clone for futures**: `JSON.parse(JSON.stringify(state))` or `structuredClone()` for creating future state copies. Prefer `structuredClone`.\n\n3. **Responsive canvas**: Set canvas width/height based on grid dimensions × cell size. Don't try to be responsive — fixed size is fine for a prototype. Center it in the viewport.\n\n4. **Event-driven rendering**: No `requestAnimationFrame` loop. Render on state change: when a new future is shown, when a future is accepted, when screens change. Only use rAF for the goal pulsing animation (a single ongoing animation).\n\n5. **Button state management**: Disable Accept/Pass buttons when not in 'choosing' phase. Hide them on title/win/lose screens. Show keyboard hints.\n\n6. **Quality spread algorithm**: When generating futures, compute the quality of each. If max-min quality spread < 0.3, discard the worst future and regenerate with biased probabilities. Cap retries at 3 to avoid infinite loops.\n\n7. **Description generation**: Keep it terse and atmospheric. \"You phase north. Sentinel Alpha advances.\" \"Drift west into the corridor. All clear.\" \"Hold position. Sentinel Beta flanks east.\"\n\n8. **Test the game is winnable**: With 3 futures per turn and the level layout above, the player should be able to win in ~15-25 turns by choosing good futures. If futures are too random, increase PLAYER_GOAL_BIAS.\n\n## Gotchas\n\n- Vite's `vanilla-ts` template includes a `counter.ts` and default `main.ts` + `style.css` — delete/replace these entirely\n- Canvas `font` must be set AFTER the font is loaded. Use `document.fonts.ready` before first render, or set a fallback.\n- Canvas text rendering: use `ctx.textAlign` and `ctx.textBaseline` for centering\n- `structuredClone` doesn't work with functions — game state should be plain data only\n- Nginx in Docker needs `daemon off;` in CMD or it exits immediately\n- The Vite build output goes to `dist/` by default — that's what Nginx should serve\n- Make sure `package.json` has `\"type\": \"module\"` (Vite default)\n\n## Acceptance Criteria Mapping\n\n1. Grid-based level with protagonist, goal, enemy → Level layout + EntityRenderer\n2. N configurable futures (2-5) → CONFIG.NUM_FUTURES + FuturesEngine\n3. Streaming future presentation (Accept/Pass) → TurnManager\n4. Passed futures not revisitable, last auto-applied → TurnManager logic\n5. Visual future overlay → FutureOverlay renderer\n6. Win/lose conditions → GameState.status + collision checks\n7. Meaningful variance in futures → Quality spread algorithm\n8. Restart after win/loss → GameController state machine\n9. Responsive core loop → Event-driven render + keyboard/click controls", - "subtasks": [], - "integration": null, - "verification": { - "buildCommand": "npm run build", - "runCommand": "npm run preview -- --host 0.0.0.0 --port 3000", - "readySignal": "Local:|localhost|127.0.0.1|ready|listening", - "appType": "web", - "port": 3000, - "checks": [ - "A dark-themed grid-based game level is visible in the browser with a retro terminal/phosphor aesthetic — cyan diamond (player), amber-orange shapes (enemies), and a green pulsing beacon (goal) are all rendered on the grid", - "A title screen displays 'QUANTUM RUNNER' with a start prompt before gameplay begins", - "During gameplay, a future overlay appears showing translucent/ghost versions of entities at proposed positions with dashed lines indicating movement directions", - "Two buttons labeled 'ACCEPT' and 'PASS' are visible and functional during the future-choosing phase — clicking Accept applies the shown future, clicking Pass advances to the next", - "A 'Future X of N' indicator shows which future is currently being previewed and how many remain", - "When all futures are passed without accepting, the last future is automatically applied (the game advances without requiring explicit accept on the final option)", - "When the player entity reaches the goal cell through accepted futures, a win screen appears with a restart option", - "When an enemy entity occupies the same cell as the player in an accepted future, a lose screen appears with a restart option", - "Pressing Enter/Space on win or lose screens (or clicking restart) returns to the title screen and a new game can be started", - "Different futures presented each turn show meaningfully different outcomes — some move the player closer to the goal while others are more dangerous, creating actual strategic choice" - ] - } -}