Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
291bcc581d | ||
|
|
26edec1bbf | ||
|
|
da4fdc90e0 | ||
|
|
ee6c4f2f4f | ||
|
|
9b9f0cdbcb | ||
|
|
e14ffe44d6 | ||
|
|
d5bc0bb27c | ||
|
|
0790074ffc | ||
|
|
1a44daf2ce | ||
|
|
9679d68154 | ||
|
|
80ba94203d | ||
|
|
87631af678 | ||
|
|
2683f5d9c9 | ||
|
|
c92076f232 | ||
|
|
99751b39b2 | ||
|
|
2c0372cdc0 | ||
|
|
0eae43a8ce | ||
|
|
76d207e2e5 | ||
|
|
b945b8ed71 | ||
|
|
d249ec0e5f | ||
|
|
0dab5da0c7 | ||
|
|
c93a1b3074 | ||
|
|
7f6fea91f6 | ||
|
|
aaa253bde0 | ||
|
|
df37260e26 | ||
|
|
b77ff78cfc | ||
|
|
9fb9786e54 | ||
|
|
72bb2eb58b | ||
|
|
55010d2bcd | ||
|
|
f735e5d3ba | ||
|
|
6e436db5e7 | ||
|
|
2953ef8917 | ||
|
|
7675e59868 | ||
|
|
357aa30618 | ||
|
|
22426f677f | ||
|
|
cf997b9cbc | ||
|
|
07d5607218 | ||
|
|
614a081ca6 | ||
|
|
71cdc342c9 | ||
|
|
214b9077ab | ||
|
|
76eb0517c2 | ||
|
|
820000f93b | ||
|
|
fa6b3b69d5 | ||
|
|
ca4ba6e2d7 | ||
|
|
ebfff1a62f | ||
|
|
ba04d7f491 | ||
|
|
054f0c0d23 | ||
|
|
45ff01e1fe | ||
|
|
7801dbb25f | ||
|
|
10eb4df09c | ||
|
|
09e21fa493 | ||
|
|
0541c115c5 | ||
|
|
325e07de59 | ||
|
|
03262dbf40 | ||
|
|
d8fdfeef74 | ||
|
|
005d945ca8 | ||
|
|
a6c20aab3b | ||
|
|
627ca68cff | ||
|
|
84d42e22ac | ||
|
|
37866ebb6d | ||
|
|
7030794fa1 | ||
|
|
ec1c8ed263 | ||
|
|
12f140d888 | ||
|
|
53bbae84af | ||
|
|
511636400c | ||
|
|
79db410b09 | ||
|
|
fedb32486a | ||
|
|
183494a22e | ||
|
|
325daeb0d9 | ||
|
|
7ed1b94b8f | ||
|
|
43f1f92900 | ||
|
|
5f146b0daf | ||
|
|
734da610b7 | ||
|
|
ea19ff918b |
248
CHANGELOG.md
248
CHANGELOG.md
@@ -1,3 +1,251 @@
|
||||
## [4.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.11.0...v4.11.1) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** center AbacusReact SVGs in card tiles ([26edec1](https://github.com/antialias/soroban-abacus-flashcards/commit/26edec1bbf038264405ec9d161edcd18f67a6fc6))
|
||||
|
||||
## [4.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.6...v4.11.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **home:** redesign home page to showcase complete platform ([ee6c4f2](https://github.com/antialias/soroban-abacus-flashcards/commit/ee6c4f2f4f39e3b30f59c54866c3857c218fb80f))
|
||||
|
||||
## [4.10.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.5...v4.10.6) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** position slots flow horizontally with wrap ([e14ffe4](https://github.com/antialias/soroban-abacus-flashcards/commit/e14ffe44d66d0c97bc0cc4e0c255698e88ce723a))
|
||||
|
||||
## [4.10.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.4...v4.10.5) (2025-10-18)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** merge /arcade/room into /arcade route ([0790074](https://github.com/antialias/soroban-abacus-flashcards/commit/0790074ffc5008bce9a162fe0ddbd1d5c214c4f7))
|
||||
|
||||
## [4.10.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.3...v4.10.4) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** match Python card layout with flex wrap ([9679d68](https://github.com/antialias/soroban-abacus-flashcards/commit/9679d68154ac8b6a2f905ec7d17a34b39bc00237))
|
||||
|
||||
## [4.10.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.2...v4.10.3) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** remove broken query param from game URLs ([87631af](https://github.com/antialias/soroban-abacus-flashcards/commit/87631af6788bd7b42e671374e55ec0ad8435900c))
|
||||
|
||||
## [4.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.1...v4.10.2) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** faithfully port UI/UX from Python original ([c92076f](https://github.com/antialias/soroban-abacus-flashcards/commit/c92076f232930aa12d9a0230fa745b73b5cc04d9)), closes [#2c5f76](https://github.com/antialias/soroban-abacus-flashcards/issues/2c5f76) [#1976d2](https://github.com/antialias/soroban-abacus-flashcards/issues/1976d2)
|
||||
|
||||
## [4.10.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.0...v4.10.1) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** remove legacy master-organizer placeholder ([76d207e](https://github.com/antialias/soroban-abacus-flashcards/commit/76d207e2e5244f84bc0d76fe3d753034f1991228))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove old single-player complement-race version ([0eae43a](https://github.com/antialias/soroban-abacus-flashcards/commit/0eae43a8ce16c1c080c04c352ba750f55165b694))
|
||||
|
||||
## [4.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.9.0...v4.10.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **card-sorting:** add UI components and fix AbacusReact props ([d249ec0](https://github.com/antialias/soroban-abacus-flashcards/commit/d249ec0e5ff4610f55f35f762d726e0c98ac366c))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove old single-player memory-quiz version ([0dab5da](https://github.com/antialias/soroban-abacus-flashcards/commit/0dab5da0c7f9186695b1970c85e5c09ea0e33c5f))
|
||||
|
||||
## [4.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.8.0...v4.9.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **card-sorting:** implement Provider with arcade session integration ([7f6fea9](https://github.com/antialias/soroban-abacus-flashcards/commit/7f6fea91f6dcc69a173eea86bcefc9921f1c1664))
|
||||
|
||||
## [4.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.1...v4.8.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **arcade:** add Card Sorting Challenge game scaffolding ([df37260](https://github.com/antialias/soroban-abacus-flashcards/commit/df37260e26bbb146493e0834e093afd98fa3f2a4))
|
||||
|
||||
## [4.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.0...v4.7.1) (2025-10-18)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** remove number-guesser and math-sprint games ([9fb9786](https://github.com/antialias/soroban-abacus-flashcards/commit/9fb9786e54928e81ecf226b36d343a73143fb674))
|
||||
|
||||
## [4.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.10...v4.7.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** enable adaptive AI difficulty in arcade ([55010d2](https://github.com/antialias/soroban-abacus-flashcards/commit/55010d2bcd953718d8fea428b1f7f613a193779c))
|
||||
|
||||
## [4.6.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.9...v4.6.10) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** improve AI speech bubble positioning ([6e436db](https://github.com/antialias/soroban-abacus-flashcards/commit/6e436db5e709d944ebffed6936ea1f8e4bd2e19e))
|
||||
* **docker:** remove reference to deleted @soroban/client package ([2953ef8](https://github.com/antialias/soroban-abacus-flashcards/commit/2953ef8917f7b13f6eb562eb7d58d14179a718da))
|
||||
|
||||
## [4.6.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.8...v4.6.9) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** add missing AI commentary cooldown updates ([357aa30](https://github.com/antialias/soroban-abacus-flashcards/commit/357aa30618f80d659ae515f94b7b9254bb458910))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove dead Python bridge and unused packages ([22426f6](https://github.com/antialias/soroban-abacus-flashcards/commit/22426f677f9b127441377b95571f0066a0990d3f))
|
||||
|
||||
## [4.6.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.7...v4.6.8) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** counter-flip AI speech bubbles to make text readable ([07d5607](https://github.com/antialias/soroban-abacus-flashcards/commit/07d5607218aee03e813eceff5d161a7838d66bcb))
|
||||
|
||||
## [4.6.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.6...v4.6.7) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use active local players pattern from navbar ([71cdc34](https://github.com/antialias/soroban-abacus-flashcards/commit/71cdc342c97ca53b5e7e4202d4d344199e8ddd98))
|
||||
|
||||
## [4.6.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.5...v4.6.6) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use local player emoji instead of first active player ([76eb051](https://github.com/antialias/soroban-abacus-flashcards/commit/76eb0517c202d1b9160b49dec0b99ff4972daff2))
|
||||
|
||||
## [4.6.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.4...v4.6.5) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** flip player avatar to face right in practice mode ([fa6b3b6](https://github.com/antialias/soroban-abacus-flashcards/commit/fa6b3b69d5a4a7eb70f8c18fc8c122c54c4d504a))
|
||||
|
||||
## [4.6.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.3...v4.6.4) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** flip AI racers to face right in practice mode ([ebfff1a](https://github.com/antialias/soroban-abacus-flashcards/commit/ebfff1a62fd104d531a8158345c8c012ec8a55d3))
|
||||
|
||||
## [4.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.2...v4.6.3) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** balance AI speeds to match original implementation ([054f0c0](https://github.com/antialias/soroban-abacus-flashcards/commit/054f0c0d235dc2b0042a0f6af48840d23a4c5ff8))
|
||||
|
||||
## [4.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.1...v4.6.2) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **build:** resolve Docker build failures preventing deployment ([7801dbb](https://github.com/antialias/soroban-abacus-flashcards/commit/7801dbb25fb0a33429c70f11294264f7238ce7a4))
|
||||
|
||||
## [4.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.0...v4.6.1) (2025-10-18)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **complement-race:** move AI opponents from server-side to client-side ([09e21fa](https://github.com/antialias/soroban-abacus-flashcards/commit/09e21fa4934c634d0ce46381ef7e40238fc134c3))
|
||||
|
||||
## [4.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.5.0...v4.6.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** restore AI opponents in practice and survival modes ([325e07d](https://github.com/antialias/soroban-abacus-flashcards/commit/325e07de5929169aa333ef16f7bca5b41eeb1622))
|
||||
|
||||
## [4.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.15...v4.5.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** add infinite win condition for Steam Sprint mode ([d8fdfee](https://github.com/antialias/soroban-abacus-flashcards/commit/d8fdfeef74a5d3bb9684254af1c9d64d264b46ad))
|
||||
|
||||
## [4.4.15](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.14...v4.4.15) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** track previous position to detect route threshold crossing ([a6c20aa](https://github.com/antialias/soroban-abacus-flashcards/commit/a6c20aab3b245d9893808d188d16a35ab80cfca9))
|
||||
|
||||
## [4.4.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.13...v4.4.14) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** remove dual game loop conflict preventing route progression ([84d42e2](https://github.com/antialias/soroban-abacus-flashcards/commit/84d42e22ac0cdd25e87e45dc698029ad7ed78559))
|
||||
|
||||
## [4.4.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.12...v4.4.13) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** show new passengers when route changes ([ec1c8ed](https://github.com/antialias/soroban-abacus-flashcards/commit/ec1c8ed263844f56477c1f709041339b42b48f4e))
|
||||
|
||||
## [4.4.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.11...v4.4.12) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** track physical car indices to prevent boarding issues ([53bbae8](https://github.com/antialias/soroban-abacus-flashcards/commit/53bbae84af7317d5e12109db2054cc70ca5bea27))
|
||||
* **complement-race:** update passenger display when state changes ([5116364](https://github.com/antialias/soroban-abacus-flashcards/commit/511636400c19776b58c6bddf8f7c9cc398a05236))
|
||||
|
||||
## [4.4.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.10...v4.4.11) (2025-10-17)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **logging:** replace per-frame debug logging with event-based logging ([fedb324](https://github.com/antialias/soroban-abacus-flashcards/commit/fedb32486ab5c6c619ebc03570b6c66529a1344e))
|
||||
|
||||
## [4.4.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.9...v4.4.10) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** correct passenger boarding to use multiplayer fields ([7ed1b94](https://github.com/antialias/soroban-abacus-flashcards/commit/7ed1b94b8fa620cb4f64ba43e160ef511704f3ce))
|
||||
|
||||
## [4.4.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.8...v4.4.9) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** reduce initial momentum from 50 to 10 to prevent train sailing past first station ([5f146b0](https://github.com/antialias/soroban-abacus-flashcards/commit/5f146b0daf74d54e1c7b9a57d3a2f37e73849ff2))
|
||||
|
||||
## [4.4.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.7...v4.4.8) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** implement client-side momentum with continuous decay for smooth train movement ([ea19ff9](https://github.com/antialias/soroban-abacus-flashcards/commit/ea19ff918bc70ad3eb0339e18dbd32195f34816e))
|
||||
|
||||
## [4.4.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.6...v4.4.7) (2025-10-17)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/core/client/node/package.json ./packages/core/client/node/
|
||||
COPY packages/core/client/typescript/package.json ./packages/core/client/typescript/
|
||||
COPY packages/abacus-react/package.json ./packages/abacus-react/
|
||||
COPY packages/templates/package.json ./packages/templates/
|
||||
|
||||
|
||||
440
apps/web/.claude/ARCADE_ROUTING_ARCHITECTURE.md
Normal file
440
apps/web/.claude/ARCADE_ROUTING_ARCHITECTURE.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# Arcade Routing Architecture - Complete Overview
|
||||
|
||||
## 1. Current /arcade Page
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/app/arcade/page.tsx` (lines 1-129)
|
||||
|
||||
**Purpose:** The main arcade landing page - displays the "Champion Arena"
|
||||
|
||||
**Key Components:**
|
||||
- `ArcadeContent()` - Renders the main arcade interface
|
||||
- Uses `EnhancedChampionArena` component which contains `GameSelector`
|
||||
- The `GameSelector` displays all available games as cards
|
||||
- `GameSelector` includes both legacy games and registry games
|
||||
|
||||
**Current Flow:**
|
||||
1. User navigates to `/arcade`
|
||||
2. Page renders `FullscreenProvider` wrapper
|
||||
3. Displays `PageWithNav` with title "🏟️ Champion Arena"
|
||||
4. Content area shows `EnhancedChampionArena` → `GameSelector`
|
||||
5. `GameSelector` renders `GameCard` components for each game
|
||||
6. When user clicks a game card, `GameCard` calls `router.push(config.url)`
|
||||
7. For registry games, `config.url` is `/arcade/room?game={gameName}`
|
||||
8. For legacy games, URL would be direct to their page
|
||||
|
||||
**State Management:**
|
||||
- `GameModeContext` provides player selection (emoji, name, color)
|
||||
- `PageWithNav` wraps content and provides mini-nav with:
|
||||
- Active player list
|
||||
- Add player button
|
||||
- Game mode indicator (single/battle/tournament)
|
||||
- Exit session handler
|
||||
|
||||
## 2. Current /arcade/room Page
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/app/arcade/room/page.tsx` (lines 1-359)
|
||||
|
||||
**Purpose:** "Magical place" that shows either a game OR the game chooser, driven by room state
|
||||
|
||||
**Three States:**
|
||||
|
||||
### State 1: Loading
|
||||
- Shows "Loading room..." message
|
||||
- Waits for `useRoomData()` hook to resolve
|
||||
|
||||
### State 2: Game Selection UI (when `!roomData.gameName`)
|
||||
- Shows large game selection buttons
|
||||
- User clicks to select a game
|
||||
- Calls `setRoomGame()` mutation to save selection to room
|
||||
- Invokes `handleGameSelect()` which:
|
||||
1. Checks if game exists in registry via `hasGame(gameType)`
|
||||
2. If registry game: calls `setRoomGame({roomId, gameName: gameType})`
|
||||
3. If legacy game: maps to internal name via `GAME_TYPE_TO_NAME`, then calls `setRoomGame()`
|
||||
4. Game selection is persisted to the room database
|
||||
|
||||
### State 3: Game Display (when `roomData.gameName` is set)
|
||||
- Checks game registry first via `hasGame(roomData.gameName)`
|
||||
- If registry game:
|
||||
- Gets game definition via `getGame(roomData.gameName)`
|
||||
- Renders: `<Provider><GameComponent /></Provider>`
|
||||
- Provider and GameComponent come from game registry definition
|
||||
- If legacy game:
|
||||
- Switch statement with TODO for individual games
|
||||
- Currently only shows "Game not yet supported"
|
||||
|
||||
**Key Hook:**
|
||||
- `useRoomData()` - Fetches current room from API and subscribes to socket updates
|
||||
- Returns `roomData` with fields: `id`, `name`, `code`, `gameName`, `gameConfig`, `members`, `memberPlayers`
|
||||
- Also returns `isLoading` boolean
|
||||
|
||||
**Navigation Flow:**
|
||||
1. User navigates to `/arcade`
|
||||
2. `GameCard` onClick calls `router.push('/arcade/room?game={gameName}')`
|
||||
3. User arrives at `/arcade/room`
|
||||
4. If NOT in a room yet: Shows error with link back to `/arcade`
|
||||
5. If in room but no game selected: Shows game selection UI
|
||||
6. If game selected: Loads and displays game
|
||||
|
||||
## 3. The "Mini App Nav" - GameContextNav Component
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/nav/GameContextNav.tsx` (lines 1-372)
|
||||
|
||||
**What It Is:**
|
||||
The "mini app nav" is actually a sophisticated component within the `PageWithNav` wrapper that intelligently shows different UI based on context:
|
||||
|
||||
**Components & Props:**
|
||||
- `navTitle` - Current page title (e.g., "Champion Arena", "Choose Game", "Speed Complement Race")
|
||||
- `navEmoji` - Icon emoji for current page
|
||||
- `gameMode` - Computed from active player count: 'none' | 'single' | 'battle' | 'tournament'
|
||||
- `activePlayers` - Array of selected players
|
||||
- `inactivePlayers` - Array of available but unselected players
|
||||
- `shouldEmphasize` - Boolean to emphasize player selection
|
||||
- `showFullscreenSelection` - Boolean to show fullscreen mode for player selection
|
||||
- `roomInfo` - Optional arcade room data (roomId, roomName, gameName, playerCount, joinCode)
|
||||
- `networkPlayers` - Remote players from room members
|
||||
|
||||
**Three Display Modes:**
|
||||
|
||||
### Mode 1: Fullscreen Player Selection
|
||||
- When `showFullscreenSelection === true`
|
||||
- Displays:
|
||||
- Large title with emoji
|
||||
- Game mode indicator
|
||||
- Fullscreen player selection UI
|
||||
- Shows all inactive players for selection
|
||||
|
||||
### Mode 2: Solo Mode (NOT in room)
|
||||
- When `roomInfo` is undefined
|
||||
- Shows:
|
||||
- **Game Title Section** (left side):
|
||||
- `GameTitleMenu` with game title and emoji
|
||||
- Menu options: Setup, New Game, Quit
|
||||
- `GameModeIndicator`
|
||||
- **Player Section** (right side):
|
||||
- `ActivePlayersList` - shows selected players
|
||||
- `AddPlayerButton` - add more players
|
||||
|
||||
### Mode 3: Room Mode (IN a room)
|
||||
- When `roomInfo` is defined
|
||||
- Shows:
|
||||
- **Hidden:** Game title section (display: none)
|
||||
- **Room Info Pane** (left side):
|
||||
- `RoomInfo` component with room details
|
||||
- Game mode indicator with color/emoji
|
||||
- Room name, player count, join code
|
||||
- `NetworkPlayerIndicator` components for remote players
|
||||
- **Player Section** (may be hidden):
|
||||
- Shows local active players
|
||||
- Add player button (for local players only)
|
||||
|
||||
**Key Sub-Components:**
|
||||
- `GameTitleMenu` - Menu for game options (setup, new game, quit)
|
||||
- `GameModeIndicator` - Shows 🎯 Solo, ⚔️ Battle, 🏆 Tournament, 👥 Select
|
||||
- `RoomInfo` - Displays room metadata
|
||||
- `NetworkPlayerIndicator` - Shows remote players with scores/streaks
|
||||
- `ActivePlayersList` - List of selected players
|
||||
- `AddPlayerButton` - Button to add more players with popover
|
||||
- `FullscreenPlayerSelection` - Large player picker for fullscreen mode
|
||||
- `PendingInvitations` - Banner for room invitations
|
||||
|
||||
**State Management:**
|
||||
- Lifted from `PageWithNav` to preserve state across remounts:
|
||||
- `showPopover` / `setShowPopover` - AddPlayerButton popover state
|
||||
- `activeTab` / `setActiveTab` - 'add' or 'invite' tab selection
|
||||
|
||||
## 4. Navigation Flow
|
||||
|
||||
### Flow 1: Solo Player → Game Selection → Room Creation → Game Start
|
||||
|
||||
```
|
||||
/arcade (Champion Arena)
|
||||
↓ [Select players - updates GameModeContext]
|
||||
↓ [Click game card - GameCard.onClick → router.push]
|
||||
/arcade/room (if not in room, shows game selector)
|
||||
↓ [Select game - calls setRoomGame mutation]
|
||||
↓ [Room created, gameName saved to roomData]
|
||||
↓ [useRoomData refetch updates roomData.gameName]
|
||||
/arcade/room (now displays the game)
|
||||
↓ [Game Provider and Component render]
|
||||
```
|
||||
|
||||
### Flow 2: Multiplayer - Room Invitation
|
||||
|
||||
```
|
||||
User A: Creates room via Champion Arena
|
||||
User B: Receives invitation
|
||||
User B: Joins room via /arcade/room
|
||||
User B: Sees same game selection (if set) or game selector (if not set)
|
||||
```
|
||||
|
||||
### Flow 3: Exit Game
|
||||
|
||||
```
|
||||
/arcade/room (in-game)
|
||||
↓ [Click "Quit" or "Exit Session" in GameContextNav]
|
||||
↓ [onExitSession callback → router.push('/arcade')]
|
||||
/arcade (back to champion arena)
|
||||
↓ Player selection reset by GameModeContext
|
||||
```
|
||||
|
||||
## 5. Game Chooser / Game Selection System
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
|
||||
|
||||
**How It Works:**
|
||||
1. `GameSelector` component gets all games from both sources:
|
||||
- Legacy `GAMES_CONFIG` (currently empty)
|
||||
- Registry games via `getAllGames()`
|
||||
|
||||
2. For each game, creates `GameCard` component with configuration including `url` field
|
||||
|
||||
3. Game Cards rendered in 2-column grid (responsive)
|
||||
|
||||
4. When card clicked:
|
||||
- `GameCard` checks `activePlayerCount` against game's `maxPlayers`
|
||||
- If valid: calls `router.push(config.url)` - client-side navigation via Next.js
|
||||
- If invalid: blocks navigation with warning
|
||||
|
||||
**Two Game Systems:**
|
||||
|
||||
### Registry Games (NEW - Modular)
|
||||
- Location: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/arcade-games/`
|
||||
- File: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/lib/arcade/game-registry.ts`
|
||||
- Examples: `complement-race`, `memory-quiz`, `matching`
|
||||
- Each game has: `manifest` (metadata), `Provider` (context), `GameComponent` (UI)
|
||||
- Games registered globally via `registerGame()` function
|
||||
|
||||
### Legacy Games (OLD)
|
||||
- Location: Directly in `/app/arcade/` directory
|
||||
- Examples: `/app/arcade/complement-race/page.tsx`
|
||||
- Currently, only complement-race is partially migrated
|
||||
- Direct URL structure: `/arcade/{gameName}/page.tsx`
|
||||
|
||||
**Game Config Structure (for display):**
|
||||
```javascript
|
||||
{
|
||||
name: string, // Display name
|
||||
fullName?: string, // Longer name for detailed view
|
||||
description: string, // Short description
|
||||
longDescription?: string, // Detailed description
|
||||
icon: emoji, // Game icon emoji
|
||||
gradient: css gradient, // Background gradient
|
||||
borderColor: css color, // Border color for availability
|
||||
maxPlayers: number, // Player limit for validation
|
||||
chips?: string[], // Feature labels
|
||||
color?: 'green'|'purple'|'blue', // Color theme
|
||||
difficulty?: string, // Difficulty level
|
||||
available: boolean, // Is game available
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Key Components Summary
|
||||
|
||||
### PageWithNav - Main Layout Wrapper
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/PageWithNav.tsx` (lines 1-192)
|
||||
|
||||
**Responsibilities:**
|
||||
- Wraps all game/arcade pages
|
||||
- Manages GameContextNav state (mini-nav)
|
||||
- Handles player configuration dialog
|
||||
- Shows moderation notifications
|
||||
- Renders top navigation bar via `AppNavBar`
|
||||
|
||||
**Key Props:**
|
||||
- `navTitle` - Passed to GameContextNav
|
||||
- `navEmoji` - Passed to GameContextNav
|
||||
- `gameName` - Internal game name for API
|
||||
- `emphasizePlayerSelection` - Highlight player controls
|
||||
- `onExitSession` - Callback when user exits
|
||||
- `onSetup`, `onNewGame` - Game-specific callbacks
|
||||
- `children` - Page content
|
||||
|
||||
### AppNavBar - Top Navigation Bar
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/AppNavBar.tsx` (lines 1-625)
|
||||
|
||||
**Variants:**
|
||||
- `full` - Standard navigation (default for non-game pages)
|
||||
- `minimal` - Game navigation (auto-selected for `/arcade` and `/games`)
|
||||
|
||||
**Minimal Nav Features:**
|
||||
- Hamburger menu (left) with:
|
||||
- Site navigation (Home, Create, Guide, Games)
|
||||
- Controls (Fullscreen, Exit Arcade)
|
||||
- Abacus style dropdown
|
||||
- Centered game context (navSlot)
|
||||
- Fullscreen indicator badge
|
||||
|
||||
### EnhancedChampionArena - Main Arcade Display
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/EnhancedChampionArena.tsx` (lines 1-40)
|
||||
|
||||
**Responsibilities:**
|
||||
- Container for game selector
|
||||
- Full-height flex layout
|
||||
- Passes configuration to `GameSelector`
|
||||
|
||||
### GameSelector - Game Grid
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
|
||||
|
||||
**Responsibilities:**
|
||||
- Fetches all games from registry
|
||||
- Arranges in responsive grid
|
||||
- Shows header "🎮 Available Games"
|
||||
- Renders GameCard for each game
|
||||
|
||||
### GameCard - Individual Game Button
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameCard.tsx` (lines 1-241)
|
||||
|
||||
**Responsibilities:**
|
||||
- Displays game with icon, name, description
|
||||
- Shows feature chips and player count indicator
|
||||
- Validates player count against game requirements
|
||||
- Handles click to navigate to game
|
||||
- Two variants: compact and detailed
|
||||
|
||||
## 7. State Management
|
||||
|
||||
### GameModeContext
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/contexts/GameModeContext.tsx` (lines 1-325)
|
||||
|
||||
**Manages:**
|
||||
- Local players (Map<string, Player>)
|
||||
- Active players (Set<string>)
|
||||
- Game mode (computed from active player count)
|
||||
- Player CRUD operations (add, update, remove)
|
||||
|
||||
**Key Features:**
|
||||
- Fetches players from user's local DB via `useUserPlayers()`
|
||||
- Creates 4 default players if none exist
|
||||
- When in room: merges room members' players (marked as isLocal: false)
|
||||
- Syncs to room members via `notifyRoomOfPlayerUpdate()`
|
||||
|
||||
**Computed Values:**
|
||||
- `activePlayerCount` - Size of activePlayers set
|
||||
- `gameMode`:
|
||||
- 1 player → 'single'
|
||||
- 2 players → 'battle'
|
||||
- 3+ players → 'tournament'
|
||||
|
||||
### useRoomData Hook
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/hooks/useRoomData.ts` (lines 1-450+)
|
||||
|
||||
**Manages:**
|
||||
- Current room fetching via TanStack Query
|
||||
- Socket.io real-time updates
|
||||
- Room state (members, players, game name)
|
||||
- Moderation events (kicked, banned, invitations)
|
||||
|
||||
**Key Operations:**
|
||||
- `fetchCurrentRoom()` - GET `/api/arcade/rooms/current`
|
||||
- `createRoomApi()` - POST `/api/arcade/rooms`
|
||||
- `joinRoomApi()` - POST `/api/arcade/rooms/{id}/join`
|
||||
- `leaveRoomApi()` - POST `/api/arcade/rooms/{id}/leave`
|
||||
- `setRoomGame()` - Updates room's gameName and gameConfig
|
||||
|
||||
**Socket Events:**
|
||||
- `join-user-channel` - Personal notifications
|
||||
- `join-room` - Subscribe to room updates
|
||||
- `room-joined` - Refresh when entering room
|
||||
- `member-joined` - When player joins
|
||||
- `member-left` - When player leaves
|
||||
- `room-players-updated` - When players change
|
||||
- Moderation events (kicked, banned, etc.)
|
||||
|
||||
## 8. Routing Summary
|
||||
|
||||
**Current URL Structure:**
|
||||
|
||||
```
|
||||
/ → Home page (Soroban Generator)
|
||||
/create → Create flashcards
|
||||
/guide → Tutorial guide
|
||||
/games → Games library (external game pages)
|
||||
/arcade → Champion Arena (main landing with game selector)
|
||||
/arcade/room → Active game display or game selection UI
|
||||
/arcade/room?game={name} → Query param for game selection (optional)
|
||||
/arcade/complement-race → OLD: Direct complement-race page (legacy)
|
||||
/arcade/complement-race/practice → Complement-race practice mode
|
||||
/arcade/complement-race/sprint → Complement-race sprint mode
|
||||
/arcade/complement-race/survival → Complement-race survival mode
|
||||
/arcade/memory-quiz → Memory quiz game page (legacy structure)
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `/arcade/room?game={gameName}` - Optional game selection (parsed by GameCard)
|
||||
|
||||
## 9. Key Differences: /arcade vs /arcade/room
|
||||
|
||||
| Aspect | /arcade | /arcade/room |
|
||||
|--------|---------|--------------|
|
||||
| **Purpose** | Game selection hub | Active game display or selection within room |
|
||||
| **Displays** | GameSelector with all games | Selected game OR game selector if no game in room |
|
||||
| **Room Context** | Optional (can start solo) | Usually in a room (fetches via useRoomData) |
|
||||
| **Navigation** | Click game → /arcade/room | Click game → Saves to room → Displays game |
|
||||
| **GameContextNav** | Shows player selector | Shows room info when joined |
|
||||
| **Player State** | Local only | Local + remote (room members) |
|
||||
| **Exit Button** | Usually hidden | Shows "Exit Session" to return to /arcade |
|
||||
| **Socket Connection** | Optional | Always connected (in room) |
|
||||
| **Page Transition** | User controls | Driven by room state updates |
|
||||
|
||||
## 10. Planning the Merge (/arcade/room → /arcade)
|
||||
|
||||
**Challenges to Consider:**
|
||||
|
||||
1. **URL Consolidation:**
|
||||
- `/arcade/room` would become a sub-path or handled by `/arcade` with state
|
||||
- Query param `?game={name}` could drive game selection
|
||||
- Current: `/arcade/room?game=complement-race`
|
||||
- Could become: `/arcade?game=complement-race&mode=play`
|
||||
|
||||
2. **Route Disambiguation:**
|
||||
- `/arcade` needs to handle: game selection display, game display, game loading
|
||||
- Same page different modes based on state
|
||||
- Or: Sub-routes like `/arcade/select`, `/arcade/play`
|
||||
|
||||
3. **State Layering:**
|
||||
- Local game mode (solo player, GameModeContext)
|
||||
- Room state (multiplayer, useRoomData)
|
||||
- Both need to coexist
|
||||
|
||||
4. **Navigation Preservation:**
|
||||
- Currently: `GameCard` → `router.push('/arcade/room?game=X')`
|
||||
- After merge: Would need new logic
|
||||
- Fullscreen state must persist (uses Next.js router, not reload)
|
||||
|
||||
5. **PageWithNav Behavior:**
|
||||
- Mini-nav shows game selection UI vs room info
|
||||
- Currently determined by `roomInfo` presence
|
||||
- After merge: Need same logic but one route
|
||||
|
||||
6. **Game Display:**
|
||||
- Currently: `/arcade/room` fetches game from registry
|
||||
- New: `/arcade` would need same game registry lookup
|
||||
- Game Provider/Component rendering must work identically
|
||||
|
||||
**Merge Strategy Options:**
|
||||
|
||||
### Option A: Single Route with Modes
|
||||
```
|
||||
/arcade
|
||||
├── Mode: browse (default, show GameSelector)
|
||||
├── Mode: select (game selected, show GameSelector for confirmation)
|
||||
└── Mode: play (in-game, show game display)
|
||||
```
|
||||
|
||||
### Option B: Sub-routes
|
||||
```
|
||||
/arcade
|
||||
├── /arcade (selector)
|
||||
├── /arcade/play (game display)
|
||||
└── /arcade/configure (player config)
|
||||
```
|
||||
|
||||
### Option C: Query-Parameter Driven
|
||||
```
|
||||
/arcade
|
||||
├── /arcade (default - selector)
|
||||
├── /arcade?game=X (game loading)
|
||||
└── /arcade?game=X&playing=true (in-game)
|
||||
```
|
||||
|
||||
**Recommendation:** Option C (Query-driven) is closest to current architecture and requires minimal changes to existing logic.
|
||||
@@ -92,7 +92,13 @@
|
||||
"Bash(ls:*)",
|
||||
"Bash(do if [ -f \"$file\" ])",
|
||||
"Bash(! echo \"$file\")",
|
||||
"Bash(then sed -i '' \"s|from ''''../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" sed -i '' \"s|from ''''../../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" fi done)"
|
||||
"Bash(then sed -i '' \"s|from ''''../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" sed -i '' \"s|from ''''../../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" fi done)",
|
||||
"Bash(pnpm install)",
|
||||
"Bash(pnpm exec turbo build --filter=@soroban/web)",
|
||||
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
|
||||
"Bash(do gh run list --limit 1 --json conclusion,status,name --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - \\(.name)\"\"')",
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
|
||||
"WebFetch(domain:abaci.one)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
1761
apps/web/CARD_SORTING_PORT_PLAN.md
Normal file
1761
apps/web/CARD_SORTING_PORT_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,6 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-spring/web": "^10.0.2",
|
||||
"@soroban/abacus-react": "workspace:*",
|
||||
"@soroban/client": "workspace:*",
|
||||
"@soroban/core": "workspace:*",
|
||||
"@soroban/templates": "workspace:*",
|
||||
"@tanstack/react-form": "^0.19.0",
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function RoomDetailPage() {
|
||||
const startGame = () => {
|
||||
if (!room) return
|
||||
// Navigate to the room game page
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
}
|
||||
|
||||
const joinRoom = async () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
|
||||
import { useAIRacers } from '../hooks/useAIRacers'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
import { useSteamJourney } from '../hooks/useSteamJourney'
|
||||
import { generatePassengers } from '../lib/passengerGenerator'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
import { CircularTrack } from './RaceTrack/CircularTrack'
|
||||
@@ -16,10 +15,9 @@ import { RouteCelebration } from './RouteCelebration'
|
||||
type FeedbackAnimation = 'correct' | 'incorrect' | null
|
||||
|
||||
export function GameDisplay() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { state, dispatch, boostMomentum } = useComplementRace()
|
||||
useAIRacers() // Activate AI racer updates (not used in sprint mode)
|
||||
const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty()
|
||||
const { boostMomentum } = useSteamJourney()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [feedbackAnimation, setFeedbackAnimation] = useState<FeedbackAnimation>(null)
|
||||
|
||||
@@ -109,7 +107,7 @@ export function GameDisplay() {
|
||||
|
||||
// Boost momentum for sprint mode
|
||||
if (state.style === 'sprint') {
|
||||
boostMomentum()
|
||||
boostMomentum(true)
|
||||
|
||||
// Play train whistle for milestones in sprint mode (line 13222-13235)
|
||||
if (newStreak >= 5 && newStreak % 3 === 0) {
|
||||
@@ -144,6 +142,11 @@ export function GameDisplay() {
|
||||
// Play incorrect sound (from web_generator.py line 11589)
|
||||
playSound('incorrect')
|
||||
|
||||
// Reduce momentum for sprint mode
|
||||
if (state.style === 'sprint') {
|
||||
boostMomentum(false)
|
||||
}
|
||||
|
||||
// Show adaptive feedback
|
||||
const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime)
|
||||
if (feedback) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
|
||||
interface PassengerCardProps {
|
||||
passenger: Passenger
|
||||
@@ -17,24 +17,27 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
if (!destinationStation || !originStation) return null
|
||||
|
||||
// Vintage train station colors
|
||||
const bgColor = passenger.isDelivered
|
||||
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
|
||||
const isBoarded = passenger.claimedBy !== null
|
||||
const isDelivered = passenger.deliveredBy !== null
|
||||
|
||||
const bgColor = isDelivered
|
||||
? '#1a3a1a' // Dark green for delivered
|
||||
: !passenger.isBoarded
|
||||
: !isBoarded
|
||||
? '#2a2419' // Dark brown/sepia for waiting
|
||||
: passenger.isUrgent
|
||||
? '#3a2419' // Dark red-brown for urgent
|
||||
: '#1a2a3a' // Dark blue for aboard
|
||||
|
||||
const accentColor = passenger.isDelivered
|
||||
const accentColor = isDelivered
|
||||
? '#4ade80' // Green
|
||||
: !passenger.isBoarded
|
||||
: !isBoarded
|
||||
? '#d4af37' // Gold for waiting
|
||||
: passenger.isUrgent
|
||||
? '#ff6b35' // Orange-red for urgent
|
||||
: '#60a5fa' // Blue for aboard
|
||||
|
||||
const borderColor =
|
||||
passenger.isUrgent && passenger.isBoarded && !passenger.isDelivered ? '#ff6b35' : '#d4af37'
|
||||
const borderColor = passenger.isUrgent && isBoarded && !isDelivered ? '#ff6b35' : '#d4af37'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -46,13 +49,13 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
minWidth: '220px',
|
||||
maxWidth: '280px',
|
||||
boxShadow:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
passenger.isUrgent && !isDelivered && isBoarded
|
||||
? '0 0 16px rgba(255, 107, 53, 0.5)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
position: 'relative',
|
||||
fontFamily: '"Courier New", Courier, monospace',
|
||||
animation:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
passenger.isUrgent && !isDelivered && isBoarded
|
||||
? 'urgentFlicker 1.5s ease-in-out infinite'
|
||||
: 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
@@ -79,7 +82,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '20px', lineHeight: '1' }}>
|
||||
{passenger.isDelivered ? '✅' : passenger.avatar}
|
||||
{isDelivered ? '✅' : passenger.avatar}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -109,7 +112,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
marginTop: '0',
|
||||
}}
|
||||
>
|
||||
{passenger.isDelivered ? 'DLVRD' : passenger.isBoarded ? 'BOARD' : 'WAIT'}
|
||||
{isDelivered ? 'DLVRD' : isBoarded ? 'BOARD' : 'WAIT'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +190,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
</div>
|
||||
|
||||
{/* Points badge */}
|
||||
{!passenger.isDelivered && (
|
||||
{!isDelivered && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -208,7 +211,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
)}
|
||||
|
||||
{/* Urgent indicator */}
|
||||
{passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded && (
|
||||
{passenger.isUrgent && !isDelivered && isBoarded && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -17,15 +17,16 @@ interface CircularTrackProps {
|
||||
|
||||
export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: CircularTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { players, activePlayers } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [celebrationCooldown, setCelebrationCooldown] = useState<Set<string>>(new Set())
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
// Get the current user's active local players (consistent with navbar pattern)
|
||||
const activeLocalPlayers = Array.from(activePlayers)
|
||||
.map((id) => players.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false)
|
||||
const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤'
|
||||
const [dimensions, setDimensions] = useState({ width: 600, height: 400 })
|
||||
|
||||
// Update dimensions on mount and resize
|
||||
@@ -400,7 +401,11 @@ export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: C
|
||||
{activeBubble && (
|
||||
<div
|
||||
style={{
|
||||
transform: `rotate(${-aiPos.angle}deg)`, // Counter-rotate bubble
|
||||
position: 'absolute',
|
||||
bottom: '100%', // Position above the AI racer
|
||||
left: '50%',
|
||||
transform: `translate(-50%, -15px) rotate(${-aiPos.angle}deg)`, // Offset 15px above, counter-rotate bubble
|
||||
zIndex: 20, // Above player (10) and AI racers (5)
|
||||
}}
|
||||
>
|
||||
<SpeechBubble
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { ComplementQuestion, Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { ComplementQuestion } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import { AbacusTarget } from '../AbacusTarget'
|
||||
import { PassengerCard } from '../PassengerCard'
|
||||
import { PressureGauge } from '../PressureGauge'
|
||||
|
||||
@@ -20,13 +20,14 @@ export function LinearTrack({
|
||||
showFinishLine = true,
|
||||
}: LinearTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { players, activePlayers } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
// Get the current user's active local players (consistent with navbar pattern)
|
||||
const activeLocalPlayers = Array.from(activePlayers)
|
||||
.map((id) => players.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false)
|
||||
const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤'
|
||||
|
||||
// Position calculation: leftPercent = Math.min(98, (progress / raceGoal) * 96 + 2)
|
||||
// 2% minimum (start), 98% maximum (near finish), 96% range for race
|
||||
@@ -110,7 +111,7 @@ export function LinearTrack({
|
||||
position: 'absolute',
|
||||
left: `${playerPosition}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transform: 'translate(-50%, -50%) scaleX(-1)',
|
||||
fontSize: '32px',
|
||||
transition: 'left 0.3s ease-out',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
@@ -132,7 +133,7 @@ export function LinearTrack({
|
||||
position: 'absolute',
|
||||
left: `${aiPosition}%`,
|
||||
top: `${35 + index * 15}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transform: 'translate(-50%, -50%) scaleX(-1)',
|
||||
fontSize: '28px',
|
||||
transition: 'left 0.2s linear',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
@@ -141,10 +142,20 @@ export function LinearTrack({
|
||||
>
|
||||
{racer.icon}
|
||||
{activeBubble && (
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%', // Position above the AI racer
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -15px) scaleX(-1)', // Offset 15px above, counter-flip bubble
|
||||
zIndex: 20, // Above player (10) and AI racers (5)
|
||||
}}
|
||||
>
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import type { Landmark } from '../../lib/landmarks'
|
||||
|
||||
interface RailroadTrackPathProps {
|
||||
@@ -100,18 +100,19 @@ export const RailroadTrackPath = memo(
|
||||
{stationPositions.map((pos, index) => {
|
||||
const station = stations[index]
|
||||
// Find passengers waiting at this station (exclude currently boarding)
|
||||
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
|
||||
const waitingPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.originStationId === station?.id &&
|
||||
!p.isBoarded &&
|
||||
!p.isDelivered &&
|
||||
p.claimedBy === null &&
|
||||
p.deliveredBy === null &&
|
||||
!boardingAnimations.has(p.id)
|
||||
)
|
||||
// Find passengers delivered at this station (exclude currently disembarking)
|
||||
const deliveredPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.destinationStationId === station?.id &&
|
||||
p.isDelivered &&
|
||||
p.deliveredBy !== null &&
|
||||
!disembarkingAnimations.has(p.id)
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { ComplementQuestion } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../../hooks/useSteamJourney'
|
||||
import { useTrackManagement } from '../../hooks/useTrackManagement'
|
||||
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
|
||||
import { calculateMaxConcurrentPassengers } from '../../lib/passengerGenerator'
|
||||
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { getRouteTheme } from '../../lib/routeThemes'
|
||||
import { GameHUD } from './GameHUD'
|
||||
@@ -94,9 +93,6 @@ export function SteamTrainJourney({
|
||||
currentInput,
|
||||
}: SteamTrainJourneyProps) {
|
||||
const { state } = useComplementRace()
|
||||
console.log(
|
||||
`🚂 Train: mom=${momentum} pos=${trainPosition} stations=${state.stations.length} passengers=${state.passengers.length}`
|
||||
)
|
||||
|
||||
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney()
|
||||
const _skyGradient = getSkyGradient()
|
||||
@@ -113,12 +109,9 @@ export function SteamTrainJourney({
|
||||
const pathRef = useRef<SVGPathElement>(null)
|
||||
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
|
||||
|
||||
// Calculate the number of train cars dynamically based on max concurrent passengers
|
||||
const maxCars = useMemo(() => {
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
|
||||
// Ensure at least 1 car, even if no passengers
|
||||
return Math.max(1, maxPassengers)
|
||||
}, [state.passengers, state.stations])
|
||||
// Use server's authoritative maxConcurrentPassengers calculation
|
||||
// This ensures visual display matches game logic and console logs
|
||||
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
|
||||
|
||||
const carSpacing = 7 // Distance between cars (in % of track)
|
||||
|
||||
@@ -170,13 +163,14 @@ export function SteamTrainJourney({
|
||||
const routeTheme = getRouteTheme(state.currentRoute)
|
||||
|
||||
// Memoize filtered passenger lists to avoid recalculating on every render
|
||||
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
|
||||
const boardedPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => p.isBoarded && !p.isDelivered),
|
||||
() => displayPassengers.filter((p) => p.claimedBy !== null && p.deliveredBy === null),
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
const nonDeliveredPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => !p.isDelivered),
|
||||
() => displayPassengers.filter((p) => p.deliveredBy === null),
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
|
||||
import type { Passenger } from '../../lib/gameTypes'
|
||||
import type { Passenger } from '@/arcade-games/complement-race/types'
|
||||
|
||||
interface TrainCarTransform {
|
||||
x: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import { GameHUD } from '../GameHUD'
|
||||
|
||||
// Mock child components
|
||||
@@ -33,9 +33,11 @@ describe('GameHUD', () => {
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
{ id: 'station3', name: 'Station 3', icon: '🏪', emoji: '🏪', position: 80 },
|
||||
]
|
||||
|
||||
// Mock passengers - initial set
|
||||
// Mock passengers - initial set (multiplayer format)
|
||||
mockPassengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
@@ -55,9 +55,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👩',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
@@ -65,9 +67,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👨',
|
||||
originStationId: 'station2',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -111,18 +115,18 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
|
||||
// Initially 2 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(false)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe(null)
|
||||
|
||||
// Board first passenger
|
||||
const boardedPassengers = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, isBoarded: true } : p
|
||||
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0 } : p
|
||||
)
|
||||
|
||||
rerender({ passengers: boardedPassengers, position: 25 })
|
||||
|
||||
// Should show updated passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
|
||||
})
|
||||
|
||||
test('passengers do NOT update during route transition (train moving)', () => {
|
||||
@@ -153,9 +157,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -196,9 +202,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -239,9 +247,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -316,18 +326,18 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
|
||||
// Initially 2 passengers, neither delivered
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(false)
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe(null)
|
||||
|
||||
// Deliver first passenger
|
||||
const deliveredPassengers = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, isBoarded: true, isDelivered: true } : p
|
||||
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0, deliveredBy: 'player1' } : p
|
||||
)
|
||||
|
||||
rerender({ passengers: deliveredPassengers, position: 55 })
|
||||
|
||||
// Should show updated passengers immediately
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
|
||||
})
|
||||
|
||||
test('multiple rapid passenger updates during same route', () => {
|
||||
@@ -350,25 +360,27 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
|
||||
// Board p1
|
||||
let updated = mockPassengers.map((p) => (p.id === 'p1' ? { ...p, isBoarded: true } : p))
|
||||
let updated = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0 } : p
|
||||
)
|
||||
rerender({ passengers: updated, position: 26 })
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
|
||||
|
||||
// Board p2
|
||||
updated = updated.map((p) => (p.id === 'p2' ? { ...p, isBoarded: true } : p))
|
||||
updated = updated.map((p) => (p.id === 'p2' ? { ...p, claimedBy: 'player1', carIndex: 1 } : p))
|
||||
rerender({ passengers: updated, position: 52 })
|
||||
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[1].claimedBy).toBe('player1')
|
||||
|
||||
// Deliver p1
|
||||
updated = updated.map((p) => (p.id === 'p1' ? { ...p, isDelivered: true } : p))
|
||||
updated = updated.map((p) => (p.id === 'p1' ? { ...p, deliveredBy: 'player1' } : p))
|
||||
rerender({ passengers: updated, position: 53 })
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
|
||||
|
||||
// All updates should have been reflected
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[1].isDelivered).toBe(false)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
|
||||
expect(result.current.displayPassengers[1].claimedBy).toBe('player1')
|
||||
expect(result.current.displayPassengers[1].deliveredBy).toBe(null)
|
||||
})
|
||||
|
||||
test('EDGE CASE: new passengers at position 0 with old route', () => {
|
||||
@@ -402,9 +414,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -445,9 +459,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -483,9 +499,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
|
||||
@@ -60,9 +60,11 @@ describe('useTrackManagement', () => {
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -155,6 +157,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -174,6 +178,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
@@ -200,6 +206,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
@@ -227,6 +235,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: -5 },
|
||||
@@ -250,9 +260,11 @@ describe('useTrackManagement', () => {
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -287,12 +299,15 @@ describe('useTrackManagement', () => {
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'passenger-2',
|
||||
name: 'Passenger 2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -328,7 +343,9 @@ describe('useTrackManagement', () => {
|
||||
})
|
||||
|
||||
test('updates passengers immediately during same route', () => {
|
||||
const updatedPassengers: Passenger[] = [{ ...mockPassengers[0], isBoarded: true }]
|
||||
const updatedPassengers: Passenger[] = [
|
||||
{ ...mockPassengers[0], claimedBy: 'player1', carIndex: 0 },
|
||||
]
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
@@ -368,6 +385,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
import { calculateMaxConcurrentPassengers, generatePassengers } from '../lib/passengerGenerator'
|
||||
import { useSoundEffects } from './useSoundEffects'
|
||||
|
||||
/**
|
||||
@@ -44,26 +43,44 @@ export function useSteamJourney() {
|
||||
const gameStartTimeRef = useRef<number>(0)
|
||||
const lastUpdateRef = useRef<number>(0)
|
||||
const routeExitThresholdRef = useRef<number>(107) // Default for 1 car: 100 + 7
|
||||
const missedPassengersRef = useRef<Set<string>>(new Set()) // Track which passengers have been logged as missed
|
||||
const pendingBoardingRef = useRef<Set<string>>(new Set()) // Track passengers with pending boarding requests across frames
|
||||
const previousTrainPositionRef = useRef<number>(0) // Track previous position to detect threshold crossings
|
||||
|
||||
// Initialize game start time and generate initial passengers
|
||||
// Initialize game start time
|
||||
useEffect(() => {
|
||||
if (state.isGameActive && state.style === 'sprint' && gameStartTimeRef.current === 0) {
|
||||
gameStartTimeRef.current = Date.now()
|
||||
lastUpdateRef.current = Date.now()
|
||||
|
||||
// Generate initial passengers if none exist
|
||||
if (state.passengers.length === 0) {
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
|
||||
// Calculate and store exit threshold for this route
|
||||
const CAR_SPACING = 7
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
const maxCars = Math.max(1, maxPassengers)
|
||||
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
|
||||
}
|
||||
}
|
||||
}, [state.isGameActive, state.style, state.stations, state.passengers.length, dispatch])
|
||||
}, [state.isGameActive, state.style, state.stations, state.passengers])
|
||||
|
||||
// Calculate exit threshold when route changes or config updates
|
||||
useEffect(() => {
|
||||
if (state.passengers.length > 0 && state.stations.length > 0) {
|
||||
const CAR_SPACING = 7
|
||||
// Use server-calculated maxConcurrentPassengers
|
||||
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
|
||||
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
|
||||
}
|
||||
}, [state.currentRoute, state.passengers, state.stations, state.maxConcurrentPassengers])
|
||||
|
||||
// Clean up pendingBoardingRef when passengers are claimed/delivered or route changes
|
||||
useEffect(() => {
|
||||
// Remove passengers from pending set if they've been claimed or delivered
|
||||
state.passengers.forEach((passenger) => {
|
||||
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) {
|
||||
pendingBoardingRef.current.delete(passenger.id)
|
||||
}
|
||||
})
|
||||
}, [state.passengers])
|
||||
|
||||
// Clear all pending boarding requests when route changes
|
||||
useEffect(() => {
|
||||
pendingBoardingRef.current.clear()
|
||||
missedPassengersRef.current.clear()
|
||||
previousTrainPositionRef.current = 0 // Reset previous position for new route
|
||||
}, [state.currentRoute])
|
||||
|
||||
// Momentum decay and position update loop
|
||||
useEffect(() => {
|
||||
@@ -77,116 +94,48 @@ export function useSteamJourney() {
|
||||
|
||||
// Steam Sprint is infinite - no time limit
|
||||
|
||||
// Get decay rate based on timeout setting (skill level)
|
||||
const decayRate =
|
||||
MOMENTUM_DECAY_RATES[state.timeoutSetting as keyof typeof MOMENTUM_DECAY_RATES] ||
|
||||
MOMENTUM_DECAY_RATES.normal
|
||||
|
||||
// Calculate momentum decay for this frame
|
||||
const momentumLoss = (decayRate * deltaTime) / 1000
|
||||
|
||||
// Update momentum (don't go below 0)
|
||||
const newMomentum = Math.max(0, state.momentum - momentumLoss)
|
||||
|
||||
// Calculate speed from momentum (% per second)
|
||||
const speed = newMomentum * SPEED_MULTIPLIER
|
||||
|
||||
// Update train position (accumulate, never go backward)
|
||||
// Allow position to go past 100% so entire train (including cars) can exit tunnel
|
||||
const positionDelta = (speed * deltaTime) / 1000
|
||||
const trainPosition = state.trainPosition + positionDelta
|
||||
|
||||
// Calculate pressure (0-150 PSI) - based on momentum as percentage of max
|
||||
const maxMomentum = 100 // Theoretical max momentum
|
||||
const pressure = Math.min(150, (newMomentum / maxMomentum) * 150)
|
||||
|
||||
// Update state
|
||||
dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: newMomentum,
|
||||
trainPosition,
|
||||
pressure,
|
||||
elapsedTime: elapsed,
|
||||
})
|
||||
// Train position, momentum, and pressure are all managed by the Provider's game loop
|
||||
// This hook only reads those values and handles game logic (boarding, delivery, route completion)
|
||||
const trainPosition = state.trainPosition
|
||||
|
||||
// Check for passengers that should board
|
||||
// Passengers board when an EMPTY car reaches their station
|
||||
const CAR_SPACING = 7 // Must match SteamTrainJourney component
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
|
||||
const maxCars = Math.max(1, maxPassengers)
|
||||
const currentBoardedPassengers = state.passengers.filter((p) => p.isBoarded && !p.isDelivered)
|
||||
// Use server-calculated maxConcurrentPassengers (updates per route based on passenger layout)
|
||||
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
|
||||
|
||||
// Debug logging flag - enable when debugging passenger boarding issues
|
||||
// TO ENABLE: Change this to true, save, and the logs will appear in the browser console
|
||||
// When you see passengers getting left behind, copy the entire console log and paste into Claude Code
|
||||
const DEBUG_PASSENGER_BOARDING = false
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n'.repeat(3))
|
||||
console.log('='.repeat(80))
|
||||
console.log('🚂 PASSENGER BOARDING DEBUG LOG')
|
||||
console.log('='.repeat(80))
|
||||
console.log('ISSUE: Passengers are getting left behind at stations')
|
||||
console.log('PURPOSE: This log captures all state during boarding/delivery logic')
|
||||
console.log('USAGE: Copy this entire log and paste into Claude Code for debugging')
|
||||
console.log('='.repeat(80))
|
||||
console.log('\n📊 CURRENT FRAME STATE:')
|
||||
console.log(` Train Position: ${trainPosition.toFixed(2)}`)
|
||||
console.log(` Speed: ${speed.toFixed(2)}% per second`)
|
||||
console.log(` Momentum: ${newMomentum.toFixed(2)}`)
|
||||
console.log(` Max Cars: ${maxCars}`)
|
||||
console.log(` Car Spacing: ${CAR_SPACING}`)
|
||||
console.log(` Distance Tolerance: 5`)
|
||||
|
||||
console.log('\n🚉 STATIONS:')
|
||||
state.stations.forEach((station) => {
|
||||
console.log(` ${station.emoji} ${station.name} (ID: ${station.id})`)
|
||||
console.log(` Position: ${station.position}`)
|
||||
})
|
||||
|
||||
console.log('\n👥 ALL PASSENGERS:')
|
||||
state.passengers.forEach((p, idx) => {
|
||||
const origin = state.stations.find((s) => s.id === p.originStationId)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
console.log(` [${idx}] ${p.name} (ID: ${p.id})`)
|
||||
// Debug: Log train configuration at start (only once per route)
|
||||
if (trainPosition < 1 && state.passengers.length > 0) {
|
||||
const lastLoggedRoute = (window as any).__lastLoggedRoute || 0
|
||||
if (lastLoggedRoute !== state.currentRoute) {
|
||||
console.log(
|
||||
` Status: ${p.isDelivered ? 'DELIVERED' : p.isBoarded ? 'BOARDED' : 'WAITING'}`
|
||||
`\n🚆 ROUTE ${state.currentRoute} START - Train has ${maxCars} cars (server maxConcurrentPassengers: ${state.maxConcurrentPassengers}) for ${state.passengers.length} passengers`
|
||||
)
|
||||
console.log(
|
||||
` Route: ${origin?.emoji} ${origin?.name} (pos ${origin?.position}) → ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`
|
||||
)
|
||||
console.log(` Urgent: ${p.isUrgent}`)
|
||||
})
|
||||
|
||||
console.log('\n🚃 CAR POSITIONS:')
|
||||
for (let i = 0; i < maxCars; i++) {
|
||||
const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING)
|
||||
console.log(` Car ${i}: position ${carPos.toFixed(2)}`)
|
||||
state.passengers.forEach((p) => {
|
||||
const origin = state.stations.find((s) => s.id === p.originStationId)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
console.log(
|
||||
` 📍 ${p.name}: ${origin?.emoji} ${origin?.name} (${origin?.position}) → ${dest?.emoji} ${dest?.name} (${dest?.position}) ${p.isUrgent ? '⚡' : ''}`
|
||||
)
|
||||
})
|
||||
console.log('') // Blank line for readability
|
||||
;(window as any).__lastLoggedRoute = state.currentRoute
|
||||
}
|
||||
|
||||
console.log('\n🔍 CURRENTLY BOARDED PASSENGERS:')
|
||||
currentBoardedPassengers.forEach((p, carIndex) => {
|
||||
const carPos = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
const distToDest = Math.abs(carPos - (dest?.position || 0))
|
||||
console.log(` Car ${carIndex}: ${p.name}`)
|
||||
console.log(` Car position: ${carPos.toFixed(2)}`)
|
||||
console.log(` Destination: ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`)
|
||||
console.log(` Distance to dest: ${distToDest.toFixed(2)}`)
|
||||
console.log(` Will deliver: ${distToDest < 5 ? 'YES' : 'NO'}`)
|
||||
})
|
||||
}
|
||||
const currentBoardedPassengers = state.passengers.filter(
|
||||
(p) => p.claimedBy !== null && p.deliveredBy === null
|
||||
)
|
||||
|
||||
// FIRST: Identify which passengers will be delivered in this frame
|
||||
const passengersToDeliver = new Set<string>()
|
||||
currentBoardedPassengers.forEach((passenger, carIndex) => {
|
||||
if (!passenger || passenger.isDelivered) return
|
||||
currentBoardedPassengers.forEach((passenger) => {
|
||||
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
// Calculate this passenger's car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
// Calculate this passenger's car position using PHYSICAL carIndex
|
||||
const carPosition = Math.max(0, trainPosition - (passenger.carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If this car is at the destination station (within 5% tolerance), mark for delivery
|
||||
@@ -195,159 +144,161 @@ export function useSteamJourney() {
|
||||
}
|
||||
})
|
||||
|
||||
// Build a map of which cars are occupied (excluding passengers being delivered this frame)
|
||||
// Build a map of which cars are occupied (using PHYSICAL car index, not array index!)
|
||||
// This is critical: passenger.carIndex stores the physical car (0-N) they're seated in
|
||||
const occupiedCars = new Map<number, (typeof currentBoardedPassengers)[0]>()
|
||||
currentBoardedPassengers.forEach((passenger, arrayIndex) => {
|
||||
currentBoardedPassengers.forEach((passenger) => {
|
||||
// Don't count a car as occupied if its passenger is being delivered this frame
|
||||
if (!passengersToDeliver.has(passenger.id)) {
|
||||
occupiedCars.set(arrayIndex, passenger)
|
||||
if (!passengersToDeliver.has(passenger.id) && passenger.carIndex !== null) {
|
||||
occupiedCars.set(passenger.carIndex, passenger) // Use physical carIndex, NOT array index!
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n📦 PASSENGERS TO DELIVER THIS FRAME:')
|
||||
if (passengersToDeliver.size === 0) {
|
||||
console.log(' None')
|
||||
} else {
|
||||
passengersToDeliver.forEach((id) => {
|
||||
const p = state.passengers.find((passenger) => passenger.id === id)
|
||||
console.log(` - ${p?.name} (ID: ${id})`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n🚗 OCCUPIED CARS (after excluding deliveries):')
|
||||
if (occupiedCars.size === 0) {
|
||||
console.log(' All cars are empty')
|
||||
} else {
|
||||
occupiedCars.forEach((passenger, carIndex) => {
|
||||
console.log(` Car ${carIndex}: ${passenger.name}`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n🔄 BOARDING ATTEMPTS:')
|
||||
}
|
||||
|
||||
// Track which cars are assigned in THIS frame to prevent double-boarding
|
||||
const carsAssignedThisFrame = new Set<number>()
|
||||
|
||||
// Find waiting passengers whose origin station has an empty car nearby
|
||||
state.passengers.forEach((passenger) => {
|
||||
if (passenger.isBoarded || passenger.isDelivered) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) return
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
`\n Passenger: ${passenger.name} waiting at ${station.emoji} ${station.name} (pos ${station.position})`
|
||||
)
|
||||
}
|
||||
|
||||
// Check if any empty car is at this station
|
||||
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
|
||||
let boarded = false
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
const isOccupied = occupiedCars.has(carIndex)
|
||||
const isAssigned = carsAssignedThisFrame.has(carIndex)
|
||||
const inRange = distance < 5
|
||||
const occupant = occupiedCars.get(carIndex)
|
||||
|
||||
console.log(` Car ${carIndex} @ pos ${carPosition.toFixed(2)}:`)
|
||||
console.log(` Distance to station: ${distance.toFixed(2)}`)
|
||||
console.log(` In range (<5): ${inRange}`)
|
||||
console.log(
|
||||
` Occupied: ${isOccupied}${isOccupied ? ` (by ${occupant?.name})` : ''}`
|
||||
)
|
||||
console.log(` Assigned this frame: ${isAssigned}`)
|
||||
console.log(` Can board: ${!isOccupied && !isAssigned && inRange}`)
|
||||
}
|
||||
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
|
||||
|
||||
const distance2 = Math.abs(carPosition - station.position)
|
||||
|
||||
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
|
||||
// Increased tolerance to ensure fast-moving trains don't miss passengers
|
||||
if (distance2 < 5) {
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(` ✅ BOARDING ${passenger.name} onto Car ${carIndex}`)
|
||||
}
|
||||
dispatch({
|
||||
type: 'BOARD_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
})
|
||||
// Mark this car as assigned in this frame
|
||||
carsAssignedThisFrame.add(carIndex)
|
||||
boarded = true
|
||||
return // Board this passenger and move on
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING && !boarded) {
|
||||
console.log(` ❌ ${passenger.name} NOT BOARDED - no suitable car found`)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n🎯 DELIVERY ATTEMPTS:')
|
||||
}
|
||||
|
||||
// Check for deliverable passengers
|
||||
// Passengers disembark when THEIR car reaches their destination
|
||||
currentBoardedPassengers.forEach((passenger, carIndex) => {
|
||||
if (!passenger || passenger.isDelivered) return
|
||||
// PRIORITY 1: Process deliveries FIRST (dispatch DELIVER moves before BOARD moves)
|
||||
// This ensures the server frees up cars before processing new boarding requests
|
||||
currentBoardedPassengers.forEach((passenger) => {
|
||||
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
// Calculate this passenger's car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
// Calculate this passenger's car position using PHYSICAL carIndex
|
||||
const carPosition = Math.max(0, trainPosition - (passenger.carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If this car is at the destination station (within 5% tolerance), deliver
|
||||
if (distance < 5) {
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
` ✅ DELIVERING ${passenger.name} from Car ${carIndex} to ${station.emoji} ${station.name}`
|
||||
)
|
||||
console.log(
|
||||
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
|
||||
)
|
||||
}
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
console.log(
|
||||
`🎯 DELIVERY: ${passenger.name} delivered from Car ${passenger.carIndex} to ${station.emoji} ${station.name} (+${points} pts) (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
|
||||
)
|
||||
dispatch({
|
||||
type: 'DELIVER_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
points,
|
||||
})
|
||||
} else if (DEBUG_PASSENGER_BOARDING) {
|
||||
}
|
||||
})
|
||||
|
||||
// Debug: Log car states periodically at stations
|
||||
const isAtStation = state.stations.some((s) => Math.abs(trainPosition - s.position) < 3)
|
||||
if (isAtStation && Math.floor(trainPosition) !== Math.floor(state.trainPosition)) {
|
||||
const nearStation = state.stations.find((s) => Math.abs(trainPosition - s.position) < 3)
|
||||
console.log(
|
||||
`\n🚃 Train arriving at ${nearStation?.emoji} ${nearStation?.name} (trainPos=${trainPosition.toFixed(1)}) - ${maxCars} cars total:`
|
||||
)
|
||||
for (let i = 0; i < maxCars; i++) {
|
||||
const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING)
|
||||
const occupant = occupiedCars.get(i)
|
||||
if (occupant) {
|
||||
const dest = state.stations.find((s) => s.id === occupant.destinationStationId)
|
||||
console.log(
|
||||
` Car ${i}: @ ${carPos.toFixed(1)}% - ${occupant.name} → ${dest?.emoji} ${dest?.name}`
|
||||
)
|
||||
} else {
|
||||
console.log(` Car ${i}: @ ${carPos.toFixed(1)}% - EMPTY`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track which cars are assigned in THIS frame to prevent double-boarding
|
||||
const carsAssignedThisFrame = new Set<number>()
|
||||
// Track which passengers are assigned in THIS frame to prevent same passenger boarding multiple cars
|
||||
const passengersAssignedThisFrame = new Set<string>()
|
||||
|
||||
// PRIORITY 2: Process boardings AFTER deliveries
|
||||
|
||||
// Find waiting passengers whose origin station has an empty car nearby
|
||||
state.passengers.forEach((passenger) => {
|
||||
// Skip if already claimed or delivered (optimistic update marks immediately)
|
||||
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) return
|
||||
|
||||
// Skip if already assigned in this frame OR has a pending boarding request from previous frames
|
||||
if (
|
||||
passengersAssignedThisFrame.has(passenger.id) ||
|
||||
pendingBoardingRef.current.has(passenger.id)
|
||||
)
|
||||
return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) return
|
||||
|
||||
// Don't allow boarding if locomotive has passed too far beyond this station
|
||||
// Station stays open until the LAST car has passed (accounting for train length)
|
||||
const STATION_CLOSURE_BUFFER = 10 // Extra buffer beyond the last car
|
||||
const lastCarOffset = maxCars * CAR_SPACING // Distance from locomotive to last car
|
||||
const stationClosureThreshold = lastCarOffset + STATION_CLOSURE_BUFFER
|
||||
|
||||
if (trainPosition > station.position + stationClosureThreshold) {
|
||||
console.log(
|
||||
` ⏳ ${passenger.name} in Car ${carIndex} heading to ${station.emoji} ${station.name}`
|
||||
`❌ MISSED: ${passenger.name} at ${station.emoji} ${station.name} - train too far past (trainPos=${trainPosition.toFixed(1)}, station=${station.position}, threshold=${stationClosureThreshold})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any empty car is at this station
|
||||
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
|
||||
let closestCarDistance = 999
|
||||
let closestCarReason = ''
|
||||
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
if (distance < closestCarDistance) {
|
||||
closestCarDistance = distance
|
||||
if (occupiedCars.has(carIndex)) {
|
||||
const occupant = occupiedCars.get(carIndex)
|
||||
closestCarReason = `Car ${carIndex} occupied by ${occupant?.name}`
|
||||
} else if (carsAssignedThisFrame.has(carIndex)) {
|
||||
closestCarReason = `Car ${carIndex} just assigned`
|
||||
} else if (distance >= 5) {
|
||||
closestCarReason = `Car ${carIndex} too far (dist=${distance.toFixed(1)})`
|
||||
} else {
|
||||
closestCarReason = 'available'
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
|
||||
|
||||
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
|
||||
if (distance < 5) {
|
||||
console.log(
|
||||
`🚂 BOARDING: ${passenger.name} boarding Car ${carIndex} at ${station.emoji} ${station.name} (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
|
||||
)
|
||||
|
||||
// Mark as pending BEFORE dispatch to prevent duplicate boarding attempts across frames
|
||||
pendingBoardingRef.current.add(passenger.id)
|
||||
|
||||
dispatch({
|
||||
type: 'BOARD_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
carIndex, // Pass physical car index to server
|
||||
})
|
||||
// Mark this car and passenger as assigned in this frame
|
||||
carsAssignedThisFrame.add(carIndex)
|
||||
passengersAssignedThisFrame.add(passenger.id)
|
||||
return // Board this passenger and move on
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, passenger wasn't boarded - log why
|
||||
if (closestCarDistance < 10) {
|
||||
// Only log if train is somewhat near
|
||||
console.log(
|
||||
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
|
||||
`⏸️ WAITING: ${passenger.name} at ${station.emoji} ${station.name} - ${closestCarReason} (trainPos=${trainPosition.toFixed(1)}, maxCars=${maxCars})`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(`\n${'='.repeat(80)}`)
|
||||
console.log('END OF DEBUG LOG')
|
||||
console.log('='.repeat(80))
|
||||
}
|
||||
|
||||
// Check for route completion (entire train exits tunnel)
|
||||
// Use stored threshold (stable for entire route)
|
||||
const ENTIRE_TRAIN_EXIT_THRESHOLD = routeExitThresholdRef.current
|
||||
const previousPosition = previousTrainPositionRef.current
|
||||
|
||||
if (
|
||||
trainPosition >= ENTIRE_TRAIN_EXIT_THRESHOLD &&
|
||||
state.trainPosition < ENTIRE_TRAIN_EXIT_THRESHOLD
|
||||
previousPosition < ENTIRE_TRAIN_EXIT_THRESHOLD
|
||||
) {
|
||||
// Play celebration whistle
|
||||
playSound('train_whistle', 0.6)
|
||||
@@ -357,52 +308,24 @@ export function useSteamJourney() {
|
||||
|
||||
// Auto-advance to next route
|
||||
const nextRoute = state.currentRoute + 1
|
||||
console.log(
|
||||
`🏁 ROUTE COMPLETE: Train crossed exit threshold (${trainPosition.toFixed(1)} >= ${ENTIRE_TRAIN_EXIT_THRESHOLD}). Advancing to Route ${nextRoute}`
|
||||
)
|
||||
dispatch({
|
||||
type: 'START_NEW_ROUTE',
|
||||
routeNumber: nextRoute,
|
||||
stations: state.stations,
|
||||
})
|
||||
|
||||
// Generate new passengers
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
|
||||
// Calculate and store new exit threshold for next route
|
||||
const newMaxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
const newMaxCars = Math.max(1, newMaxPassengers)
|
||||
routeExitThresholdRef.current = 100 + newMaxCars * CAR_SPACING
|
||||
// Note: New passengers will be generated by the server when it handles START_NEW_ROUTE
|
||||
}
|
||||
|
||||
// Update previous position for next frame
|
||||
previousTrainPositionRef.current = trainPosition
|
||||
}, UPDATE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
state.isGameActive,
|
||||
state.style,
|
||||
state.momentum,
|
||||
state.trainPosition,
|
||||
state.timeoutSetting,
|
||||
state.passengers,
|
||||
state.stations,
|
||||
state.currentRoute,
|
||||
dispatch,
|
||||
playSound,
|
||||
])
|
||||
|
||||
// Auto-regenerate passengers when all are delivered
|
||||
useEffect(() => {
|
||||
if (!state.isGameActive || state.style !== 'sprint') return
|
||||
|
||||
// Check if all passengers are delivered
|
||||
const allDelivered = state.passengers.length > 0 && state.passengers.every((p) => p.isDelivered)
|
||||
|
||||
if (allDelivered) {
|
||||
// Generate new passengers after a short delay
|
||||
setTimeout(() => {
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
}, 1000)
|
||||
}
|
||||
}, [state.isGameActive, state.style, state.passengers, state.stations, dispatch])
|
||||
}, [state.isGameActive, state.style, state.timeoutSetting, dispatch, playSound])
|
||||
|
||||
// Add momentum on correct answer
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import { generateLandmarks, type Landmark } from '../lib/landmarks'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
|
||||
@@ -67,7 +67,7 @@ export function useTrackManagement({
|
||||
|
||||
// Apply pending track when train resets to beginning
|
||||
useEffect(() => {
|
||||
if (pendingTrackData && trainPosition < 0) {
|
||||
if (pendingTrackData && trainPosition <= 0) {
|
||||
setTrackData(pendingTrackData)
|
||||
previousRouteRef.current = currentRoute
|
||||
setPendingTrackData(null)
|
||||
@@ -77,22 +77,34 @@ export function useTrackManagement({
|
||||
// Manage passenger display during route transitions
|
||||
useEffect(() => {
|
||||
// Only switch to new passengers when:
|
||||
// 1. Train has reset to start position (< 0) - track has changed, OR
|
||||
// 2. Same route AND train is in middle of track (10-90%) - gameplay updates like boarding/delivering
|
||||
const trainReset = trainPosition < 0
|
||||
// 1. Train has reset to start position (<= 0) - track has changed, OR
|
||||
// 2. Same route AND (in middle of track OR passengers have changed state)
|
||||
const trainReset = trainPosition <= 0
|
||||
const sameRoute = currentRoute === displayRouteRef.current
|
||||
const inMiddleOfTrack = trainPosition >= 10 && trainPosition < 90 // Avoid start/end transition zones
|
||||
|
||||
// Detect if passenger states have changed (boarding or delivery)
|
||||
// This allows updates even when train is past 90% threshold
|
||||
const passengerStatesChanged =
|
||||
sameRoute &&
|
||||
passengers.some((p) => {
|
||||
const oldPassenger = displayPassengers.find((dp) => dp.id === p.id)
|
||||
return (
|
||||
oldPassenger &&
|
||||
(oldPassenger.claimedBy !== p.claimedBy || oldPassenger.deliveredBy !== p.deliveredBy)
|
||||
)
|
||||
})
|
||||
|
||||
if (trainReset) {
|
||||
// Train reset - update to new route's passengers
|
||||
setDisplayPassengers(passengers)
|
||||
displayRouteRef.current = currentRoute
|
||||
} else if (sameRoute && inMiddleOfTrack) {
|
||||
// Same route and train in middle of track - update passengers for gameplay changes (boarding/delivery)
|
||||
} else if (sameRoute && (inMiddleOfTrack || passengerStatesChanged)) {
|
||||
// Same route and either in middle of track OR passenger states changed - update for gameplay
|
||||
setDisplayPassengers(passengers)
|
||||
}
|
||||
// Otherwise, keep displaying old passengers until train resets
|
||||
}, [passengers, trainPosition, currentRoute])
|
||||
}, [passengers, displayPassengers, trainPosition, currentRoute])
|
||||
|
||||
// Generate ties and rails when path is ready
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from './components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from './context/ComplementRaceContext'
|
||||
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
|
||||
|
||||
export default function ComplementRacePage() {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
|
||||
|
||||
export default function PracticeModePage() {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
|
||||
|
||||
export default function SprintModePage() {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
|
||||
|
||||
export default function SurvivalModePage() {
|
||||
return (
|
||||
|
||||
@@ -1,128 +1,315 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { EnhancedChampionArena } from '../../components/EnhancedChampionArena'
|
||||
import { FullscreenProvider, useFullscreen } from '../../contexts/FullscreenContext'
|
||||
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
|
||||
|
||||
function ArcadeContent() {
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const arcadeRef = useRef<HTMLDivElement>(null)
|
||||
/**
|
||||
* /arcade - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*
|
||||
* Shows game selection when no game is set, then shows the game itself once selected.
|
||||
* URL never changes - it's always /arcade regardless of selection, setup, or gameplay.
|
||||
*
|
||||
* Note: We show a friendly message with a link if no room exists to avoid navigation loops.
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
|
||||
useEffect(() => {
|
||||
// Register this component's main div as the fullscreen element
|
||||
if (arcadeRef.current) {
|
||||
setFullscreenElement(arcadeRef.current)
|
||||
}
|
||||
}, [setFullscreenElement])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={arcadeRef}
|
||||
className={css({
|
||||
minHeight: 'calc(100vh - 80px)', // Account for mini nav height
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
py: { base: '4', md: '6' },
|
||||
})}
|
||||
>
|
||||
{/* Animated background elements */}
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: `
|
||||
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(120, 219, 255, 0.2) 0%, transparent 50%)
|
||||
`,
|
||||
animation: 'arcadeFloat 20s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Main Champion Arena - takes remaining space */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
style={{
|
||||
display: 'flex',
|
||||
px: { base: '2', md: '4' },
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
minHeight: 0, // Important for flex children
|
||||
})}
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<EnhancedChampionArena
|
||||
onConfigurePlayer={() => {}}
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
// All games are now in the registry
|
||||
if (hasGame(gameType)) {
|
||||
const gameDef = getGame(gameType)
|
||||
if (!gameDef?.manifest.available) {
|
||||
console.log('[RoomPage] Registry game not available, blocking selection')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Selecting registry game:', gameType)
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: gameType,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Unknown game type:', gameType)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Choose Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '8',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a Game
|
||||
</h1>
|
||||
|
||||
function ArcadePageWithRedirect() {
|
||||
return (
|
||||
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizePlayerSelection={true}>
|
||||
<ArcadeContent />
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
gap: '4',
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Legacy games */}
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]: [string, any]) => {
|
||||
const isAvailable = !('available' in config) || config.available !== false
|
||||
return (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
export default function ArcadePage() {
|
||||
return (
|
||||
<FullscreenProvider>
|
||||
<ArcadePageWithRedirect />
|
||||
</FullscreenProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Arcade-specific animations
|
||||
const arcadeAnimations = `
|
||||
@keyframes arcadeFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
opacity: 0.7;
|
||||
{/* Registry games */}
|
||||
{getAllGames().map((gameDef) => {
|
||||
const isAvailable = gameDef.manifest.available
|
||||
return (
|
||||
<button
|
||||
key={gameDef.manifest.name}
|
||||
onClick={() => handleGameSelect(gameDef.manifest.name)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: gameDef.manifest.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.displayName}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-20px) rotate(1deg);
|
||||
opacity: 1;
|
||||
|
||||
// Check if this is a registry game first
|
||||
if (hasGame(roomData.gameName)) {
|
||||
const gameDef = getGame(roomData.gameName)
|
||||
if (!gameDef) {
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Found"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not found in registry
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Render registry game dynamically
|
||||
const { Provider, GameComponent } = gameDef
|
||||
return (
|
||||
<Provider>
|
||||
<GameComponent />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
66% {
|
||||
transform: translateY(-10px) rotate(-1deg);
|
||||
opacity: 0.8;
|
||||
|
||||
// Render legacy games based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
// TODO: Add other legacy games (complement-race, etc.) once migrated
|
||||
default:
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Available"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes arcadePulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(96, 165, 250, 0.6);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Inject arcade animations
|
||||
if (typeof document !== 'undefined' && !document.getElementById('arcade-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'arcade-animations'
|
||||
style.textContent = arcadeAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
|
||||
|
||||
// Map GameType keys to internal game names
|
||||
// Note: "battle-arena" removed - now handled by game registry as "matching"
|
||||
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
|
||||
'complement-race': 'complement-race',
|
||||
'master-organizer': 'master-organizer',
|
||||
}
|
||||
|
||||
/**
|
||||
* /arcade/room - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*
|
||||
* Shows game selection when no game is set, then shows the game itself once selected.
|
||||
* URL never changes - it's always /arcade/room regardless of selection, setup, or gameplay.
|
||||
*
|
||||
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
|
||||
* Instead, we show a friendly message with a link back to the Champion Arena.
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
// Check if it's a registry game first
|
||||
if (hasGame(gameType)) {
|
||||
const gameDef = getGame(gameType)
|
||||
if (!gameDef?.manifest.available) {
|
||||
console.log('[RoomPage] Registry game not available, blocking selection')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Selecting registry game:', gameType)
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: gameType, // Use the game name directly for registry games
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy game handling
|
||||
const gameConfig = GAMES_CONFIG[gameType as keyof typeof GAMES_CONFIG]
|
||||
if (!gameConfig) {
|
||||
console.log('[RoomPage] Unknown game type:', gameType)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Game config:', {
|
||||
name: gameConfig.name,
|
||||
available: 'available' in gameConfig ? gameConfig.available : true,
|
||||
})
|
||||
|
||||
if ('available' in gameConfig && gameConfig.available === false) {
|
||||
console.log('[RoomPage] Game not available, blocking selection')
|
||||
return // Don't allow selecting unavailable games
|
||||
}
|
||||
|
||||
// Map GameType to internal game name
|
||||
const internalGameName = GAME_TYPE_TO_NAME[gameType]
|
||||
console.log('[RoomPage] Mapping:', {
|
||||
gameType,
|
||||
internalGameName,
|
||||
mappingExists: !!internalGameName,
|
||||
})
|
||||
|
||||
console.log('[RoomPage] Calling setRoomGame with:', {
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
preservingGameConfig: true,
|
||||
})
|
||||
|
||||
// Don't pass gameConfig - we want to preserve existing settings for all games
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Choose Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '8',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a Game
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
gap: '4',
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Legacy games */}
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => {
|
||||
const isAvailable = !('available' in config) || config.available !== false
|
||||
return (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Registry games */}
|
||||
{getAllGames().map((gameDef) => {
|
||||
const isAvailable = gameDef.manifest.available
|
||||
return (
|
||||
<button
|
||||
key={gameDef.manifest.name}
|
||||
onClick={() => handleGameSelect(gameDef.manifest.name)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: gameDef.manifest.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.displayName}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this is a registry game first
|
||||
if (hasGame(roomData.gameName)) {
|
||||
const gameDef = getGame(roomData.gameName)
|
||||
if (!gameDef) {
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Found"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not found in registry
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Render registry game dynamically
|
||||
const { Provider, GameComponent } = gameDef
|
||||
return (
|
||||
<Provider>
|
||||
<GameComponent />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Render legacy games based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
// TODO: Add other legacy games (complement-race, etc.) once migrated
|
||||
default:
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Available"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface SpeechBubbleProps {
|
||||
message: string
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
export function SpeechBubble({ message, onHide }: SpeechBubbleProps) {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-hide after 3.5s (line 11749-11752)
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
setTimeout(onHide, 300) // Wait for fade-out animation
|
||||
}, 3500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [onHide])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 10px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'white',
|
||||
borderRadius: '15px',
|
||||
padding: '10px 15px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
fontSize: '14px',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
maxWidth: '250px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
{/* Tail pointing down */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-8px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '8px solid transparent',
|
||||
borderRight: '8px solid transparent',
|
||||
borderTop: '8px solid white',
|
||||
filter: 'drop-shadow(0 2px 2px rgba(0,0,0,0.1))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
|
||||
export type CommentaryContext =
|
||||
| 'ahead'
|
||||
| 'behind'
|
||||
| 'adaptive_struggle'
|
||||
| 'adaptive_mastery'
|
||||
| 'player_passed'
|
||||
| 'ai_passed'
|
||||
| 'lapped'
|
||||
| 'desperate_catchup'
|
||||
|
||||
// Swift AI - Competitive personality (lines 11768-11834)
|
||||
export const swiftAICommentary: Record<CommentaryContext, string[]> = {
|
||||
ahead: [
|
||||
'💨 Eat my dust!',
|
||||
'🔥 Too slow for me!',
|
||||
"⚡ You can't catch me!",
|
||||
"🚀 I'm built for speed!",
|
||||
'🏃♂️ This is way too easy!',
|
||||
],
|
||||
behind: [
|
||||
'😤 Not over yet!',
|
||||
"💪 I'm just getting started!",
|
||||
'🔥 Watch me catch up to you!',
|
||||
"⚡ I'm coming for you!",
|
||||
'🏃♂️ This is my comeback!',
|
||||
],
|
||||
adaptive_struggle: [
|
||||
'😏 You struggling much?',
|
||||
'🤖 Math is easy for me!',
|
||||
'⚡ You need to think faster!',
|
||||
'🔥 Need me to slow down?',
|
||||
],
|
||||
adaptive_mastery: [
|
||||
"😮 You're actually impressive!",
|
||||
"🤔 You're getting faster...",
|
||||
'😤 Time for me to step it up!',
|
||||
'⚡ Not bad for a human!',
|
||||
],
|
||||
player_passed: [
|
||||
'😠 No way you just passed me!',
|
||||
"🔥 This isn't over!",
|
||||
"💨 I'm just getting warmed up!",
|
||||
"😤 Your lucky streak won't last!",
|
||||
"⚡ I'll be back in front of you soon!",
|
||||
],
|
||||
ai_passed: [
|
||||
'💨 See ya later, slowpoke!',
|
||||
'😎 Thanks for the warm-up!',
|
||||
"🔥 This is how it's done!",
|
||||
"⚡ I'll see you at the finish line!",
|
||||
'💪 Try to keep up with me!',
|
||||
],
|
||||
lapped: [
|
||||
'😡 You just lapped me?! No way!',
|
||||
'🤬 This is embarrassing for me!',
|
||||
"😤 I'm not going down without a fight!",
|
||||
'💢 How did you get so far ahead?!',
|
||||
'🔥 Time to show you my real speed!',
|
||||
"😠 You won't stay ahead for long!",
|
||||
],
|
||||
desperate_catchup: [
|
||||
"🚨 TURBO MODE ACTIVATED! I'm coming for you!",
|
||||
'💥 You forced me to unleash my true power!',
|
||||
'🔥 NO MORE MR. NICE AI! Time to go all out!',
|
||||
"⚡ I'm switching to MAXIMUM OVERDRIVE!",
|
||||
"😤 You made me angry - now you'll see what I can do!",
|
||||
"🚀 AFTERBURNERS ENGAGED! This isn't over!",
|
||||
],
|
||||
}
|
||||
|
||||
// Math Bot - Analytical personality (lines 11835-11901)
|
||||
export const mathBotCommentary: Record<CommentaryContext, string[]> = {
|
||||
ahead: [
|
||||
'📊 My performance is optimal!',
|
||||
'🤖 My logic beats your speed!',
|
||||
'📈 I have 87% win probability!',
|
||||
"⚙️ I'm perfectly calibrated!",
|
||||
'🔬 Science prevails over you!',
|
||||
],
|
||||
behind: [
|
||||
'🤔 Recalculating my strategy...',
|
||||
"📊 You're exceeding my projections!",
|
||||
'⚙️ Adjusting my parameters!',
|
||||
"🔬 I'm analyzing your technique!",
|
||||
"📈 You're a statistical anomaly!",
|
||||
],
|
||||
adaptive_struggle: [
|
||||
'📊 I detect inefficiencies in you!',
|
||||
'🔬 You should focus on patterns!',
|
||||
'⚙️ Use that extra time wisely!',
|
||||
'📈 You have room for improvement!',
|
||||
],
|
||||
adaptive_mastery: [
|
||||
'🤖 Your optimization is excellent!',
|
||||
'📊 Your metrics are impressive!',
|
||||
"⚙️ I'm updating my models because of you!",
|
||||
'🔬 You have near-AI efficiency!',
|
||||
],
|
||||
player_passed: [
|
||||
'🤖 Your strategy is fascinating!',
|
||||
"📊 You're an unexpected variable!",
|
||||
"⚙️ I'm adjusting my algorithms...",
|
||||
'🔬 Your execution is impressive!',
|
||||
"📈 I'm recalculating the odds!",
|
||||
],
|
||||
ai_passed: [
|
||||
'🤖 My efficiency is optimized!',
|
||||
'📊 Just as I calculated!',
|
||||
'⚙️ All my systems nominal!',
|
||||
'🔬 My logic prevails over you!',
|
||||
"📈 I'm at 96% confidence level!",
|
||||
],
|
||||
lapped: [
|
||||
'🤖 Error: You have exceeded my projections!',
|
||||
'📊 This outcome has 0.3% probability!',
|
||||
'⚙️ I need to recalibrate my systems!',
|
||||
'🔬 Your performance is... statistically improbable!',
|
||||
'📈 My confidence level just dropped to 12%!',
|
||||
'🤔 I must analyze your methodology!',
|
||||
],
|
||||
desperate_catchup: [
|
||||
'🤖 EMERGENCY PROTOCOL ACTIVATED! Initiating maximum speed!',
|
||||
'🚨 CRITICAL GAP DETECTED! Engaging catchup algorithms!',
|
||||
'⚙️ OVERCLOCKING MY PROCESSORS! Prepare for rapid acceleration!',
|
||||
'📊 PROBABILITY OF FAILURE: UNACCEPTABLE! Switching to turbo mode!',
|
||||
"🔬 HYPOTHESIS: You're about to see my true potential!",
|
||||
'📈 CONFIDENCE LEVEL: RISING! My comeback protocol is online!',
|
||||
],
|
||||
}
|
||||
|
||||
// Get AI commentary message (lines 11636-11657)
|
||||
export function getAICommentary(
|
||||
racer: AIRacer,
|
||||
context: CommentaryContext,
|
||||
_playerProgress: number,
|
||||
_aiProgress: number
|
||||
): string | null {
|
||||
// Check cooldown (line 11759-11761)
|
||||
const now = Date.now()
|
||||
if (now - racer.lastComment < racer.commentCooldown) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Select message set based on personality and context
|
||||
const messages =
|
||||
racer.personality === 'competitive' ? swiftAICommentary[context] : mathBotCommentary[context]
|
||||
|
||||
if (!messages || messages.length === 0) return null
|
||||
|
||||
// Return random message
|
||||
return messages[Math.floor(Math.random() * messages.length)]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
interface AbacusTargetProps {
|
||||
number: number // The complement number to display
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a small abacus showing a complement number inline in the equation
|
||||
* Used to help learners recognize the abacus representation of complement numbers
|
||||
*/
|
||||
export function AbacusTarget({ number }: AbacusTargetProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns={1}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.72}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { GameControls } from './GameControls'
|
||||
import { GameCountdown } from './GameCountdown'
|
||||
import { GameDisplay } from './GameDisplay'
|
||||
import { GameIntro } from './GameIntro'
|
||||
import { GameResults } from './GameResults'
|
||||
|
||||
export function ComplementRaceGame() {
|
||||
const { state } = useComplementRace()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="game-page-root"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
padding: '20px 8px',
|
||||
minHeight: '100vh',
|
||||
maxHeight: '100vh',
|
||||
background:
|
||||
state.style === 'sprint'
|
||||
? 'linear-gradient(to bottom, #2563eb 0%, #60a5fa 100%)'
|
||||
: 'radial-gradient(ellipse at center, #8db978 0%, #7ba565 40%, #6a9354 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Background pattern - subtle grass texture */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
opacity: 0.15,
|
||||
}}
|
||||
>
|
||||
<svg width="100%" height="100%">
|
||||
<defs>
|
||||
<pattern
|
||||
id="grass-texture"
|
||||
x="0"
|
||||
y="0"
|
||||
width="40"
|
||||
height="40"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<rect width="40" height="40" fill="transparent" />
|
||||
<line x1="2" y1="5" x2="8" y2="5" stroke="#2d5016" strokeWidth="1" opacity="0.3" />
|
||||
<line
|
||||
x1="15"
|
||||
y1="8"
|
||||
x2="20"
|
||||
y2="8"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<line
|
||||
x1="25"
|
||||
y1="12"
|
||||
x2="32"
|
||||
y2="12"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<line
|
||||
x1="5"
|
||||
y1="18"
|
||||
x2="12"
|
||||
y2="18"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<line
|
||||
x1="28"
|
||||
y1="22"
|
||||
x2="35"
|
||||
y2="22"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="30"
|
||||
x2="16"
|
||||
y2="30"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<line
|
||||
x1="22"
|
||||
y1="35"
|
||||
x2="28"
|
||||
y2="35"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grass-texture)" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle tree clusters around edges - top-down view with gentle sway */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
{/* Top-left tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '5%',
|
||||
left: '3%',
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.2,
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway1 8s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top-right tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8%',
|
||||
right: '5%',
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.18,
|
||||
filter: 'blur(5px)',
|
||||
animation: 'treeSway2 10s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom-left tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '10%',
|
||||
left: '8%',
|
||||
width: '90px',
|
||||
height: '90px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.15,
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway1 9s ease-in-out infinite reverse',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom-right tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5%',
|
||||
right: '4%',
|
||||
width: '110px',
|
||||
height: '110px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.2,
|
||||
filter: 'blur(6px)',
|
||||
animation: 'treeSway2 11s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Additional smaller clusters for depth */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '2%',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.12,
|
||||
filter: 'blur(3px)',
|
||||
animation: 'treeSway1 7s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '55%',
|
||||
right: '3%',
|
||||
width: '70px',
|
||||
height: '70px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.14,
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway2 8.5s ease-in-out infinite reverse',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flying bird shadows - very subtle from aerial view */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '30%',
|
||||
left: '-5%',
|
||||
width: '15px',
|
||||
height: '8px',
|
||||
background: 'rgba(0, 0, 0, 0.08)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(2px)',
|
||||
animation: 'birdFly1 20s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: '-5%',
|
||||
width: '12px',
|
||||
height: '6px',
|
||||
background: 'rgba(0, 0, 0, 0.06)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(2px)',
|
||||
animation: 'birdFly2 28s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '45%',
|
||||
left: '-5%',
|
||||
width: '10px',
|
||||
height: '5px',
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(1px)',
|
||||
animation: 'birdFly1 35s linear infinite',
|
||||
animationDelay: '-12s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle cloud shadows moving across field */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: '-20%',
|
||||
width: '300px',
|
||||
height: '200px',
|
||||
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.03) 0%, transparent 60%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(20px)',
|
||||
animation: 'cloudShadow1 45s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: '-20%',
|
||||
width: '250px',
|
||||
height: '180px',
|
||||
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.025) 0%, transparent 60%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(25px)',
|
||||
animation: 'cloudShadow2 60s linear infinite',
|
||||
animationDelay: '-20s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS animations */}
|
||||
<style>{`
|
||||
@keyframes treeSway1 {
|
||||
0%, 100% { transform: scale(1) translate(0, 0); }
|
||||
25% { transform: scale(1.02) translate(2px, -1px); }
|
||||
50% { transform: scale(0.98) translate(-1px, 1px); }
|
||||
75% { transform: scale(1.01) translate(-2px, -1px); }
|
||||
}
|
||||
@keyframes treeSway2 {
|
||||
0%, 100% { transform: scale(1) translate(0, 0); }
|
||||
30% { transform: scale(1.015) translate(-2px, 1px); }
|
||||
60% { transform: scale(0.985) translate(2px, -1px); }
|
||||
80% { transform: scale(1.01) translate(1px, 1px); }
|
||||
}
|
||||
@keyframes birdFly1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 100px), -20vh); }
|
||||
}
|
||||
@keyframes birdFly2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 100px), 15vh); }
|
||||
}
|
||||
@keyframes cloudShadow1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 400px), 30vh); }
|
||||
}
|
||||
@keyframes cloudShadow2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 350px), -20vh); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'intro' && <GameIntro />}
|
||||
{state.gamePhase === 'controls' && <GameControls />}
|
||||
{state.gamePhase === 'countdown' && <GameCountdown />}
|
||||
{state.gamePhase === 'playing' && <GameDisplay />}
|
||||
{state.gamePhase === 'results' && <GameResults />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import type { ComplementDisplay, GameMode, GameStyle, TimeoutSetting } from '../lib/gameTypes'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
|
||||
export function GameControls() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
const handleModeSelect = (mode: GameMode) => {
|
||||
dispatch({ type: 'SET_MODE', mode })
|
||||
}
|
||||
|
||||
const handleStyleSelect = (style: GameStyle) => {
|
||||
dispatch({ type: 'SET_STYLE', style })
|
||||
// Start the game immediately - no navigation needed
|
||||
if (style === 'sprint') {
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
} else {
|
||||
dispatch({ type: 'START_COUNTDOWN' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeoutSelect = (timeout: TimeoutSetting) => {
|
||||
dispatch({ type: 'SET_TIMEOUT', timeout })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'linear-gradient(to bottom, #0f172a 0%, #1e293b 50%, #334155 100%)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Animated background pattern */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 20% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
margin: 0,
|
||||
letterSpacing: '-0.5px',
|
||||
}}
|
||||
>
|
||||
Complement Race
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Settings Bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: '0 20px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Number Mode & Display */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(30, 41, 59, 0.8)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: '16px',
|
||||
padding: '16px',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '20px',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Number Mode Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Mode:
|
||||
</span>
|
||||
{[
|
||||
{ mode: 'friends5' as GameMode, label: '5' },
|
||||
{ mode: 'friends10' as GameMode, label: '10' },
|
||||
{ mode: 'mixed' as GameMode, label: 'Mix' },
|
||||
].map(({ mode, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => handleModeSelect(mode)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.mode === mode
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.mode === mode ? 'white' : '#94a3b8',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Complement Display Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Show:
|
||||
</span>
|
||||
{(['number', 'abacus', 'random'] as ComplementDisplay[]).map((displayMode) => (
|
||||
<button
|
||||
key={displayMode}
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: 'SET_COMPLEMENT_DISPLAY',
|
||||
display: displayMode,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.complementDisplay === displayMode
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.complementDisplay === displayMode ? 'white' : '#94a3b8',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{displayMode === 'number' ? '123' : displayMode === 'abacus' ? '🧮' : '🎲'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Speed Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: '200px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Speed:
|
||||
</span>
|
||||
{(
|
||||
[
|
||||
'preschool',
|
||||
'kindergarten',
|
||||
'relaxed',
|
||||
'slow',
|
||||
'normal',
|
||||
'fast',
|
||||
'expert',
|
||||
] as TimeoutSetting[]
|
||||
).map((timeout) => (
|
||||
<button
|
||||
key={timeout}
|
||||
onClick={() => handleTimeoutSelect(timeout)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.timeoutSetting === timeout
|
||||
? 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.timeoutSetting === timeout ? 'white' : '#94a3b8',
|
||||
fontWeight: state.timeoutSetting === timeout ? 'bold' : 'normal',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
{timeout === 'preschool'
|
||||
? 'Pre'
|
||||
: timeout === 'kindergarten'
|
||||
? 'K'
|
||||
: timeout.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview - compact */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '11px', color: '#94a3b8', fontWeight: '600' }}>Preview:</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
?
|
||||
</div>
|
||||
<span style={{ fontSize: '16px', color: '#64748b' }}>+</span>
|
||||
{state.complementDisplay === 'number' ? (
|
||||
<span>3</span>
|
||||
) : state.complementDisplay === 'abacus' ? (
|
||||
<div style={{ transform: 'scale(0.8)' }}>
|
||||
<AbacusTarget number={3} />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '14px' }}>🎲</span>
|
||||
)}
|
||||
<span style={{ fontSize: '16px', color: '#64748b' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>
|
||||
{state.mode === 'friends5' ? '5' : state.mode === 'friends10' ? '10' : '?'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HERO SECTION - Race Cards */}
|
||||
<div
|
||||
data-component="race-cards-container"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0 20px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
style: 'practice' as GameStyle,
|
||||
emoji: '🏁',
|
||||
title: 'Practice Race',
|
||||
desc: 'Race against AI to 20 correct answers',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
shadowColor: 'rgba(16, 185, 129, 0.5)',
|
||||
accentColor: '#34d399',
|
||||
},
|
||||
{
|
||||
style: 'sprint' as GameStyle,
|
||||
emoji: '🚂',
|
||||
title: 'Steam Sprint',
|
||||
desc: 'High-speed 60-second train journey',
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
shadowColor: 'rgba(245, 158, 11, 0.5)',
|
||||
accentColor: '#fbbf24',
|
||||
},
|
||||
{
|
||||
style: 'survival' as GameStyle,
|
||||
emoji: '🔄',
|
||||
title: 'Survival Circuit',
|
||||
desc: 'Endless laps - beat your best time',
|
||||
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
|
||||
shadowColor: 'rgba(139, 92, 246, 0.5)',
|
||||
accentColor: '#a78bfa',
|
||||
},
|
||||
].map(({ style, emoji, title, desc, gradient, shadowColor, accentColor }) => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => handleStyleSelect(style)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
borderRadius: '24px',
|
||||
background: gradient,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`,
|
||||
transform: 'translateY(0)',
|
||||
flex: 1,
|
||||
minHeight: '140px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-8px) scale(1.02)'
|
||||
e.currentTarget.style.boxShadow = `0 20px 60px ${shadowColor}, 0 0 0 2px ${accentColor}`
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)'
|
||||
e.currentTarget.style.boxShadow = `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`
|
||||
}}
|
||||
>
|
||||
{/* Shine effect overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '28px 32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
lineHeight: 1,
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))',
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
marginBottom: '6px',
|
||||
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
textShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PLAY NOW button */}
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
color: gradient.includes('10b981')
|
||||
? '#047857'
|
||||
: gradient.includes('f59e0b')
|
||||
? '#d97706'
|
||||
: '#6b21a8',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '18px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span>PLAY</span>
|
||||
<span style={{ fontSize: '24px' }}>▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
|
||||
export function GameCountdown() {
|
||||
const { dispatch } = useComplementRace()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [count, setCount] = useState(3)
|
||||
const [showGo, setShowGo] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const countdownInterval = setInterval(() => {
|
||||
setCount((prevCount) => {
|
||||
if (prevCount > 1) {
|
||||
// Play countdown beep (volume 0.4)
|
||||
playSound('countdown', 0.4)
|
||||
return prevCount - 1
|
||||
} else if (prevCount === 1) {
|
||||
// Show GO!
|
||||
setShowGo(true)
|
||||
// Play race start fanfare (volume 0.6)
|
||||
playSound('race_start', 0.6)
|
||||
return 0
|
||||
}
|
||||
return prevCount
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(countdownInterval)
|
||||
}, [playSound])
|
||||
|
||||
useEffect(() => {
|
||||
if (showGo) {
|
||||
// Hide countdown and start game after GO animation
|
||||
const timer = setTimeout(() => {
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showGo, dispatch])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0, 0, 0, 0.9)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: showGo ? '120px' : '160px',
|
||||
fontWeight: 'bold',
|
||||
color: showGo ? '#10b981' : 'white',
|
||||
textShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
|
||||
animation: showGo ? 'scaleUp 1s ease-out' : 'pulse 0.5s ease-in-out',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{showGo ? 'GO!' : count}
|
||||
</div>
|
||||
|
||||
{!showGo && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '32px',
|
||||
fontSize: '24px',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Get Ready!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
}
|
||||
@keyframes scaleUp {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
50% { transform: scale(1.2); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
|
||||
import { useAIRacers } from '../hooks/useAIRacers'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
import { useSteamJourney } from '../hooks/useSteamJourney'
|
||||
import { generatePassengers } from '../lib/passengerGenerator'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
import { CircularTrack } from './RaceTrack/CircularTrack'
|
||||
import { LinearTrack } from './RaceTrack/LinearTrack'
|
||||
import { SteamTrainJourney } from './RaceTrack/SteamTrainJourney'
|
||||
import { RouteCelebration } from './RouteCelebration'
|
||||
|
||||
type FeedbackAnimation = 'correct' | 'incorrect' | null
|
||||
|
||||
export function GameDisplay() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
useAIRacers() // Activate AI racer updates (not used in sprint mode)
|
||||
const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty()
|
||||
const { boostMomentum } = useSteamJourney()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [feedbackAnimation, setFeedbackAnimation] = useState<FeedbackAnimation>(null)
|
||||
|
||||
// Clear feedback animation after it plays (line 1996, 2001)
|
||||
useEffect(() => {
|
||||
if (feedbackAnimation) {
|
||||
const timer = setTimeout(() => {
|
||||
setFeedbackAnimation(null)
|
||||
}, 500) // Match animation duration
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [feedbackAnimation])
|
||||
|
||||
// Show adaptive feedback with auto-hide
|
||||
useEffect(() => {
|
||||
if (state.adaptiveFeedback) {
|
||||
const timer = setTimeout(() => {
|
||||
dispatch({ type: 'CLEAR_ADAPTIVE_FEEDBACK' })
|
||||
}, 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [state.adaptiveFeedback, dispatch])
|
||||
|
||||
// Check for finish line (player reaches race goal) - only for practice mode
|
||||
useEffect(() => {
|
||||
if (
|
||||
state.correctAnswers >= state.raceGoal &&
|
||||
state.isGameActive &&
|
||||
state.style === 'practice'
|
||||
) {
|
||||
// Play celebration sound (line 14182)
|
||||
playSound('celebration')
|
||||
// End the game
|
||||
dispatch({ type: 'END_RACE' })
|
||||
// Show results after a short delay
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'SHOW_RESULTS' })
|
||||
}, 1500)
|
||||
}
|
||||
}, [state.correctAnswers, state.raceGoal, state.isGameActive, state.style, dispatch, playSound])
|
||||
|
||||
// For survival mode (endless circuit), track laps but never end
|
||||
// For sprint mode (steam sprint), end after 60 seconds (will implement later)
|
||||
|
||||
// Handle keyboard input
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
// Only process number keys
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
const newInput = state.currentInput + e.key
|
||||
dispatch({ type: 'UPDATE_INPUT', input: newInput })
|
||||
|
||||
// Check if answer is complete
|
||||
if (state.currentQuestion) {
|
||||
const answer = parseInt(newInput, 10)
|
||||
const correctAnswer = state.currentQuestion.correctAnswer
|
||||
|
||||
// If we have enough digits to match the answer, submit
|
||||
if (newInput.length >= correctAnswer.toString().length) {
|
||||
const responseTime = Date.now() - state.questionStartTime
|
||||
const isCorrect = answer === correctAnswer
|
||||
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
|
||||
|
||||
if (isCorrect) {
|
||||
// Correct answer
|
||||
dispatch({ type: 'SUBMIT_ANSWER', answer })
|
||||
trackPerformance(true, responseTime)
|
||||
|
||||
// Trigger correct answer animation (line 1996)
|
||||
setFeedbackAnimation('correct')
|
||||
|
||||
// Play appropriate sound based on performance (from web_generator.py lines 11530-11542)
|
||||
const newStreak = state.streak + 1
|
||||
if (newStreak > 0 && newStreak % 5 === 0) {
|
||||
// Epic streak sound for every 5th correct answer
|
||||
playSound('streak')
|
||||
} else if (responseTime < 800) {
|
||||
// Whoosh sound for very fast responses (under 800ms)
|
||||
playSound('whoosh')
|
||||
} else if (responseTime < 1200 && state.streak >= 3) {
|
||||
// Combo sound for rapid answers while on a streak
|
||||
playSound('combo')
|
||||
} else {
|
||||
// Regular correct sound
|
||||
playSound('correct')
|
||||
}
|
||||
|
||||
// Boost momentum for sprint mode
|
||||
if (state.style === 'sprint') {
|
||||
boostMomentum()
|
||||
|
||||
// Play train whistle for milestones in sprint mode (line 13222-13235)
|
||||
if (newStreak >= 5 && newStreak % 3 === 0) {
|
||||
// Major milestone - play train whistle
|
||||
setTimeout(() => {
|
||||
playSound('train_whistle', 0.4)
|
||||
}, 200)
|
||||
} else if (state.momentum >= 90) {
|
||||
// High momentum celebration - occasional whistle
|
||||
if (Math.random() < 0.3) {
|
||||
setTimeout(() => {
|
||||
playSound('train_whistle', 0.25)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show adaptive feedback
|
||||
const feedback = getAdaptiveFeedbackMessage(pairKey, true, responseTime)
|
||||
if (feedback) {
|
||||
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
|
||||
}
|
||||
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
} else {
|
||||
// Incorrect answer
|
||||
trackPerformance(false, responseTime)
|
||||
|
||||
// Trigger incorrect answer animation (line 2001)
|
||||
setFeedbackAnimation('incorrect')
|
||||
|
||||
// Play incorrect sound (from web_generator.py line 11589)
|
||||
playSound('incorrect')
|
||||
|
||||
// Show adaptive feedback
|
||||
const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime)
|
||||
if (feedback) {
|
||||
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
|
||||
}
|
||||
|
||||
dispatch({ type: 'UPDATE_INPUT', input: '' })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Backspace') {
|
||||
dispatch({
|
||||
type: 'UPDATE_INPUT',
|
||||
input: state.currentInput.slice(0, -1),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress)
|
||||
return () => window.removeEventListener('keydown', handleKeyPress)
|
||||
}, [
|
||||
state.currentInput,
|
||||
state.currentQuestion,
|
||||
state.questionStartTime,
|
||||
state.style,
|
||||
state.streak,
|
||||
dispatch,
|
||||
trackPerformance,
|
||||
getAdaptiveFeedbackMessage,
|
||||
boostMomentum,
|
||||
playSound,
|
||||
state.momentum,
|
||||
])
|
||||
|
||||
// Handle route celebration continue
|
||||
const handleContinueToNextRoute = () => {
|
||||
const nextRoute = state.currentRoute + 1
|
||||
|
||||
// Start new route (this also hides celebration)
|
||||
dispatch({
|
||||
type: 'START_NEW_ROUTE',
|
||||
routeNumber: nextRoute,
|
||||
stations: state.stations, // Keep same stations for now
|
||||
})
|
||||
|
||||
// Generate new passengers
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
}
|
||||
|
||||
if (!state.currentQuestion) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="game-display"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Adaptive Feedback */}
|
||||
{state.adaptiveFeedback && (
|
||||
<div
|
||||
data-component="adaptive-feedback"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '80px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 20px rgba(102, 126, 234, 0.4)',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
zIndex: 1000,
|
||||
animation: 'slideDown 0.3s ease-out',
|
||||
maxWidth: '600px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{state.adaptiveFeedback.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Header - constrained width, hidden for sprint mode */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
data-component="stats-container"
|
||||
style={{
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
padding: '0 20px',
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="stats-header"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
marginBottom: '10px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '10px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div data-stat="score" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Score
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: '24px',
|
||||
color: '#3b82f6',
|
||||
}}
|
||||
>
|
||||
{state.score}
|
||||
</div>
|
||||
</div>
|
||||
<div data-stat="streak" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Streak
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: '24px',
|
||||
color: '#10b981',
|
||||
}}
|
||||
>
|
||||
{state.streak} 🔥
|
||||
</div>
|
||||
</div>
|
||||
<div data-stat="progress" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Progress
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: '24px',
|
||||
color: '#f59e0b',
|
||||
}}
|
||||
>
|
||||
{state.correctAnswers}/{state.raceGoal}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Race Track - full width, break out of padding */}
|
||||
<div
|
||||
data-component="track-container"
|
||||
style={{
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
left: '50%',
|
||||
right: '50%',
|
||||
marginLeft: '-50vw',
|
||||
marginRight: '-50vw',
|
||||
padding: state.style === 'sprint' ? '0' : '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: state.style === 'sprint' ? 'stretch' : 'center',
|
||||
background: 'transparent',
|
||||
flex: state.style === 'sprint' ? 1 : 'initial',
|
||||
minHeight: state.style === 'sprint' ? 0 : 'initial',
|
||||
}}
|
||||
>
|
||||
{state.style === 'survival' ? (
|
||||
<CircularTrack
|
||||
playerProgress={state.correctAnswers}
|
||||
playerLap={state.playerLap}
|
||||
aiRacers={state.aiRacers}
|
||||
aiLaps={state.aiLaps}
|
||||
/>
|
||||
) : state.style === 'sprint' ? (
|
||||
<SteamTrainJourney
|
||||
momentum={state.momentum}
|
||||
trainPosition={state.trainPosition}
|
||||
pressure={state.pressure}
|
||||
elapsedTime={state.elapsedTime}
|
||||
currentQuestion={state.currentQuestion}
|
||||
currentInput={state.currentInput}
|
||||
/>
|
||||
) : (
|
||||
<LinearTrack
|
||||
playerProgress={state.correctAnswers}
|
||||
aiRacers={state.aiRacers}
|
||||
raceGoal={state.raceGoal}
|
||||
showFinishLine={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Question Display - only for non-sprint modes */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
data-component="question-container"
|
||||
style={{
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
padding: '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="question-display"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
borderRadius: '24px',
|
||||
padding: '28px 50px',
|
||||
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.3), 0 0 0 5px rgba(59, 130, 246, 0.4)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '4px solid rgba(255, 255, 255, 0.95)',
|
||||
}}
|
||||
>
|
||||
{/* Complement equation as main focus */}
|
||||
<div
|
||||
data-element="question-equation"
|
||||
style={{
|
||||
fontSize: '96px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
lineHeight: '1.1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '16px',
|
||||
minWidth: '140px',
|
||||
display: 'inline-block',
|
||||
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
{state.currentInput || '?'}
|
||||
</span>
|
||||
<span style={{ color: '#6b7280' }}>+</span>
|
||||
{state.currentQuestion.showAsAbacus ? (
|
||||
<div
|
||||
style={{
|
||||
transform: 'scale(2.4) translateY(8%)',
|
||||
transformOrigin: 'center center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusTarget number={state.currentQuestion.number} />
|
||||
</div>
|
||||
) : (
|
||||
<span>{state.currentQuestion.number}</span>
|
||||
)}
|
||||
<span style={{ color: '#6b7280' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>{state.currentQuestion.targetSum}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route Celebration Modal */}
|
||||
{state.showRouteCelebration && state.style === 'sprint' && (
|
||||
<RouteCelebration
|
||||
completedRouteNumber={state.currentRoute}
|
||||
nextRouteNumber={state.currentRoute + 1}
|
||||
onContinue={handleContinueToNextRoute}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
|
||||
export function GameIntro() {
|
||||
const { dispatch } = useComplementRace()
|
||||
|
||||
const handleStartClick = () => {
|
||||
dispatch({ type: 'SHOW_CONTROLS' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
maxWidth: '800px',
|
||||
margin: '20px auto 0',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '48px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
Speed Complement Race
|
||||
</h1>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '32px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
>
|
||||
Race against AI opponents while solving complement problems! Find the missing number to
|
||||
complete the equation.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
marginBottom: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
How to Play
|
||||
</h2>
|
||||
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🎯</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Find the complement number to reach the target sum
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>⚡</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Type your answer quickly to move forward in the race
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🤖</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Compete against Swift AI and Math Bot with unique personalities
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🏆</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Earn points for correct answers and build up your streak
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleStartClick}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 48px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(16, 185, 129, 0.4)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.3)'
|
||||
}}
|
||||
>
|
||||
Start Racing!
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
|
||||
export function GameResults() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
// Determine race outcome
|
||||
const playerWon = state.aiRacers.every((racer) => state.correctAnswers > racer.position)
|
||||
const playerPosition =
|
||||
state.aiRacers.filter((racer) => racer.position >= state.correctAnswers).length + 1
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '60px 40px 40px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '24px',
|
||||
padding: '48px',
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Result Header */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
{playerWon ? '🏆' : playerPosition === 2 ? '🥈' : playerPosition === 3 ? '🥉' : '🎯'}
|
||||
</div>
|
||||
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '36px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{playerWon ? 'Victory!' : `${playerPosition}${getOrdinalSuffix(playerPosition)} Place`}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
{playerWon ? 'You beat all the AI racers!' : `You finished the race!`}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '16px',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Final Score
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#3b82f6' }}>
|
||||
{state.score}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Best Streak
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#10b981' }}>
|
||||
{state.bestStreak} 🔥
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Total Questions
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#f59e0b' }}>
|
||||
{state.totalQuestions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Accuracy
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#8b5cf6' }}>
|
||||
{state.totalQuestions > 0
|
||||
? Math.round((state.correctAnswers / state.totalQuestions) * 100)
|
||||
: 0}
|
||||
%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final Standings */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '32px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
Final Standings
|
||||
</h3>
|
||||
|
||||
{[
|
||||
{ name: 'You', position: state.correctAnswers, icon: '👤' },
|
||||
...state.aiRacers.map((racer) => ({
|
||||
name: racer.name,
|
||||
position: racer.position,
|
||||
icon: racer.icon,
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map((racer, index) => (
|
||||
<div
|
||||
key={racer.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px',
|
||||
background: racer.name === 'You' ? '#eff6ff' : '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '8px',
|
||||
border: racer.name === 'You' ? '2px solid #3b82f6' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: '#9ca3af',
|
||||
minWidth: '32px',
|
||||
}}
|
||||
>
|
||||
#{index + 1}
|
||||
</div>
|
||||
<div style={{ fontSize: '20px' }}>{racer.icon}</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: racer.name === 'You' ? 'bold' : 'normal',
|
||||
}}
|
||||
>
|
||||
{racer.name}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
{Math.floor(racer.position)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'RESET_GAME' })}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.4)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
}}
|
||||
>
|
||||
Race Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getOrdinalSuffix(num: number): string {
|
||||
if (num === 1) return 'st'
|
||||
if (num === 2) return 'nd'
|
||||
if (num === 3) return 'rd'
|
||||
return 'th'
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
|
||||
interface PassengerCardProps {
|
||||
passenger: Passenger
|
||||
originStation: Station | undefined
|
||||
destinationStation: Station | undefined
|
||||
}
|
||||
|
||||
export const PassengerCard = memo(function PassengerCard({
|
||||
passenger,
|
||||
originStation,
|
||||
destinationStation,
|
||||
}: PassengerCardProps) {
|
||||
if (!destinationStation || !originStation) return null
|
||||
|
||||
// Vintage train station colors
|
||||
const bgColor = passenger.isDelivered
|
||||
? '#1a3a1a' // Dark green for delivered
|
||||
: !passenger.isBoarded
|
||||
? '#2a2419' // Dark brown/sepia for waiting
|
||||
: passenger.isUrgent
|
||||
? '#3a2419' // Dark red-brown for urgent
|
||||
: '#1a2a3a' // Dark blue for aboard
|
||||
|
||||
const accentColor = passenger.isDelivered
|
||||
? '#4ade80' // Green
|
||||
: !passenger.isBoarded
|
||||
? '#d4af37' // Gold for waiting
|
||||
: passenger.isUrgent
|
||||
? '#ff6b35' // Orange-red for urgent
|
||||
: '#60a5fa' // Blue for aboard
|
||||
|
||||
const borderColor =
|
||||
passenger.isUrgent && passenger.isBoarded && !passenger.isDelivered ? '#ff6b35' : '#d4af37'
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: bgColor,
|
||||
border: `2px solid ${borderColor}`,
|
||||
borderRadius: '4px',
|
||||
padding: '8px 10px',
|
||||
minWidth: '220px',
|
||||
maxWidth: '280px',
|
||||
boxShadow:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
? '0 0 16px rgba(255, 107, 53, 0.5)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
position: 'relative',
|
||||
fontFamily: '"Courier New", Courier, monospace',
|
||||
animation:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
? 'urgentFlicker 1.5s ease-in-out infinite'
|
||||
: 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* Top row: Passenger info and status */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '6px',
|
||||
borderBottom: `1px solid ${accentColor}33`,
|
||||
paddingBottom: '4px',
|
||||
paddingRight: '42px', // Make room for points badge
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '20px', lineHeight: '1' }}>
|
||||
{passenger.isDelivered ? '✅' : passenger.avatar}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: accentColor,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{passenger.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
color: accentColor,
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: '0.5px',
|
||||
background: `${accentColor}22`,
|
||||
padding: '2px 6px',
|
||||
borderRadius: '2px',
|
||||
border: `1px solid ${accentColor}66`,
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: '0',
|
||||
}}
|
||||
>
|
||||
{passenger.isDelivered ? 'DLVRD' : passenger.isBoarded ? 'BOARD' : 'WAIT'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route information */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
color: '#e8d4a0',
|
||||
}}
|
||||
>
|
||||
{/* From station */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: accentColor,
|
||||
fontSize: '8px',
|
||||
fontWeight: 'bold',
|
||||
width: '28px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
FROM:
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', lineHeight: '1' }}>{originStation.icon}</span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
{originStation.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* To station */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: accentColor,
|
||||
fontSize: '8px',
|
||||
fontWeight: 'bold',
|
||||
width: '28px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
TO:
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', lineHeight: '1' }}>{destinationStation.icon}</span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
{destinationStation.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points badge */}
|
||||
{!passenger.isDelivered && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '6px',
|
||||
right: '6px',
|
||||
background: `${accentColor}33`,
|
||||
border: `1px solid ${accentColor}`,
|
||||
borderRadius: '2px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
color: accentColor,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{passenger.isUrgent ? '+20' : '+10'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Urgent indicator */}
|
||||
{passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '8px',
|
||||
bottom: '6px',
|
||||
fontSize: '10px',
|
||||
animation: 'urgentBlink 0.8s ease-in-out infinite',
|
||||
filter: 'drop-shadow(0 0 4px rgba(255, 107, 53, 0.8))',
|
||||
}}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes urgentFlicker {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 16px rgba(255, 107, 53, 0.5);
|
||||
border-color: #ff6b35;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 24px rgba(255, 107, 53, 0.8);
|
||||
border-color: #ffaa35;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes urgentBlink {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,180 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
interface PressureGaugeProps {
|
||||
pressure: number // 0-150 PSI
|
||||
}
|
||||
|
||||
export function PressureGauge({ pressure }: PressureGaugeProps) {
|
||||
const maxPressure = 150
|
||||
|
||||
// Animate pressure value smoothly with spring physics
|
||||
const spring = useSpring({
|
||||
pressure,
|
||||
config: {
|
||||
tension: 120,
|
||||
friction: 14,
|
||||
clamp: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Calculate needle angle - sweeps 180° from left to right
|
||||
// 0 PSI = 180° (pointing left), 150 PSI = 0° (pointing right)
|
||||
const angle = spring.pressure.to((p) => 180 - (p / maxPressure) * 180)
|
||||
|
||||
// Get pressure color (animated)
|
||||
const color = spring.pressure.to((p) => {
|
||||
if (p < 50) return '#ef4444' // Red (low)
|
||||
if (p < 100) return '#f59e0b' // Orange (medium)
|
||||
return '#10b981' // Green (high)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
minWidth: '220px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '8px',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
PRESSURE
|
||||
</div>
|
||||
|
||||
{/* SVG Gauge */}
|
||||
<svg
|
||||
viewBox="-40 -20 280 170"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{/* Background arc - semicircle from left to right (bottom half) */}
|
||||
<path
|
||||
d="M 20 100 A 80 80 0 0 1 180 100"
|
||||
fill="none"
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Tick marks */}
|
||||
{[0, 50, 100, 150].map((psi, index) => {
|
||||
// Angle from 180° (left) to 0° (right)
|
||||
const tickAngle = 180 - (psi / maxPressure) * 180
|
||||
const tickRad = (tickAngle * Math.PI) / 180
|
||||
const x1 = 100 + Math.cos(tickRad) * 70
|
||||
const y1 = 100 - Math.sin(tickRad) * 70 // Subtract for SVG coords
|
||||
const x2 = 100 + Math.cos(tickRad) * 80
|
||||
const y2 = 100 - Math.sin(tickRad) * 80 // Subtract for SVG coords
|
||||
|
||||
// Position for abacus label
|
||||
const labelX = 100 + Math.cos(tickRad) * 112
|
||||
const labelY = 100 - Math.sin(tickRad) * 112
|
||||
|
||||
return (
|
||||
<g key={`tick-${index}`}>
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="#6b7280"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<foreignObject x={labelX - 30} y={labelY - 25} width="60" height="100">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={psi}
|
||||
columns={3}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
hideInactiveBeads={false}
|
||||
scaleFactor={0.6}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Center pivot */}
|
||||
<circle cx="100" cy="100" r="4" fill="#1f2937" />
|
||||
|
||||
{/* Needle - animated */}
|
||||
<animated.line
|
||||
x1="100"
|
||||
y1="100"
|
||||
x2={angle.to((a) => 100 + Math.cos((a * Math.PI) / 180) * 70)}
|
||||
y2={angle.to((a) => 100 - Math.sin((a * Math.PI) / 180) * 70)}
|
||||
stroke={color}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
style={{
|
||||
filter: color.to((c) => `drop-shadow(0 2px 3px ${c})`),
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Abacus readout */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
minHeight: '32px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={Math.round(pressure)}
|
||||
columns={3}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.35}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', color: '#6b7280', fontWeight: 'bold' }}>PSI</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import { useSoundEffects } from '../../hooks/useSoundEffects'
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
import { SpeechBubble } from '../AISystem/SpeechBubble'
|
||||
|
||||
interface CircularTrackProps {
|
||||
playerProgress: number
|
||||
playerLap: number
|
||||
aiRacers: AIRacer[]
|
||||
aiLaps: Map<string, number>
|
||||
}
|
||||
|
||||
export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: CircularTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [celebrationCooldown, setCelebrationCooldown] = useState<Set<string>>(new Set())
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
const [dimensions, setDimensions] = useState({ width: 600, height: 400 })
|
||||
|
||||
// Update dimensions on mount and resize
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const isLandscape = vw > vh
|
||||
|
||||
if (isLandscape) {
|
||||
// Landscape: wider track (emphasize horizontal straights)
|
||||
const width = Math.min(vw * 0.75, 800)
|
||||
const height = Math.min(vh * 0.5, 350)
|
||||
setDimensions({ width, height })
|
||||
} else {
|
||||
// Portrait: taller track (emphasize vertical straights)
|
||||
const width = Math.min(vw * 0.85, 350)
|
||||
const height = Math.min(vh * 0.5, 550)
|
||||
setDimensions({ width, height })
|
||||
}
|
||||
}
|
||||
|
||||
updateDimensions()
|
||||
window.addEventListener('resize', updateDimensions)
|
||||
return () => window.removeEventListener('resize', updateDimensions)
|
||||
}, [])
|
||||
|
||||
const padding = 40
|
||||
const trackWidth = dimensions.width - padding * 2
|
||||
const trackHeight = dimensions.height - padding * 2
|
||||
|
||||
// For a rounded rectangle track, we have straight sections and curved ends
|
||||
const straightLength = Math.max(trackWidth, trackHeight) - Math.min(trackWidth, trackHeight)
|
||||
const radius = Math.min(trackWidth, trackHeight) / 2
|
||||
const isHorizontal = trackWidth > trackHeight
|
||||
|
||||
// Calculate position on rounded rectangle track
|
||||
const getCircularPosition = (progress: number) => {
|
||||
const progressPerLap = 50
|
||||
const normalizedProgress = (progress % progressPerLap) / progressPerLap
|
||||
|
||||
// Track perimeter consists of: 2 straights + 2 semicircles
|
||||
const straightPerim = straightLength
|
||||
const curvePerim = Math.PI * radius
|
||||
const totalPerim = 2 * straightPerim + 2 * curvePerim
|
||||
|
||||
const distanceAlongTrack = normalizedProgress * totalPerim
|
||||
|
||||
const centerX = dimensions.width / 2
|
||||
const centerY = dimensions.height / 2
|
||||
|
||||
let x: number, y: number, angle: number
|
||||
|
||||
if (isHorizontal) {
|
||||
// Horizontal track: straight sections on top/bottom, curves on left/right
|
||||
const topStraightEnd = straightPerim
|
||||
const rightCurveEnd = topStraightEnd + curvePerim
|
||||
const bottomStraightEnd = rightCurveEnd + straightPerim
|
||||
const _leftCurveEnd = bottomStraightEnd + curvePerim
|
||||
|
||||
if (distanceAlongTrack < topStraightEnd) {
|
||||
// Top straight (moving right)
|
||||
const t = distanceAlongTrack / straightPerim
|
||||
x = centerX - straightLength / 2 + t * straightLength
|
||||
y = centerY - radius
|
||||
angle = 90
|
||||
} else if (distanceAlongTrack < rightCurveEnd) {
|
||||
// Right curve
|
||||
const curveProgress = (distanceAlongTrack - topStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI - Math.PI / 2
|
||||
x = centerX + straightLength / 2 + radius * Math.cos(curveAngle)
|
||||
y = centerY + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180 + 90
|
||||
} else if (distanceAlongTrack < bottomStraightEnd) {
|
||||
// Bottom straight (moving left)
|
||||
const t = (distanceAlongTrack - rightCurveEnd) / straightPerim
|
||||
x = centerX + straightLength / 2 - t * straightLength
|
||||
y = centerY + radius
|
||||
angle = 270
|
||||
} else {
|
||||
// Left curve
|
||||
const curveProgress = (distanceAlongTrack - bottomStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI + Math.PI / 2
|
||||
x = centerX - straightLength / 2 + radius * Math.cos(curveAngle)
|
||||
y = centerY + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180 + 270
|
||||
}
|
||||
} else {
|
||||
// Vertical track: straight sections on left/right, curves on top/bottom
|
||||
const leftStraightEnd = straightPerim
|
||||
const bottomCurveEnd = leftStraightEnd + curvePerim
|
||||
const rightStraightEnd = bottomCurveEnd + straightPerim
|
||||
const _topCurveEnd = rightStraightEnd + curvePerim
|
||||
|
||||
if (distanceAlongTrack < leftStraightEnd) {
|
||||
// Left straight (moving down)
|
||||
const t = distanceAlongTrack / straightPerim
|
||||
x = centerX - radius
|
||||
y = centerY - straightLength / 2 + t * straightLength
|
||||
angle = 180
|
||||
} else if (distanceAlongTrack < bottomCurveEnd) {
|
||||
// Bottom curve
|
||||
const curveProgress = (distanceAlongTrack - leftStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI
|
||||
x = centerX + radius * Math.cos(curveAngle)
|
||||
y = centerY + straightLength / 2 + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180 + 180
|
||||
} else if (distanceAlongTrack < rightStraightEnd) {
|
||||
// Right straight (moving up)
|
||||
const t = (distanceAlongTrack - bottomCurveEnd) / straightPerim
|
||||
x = centerX + radius
|
||||
y = centerY + straightLength / 2 - t * straightLength
|
||||
angle = 0
|
||||
} else {
|
||||
// Top curve
|
||||
const curveProgress = (distanceAlongTrack - rightStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI + Math.PI
|
||||
x = centerX + radius * Math.cos(curveAngle)
|
||||
y = centerY - straightLength / 2 + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y, angle }
|
||||
}
|
||||
|
||||
// Check for lap completions and show celebrations
|
||||
useEffect(() => {
|
||||
// Check player lap
|
||||
const playerCurrentLap = Math.floor(playerProgress / 50)
|
||||
if (playerCurrentLap > playerLap && !celebrationCooldown.has('player')) {
|
||||
dispatch({ type: 'COMPLETE_LAP', racerId: 'player' })
|
||||
// Play celebration sound (line 12801)
|
||||
playSound('lap_celebration', 0.6)
|
||||
setCelebrationCooldown((prev) => new Set(prev).add('player'))
|
||||
setTimeout(() => {
|
||||
setCelebrationCooldown((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete('player')
|
||||
return next
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Check AI laps
|
||||
aiRacers.forEach((racer) => {
|
||||
const aiCurrentLap = Math.floor(racer.position / 50)
|
||||
const aiPreviousLap = aiLaps.get(racer.id) || 0
|
||||
if (aiCurrentLap > aiPreviousLap && !celebrationCooldown.has(racer.id)) {
|
||||
dispatch({ type: 'COMPLETE_LAP', racerId: racer.id })
|
||||
setCelebrationCooldown((prev) => new Set(prev).add(racer.id))
|
||||
setTimeout(() => {
|
||||
setCelebrationCooldown((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(racer.id)
|
||||
return next
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
}, [
|
||||
playerProgress,
|
||||
playerLap,
|
||||
aiRacers,
|
||||
aiLaps,
|
||||
celebrationCooldown,
|
||||
dispatch, // Play celebration sound (line 12801)
|
||||
playSound,
|
||||
])
|
||||
|
||||
const playerPos = getCircularPosition(playerProgress)
|
||||
|
||||
// Create rounded rectangle path with wider curves (banking effect)
|
||||
const createRoundedRectPath = (radiusOffset: number, isOuter: boolean = false) => {
|
||||
const centerX = dimensions.width / 2
|
||||
const centerY = dimensions.height / 2
|
||||
|
||||
// Make curves wider by increasing radius more on outer edges
|
||||
const curveWidthBonus = isOuter ? radiusOffset * 0.15 : radiusOffset * -0.1
|
||||
const r = radius + radiusOffset + curveWidthBonus
|
||||
|
||||
if (isHorizontal) {
|
||||
// Horizontal track - curved ends on left/right
|
||||
const leftCenterX = centerX - straightLength / 2
|
||||
const rightCenterX = centerX + straightLength / 2
|
||||
const curveTopY = centerY - r
|
||||
const curveBottomY = centerY + r
|
||||
|
||||
return `
|
||||
M ${leftCenterX} ${curveTopY}
|
||||
L ${rightCenterX} ${curveTopY}
|
||||
A ${r} ${r} 0 0 1 ${rightCenterX} ${curveBottomY}
|
||||
L ${leftCenterX} ${curveBottomY}
|
||||
A ${r} ${r} 0 0 1 ${leftCenterX} ${curveTopY}
|
||||
Z
|
||||
`
|
||||
} else {
|
||||
// Vertical track - curved ends on top/bottom
|
||||
const topCenterY = centerY - straightLength / 2
|
||||
const bottomCenterY = centerY + straightLength / 2
|
||||
const curveLeftX = centerX - r
|
||||
const curveRightX = centerX + r
|
||||
|
||||
return `
|
||||
M ${curveLeftX} ${topCenterY}
|
||||
L ${curveLeftX} ${bottomCenterY}
|
||||
A ${r} ${r} 0 0 0 ${curveRightX} ${bottomCenterY}
|
||||
L ${curveRightX} ${topCenterY}
|
||||
A ${r} ${r} 0 0 0 ${curveLeftX} ${topCenterY}
|
||||
Z
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="circular-track"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: `${dimensions.width}px`,
|
||||
height: `${dimensions.height}px`,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
{/* SVG Track */}
|
||||
<svg
|
||||
data-component="track-svg"
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
{/* Infield grass */}
|
||||
<path d={createRoundedRectPath(15, false)} fill="#7cb342" stroke="none" />
|
||||
|
||||
{/* Track background - reddish clay color */}
|
||||
<path d={createRoundedRectPath(-10, true)} fill="#d97757" stroke="none" />
|
||||
|
||||
{/* Track outer edge - white boundary */}
|
||||
<path d={createRoundedRectPath(-15, true)} fill="none" stroke="white" strokeWidth="3" />
|
||||
|
||||
{/* Track inner edge - white boundary */}
|
||||
<path d={createRoundedRectPath(15, false)} fill="none" stroke="white" strokeWidth="3" />
|
||||
|
||||
{/* Lane markers - dashed white lines */}
|
||||
{[-5, 0, 5].map((offset) => (
|
||||
<path
|
||||
key={offset}
|
||||
d={createRoundedRectPath(offset, offset < 0)}
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="8 8"
|
||||
opacity="0.6"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Start/Finish line - checkered flag pattern */}
|
||||
{(() => {
|
||||
const centerX = dimensions.width / 2
|
||||
const centerY = dimensions.height / 2
|
||||
const trackThickness = 35 // Track width from inner to outer edge
|
||||
|
||||
if (isHorizontal) {
|
||||
// Horizontal track: vertical finish line crossing the top straight
|
||||
const x = centerX
|
||||
const yStart = centerY - radius - 18 // Outer edge
|
||||
const squareSize = trackThickness / 6
|
||||
const lineWidth = 12
|
||||
return (
|
||||
<g>
|
||||
{/* Checkered pattern - vertical line */}
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={x - lineWidth / 2}
|
||||
y={yStart + squareSize * i}
|
||||
width={lineWidth}
|
||||
height={squareSize}
|
||||
fill={i % 2 === 0 ? 'black' : 'white'}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
} else {
|
||||
// Vertical track: horizontal finish line crossing the left straight
|
||||
const xStart = centerX - radius - 18 // Outer edge
|
||||
const y = centerY
|
||||
const squareSize = trackThickness / 6
|
||||
const lineWidth = 12
|
||||
return (
|
||||
<g>
|
||||
{/* Checkered pattern - horizontal line */}
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={xStart + squareSize * i}
|
||||
y={y - lineWidth / 2}
|
||||
width={squareSize}
|
||||
height={lineWidth}
|
||||
fill={i % 2 === 0 ? 'black' : 'white'}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Distance markers (quarter points) */}
|
||||
{[0.25, 0.5, 0.75].map((fraction) => {
|
||||
const pos = getCircularPosition(fraction * 50)
|
||||
const markerLength = 12
|
||||
const perpAngle = (pos.angle + 90) * (Math.PI / 180)
|
||||
const x1 = pos.x - markerLength * Math.cos(perpAngle)
|
||||
const y1 = pos.y - markerLength * Math.sin(perpAngle)
|
||||
const x2 = pos.x + markerLength * Math.cos(perpAngle)
|
||||
const y2 = pos.y + markerLength * Math.sin(perpAngle)
|
||||
return (
|
||||
<line
|
||||
key={fraction}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Player racer */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${playerPos.x}px`,
|
||||
top: `${playerPos.y}px`,
|
||||
transform: `translate(-50%, -50%) rotate(${playerPos.angle}deg)`,
|
||||
fontSize: '32px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 10,
|
||||
transition: 'left 0.3s ease-out, top 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
{playerEmoji}
|
||||
</div>
|
||||
|
||||
{/* AI racers */}
|
||||
{aiRacers.map((racer, _index) => {
|
||||
const aiPos = getCircularPosition(racer.position)
|
||||
const activeBubble = state.activeSpeechBubbles.get(racer.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={racer.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${aiPos.x}px`,
|
||||
top: `${aiPos.y}px`,
|
||||
transform: `translate(-50%, -50%) rotate(${aiPos.angle}deg)`,
|
||||
fontSize: '28px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 5,
|
||||
transition: 'left 0.2s linear, top 0.2s linear',
|
||||
}}
|
||||
>
|
||||
{racer.icon}
|
||||
{activeBubble && (
|
||||
<div
|
||||
style={{
|
||||
transform: `rotate(${-aiPos.angle}deg)`, // Counter-rotate bubble
|
||||
}}
|
||||
>
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Lap counter */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: '50%',
|
||||
width: '120px',
|
||||
height: '120px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
border: '3px solid #3b82f6',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '4px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Lap
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '36px',
|
||||
fontWeight: 'bold',
|
||||
color: '#3b82f6',
|
||||
}}
|
||||
>
|
||||
{playerLap + 1}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#9ca3af',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{Math.floor(((playerProgress % 50) / 50) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lap celebration */}
|
||||
{celebrationCooldown.has('player') && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
color: 'white',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 4px 20px rgba(251, 191, 36, 0.4)',
|
||||
animation: 'bounce 0.5s ease',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
🎉 Lap {playerLap + 1} Complete! 🎉
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { ComplementQuestion, Passenger, Station } from '../../lib/gameTypes'
|
||||
import { AbacusTarget } from '../AbacusTarget'
|
||||
import { PassengerCard } from '../PassengerCard'
|
||||
import { PressureGauge } from '../PressureGauge'
|
||||
|
||||
interface RouteTheme {
|
||||
emoji: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface GameHUDProps {
|
||||
routeTheme: RouteTheme
|
||||
currentRoute: number
|
||||
periodName: string
|
||||
timeRemaining: number
|
||||
pressure: number
|
||||
nonDeliveredPassengers: Passenger[]
|
||||
stations: Station[]
|
||||
currentQuestion: ComplementQuestion | null
|
||||
currentInput: string
|
||||
}
|
||||
|
||||
export const GameHUD = memo(
|
||||
({
|
||||
routeTheme,
|
||||
currentRoute,
|
||||
periodName,
|
||||
timeRemaining,
|
||||
pressure,
|
||||
nonDeliveredPassengers,
|
||||
stations,
|
||||
currentQuestion,
|
||||
currentInput,
|
||||
}: GameHUDProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Route and time of day indicator */}
|
||||
<div
|
||||
data-component="route-info"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* Current Route */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
color: 'white',
|
||||
padding: '8px 14px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '20px' }}>{routeTheme.emoji}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', opacity: 0.8 }}>Route {currentRoute}</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.9 }}>{routeTheme.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time of Day */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
color: 'white',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
{periodName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time remaining */}
|
||||
<div
|
||||
data-component="time-remaining"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
color: 'white',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
⏱️ {timeRemaining}s
|
||||
</div>
|
||||
|
||||
{/* Pressure gauge */}
|
||||
<div
|
||||
data-component="pressure-gauge-container"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
left: '20px',
|
||||
zIndex: 1000,
|
||||
width: '120px',
|
||||
}}
|
||||
>
|
||||
<PressureGauge pressure={pressure} />
|
||||
</div>
|
||||
|
||||
{/* Passenger cards - show all non-delivered passengers */}
|
||||
{nonDeliveredPassengers.length > 0 && (
|
||||
<div
|
||||
data-component="passenger-list"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column-reverse',
|
||||
gap: '8px',
|
||||
zIndex: 1000,
|
||||
maxHeight: 'calc(100vh - 40px)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{nonDeliveredPassengers.map((passenger) => (
|
||||
<PassengerCard
|
||||
key={passenger.id}
|
||||
passenger={passenger}
|
||||
originStation={stations.find((s) => s.id === passenger.originStationId)}
|
||||
destinationStation={stations.find((s) => s.id === passenger.destinationStationId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question Display - centered at bottom, equation-focused */}
|
||||
{currentQuestion && (
|
||||
<div
|
||||
data-component="sprint-question-display"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
borderRadius: '24px',
|
||||
padding: '28px 50px',
|
||||
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.5), 0 0 0 5px rgba(59, 130, 246, 0.4)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '4px solid rgba(255, 255, 255, 0.95)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{/* Complement equation as main focus */}
|
||||
<div
|
||||
data-element="sprint-question-equation"
|
||||
style={{
|
||||
fontSize: '96px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
lineHeight: '1.1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '16px',
|
||||
minWidth: '140px',
|
||||
display: 'inline-block',
|
||||
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
{currentInput || '?'}
|
||||
</span>
|
||||
<span style={{ color: '#6b7280' }}>+</span>
|
||||
{currentQuestion.showAsAbacus ? (
|
||||
<div
|
||||
style={{
|
||||
transform: 'scale(2.4) translateY(8%)',
|
||||
transformOrigin: 'center center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusTarget number={currentQuestion.number} />
|
||||
</div>
|
||||
) : (
|
||||
<span>{currentQuestion.number}</span>
|
||||
)}
|
||||
<span style={{ color: '#6b7280' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>{currentQuestion.targetSum}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
GameHUD.displayName = 'GameHUD'
|
||||
@@ -1,172 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
import { SpeechBubble } from '../AISystem/SpeechBubble'
|
||||
|
||||
interface LinearTrackProps {
|
||||
playerProgress: number
|
||||
aiRacers: AIRacer[]
|
||||
raceGoal: number
|
||||
showFinishLine?: boolean
|
||||
}
|
||||
|
||||
export function LinearTrack({
|
||||
playerProgress,
|
||||
aiRacers,
|
||||
raceGoal,
|
||||
showFinishLine = true,
|
||||
}: LinearTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
|
||||
// Position calculation: leftPercent = Math.min(98, (progress / raceGoal) * 96 + 2)
|
||||
// 2% minimum (start), 98% maximum (near finish), 96% range for race
|
||||
const getPosition = (progress: number) => {
|
||||
return Math.min(98, (progress / raceGoal) * 96 + 2)
|
||||
}
|
||||
|
||||
const playerPosition = getPosition(playerProgress)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="linear-track"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '200px',
|
||||
background:
|
||||
'linear-gradient(to bottom, #87ceeb 0%, #e0f2fe 50%, #90ee90 50%, #d4f1d4 100%)',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
{/* Track lines */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '2px',
|
||||
background: 'rgba(0, 0, 0, 0.1)',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Finish line */}
|
||||
{showFinishLine && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '2%',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '4px',
|
||||
background:
|
||||
'repeating-linear-gradient(0deg, black 0px, black 10px, white 10px, white 20px)',
|
||||
boxShadow: '0 0 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Player racer */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${playerPosition}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '32px',
|
||||
transition: 'left 0.3s ease-out',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{playerEmoji}
|
||||
</div>
|
||||
|
||||
{/* AI racers */}
|
||||
{aiRacers.map((racer, index) => {
|
||||
const aiPosition = getPosition(racer.position)
|
||||
const activeBubble = state.activeSpeechBubbles.get(racer.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={racer.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${aiPosition}%`,
|
||||
top: `${35 + index * 15}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '28px',
|
||||
transition: 'left 0.2s linear',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{racer.icon}
|
||||
{activeBubble && (
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '10px',
|
||||
left: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{playerProgress} / {raceGoal}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Landmark } from '../../lib/landmarks'
|
||||
|
||||
interface RailroadTrackPathProps {
|
||||
tiesAndRails: {
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
|
||||
leftRailPath: string
|
||||
rightRailPath: string
|
||||
} | null
|
||||
referencePath: string
|
||||
pathRef: React.RefObject<SVGPathElement>
|
||||
landmarkPositions: Array<{ x: number; y: number }>
|
||||
landmarks: Landmark[]
|
||||
stationPositions: Array<{ x: number; y: number }>
|
||||
stations: Station[]
|
||||
passengers: Passenger[]
|
||||
boardingAnimations: Map<string, unknown>
|
||||
disembarkingAnimations: Map<string, unknown>
|
||||
}
|
||||
|
||||
export const RailroadTrackPath = memo(
|
||||
({
|
||||
tiesAndRails,
|
||||
referencePath,
|
||||
pathRef,
|
||||
landmarkPositions,
|
||||
landmarks,
|
||||
stationPositions,
|
||||
stations,
|
||||
passengers,
|
||||
boardingAnimations,
|
||||
disembarkingAnimations,
|
||||
}: RailroadTrackPathProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Railroad ties */}
|
||||
{tiesAndRails?.ties.map((tie, index) => (
|
||||
<line
|
||||
key={`tie-${index}`}
|
||||
x1={tie.x1}
|
||||
y1={tie.y1}
|
||||
x2={tie.x2}
|
||||
y2={tie.y2}
|
||||
stroke="#654321"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
opacity="0.8"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Left rail */}
|
||||
{tiesAndRails?.leftRailPath && (
|
||||
<path
|
||||
d={tiesAndRails.leftRailPath}
|
||||
fill="none"
|
||||
stroke="#C0C0C0"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right rail */}
|
||||
{tiesAndRails?.rightRailPath && (
|
||||
<path
|
||||
d={tiesAndRails.rightRailPath}
|
||||
fill="none"
|
||||
stroke="#C0C0C0"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reference path (invisible, used for positioning) */}
|
||||
<path ref={pathRef} d={referencePath} fill="none" stroke="transparent" strokeWidth="2" />
|
||||
|
||||
{/* Landmarks - background scenery */}
|
||||
{landmarkPositions.map((pos, index) => (
|
||||
<text
|
||||
key={`landmark-${index}`}
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: `${(landmarks[index]?.size || 24) * 2.0}px`,
|
||||
pointerEvents: 'none',
|
||||
opacity: 0.7,
|
||||
filter: 'drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2))',
|
||||
}}
|
||||
>
|
||||
{landmarks[index]?.emoji}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Station markers */}
|
||||
{stationPositions.map((pos, index) => {
|
||||
const station = stations[index]
|
||||
// Find passengers waiting at this station (exclude currently boarding)
|
||||
const waitingPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.originStationId === station?.id &&
|
||||
!p.isBoarded &&
|
||||
!p.isDelivered &&
|
||||
!boardingAnimations.has(p.id)
|
||||
)
|
||||
// Find passengers delivered at this station (exclude currently disembarking)
|
||||
const deliveredPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.destinationStationId === station?.id &&
|
||||
p.isDelivered &&
|
||||
!disembarkingAnimations.has(p.id)
|
||||
)
|
||||
|
||||
return (
|
||||
<g key={`station-${index}`}>
|
||||
{/* Station platform */}
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r="18"
|
||||
fill="#8B4513"
|
||||
stroke="#654321"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
{/* Station icon */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y - 40}
|
||||
textAnchor="middle"
|
||||
fontSize="48"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{station?.icon}
|
||||
</text>
|
||||
{/* Station name */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + 50}
|
||||
textAnchor="middle"
|
||||
fontSize="20"
|
||||
fill="#1f2937"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="0.5"
|
||||
style={{
|
||||
fontWeight: 900,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: '"Comic Sans MS", "Chalkboard SE", "Bradley Hand", cursive',
|
||||
textShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
|
||||
letterSpacing: '0.5px',
|
||||
paintOrder: 'stroke fill',
|
||||
}}
|
||||
>
|
||||
{station?.name}
|
||||
</text>
|
||||
|
||||
{/* Waiting passengers at this station */}
|
||||
{waitingPassengers.map((passenger, pIndex) => (
|
||||
<text
|
||||
key={`waiting-${passenger.id}`}
|
||||
x={pos.x + (pIndex - waitingPassengers.length / 2 + 0.5) * 28}
|
||||
y={pos.y - 30}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: passenger.isUrgent
|
||||
? 'drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
}}
|
||||
>
|
||||
{passenger.avatar}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Delivered passengers at this station (celebrating) */}
|
||||
{deliveredPassengers.map((passenger, pIndex) => (
|
||||
<text
|
||||
key={`delivered-${passenger.id}`}
|
||||
x={pos.x + (pIndex - deliveredPassengers.length / 2 + 0.5) * 28}
|
||||
y={pos.y - 30}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: 'drop-shadow(0 0 12px rgba(16, 185, 129, 0.8))',
|
||||
animation: 'celebrateDelivery 2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{passenger.avatar}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
RailroadTrackPath.displayName = 'RailroadTrackPath'
|
||||
@@ -1,318 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { memo, useMemo, useRef, useState } from 'react'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import {
|
||||
type BoardingAnimation,
|
||||
type DisembarkingAnimation,
|
||||
usePassengerAnimations,
|
||||
} from '../../hooks/usePassengerAnimations'
|
||||
import type { ComplementQuestion } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../../hooks/useSteamJourney'
|
||||
import { useTrackManagement } from '../../hooks/useTrackManagement'
|
||||
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
|
||||
import { calculateMaxConcurrentPassengers } from '../../lib/passengerGenerator'
|
||||
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { getRouteTheme } from '../../lib/routeThemes'
|
||||
import { GameHUD } from './GameHUD'
|
||||
import { RailroadTrackPath } from './RailroadTrackPath'
|
||||
import { TrainAndCars } from './TrainAndCars'
|
||||
import { TrainTerrainBackground } from './TrainTerrainBackground'
|
||||
|
||||
const BoardingPassengerAnimation = memo(({ animation }: { animation: BoardingAnimation }) => {
|
||||
const spring = useSpring({
|
||||
from: { x: animation.fromX, y: animation.fromY, opacity: 1 },
|
||||
to: { x: animation.toX, y: animation.toY, opacity: 1 },
|
||||
config: { tension: 120, friction: 14 },
|
||||
})
|
||||
|
||||
return (
|
||||
<animated.text
|
||||
x={spring.x}
|
||||
y={spring.y}
|
||||
textAnchor="middle"
|
||||
opacity={spring.opacity}
|
||||
style={{
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: animation.passenger.isUrgent
|
||||
? 'drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
}}
|
||||
>
|
||||
{animation.passenger.avatar}
|
||||
</animated.text>
|
||||
)
|
||||
})
|
||||
BoardingPassengerAnimation.displayName = 'BoardingPassengerAnimation'
|
||||
|
||||
const DisembarkingPassengerAnimation = memo(
|
||||
({ animation }: { animation: DisembarkingAnimation }) => {
|
||||
const spring = useSpring({
|
||||
from: { x: animation.fromX, y: animation.fromY, opacity: 1 },
|
||||
to: { x: animation.toX, y: animation.toY, opacity: 1 },
|
||||
config: { tension: 120, friction: 14 },
|
||||
})
|
||||
|
||||
return (
|
||||
<animated.text
|
||||
x={spring.x}
|
||||
y={spring.y}
|
||||
textAnchor="middle"
|
||||
opacity={spring.opacity}
|
||||
style={{
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: 'drop-shadow(0 0 12px rgba(16, 185, 129, 0.8))',
|
||||
}}
|
||||
>
|
||||
{animation.passenger.avatar}
|
||||
</animated.text>
|
||||
)
|
||||
}
|
||||
)
|
||||
DisembarkingPassengerAnimation.displayName = 'DisembarkingPassengerAnimation'
|
||||
|
||||
interface SteamTrainJourneyProps {
|
||||
momentum: number
|
||||
trainPosition: number
|
||||
pressure: number
|
||||
elapsedTime: number
|
||||
currentQuestion: ComplementQuestion | null
|
||||
currentInput: string
|
||||
}
|
||||
|
||||
export function SteamTrainJourney({
|
||||
momentum,
|
||||
trainPosition,
|
||||
pressure,
|
||||
elapsedTime,
|
||||
currentQuestion,
|
||||
currentInput,
|
||||
}: SteamTrainJourneyProps) {
|
||||
const { state } = useComplementRace()
|
||||
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney()
|
||||
const _skyGradient = getSkyGradient()
|
||||
const period = getTimeOfDayPeriod()
|
||||
const { players } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const pathRef = useRef<SVGPathElement>(null)
|
||||
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
|
||||
|
||||
// Calculate the number of train cars dynamically based on max concurrent passengers
|
||||
const maxCars = useMemo(() => {
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
|
||||
// Ensure at least 1 car, even if no passengers
|
||||
return Math.max(1, maxPassengers)
|
||||
}, [state.passengers, state.stations])
|
||||
|
||||
const carSpacing = 7 // Distance between cars (in % of track)
|
||||
|
||||
// Train transforms (extracted to hook)
|
||||
const { trainTransform, trainCars, locomotiveOpacity } = useTrainTransforms({
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
maxCars,
|
||||
carSpacing,
|
||||
})
|
||||
|
||||
// Track management (extracted to hook)
|
||||
const {
|
||||
trackData,
|
||||
tiesAndRails,
|
||||
stationPositions,
|
||||
landmarks,
|
||||
landmarkPositions,
|
||||
displayPassengers,
|
||||
} = useTrackManagement({
|
||||
currentRoute: state.currentRoute,
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
stations: state.stations,
|
||||
passengers: state.passengers,
|
||||
maxCars,
|
||||
carSpacing,
|
||||
})
|
||||
|
||||
// Passenger animations (extracted to hook)
|
||||
const { boardingAnimations, disembarkingAnimations } = usePassengerAnimations({
|
||||
passengers: state.passengers,
|
||||
stations: state.stations,
|
||||
stationPositions,
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
})
|
||||
|
||||
// Time remaining (60 seconds total)
|
||||
const timeRemaining = Math.max(0, 60 - Math.floor(elapsedTime / 1000))
|
||||
|
||||
// Period names for display
|
||||
const periodNames = ['Dawn', 'Morning', 'Midday', 'Afternoon', 'Dusk', 'Night']
|
||||
|
||||
// Get current route theme
|
||||
const routeTheme = getRouteTheme(state.currentRoute)
|
||||
|
||||
// Memoize filtered passenger lists to avoid recalculating on every render
|
||||
const boardedPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => p.isBoarded && !p.isDelivered),
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
const nonDeliveredPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => !p.isDelivered),
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
// Memoize ground texture circles to avoid recreating on every render
|
||||
const groundTextureCircles = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 30 }).map((_, i) => ({
|
||||
key: `ground-texture-${i}`,
|
||||
cx: -30 + i * 28 + (i % 3) * 10,
|
||||
cy: 140 + (i % 5) * 60,
|
||||
r: 2 + (i % 3),
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
if (!trackData) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="steam-train-journey"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'transparent',
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'stretch',
|
||||
}}
|
||||
>
|
||||
{/* Game HUD - overlays and UI elements */}
|
||||
<GameHUD
|
||||
routeTheme={routeTheme}
|
||||
currentRoute={state.currentRoute}
|
||||
periodName={periodNames[period]}
|
||||
timeRemaining={timeRemaining}
|
||||
pressure={pressure}
|
||||
nonDeliveredPassengers={nonDeliveredPassengers}
|
||||
stations={state.stations}
|
||||
currentQuestion={currentQuestion}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
|
||||
{/* Railroad track SVG */}
|
||||
<svg
|
||||
data-component="railroad-track"
|
||||
ref={svgRef}
|
||||
viewBox="-50 -50 900 700"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '800 / 600',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{/* Terrain background - ground, mountains, and tunnels */}
|
||||
<TrainTerrainBackground
|
||||
ballastPath={trackData.ballastPath}
|
||||
groundTextureCircles={groundTextureCircles}
|
||||
/>
|
||||
|
||||
{/* Railroad track, landmarks, and stations */}
|
||||
<RailroadTrackPath
|
||||
tiesAndRails={tiesAndRails}
|
||||
referencePath={trackData.referencePath}
|
||||
pathRef={pathRef}
|
||||
landmarkPositions={landmarkPositions}
|
||||
landmarks={landmarks}
|
||||
stationPositions={stationPositions}
|
||||
stations={state.stations}
|
||||
passengers={displayPassengers}
|
||||
boardingAnimations={boardingAnimations}
|
||||
disembarkingAnimations={disembarkingAnimations}
|
||||
/>
|
||||
|
||||
{/* Train, cars, and passenger animations */}
|
||||
<TrainAndCars
|
||||
boardingAnimations={boardingAnimations}
|
||||
disembarkingAnimations={disembarkingAnimations}
|
||||
BoardingPassengerAnimation={BoardingPassengerAnimation}
|
||||
DisembarkingPassengerAnimation={DisembarkingPassengerAnimation}
|
||||
trainCars={trainCars}
|
||||
boardedPassengers={boardedPassengers}
|
||||
trainTransform={trainTransform}
|
||||
locomotiveOpacity={locomotiveOpacity}
|
||||
playerEmoji={playerEmoji}
|
||||
momentum={momentum}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* CSS animations */}
|
||||
<style>{`
|
||||
@keyframes steamPuffSVG {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.5) translate(0, 0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(1.5) translate(15px, -30px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(2) translate(25px, -60px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes coalFallingSVG {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: translate(5px, 15px) scale(0.8);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(8px, 30px) scale(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes celebrateDelivery {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.3) translateY(-10px);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.2) translateY(-5px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translateY(-20px);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
|
||||
import type { Passenger } from '../../lib/gameTypes'
|
||||
|
||||
interface TrainCarTransform {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
position: number
|
||||
opacity: number
|
||||
}
|
||||
|
||||
interface TrainTransform {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
}
|
||||
|
||||
interface TrainAndCarsProps {
|
||||
boardingAnimations: Map<string, BoardingAnimation>
|
||||
disembarkingAnimations: Map<string, DisembarkingAnimation>
|
||||
BoardingPassengerAnimation: React.ComponentType<{
|
||||
animation: BoardingAnimation
|
||||
}>
|
||||
DisembarkingPassengerAnimation: React.ComponentType<{
|
||||
animation: DisembarkingAnimation
|
||||
}>
|
||||
trainCars: TrainCarTransform[]
|
||||
boardedPassengers: Passenger[]
|
||||
trainTransform: TrainTransform
|
||||
locomotiveOpacity: number
|
||||
playerEmoji: string
|
||||
momentum: number
|
||||
}
|
||||
|
||||
export const TrainAndCars = memo(
|
||||
({
|
||||
boardingAnimations,
|
||||
disembarkingAnimations,
|
||||
BoardingPassengerAnimation,
|
||||
DisembarkingPassengerAnimation,
|
||||
trainCars,
|
||||
boardedPassengers,
|
||||
trainTransform,
|
||||
locomotiveOpacity,
|
||||
playerEmoji,
|
||||
momentum,
|
||||
}: TrainAndCarsProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Boarding animations - passengers moving from station to train car */}
|
||||
{Array.from(boardingAnimations.values()).map((animation) => (
|
||||
<BoardingPassengerAnimation
|
||||
key={`boarding-${animation.passenger.id}`}
|
||||
animation={animation}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Disembarking animations - passengers moving from train car to station */}
|
||||
{Array.from(disembarkingAnimations.values()).map((animation) => (
|
||||
<DisembarkingPassengerAnimation
|
||||
key={`disembarking-${animation.passenger.id}`}
|
||||
animation={animation}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Train cars - render in reverse order so locomotive appears on top */}
|
||||
{trainCars.map((carTransform, carIndex) => {
|
||||
// Assign passenger to this car (if one exists for this car index)
|
||||
const passenger = boardedPassengers[carIndex]
|
||||
|
||||
return (
|
||||
<g
|
||||
key={`train-car-${carIndex}`}
|
||||
data-component="train-car"
|
||||
transform={`translate(${carTransform.x}, ${carTransform.y}) rotate(${carTransform.rotation}) scale(-1, 1)`}
|
||||
opacity={carTransform.opacity}
|
||||
style={{
|
||||
transition: 'opacity 0.5s ease-in',
|
||||
}}
|
||||
>
|
||||
{/* Train car */}
|
||||
<text
|
||||
data-element="train-car-body"
|
||||
x={0}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '65px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
🚃
|
||||
</text>
|
||||
|
||||
{/* Passenger inside this car (hide if currently boarding) */}
|
||||
{passenger && !boardingAnimations.has(passenger.id) && (
|
||||
<text
|
||||
data-element="car-passenger"
|
||||
x={0}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '42px',
|
||||
filter: passenger.isUrgent
|
||||
? 'drop-shadow(0 0 6px rgba(245, 158, 11, 0.8))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{passenger.avatar}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Locomotive - rendered last so it appears on top */}
|
||||
<g
|
||||
data-component="locomotive-group"
|
||||
transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`}
|
||||
opacity={locomotiveOpacity}
|
||||
style={{
|
||||
transition: 'opacity 0.5s ease-in',
|
||||
}}
|
||||
>
|
||||
{/* Train locomotive */}
|
||||
<text
|
||||
data-element="train-locomotive"
|
||||
x={0}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '100px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
🚂
|
||||
</text>
|
||||
|
||||
{/* Player engineer - layered over the train */}
|
||||
<text
|
||||
data-element="player-engineer"
|
||||
x={45}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '70px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{playerEmoji}
|
||||
</text>
|
||||
|
||||
{/* Steam puffs - positioned at smokestack, layered over train */}
|
||||
{momentum > 10 &&
|
||||
[0, 0.6, 1.2].map((delay, i) => (
|
||||
<circle
|
||||
key={`steam-${i}`}
|
||||
cx={-35}
|
||||
cy={-35}
|
||||
r="10"
|
||||
fill="rgba(255, 255, 255, 0.6)"
|
||||
style={{
|
||||
filter: 'blur(4px)',
|
||||
animation: `steamPuffSVG 2s ease-out infinite`,
|
||||
animationDelay: `${delay}s`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Coal particles - animated when shoveling */}
|
||||
{momentum > 60 &&
|
||||
[0, 0.3, 0.6].map((delay, i) => (
|
||||
<circle
|
||||
key={`coal-${i}`}
|
||||
cx={25}
|
||||
cy={0}
|
||||
r="3"
|
||||
fill="#2c2c2c"
|
||||
style={{
|
||||
animation: 'coalFallingSVG 1.2s ease-out infinite',
|
||||
animationDelay: `${delay}s`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TrainAndCars.displayName = 'TrainAndCars'
|
||||
@@ -1,144 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
|
||||
interface TrainTerrainBackgroundProps {
|
||||
ballastPath: string
|
||||
groundTextureCircles: Array<{
|
||||
key: string
|
||||
cx: number
|
||||
cy: number
|
||||
r: number
|
||||
}>
|
||||
}
|
||||
|
||||
export const TrainTerrainBackground = memo(
|
||||
({ ballastPath, groundTextureCircles }: TrainTerrainBackgroundProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Gradient definitions for mountain shading and ground */}
|
||||
<defs>
|
||||
<linearGradient id="mountainGradientLeft" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#a0a0a0', stopOpacity: 0.8 }} />
|
||||
<stop offset="50%" style={{ stopColor: '#7a7a7a', stopOpacity: 0.6 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#5a5a5a', stopOpacity: 0.4 }} />
|
||||
</linearGradient>
|
||||
<linearGradient id="mountainGradientRight" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#5a5a5a', stopOpacity: 0.4 }} />
|
||||
<stop offset="50%" style={{ stopColor: '#7a7a7a', stopOpacity: 0.6 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#a0a0a0', stopOpacity: 0.8 }} />
|
||||
</linearGradient>
|
||||
<linearGradient id="groundGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#6a8759', stopOpacity: 0.3 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#8B7355', stopOpacity: 0 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Ground layer - extends full width and height to cover entire track area */}
|
||||
<rect x="-50" y="120" width="900" height="530" fill="#8B7355" />
|
||||
|
||||
{/* Ground surface gradient for depth */}
|
||||
<rect x="-50" y="120" width="900" height="60" fill="url(#groundGradient)" />
|
||||
|
||||
{/* Ground texture - scattered rocks/pebbles */}
|
||||
{groundTextureCircles.map((circle) => (
|
||||
<circle
|
||||
key={circle.key}
|
||||
cx={circle.cx}
|
||||
cy={circle.cy}
|
||||
r={circle.r}
|
||||
fill="#654321"
|
||||
opacity={0.3}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Railroad ballast (gravel bed) */}
|
||||
<path d={ballastPath} fill="none" stroke="#8B7355" strokeWidth="40" strokeLinecap="round" />
|
||||
|
||||
{/* Left mountain and tunnel */}
|
||||
<g data-element="left-tunnel">
|
||||
{/* Mountain base - extends from left edge */}
|
||||
<rect x="-50" y="200" width="120" height="450" fill="#6b7280" />
|
||||
|
||||
{/* Mountain peak - triangular slope */}
|
||||
<path d="M -50 200 L 70 200 L 20 -50 L -50 100 Z" fill="#8b8b8b" />
|
||||
|
||||
{/* Mountain ridge shading */}
|
||||
<path d="M -50 200 L 70 200 L 20 -50 Z" fill="url(#mountainGradientLeft)" />
|
||||
|
||||
{/* Tunnel depth/interior (dark entrance) */}
|
||||
<ellipse cx="20" cy="300" rx="50" ry="55" fill="#0a0a0a" />
|
||||
|
||||
{/* Tunnel arch opening */}
|
||||
<path
|
||||
d="M 20 355 L -50 355 L -50 245 Q -50 235, 20 235 Q 70 235, 70 245 L 70 355 Z"
|
||||
fill="#1a1a1a"
|
||||
stroke="#4a4a4a"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
|
||||
{/* Tunnel arch rim (stone bricks) */}
|
||||
<path
|
||||
d="M -50 245 Q -50 235, 20 235 Q 70 235, 70 245"
|
||||
fill="none"
|
||||
stroke="#8b7355"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Stone brick texture around arch */}
|
||||
<path
|
||||
d="M -50 245 Q -50 235, 20 235 Q 70 235, 70 245"
|
||||
fill="none"
|
||||
stroke="#654321"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="15,10"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Right mountain and tunnel */}
|
||||
<g data-element="right-tunnel">
|
||||
{/* Mountain base - extends to right edge */}
|
||||
<rect x="680" y="200" width="170" height="450" fill="#6b7280" />
|
||||
|
||||
{/* Mountain peak - triangular slope */}
|
||||
<path d="M 730 200 L 850 200 L 850 100 L 780 -50 Z" fill="#8b8b8b" />
|
||||
|
||||
{/* Mountain ridge shading */}
|
||||
<path d="M 730 200 L 850 150 L 780 -50 Z" fill="url(#mountainGradientRight)" />
|
||||
|
||||
{/* Tunnel depth/interior (dark entrance) */}
|
||||
<ellipse cx="780" cy="300" rx="50" ry="55" fill="#0a0a0a" />
|
||||
|
||||
{/* Tunnel arch opening */}
|
||||
<path
|
||||
d="M 780 355 L 730 355 L 730 245 Q 730 235, 780 235 Q 850 235, 850 245 L 850 355 Z"
|
||||
fill="#1a1a1a"
|
||||
stroke="#4a4a4a"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
|
||||
{/* Tunnel arch rim (stone bricks) */}
|
||||
<path
|
||||
d="M 730 245 Q 730 235, 780 235 Q 850 235, 850 245"
|
||||
fill="none"
|
||||
stroke="#8b7355"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Stone brick texture around arch */}
|
||||
<path
|
||||
d="M 730 245 Q 730 235, 780 235 Q 850 235, 850 245"
|
||||
fill="none"
|
||||
stroke="#654321"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="15,10"
|
||||
/>
|
||||
</g>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TrainTerrainBackground.displayName = 'TrainTerrainBackground'
|
||||
@@ -1,166 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../../lib/gameTypes'
|
||||
import { GameHUD } from '../GameHUD'
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../../PassengerCard', () => ({
|
||||
PassengerCard: ({ passenger }: { passenger: Passenger }) => (
|
||||
<div data-testid="passenger-card">{passenger.avatar}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../PressureGauge', () => ({
|
||||
PressureGauge: ({ pressure }: { pressure: number }) => (
|
||||
<div data-testid="pressure-gauge">{pressure}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('GameHUD', () => {
|
||||
const mockRouteTheme = {
|
||||
emoji: '🚂',
|
||||
name: 'Mountain Pass',
|
||||
}
|
||||
|
||||
const mockStations: Station[] = [
|
||||
{ id: 'station-1', name: 'Station 1', position: 20, icon: '🏭' },
|
||||
{ id: 'station-2', name: 'Station 2', position: 60, icon: '🏛️' },
|
||||
]
|
||||
|
||||
const mockPassenger: Passenger = {
|
||||
id: 'passenger-1',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
routeTheme: mockRouteTheme,
|
||||
currentRoute: 1,
|
||||
periodName: '🌅 Dawn',
|
||||
timeRemaining: 45,
|
||||
pressure: 75,
|
||||
nonDeliveredPassengers: [],
|
||||
stations: mockStations,
|
||||
currentQuestion: {
|
||||
number: 3,
|
||||
targetSum: 10,
|
||||
correctAnswer: 7,
|
||||
showAsAbacus: false,
|
||||
},
|
||||
currentInput: '7',
|
||||
}
|
||||
|
||||
test('renders route information', () => {
|
||||
render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Route 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mountain Pass')).toBeInTheDocument()
|
||||
expect(screen.getByText('🚂')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders time of day period', () => {
|
||||
render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('🌅 Dawn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders time remaining', () => {
|
||||
render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/45s/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders pressure gauge', () => {
|
||||
render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('pressure-gauge')).toBeInTheDocument()
|
||||
expect(screen.getByText('75')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders passenger list when passengers exist', () => {
|
||||
render(<GameHUD {...defaultProps} nonDeliveredPassengers={[mockPassenger]} />)
|
||||
|
||||
expect(screen.getByTestId('passenger-card')).toBeInTheDocument()
|
||||
expect(screen.getByText('👨')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('does not render passenger list when empty', () => {
|
||||
render(<GameHUD {...defaultProps} nonDeliveredPassengers={[]} />)
|
||||
|
||||
expect(screen.queryByTestId('passenger-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders current question when provided', () => {
|
||||
render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('7')).toBeInTheDocument() // currentInput
|
||||
expect(screen.getByText('3')).toBeInTheDocument() // question.number
|
||||
expect(screen.getByText('10')).toBeInTheDocument() // targetSum
|
||||
expect(screen.getByText('+')).toBeInTheDocument()
|
||||
expect(screen.getByText('=')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows question mark when no input', () => {
|
||||
render(<GameHUD {...defaultProps} currentInput="" />)
|
||||
|
||||
expect(screen.getByText('?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('does not render question display when currentQuestion is null', () => {
|
||||
render(<GameHUD {...defaultProps} currentQuestion={null} />)
|
||||
|
||||
expect(screen.queryByText('+')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('=')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders multiple passengers', () => {
|
||||
const passengers = [
|
||||
mockPassenger,
|
||||
{ ...mockPassenger, id: 'passenger-2', avatar: '👩' },
|
||||
{ ...mockPassenger, id: 'passenger-3', avatar: '👧' },
|
||||
]
|
||||
|
||||
render(<GameHUD {...defaultProps} nonDeliveredPassengers={passengers} />)
|
||||
|
||||
expect(screen.getAllByTestId('passenger-card')).toHaveLength(3)
|
||||
expect(screen.getByText('👨')).toBeInTheDocument()
|
||||
expect(screen.getByText('👩')).toBeInTheDocument()
|
||||
expect(screen.getByText('👧')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('updates when route changes', () => {
|
||||
const { rerender } = render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Route 1')).toBeInTheDocument()
|
||||
|
||||
rerender(<GameHUD {...defaultProps} currentRoute={2} />)
|
||||
|
||||
expect(screen.getByText('Route 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('updates when time remaining changes', () => {
|
||||
const { rerender } = render(<GameHUD {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/45s/)).toBeInTheDocument()
|
||||
|
||||
rerender(<GameHUD {...defaultProps} timeRemaining={30} />)
|
||||
|
||||
expect(screen.getByText(/30s/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('memoization: same props do not cause re-render', () => {
|
||||
const { rerender, container } = render(<GameHUD {...defaultProps} />)
|
||||
|
||||
const initialHTML = container.innerHTML
|
||||
|
||||
// Rerender with same props
|
||||
rerender(<GameHUD {...defaultProps} />)
|
||||
|
||||
// Should be memoized (same HTML)
|
||||
expect(container.innerHTML).toBe(initialHTML)
|
||||
})
|
||||
})
|
||||
@@ -1,191 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { TrainTerrainBackground } from '../TrainTerrainBackground'
|
||||
|
||||
describe('TrainTerrainBackground', () => {
|
||||
const mockGroundCircles = [
|
||||
{ key: 'ground-1', cx: 10, cy: 150, r: 2 },
|
||||
{ key: 'ground-2', cx: 40, cy: 180, r: 3 },
|
||||
]
|
||||
|
||||
test('renders without crashing', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
test('renders gradient definitions', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const defs = container.querySelector('defs')
|
||||
expect(defs).toBeTruthy()
|
||||
|
||||
// Check for gradient IDs
|
||||
expect(container.querySelector('#mountainGradientLeft')).toBeTruthy()
|
||||
expect(container.querySelector('#mountainGradientRight')).toBeTruthy()
|
||||
expect(container.querySelector('#groundGradient')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('renders ground layer rects', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const rects = container.querySelectorAll('rect')
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
|
||||
// Check for ground base layer
|
||||
const groundRect = Array.from(rects).find(
|
||||
(rect) => rect.getAttribute('fill') === '#8B7355' && rect.getAttribute('width') === '900'
|
||||
)
|
||||
expect(groundRect).toBeTruthy()
|
||||
})
|
||||
|
||||
test('renders ground texture circles', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const circles = container.querySelectorAll('circle')
|
||||
expect(circles.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Verify circle attributes
|
||||
const firstCircle = circles[0]
|
||||
expect(firstCircle.getAttribute('cx')).toBe('10')
|
||||
expect(firstCircle.getAttribute('cy')).toBe('150')
|
||||
expect(firstCircle.getAttribute('r')).toBe('2')
|
||||
})
|
||||
|
||||
test('renders ballast path with correct attributes', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ballastPath = Array.from(container.querySelectorAll('path')).find(
|
||||
(path) =>
|
||||
path.getAttribute('d') === 'M 0 300 L 800 300' && path.getAttribute('stroke') === '#8B7355'
|
||||
)
|
||||
expect(ballastPath).toBeTruthy()
|
||||
expect(ballastPath?.getAttribute('stroke-width')).toBe('40')
|
||||
})
|
||||
|
||||
test('renders left tunnel structure', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const leftTunnel = container.querySelector('[data-element="left-tunnel"]')
|
||||
expect(leftTunnel).toBeTruthy()
|
||||
|
||||
// Check for tunnel elements
|
||||
const ellipses = leftTunnel?.querySelectorAll('ellipse')
|
||||
expect(ellipses?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('renders right tunnel structure', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const rightTunnel = container.querySelector('[data-element="right-tunnel"]')
|
||||
expect(rightTunnel).toBeTruthy()
|
||||
|
||||
// Check for tunnel elements
|
||||
const ellipses = rightTunnel?.querySelectorAll('ellipse')
|
||||
expect(ellipses?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('renders mountains with gradient fills', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Check for paths with gradient fills
|
||||
const gradientPaths = Array.from(container.querySelectorAll('path')).filter((path) =>
|
||||
path.getAttribute('fill')?.includes('url(#mountainGradient')
|
||||
)
|
||||
expect(gradientPaths.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('handles empty groundTextureCircles array', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={[]} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Should still render other elements
|
||||
expect(container.querySelector('defs')).toBeTruthy()
|
||||
expect(container.querySelector('[data-element="left-tunnel"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('memoization: does not re-render with same props', () => {
|
||||
const { rerender, container } = render(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const initialHTML = container.innerHTML
|
||||
|
||||
// Rerender with same props
|
||||
rerender(
|
||||
<svg>
|
||||
<TrainTerrainBackground
|
||||
ballastPath="M 0 300 L 800 300"
|
||||
groundTextureCircles={mockGroundCircles}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
// HTML should be identical (component memoized)
|
||||
expect(container.innerHTML).toBe(initialHTML)
|
||||
})
|
||||
})
|
||||
@@ -1,171 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { getRouteTheme } from '../lib/routeThemes'
|
||||
|
||||
interface RouteCelebrationProps {
|
||||
completedRouteNumber: number
|
||||
nextRouteNumber: number
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
export function RouteCelebration({
|
||||
completedRouteNumber,
|
||||
nextRouteNumber,
|
||||
onContinue,
|
||||
}: RouteCelebrationProps) {
|
||||
const completedTheme = getRouteTheme(completedRouteNumber)
|
||||
const nextTheme = getRouteTheme(nextRouteNumber)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
animation: 'fadeIn 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '24px',
|
||||
padding: '40px',
|
||||
maxWidth: '500px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||
animation: 'scaleIn 0.5s ease-out',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{/* Celebration header */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
marginBottom: '20px',
|
||||
animation: 'bounce 1s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
🎉
|
||||
</div>
|
||||
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
textShadow: '0 2px 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
Route Complete!
|
||||
</h2>
|
||||
|
||||
{/* Completed route info */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '40px', marginBottom: '8px' }}>{completedTheme.emoji}</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: '600' }}>{completedTheme.name}</div>
|
||||
<div style={{ fontSize: '16px', opacity: 0.9, marginTop: '4px' }}>
|
||||
Route {completedRouteNumber}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next route preview */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
opacity: 0.9,
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Next destination:
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px',
|
||||
marginBottom: '24px',
|
||||
border: '2px dashed rgba(255, 255, 255, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '32px', marginBottom: '4px' }}>{nextTheme.emoji}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600' }}>{nextTheme.name}</div>
|
||||
<div style={{ fontSize: '14px', opacity: 0.8, marginTop: '4px' }}>
|
||||
Route {nextRouteNumber}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Continue button */}
|
||||
<button
|
||||
onClick={onContinue}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#667eea',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 32px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.2)'
|
||||
}}
|
||||
>
|
||||
Continue Journey 🚂
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { createContext, type ReactNode, useContext, useReducer } from 'react'
|
||||
import type { AIRacer, DifficultyTracker, GameAction, GameState, Station } from '../lib/gameTypes'
|
||||
|
||||
const initialDifficultyTracker: DifficultyTracker = {
|
||||
pairPerformance: new Map(),
|
||||
baseTimeLimit: 3000,
|
||||
currentTimeLimit: 3000,
|
||||
difficultyLevel: 1,
|
||||
consecutiveCorrect: 0,
|
||||
consecutiveIncorrect: 0,
|
||||
learningMode: true,
|
||||
adaptationRate: 0.1,
|
||||
}
|
||||
|
||||
const initialAIRacers: AIRacer[] = [
|
||||
{
|
||||
id: 'ai-racer-1',
|
||||
position: 0,
|
||||
speed: 0.32, // Balanced speed for good challenge
|
||||
name: 'Swift AI',
|
||||
personality: 'competitive',
|
||||
icon: '🏃♂️',
|
||||
lastComment: 0,
|
||||
commentCooldown: 0,
|
||||
previousPosition: 0,
|
||||
},
|
||||
{
|
||||
id: 'ai-racer-2',
|
||||
position: 0,
|
||||
speed: 0.2, // Balanced speed for good challenge
|
||||
name: 'Math Bot',
|
||||
personality: 'analytical',
|
||||
icon: '🏃',
|
||||
lastComment: 0,
|
||||
commentCooldown: 0,
|
||||
previousPosition: 0,
|
||||
},
|
||||
]
|
||||
|
||||
const initialStations: Station[] = [
|
||||
{ id: 'station-0', name: 'Depot', position: 0, icon: '🏭' },
|
||||
{ id: 'station-1', name: 'Riverside', position: 20, icon: '🌊' },
|
||||
{ id: 'station-2', name: 'Hillside', position: 40, icon: '⛰️' },
|
||||
{ id: 'station-3', name: 'Canyon View', position: 60, icon: '🏜️' },
|
||||
{ id: 'station-4', name: 'Meadows', position: 80, icon: '🌾' },
|
||||
{ id: 'station-5', name: 'Grand Central', position: 100, icon: '🏛️' },
|
||||
]
|
||||
|
||||
const initialState: GameState = {
|
||||
// Game configuration
|
||||
mode: 'friends5',
|
||||
style: 'practice',
|
||||
timeoutSetting: 'normal',
|
||||
complementDisplay: 'abacus', // Default to showing abacus
|
||||
|
||||
// Current question
|
||||
currentQuestion: null,
|
||||
previousQuestion: null,
|
||||
|
||||
// Game progress
|
||||
score: 0,
|
||||
streak: 0,
|
||||
bestStreak: 0,
|
||||
totalQuestions: 0,
|
||||
correctAnswers: 0,
|
||||
|
||||
// Game status
|
||||
isGameActive: false,
|
||||
isPaused: false,
|
||||
gamePhase: 'controls',
|
||||
|
||||
// Timing
|
||||
gameStartTime: null,
|
||||
questionStartTime: Date.now(),
|
||||
|
||||
// Race mechanics
|
||||
raceGoal: 20,
|
||||
timeLimit: null,
|
||||
speedMultiplier: 1.0,
|
||||
aiRacers: initialAIRacers,
|
||||
|
||||
// Adaptive difficulty
|
||||
difficultyTracker: initialDifficultyTracker,
|
||||
|
||||
// Survival mode specific
|
||||
playerLap: 0,
|
||||
aiLaps: new Map(),
|
||||
survivalMultiplier: 1.0,
|
||||
|
||||
// Sprint mode specific
|
||||
momentum: 0,
|
||||
trainPosition: 0,
|
||||
pressure: 0,
|
||||
elapsedTime: 0,
|
||||
lastCorrectAnswerTime: Date.now(),
|
||||
currentRoute: 1,
|
||||
stations: initialStations,
|
||||
passengers: [],
|
||||
deliveredPassengers: 0,
|
||||
cumulativeDistance: 0,
|
||||
showRouteCelebration: false,
|
||||
|
||||
// Input
|
||||
currentInput: '',
|
||||
|
||||
// UI state
|
||||
showScoreModal: false,
|
||||
activeSpeechBubbles: new Map(),
|
||||
adaptiveFeedback: null,
|
||||
}
|
||||
|
||||
function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
switch (action.type) {
|
||||
case 'SET_MODE':
|
||||
return { ...state, mode: action.mode }
|
||||
|
||||
case 'SET_STYLE':
|
||||
return { ...state, style: action.style }
|
||||
|
||||
case 'SET_TIMEOUT':
|
||||
return { ...state, timeoutSetting: action.timeout }
|
||||
|
||||
case 'SET_COMPLEMENT_DISPLAY':
|
||||
return { ...state, complementDisplay: action.display }
|
||||
|
||||
case 'SHOW_CONTROLS':
|
||||
return { ...state, gamePhase: 'controls' }
|
||||
|
||||
case 'START_COUNTDOWN':
|
||||
return { ...state, gamePhase: 'countdown' }
|
||||
|
||||
case 'BEGIN_GAME': {
|
||||
// Generate first question when game starts
|
||||
const generateFirstQuestion = () => {
|
||||
let targetSum: number
|
||||
if (state.mode === 'friends5') {
|
||||
targetSum = 5
|
||||
} else if (state.mode === 'friends10') {
|
||||
targetSum = 10
|
||||
} else {
|
||||
targetSum = Math.random() > 0.5 ? 5 : 10
|
||||
}
|
||||
|
||||
const newNumber =
|
||||
targetSum === 5 ? Math.floor(Math.random() * 5) : Math.floor(Math.random() * 10)
|
||||
|
||||
// Decide once whether to show as abacus
|
||||
const showAsAbacus =
|
||||
state.complementDisplay === 'abacus' ||
|
||||
(state.complementDisplay === 'random' && Math.random() < 0.5)
|
||||
|
||||
return {
|
||||
number: newNumber,
|
||||
targetSum,
|
||||
correctAnswer: targetSum - newNumber,
|
||||
showAsAbacus,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
isGameActive: true,
|
||||
gameStartTime: Date.now(),
|
||||
questionStartTime: Date.now(),
|
||||
currentQuestion: generateFirstQuestion(),
|
||||
}
|
||||
}
|
||||
|
||||
case 'NEXT_QUESTION': {
|
||||
// Generate new question based on mode
|
||||
const generateQuestion = () => {
|
||||
let targetSum: number
|
||||
if (state.mode === 'friends5') {
|
||||
targetSum = 5
|
||||
} else if (state.mode === 'friends10') {
|
||||
targetSum = 10
|
||||
} else {
|
||||
targetSum = Math.random() > 0.5 ? 5 : 10
|
||||
}
|
||||
|
||||
let newNumber: number
|
||||
let attempts = 0
|
||||
|
||||
do {
|
||||
if (targetSum === 5) {
|
||||
newNumber = Math.floor(Math.random() * 5)
|
||||
} else {
|
||||
newNumber = Math.floor(Math.random() * 10)
|
||||
}
|
||||
attempts++
|
||||
} while (
|
||||
state.currentQuestion &&
|
||||
state.currentQuestion.number === newNumber &&
|
||||
state.currentQuestion.targetSum === targetSum &&
|
||||
attempts < 10
|
||||
)
|
||||
|
||||
// Decide once whether to show as abacus
|
||||
const showAsAbacus =
|
||||
state.complementDisplay === 'abacus' ||
|
||||
(state.complementDisplay === 'random' && Math.random() < 0.5)
|
||||
|
||||
return {
|
||||
number: newNumber,
|
||||
targetSum,
|
||||
correctAnswer: targetSum - newNumber,
|
||||
showAsAbacus,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
previousQuestion: state.currentQuestion,
|
||||
currentQuestion: generateQuestion(),
|
||||
questionStartTime: Date.now(),
|
||||
currentInput: '',
|
||||
}
|
||||
}
|
||||
|
||||
case 'UPDATE_INPUT':
|
||||
return { ...state, currentInput: action.input }
|
||||
|
||||
case 'SUBMIT_ANSWER': {
|
||||
if (!state.currentQuestion) return state
|
||||
|
||||
const isCorrect = action.answer === state.currentQuestion.correctAnswer
|
||||
const responseTime = Date.now() - state.questionStartTime
|
||||
|
||||
if (isCorrect) {
|
||||
// Calculate speed bonus: max(0, 300 - (avgTime * 10))
|
||||
const speedBonus = Math.max(0, 300 - responseTime / 100)
|
||||
|
||||
// Update score: correctAnswers * 100 + streak * 50 + speedBonus
|
||||
const newStreak = state.streak + 1
|
||||
const newCorrectAnswers = state.correctAnswers + 1
|
||||
const newScore = state.score + 100 + newStreak * 50 + speedBonus
|
||||
|
||||
return {
|
||||
...state,
|
||||
correctAnswers: newCorrectAnswers,
|
||||
streak: newStreak,
|
||||
bestStreak: Math.max(state.bestStreak, newStreak),
|
||||
score: Math.round(newScore),
|
||||
totalQuestions: state.totalQuestions + 1,
|
||||
}
|
||||
} else {
|
||||
// Incorrect answer - reset streak but keep score
|
||||
return {
|
||||
...state,
|
||||
streak: 0,
|
||||
totalQuestions: state.totalQuestions + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'UPDATE_AI_POSITIONS':
|
||||
return {
|
||||
...state,
|
||||
aiRacers: state.aiRacers.map((racer) => {
|
||||
const update = action.positions.find((p) => p.id === racer.id)
|
||||
return update
|
||||
? {
|
||||
...racer,
|
||||
previousPosition: racer.position,
|
||||
position: update.position,
|
||||
}
|
||||
: racer
|
||||
}),
|
||||
}
|
||||
|
||||
case 'UPDATE_MOMENTUM':
|
||||
return { ...state, momentum: action.momentum }
|
||||
|
||||
case 'UPDATE_TRAIN_POSITION':
|
||||
return { ...state, trainPosition: action.position }
|
||||
|
||||
case 'UPDATE_STEAM_JOURNEY':
|
||||
return {
|
||||
...state,
|
||||
momentum: action.momentum,
|
||||
trainPosition: action.trainPosition,
|
||||
pressure: action.pressure,
|
||||
elapsedTime: action.elapsedTime,
|
||||
}
|
||||
|
||||
case 'COMPLETE_LAP':
|
||||
if (action.racerId === 'player') {
|
||||
return { ...state, playerLap: state.playerLap + 1 }
|
||||
} else {
|
||||
const newAILaps = new Map(state.aiLaps)
|
||||
newAILaps.set(action.racerId, (newAILaps.get(action.racerId) || 0) + 1)
|
||||
return { ...state, aiLaps: newAILaps }
|
||||
}
|
||||
|
||||
case 'PAUSE_RACE':
|
||||
return { ...state, isPaused: true }
|
||||
|
||||
case 'RESUME_RACE':
|
||||
return { ...state, isPaused: false }
|
||||
|
||||
case 'END_RACE':
|
||||
return { ...state, isGameActive: false }
|
||||
|
||||
case 'SHOW_RESULTS':
|
||||
return { ...state, gamePhase: 'results', showScoreModal: true }
|
||||
|
||||
case 'RESET_GAME':
|
||||
return {
|
||||
...initialState,
|
||||
// Preserve configuration settings
|
||||
mode: state.mode,
|
||||
style: state.style,
|
||||
timeoutSetting: state.timeoutSetting,
|
||||
complementDisplay: state.complementDisplay,
|
||||
gamePhase: 'controls',
|
||||
}
|
||||
|
||||
case 'TRIGGER_AI_COMMENTARY': {
|
||||
const newBubbles = new Map(state.activeSpeechBubbles)
|
||||
newBubbles.set(action.racerId, action.message)
|
||||
return {
|
||||
...state,
|
||||
activeSpeechBubbles: newBubbles,
|
||||
// Update racer's lastComment time and cooldown
|
||||
aiRacers: state.aiRacers.map((racer) =>
|
||||
racer.id === action.racerId
|
||||
? {
|
||||
...racer,
|
||||
lastComment: Date.now(),
|
||||
commentCooldown: Math.random() * 4000 + 2000, // 2-6 seconds
|
||||
}
|
||||
: racer
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'CLEAR_AI_COMMENT': {
|
||||
const clearedBubbles = new Map(state.activeSpeechBubbles)
|
||||
clearedBubbles.delete(action.racerId)
|
||||
return {
|
||||
...state,
|
||||
activeSpeechBubbles: clearedBubbles,
|
||||
}
|
||||
}
|
||||
|
||||
case 'UPDATE_DIFFICULTY_TRACKER':
|
||||
return {
|
||||
...state,
|
||||
difficultyTracker: action.tracker,
|
||||
}
|
||||
|
||||
case 'UPDATE_AI_SPEEDS':
|
||||
return {
|
||||
...state,
|
||||
aiRacers: action.racers,
|
||||
}
|
||||
|
||||
case 'SHOW_ADAPTIVE_FEEDBACK':
|
||||
return {
|
||||
...state,
|
||||
adaptiveFeedback: action.feedback,
|
||||
}
|
||||
|
||||
case 'CLEAR_ADAPTIVE_FEEDBACK':
|
||||
return {
|
||||
...state,
|
||||
adaptiveFeedback: null,
|
||||
}
|
||||
|
||||
case 'GENERATE_PASSENGERS':
|
||||
return {
|
||||
...state,
|
||||
passengers: action.passengers,
|
||||
}
|
||||
|
||||
case 'BOARD_PASSENGER':
|
||||
return {
|
||||
...state,
|
||||
passengers: state.passengers.map((p) =>
|
||||
p.id === action.passengerId ? { ...p, isBoarded: true } : p
|
||||
),
|
||||
}
|
||||
|
||||
case 'DELIVER_PASSENGER':
|
||||
return {
|
||||
...state,
|
||||
passengers: state.passengers.map((p) =>
|
||||
p.id === action.passengerId ? { ...p, isDelivered: true } : p
|
||||
),
|
||||
deliveredPassengers: state.deliveredPassengers + 1,
|
||||
score: state.score + action.points,
|
||||
}
|
||||
|
||||
case 'START_NEW_ROUTE':
|
||||
return {
|
||||
...state,
|
||||
currentRoute: action.routeNumber,
|
||||
stations: action.stations,
|
||||
trainPosition: -5, // Start off-screen to the left for smooth fade-in
|
||||
deliveredPassengers: 0,
|
||||
showRouteCelebration: false,
|
||||
momentum: 50, // Give some starting momentum for the new route
|
||||
pressure: 50,
|
||||
}
|
||||
|
||||
case 'COMPLETE_ROUTE':
|
||||
return {
|
||||
...state,
|
||||
cumulativeDistance: state.cumulativeDistance + 100,
|
||||
showRouteCelebration: true,
|
||||
}
|
||||
|
||||
case 'HIDE_ROUTE_CELEBRATION':
|
||||
return {
|
||||
...state,
|
||||
showRouteCelebration: false,
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
interface ComplementRaceContextType {
|
||||
state: GameState
|
||||
dispatch: React.Dispatch<GameAction>
|
||||
}
|
||||
|
||||
const ComplementRaceContext = createContext<ComplementRaceContextType | undefined>(undefined)
|
||||
|
||||
interface ComplementRaceProviderProps {
|
||||
children: ReactNode
|
||||
initialStyle?: 'practice' | 'sprint' | 'survival'
|
||||
}
|
||||
|
||||
export function ComplementRaceProvider({ children, initialStyle }: ComplementRaceProviderProps) {
|
||||
const [state, dispatch] = useReducer(gameReducer, {
|
||||
...initialState,
|
||||
style: initialStyle || initialState.style,
|
||||
})
|
||||
|
||||
return (
|
||||
<ComplementRaceContext.Provider value={{ state, dispatch }}>
|
||||
{children}
|
||||
</ComplementRaceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useComplementRace() {
|
||||
const context = useContext(ComplementRaceContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useComplementRace must be used within ComplementRaceProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { usePassengerAnimations } from '../usePassengerAnimations'
|
||||
|
||||
describe('usePassengerAnimations', () => {
|
||||
let mockPathRef: React.RefObject<SVGPathElement>
|
||||
let mockTrackGenerator: RailroadTrackGenerator
|
||||
let mockStation1: Station
|
||||
let mockStation2: Station
|
||||
let mockPassenger1: Passenger
|
||||
let mockPassenger2: Passenger
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock path element
|
||||
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
mockPathRef = { current: mockPath }
|
||||
|
||||
// Mock track generator
|
||||
mockTrackGenerator = {
|
||||
getTrainTransform: vi.fn((_path: SVGPathElement, position: number) => ({
|
||||
x: position * 10,
|
||||
y: 300,
|
||||
rotation: 0,
|
||||
})),
|
||||
} as unknown as RailroadTrackGenerator
|
||||
|
||||
// Create mock stations
|
||||
mockStation1 = {
|
||||
id: 'station-1',
|
||||
name: 'Station 1',
|
||||
position: 20,
|
||||
icon: '🏭',
|
||||
}
|
||||
|
||||
mockStation2 = {
|
||||
id: 'station-2',
|
||||
name: 'Station 2',
|
||||
position: 60,
|
||||
icon: '🏛️',
|
||||
}
|
||||
|
||||
// Create mock passengers
|
||||
mockPassenger1 = {
|
||||
id: 'passenger-1',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
}
|
||||
|
||||
mockPassenger2 = {
|
||||
id: 'passenger-2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: true,
|
||||
}
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('initializes with empty animation maps', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePassengerAnimations({
|
||||
passengers: [],
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.boardingAnimations.size).toBe(0)
|
||||
expect(result.current.disembarkingAnimations.size).toBe(0)
|
||||
})
|
||||
|
||||
test('creates boarding animation when passenger boards', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers }) =>
|
||||
usePassengerAnimations({
|
||||
passengers,
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 20,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
passengers: [mockPassenger1],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Initially no boarding animations
|
||||
expect(result.current.boardingAnimations.size).toBe(0)
|
||||
|
||||
// Passenger boards
|
||||
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
|
||||
rerender({ passengers: [boardedPassenger] })
|
||||
|
||||
// Should create boarding animation
|
||||
expect(result.current.boardingAnimations.size).toBe(1)
|
||||
expect(result.current.boardingAnimations.has('passenger-1')).toBe(true)
|
||||
|
||||
const animation = result.current.boardingAnimations.get('passenger-1')
|
||||
expect(animation).toBeDefined()
|
||||
expect(animation?.passenger).toEqual(boardedPassenger)
|
||||
expect(animation?.fromX).toBe(100) // Station position
|
||||
expect(animation?.fromY).toBe(270) // Station position - 30
|
||||
expect(mockTrackGenerator.getTrainTransform).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('creates disembarking animation when passenger is delivered', () => {
|
||||
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers }) =>
|
||||
usePassengerAnimations({
|
||||
passengers,
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 60,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
passengers: [boardedPassenger],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Initially no disembarking animations
|
||||
expect(result.current.disembarkingAnimations.size).toBe(0)
|
||||
|
||||
// Passenger is delivered
|
||||
const deliveredPassenger = { ...boardedPassenger, isDelivered: true }
|
||||
rerender({ passengers: [deliveredPassenger] })
|
||||
|
||||
// Should create disembarking animation
|
||||
expect(result.current.disembarkingAnimations.size).toBe(1)
|
||||
expect(result.current.disembarkingAnimations.has('passenger-1')).toBe(true)
|
||||
|
||||
const animation = result.current.disembarkingAnimations.get('passenger-1')
|
||||
expect(animation).toBeDefined()
|
||||
expect(animation?.passenger).toEqual(deliveredPassenger)
|
||||
expect(animation?.toX).toBe(500) // Destination station position
|
||||
expect(animation?.toY).toBe(270) // Station position - 30
|
||||
})
|
||||
|
||||
test('handles multiple passengers boarding simultaneously', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers }) =>
|
||||
usePassengerAnimations({
|
||||
passengers,
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 20,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
passengers: [mockPassenger1, mockPassenger2],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Both passengers board
|
||||
const boardedPassengers = [
|
||||
{ ...mockPassenger1, isBoarded: true },
|
||||
{ ...mockPassenger2, isBoarded: true },
|
||||
]
|
||||
rerender({ passengers: boardedPassengers })
|
||||
|
||||
// Should create boarding animations for both
|
||||
expect(result.current.boardingAnimations.size).toBe(2)
|
||||
expect(result.current.boardingAnimations.has('passenger-1')).toBe(true)
|
||||
expect(result.current.boardingAnimations.has('passenger-2')).toBe(true)
|
||||
})
|
||||
|
||||
test('does not create animation if passenger already boarded in previous state', () => {
|
||||
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePassengerAnimations({
|
||||
passengers: [boardedPassenger],
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 20,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
})
|
||||
)
|
||||
|
||||
// No animation since passenger was already boarded
|
||||
expect(result.current.boardingAnimations.size).toBe(0)
|
||||
})
|
||||
|
||||
test('returns empty animations when pathRef is null', () => {
|
||||
const nullPathRef: React.RefObject<SVGPathElement> = { current: null }
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers }) =>
|
||||
usePassengerAnimations({
|
||||
passengers,
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [
|
||||
{ x: 100, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
],
|
||||
trainPosition: 20,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: nullPathRef,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
passengers: [mockPassenger1],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Passenger boards
|
||||
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
|
||||
rerender({ passengers: [boardedPassenger] })
|
||||
|
||||
// Should not create animation without path
|
||||
expect(result.current.boardingAnimations.size).toBe(0)
|
||||
})
|
||||
|
||||
test('returns empty animations when stationPositions is empty', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers }) =>
|
||||
usePassengerAnimations({
|
||||
passengers,
|
||||
stations: [mockStation1, mockStation2],
|
||||
stationPositions: [],
|
||||
trainPosition: 20,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
passengers: [mockPassenger1],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Passenger boards
|
||||
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
|
||||
rerender({ passengers: [boardedPassenger] })
|
||||
|
||||
// Should not create animation without station positions
|
||||
expect(result.current.boardingAnimations.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,353 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Mock sound effects
|
||||
vi.mock('../useSoundEffects', () => ({
|
||||
useSoundEffects: () => ({
|
||||
playSound: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Boarding Logic Tests
|
||||
*
|
||||
* These tests simulate the game loop's boarding logic to find edge cases
|
||||
* where passengers get left behind at stations.
|
||||
*/
|
||||
|
||||
interface Passenger {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
originStationId: string
|
||||
destinationStationId: string
|
||||
isBoarded: boolean
|
||||
isDelivered: boolean
|
||||
isUrgent: boolean
|
||||
}
|
||||
|
||||
interface Station {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
position: number
|
||||
}
|
||||
|
||||
describe('useSteamJourney - Boarding Logic', () => {
|
||||
const CAR_SPACING = 7
|
||||
let stations: Station[]
|
||||
let passengers: Passenger[]
|
||||
|
||||
beforeEach(() => {
|
||||
stations = [
|
||||
{ id: 's1', name: 'Station 1', icon: '🏠', position: 20 },
|
||||
{ id: 's2', name: 'Station 2', icon: '🏢', position: 50 },
|
||||
{ id: 's3', name: 'Station 3', icon: '🏪', position: 80 },
|
||||
]
|
||||
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/**
|
||||
* Simulate the boarding logic from useSteamJourney (with fix)
|
||||
*/
|
||||
function simulateBoardingAtPosition(
|
||||
trainPosition: number,
|
||||
passengers: Passenger[],
|
||||
stations: Station[],
|
||||
maxCars: number
|
||||
): Passenger[] {
|
||||
const updatedPassengers = [...passengers]
|
||||
const currentBoardedPassengers = updatedPassengers.filter((p) => p.isBoarded && !p.isDelivered)
|
||||
|
||||
// Track which cars are assigned in THIS frame to prevent double-boarding
|
||||
const carsAssignedThisFrame = new Set<number>()
|
||||
|
||||
// Simulate the boarding logic
|
||||
updatedPassengers.forEach((passenger, passengerIndex) => {
|
||||
if (passenger.isBoarded || passenger.isDelivered) return
|
||||
|
||||
const station = stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) return
|
||||
|
||||
// Check if any empty car is at this station
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (currentBoardedPassengers[carIndex] || carsAssignedThisFrame.has(carIndex)) continue
|
||||
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If car is at station (within 3% tolerance), board this passenger
|
||||
if (distance < 3) {
|
||||
updatedPassengers[passengerIndex] = { ...passenger, isBoarded: true }
|
||||
// Mark this car as assigned in this frame
|
||||
carsAssignedThisFrame.add(carIndex)
|
||||
return // Board this passenger and move on
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return updatedPassengers
|
||||
}
|
||||
|
||||
test('single passenger at station boards when car arrives', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Train at position 27%, first car at position 20% (station 1)
|
||||
const result = simulateBoardingAtPosition(27, passengers, stations, 1)
|
||||
|
||||
expect(result[0].isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('EDGE CASE: multiple passengers at same station with enough cars', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Train at position 34%, cars at: 27%, 20%, 13%
|
||||
// Car 1 (27%): 7% away from station (too far)
|
||||
// Car 2 (20%): 0% away from station (at station!)
|
||||
// Car 3 (13%): 7% away from station (too far)
|
||||
let result = simulateBoardingAtPosition(34, passengers, stations, 3)
|
||||
|
||||
// First iteration: car 2 is at station, should board first passenger
|
||||
expect(result[0].isBoarded).toBe(true)
|
||||
|
||||
// But what about the other passengers? They should board on subsequent frames
|
||||
// Let's simulate the train advancing slightly
|
||||
result = simulateBoardingAtPosition(35, result, stations, 3)
|
||||
|
||||
// Now car 1 is at 28% (still too far), car 2 at 21% (still close), car 3 at 14% (too far)
|
||||
// Passenger 2 should still not board yet
|
||||
|
||||
// Advance more - when does car 1 reach the station?
|
||||
result = simulateBoardingAtPosition(27, result, stations, 3)
|
||||
// Car 1 at 20% (at station!)
|
||||
expect(result[1].isBoarded).toBe(true)
|
||||
|
||||
// What about passenger 3? Need car 3 to reach station
|
||||
// Car 3 position = trainPosition - (3 * 7) = trainPosition - 21
|
||||
// For car 3 to be at 20%, need trainPosition = 41
|
||||
result = simulateBoardingAtPosition(41, result, stations, 3)
|
||||
// Car 3 at 20% (at station!)
|
||||
expect(result[2].isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('EDGE CASE: passengers left behind when train moves too fast', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Simulate train speeding through station
|
||||
// Only 2 cars, but 2 passengers at same station
|
||||
|
||||
// Frame 1: Train at 27%, car 1 at 20%, car 2 at 13%
|
||||
let result = simulateBoardingAtPosition(27, passengers, stations, 2)
|
||||
expect(result[0].isBoarded).toBe(true)
|
||||
expect(result[1].isBoarded).toBe(false)
|
||||
|
||||
// Frame 2: Train jumps to 35% (high momentum)
|
||||
// Car 1 at 28%, car 2 at 21%
|
||||
result = simulateBoardingAtPosition(35, result, stations, 2)
|
||||
// Car 2 is at 21%, within 1% of station at 20%
|
||||
expect(result[1].isBoarded).toBe(true)
|
||||
|
||||
// Frame 3: Train at 45% - both cars past station
|
||||
result = simulateBoardingAtPosition(45, result, stations, 2)
|
||||
// Car 1 at 38%, car 2 at 31% - both way past 20%
|
||||
|
||||
// All passengers should have boarded
|
||||
expect(result.every((p) => p.isBoarded)).toBe(true)
|
||||
})
|
||||
|
||||
test('EDGE CASE: passenger left behind when boarding window is missed', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Only 1 car, 2 passengers
|
||||
// Frame 1: Train at 27%, car at 20%
|
||||
let result = simulateBoardingAtPosition(27, passengers, stations, 1)
|
||||
expect(result[0].isBoarded).toBe(true)
|
||||
expect(result[1].isBoarded).toBe(false) // Second passenger waiting
|
||||
|
||||
// Frame 2: Train jumps way past (very high momentum)
|
||||
result = simulateBoardingAtPosition(50, result, stations, 1)
|
||||
// Car at 43% - way past station at 20%
|
||||
|
||||
// Second passenger SHOULD BE LEFT BEHIND!
|
||||
expect(result[1].isBoarded).toBe(false)
|
||||
})
|
||||
|
||||
test('EDGE CASE: only one passenger boards per car per frame', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Only 1 car, both passengers at same station
|
||||
// With the fix, only first passenger should board in this frame
|
||||
const result = simulateBoardingAtPosition(27, passengers, stations, 1)
|
||||
|
||||
// First passenger boards
|
||||
expect(result[0].isBoarded).toBe(true)
|
||||
// Second passenger does NOT board (car already assigned this frame)
|
||||
expect(result[1].isBoarded).toBe(false)
|
||||
})
|
||||
|
||||
test('all passengers board before train completely passes station', () => {
|
||||
passengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 's1',
|
||||
destinationStationId: 's2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// 3 passengers, 3 cars
|
||||
// Simulate train moving through station frame by frame
|
||||
let result = passengers
|
||||
|
||||
// Train approaching station
|
||||
for (let pos = 13; pos <= 40; pos += 1) {
|
||||
result = simulateBoardingAtPosition(pos, result, stations, 3)
|
||||
}
|
||||
|
||||
// All passengers should have boarded by the time last car passes
|
||||
const allBoarded = result.every((p) => p.isBoarded)
|
||||
const leftBehind = result.filter((p) => !p.isBoarded)
|
||||
|
||||
expect(allBoarded).toBe(true)
|
||||
if (!allBoarded) {
|
||||
console.log(
|
||||
'Passengers left behind:',
|
||||
leftBehind.map((p) => p.name)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* Unit tests for passenger boarding/delivery logic in useSteamJourney
|
||||
*
|
||||
* These tests ensure that:
|
||||
* 1. Passengers always board when an empty car reaches their origin station
|
||||
* 2. Passengers are never left behind
|
||||
* 3. Multiple passengers can board at the same station on different cars
|
||||
* 4. Passengers are delivered to the correct destination
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ComplementRaceProvider, useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../useSteamJourney'
|
||||
|
||||
// Mock sound effects
|
||||
vi.mock('../useSoundEffects', () => ({
|
||||
useSoundEffects: () => ({
|
||||
playSound: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Wrapper component
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ComplementRaceProvider initialStyle="sprint">{children}</ComplementRaceProvider>
|
||||
)
|
||||
|
||||
// Helper to create test passengers
|
||||
const createPassenger = (
|
||||
id: string,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
isBoarded = false,
|
||||
isDelivered = false
|
||||
): Passenger => ({
|
||||
id,
|
||||
name: `Passenger ${id}`,
|
||||
avatar: '👤',
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
isUrgent: false,
|
||||
isBoarded,
|
||||
isDelivered,
|
||||
})
|
||||
|
||||
// Test stations
|
||||
const _testStations: Station[] = [
|
||||
{ id: 'station-0', name: 'Start', position: 0, icon: '🏁' },
|
||||
{ id: 'station-1', name: 'Middle', position: 50, icon: '🏢' },
|
||||
{ id: 'station-2', name: 'End', position: 100, icon: '🏁' },
|
||||
]
|
||||
|
||||
describe('useSteamJourney - Passenger Boarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('passenger boards when train reaches their origin station', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
},
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
// Setup: Add passenger waiting at station-1 (position 50)
|
||||
const passenger = createPassenger('p1', 'station-1', 'station-2')
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers: [passenger],
|
||||
})
|
||||
// Set train position just before station-1
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 40, // First car will be at ~33 (40 - 7)
|
||||
pressure: 75,
|
||||
elapsedTime: 1000,
|
||||
})
|
||||
})
|
||||
|
||||
// Verify passenger is waiting
|
||||
expect(result.current.race.state.passengers[0].isBoarded).toBe(false)
|
||||
|
||||
// Move train to station-1 position
|
||||
act(() => {
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 57, // First car at position 50 (57 - 7)
|
||||
pressure: 75,
|
||||
elapsedTime: 2000,
|
||||
})
|
||||
})
|
||||
|
||||
// Advance timers to trigger the interval
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Verify passenger boarded
|
||||
const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
|
||||
expect(boardedPassenger?.isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('multiple passengers can board at the same station on different cars', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
},
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
// Setup: Three passengers waiting at station-1
|
||||
const passengers = [
|
||||
createPassenger('p1', 'station-1', 'station-2'),
|
||||
createPassenger('p2', 'station-1', 'station-2'),
|
||||
createPassenger('p3', 'station-1', 'station-2'),
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers,
|
||||
})
|
||||
// Set train with 3 empty cars approaching station-1 (position 50)
|
||||
// Cars at: 50 (57-7), 43 (57-14), 36 (57-21)
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 60,
|
||||
trainPosition: 57,
|
||||
pressure: 90,
|
||||
elapsedTime: 1000,
|
||||
})
|
||||
})
|
||||
|
||||
// Advance timers
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// All three passengers should board (one per car)
|
||||
const boardedCount = result.current.race.state.passengers.filter((p) => p.isBoarded).length
|
||||
expect(boardedCount).toBe(3)
|
||||
})
|
||||
|
||||
test('passenger is not left behind when train passes quickly', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
},
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
const passenger = createPassenger('p1', 'station-1', 'station-2')
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers: [passenger],
|
||||
})
|
||||
})
|
||||
|
||||
// Simulate train passing through station quickly
|
||||
const positions = [40, 45, 50, 52, 54, 56, 58, 60, 65, 70]
|
||||
|
||||
for (const pos of positions) {
|
||||
act(() => {
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 80,
|
||||
trainPosition: pos,
|
||||
pressure: 120,
|
||||
elapsedTime: 1000 + pos * 50,
|
||||
})
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
// Check if passenger boarded
|
||||
const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
|
||||
if (boardedPassenger?.isBoarded) {
|
||||
// Success! Passenger boarded during the pass
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, passenger was left behind
|
||||
const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
|
||||
expect(boardedPassenger?.isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('passenger boards on correct car based on availability', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
},
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
// Setup: One passenger already on car 0, another waiting
|
||||
const passengers = [
|
||||
createPassenger('p1', 'station-0', 'station-2', true, false), // Already boarded on car 0
|
||||
createPassenger('p2', 'station-1', 'station-2'), // Waiting at station-1
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers,
|
||||
})
|
||||
// Train at station-1, car 0 occupied, car 1 empty
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 57, // Car 0 at 50, Car 1 at 43
|
||||
pressure: 75,
|
||||
elapsedTime: 2000,
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// p2 should board (on car 1 since car 0 is occupied)
|
||||
const p2 = result.current.race.state.passengers.find((p) => p.id === 'p2')
|
||||
expect(p2?.isBoarded).toBe(true)
|
||||
|
||||
// p1 should still be boarded
|
||||
const p1 = result.current.race.state.passengers.find((p) => p.id === 'p1')
|
||||
expect(p1?.isBoarded).toBe(true)
|
||||
expect(p1?.isDelivered).toBe(false)
|
||||
})
|
||||
|
||||
test('passenger is delivered when their car reaches destination', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
},
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
// Setup: Passenger already boarded, heading to station-2 (position 100)
|
||||
const passenger = createPassenger('p1', 'station-0', 'station-2', true, false)
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers: [passenger],
|
||||
})
|
||||
// Move train so car 0 reaches station-2
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 107, // Car 0 at position 100 (107 - 7)
|
||||
pressure: 75,
|
||||
elapsedTime: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Passenger should be delivered
|
||||
const deliveredPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
|
||||
expect(deliveredPassenger?.isDelivered).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,500 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
|
||||
describe('useTrackManagement - Passenger Display', () => {
|
||||
let mockPathRef: React.RefObject<SVGPathElement>
|
||||
let mockTrackGenerator: RailroadTrackGenerator
|
||||
let mockStations: Station[]
|
||||
let mockPassengers: Passenger[]
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock path element
|
||||
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
mockPath.getTotalLength = vi.fn(() => 1000)
|
||||
mockPath.getPointAtLength = vi.fn((distance: number) => ({
|
||||
x: distance,
|
||||
y: 300,
|
||||
}))
|
||||
mockPathRef = { current: mockPath }
|
||||
|
||||
// Mock track generator
|
||||
mockTrackGenerator = {
|
||||
generateTrack: vi.fn(() => ({
|
||||
ballastPath: 'M 0 0',
|
||||
referencePath: 'M 0 0',
|
||||
ties: [],
|
||||
leftRailPath: 'M 0 0',
|
||||
rightRailPath: 'M 0 0',
|
||||
})),
|
||||
generateTiesAndRails: vi.fn(() => ({
|
||||
ties: [],
|
||||
leftRailPath: 'M 0 0',
|
||||
rightRailPath: 'M 0 0',
|
||||
})),
|
||||
} as unknown as RailroadTrackGenerator
|
||||
|
||||
// Mock stations
|
||||
mockStations = [
|
||||
{ id: 'station1', name: 'Station 1', icon: '🏠', position: 20 },
|
||||
{ id: 'station2', name: 'Station 2', icon: '🏢', position: 50 },
|
||||
{ id: 'station3', name: 'Station 3', icon: '🏪', position: 80 },
|
||||
]
|
||||
|
||||
// Mock passengers - initial set
|
||||
mockPassengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Alice',
|
||||
avatar: '👩',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Bob',
|
||||
avatar: '👨',
|
||||
originStationId: 'station2',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('initial passengers are displayed', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 10,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
expect(result.current.displayPassengers[1].id).toBe('p2')
|
||||
})
|
||||
|
||||
test('passengers update when boarded (same route gameplay)', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { passengers: mockPassengers, position: 25 } }
|
||||
)
|
||||
|
||||
// Initially 2 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(false)
|
||||
|
||||
// Board first passenger
|
||||
const boardedPassengers = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, isBoarded: true } : p
|
||||
)
|
||||
|
||||
rerender({ passengers: boardedPassengers, position: 25 })
|
||||
|
||||
// Should show updated passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('passengers do NOT update during route transition (train moving)', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 50 } }
|
||||
)
|
||||
|
||||
// Initially route 1 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Generate new passengers for route 2
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Change route but train still moving
|
||||
rerender({ route: 2, passengers: newPassengers, position: 60 })
|
||||
|
||||
// Should STILL show old passengers (route 1)
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
expect(result.current.displayPassengers[0].name).toBe('Alice')
|
||||
})
|
||||
|
||||
test('passengers update when train resets to start (negative position)', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 50 } }
|
||||
)
|
||||
|
||||
// Initially route 1 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Generate new passengers for route 2
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Change route and train resets
|
||||
rerender({ route: 2, passengers: newPassengers, position: -5 })
|
||||
|
||||
// Should now show NEW passengers (route 2)
|
||||
expect(result.current.displayPassengers).toHaveLength(1)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p3')
|
||||
expect(result.current.displayPassengers[0].name).toBe('Charlie')
|
||||
})
|
||||
|
||||
test('passengers do NOT flash when transitioning through 100%', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 95 } }
|
||||
)
|
||||
|
||||
// At 95% - show route 1 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Generate new passengers for route 2
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Train exits (105%) but route hasn't changed yet
|
||||
rerender({ route: 1, passengers: mockPassengers, position: 105 })
|
||||
|
||||
// Should STILL show route 1 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Now route changes to 2, but train still at 105%
|
||||
rerender({ route: 2, passengers: newPassengers, position: 105 })
|
||||
|
||||
// Should STILL show route 1 passengers (old ones)
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Train resets to start
|
||||
rerender({ route: 2, passengers: newPassengers, position: -5 })
|
||||
|
||||
// NOW should show route 2 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(1)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p3')
|
||||
})
|
||||
|
||||
test('passengers do NOT update when array reference changes but same route', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { passengers: mockPassengers, position: 50 } }
|
||||
)
|
||||
|
||||
// Initially route 1 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Create new array with same content (different reference)
|
||||
const samePassengersNewRef = mockPassengers.map((p) => ({ ...p }))
|
||||
|
||||
// Update with new reference but same content
|
||||
rerender({ passengers: samePassengersNewRef, position: 50 })
|
||||
|
||||
// Display should update because it's the same route (gameplay update)
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
})
|
||||
|
||||
test('delivered passengers update immediately (same route)', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { passengers: mockPassengers, position: 25 } }
|
||||
)
|
||||
|
||||
// Initially 2 passengers, neither delivered
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(false)
|
||||
|
||||
// Deliver first passenger
|
||||
const deliveredPassengers = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, isBoarded: true, isDelivered: true } : p
|
||||
)
|
||||
|
||||
rerender({ passengers: deliveredPassengers, position: 55 })
|
||||
|
||||
// Should show updated passengers immediately
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
})
|
||||
|
||||
test('multiple rapid passenger updates during same route', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { passengers: mockPassengers, position: 25 } }
|
||||
)
|
||||
|
||||
// Initially 2 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
|
||||
// Board p1
|
||||
let updated = mockPassengers.map((p) => (p.id === 'p1' ? { ...p, isBoarded: true } : p))
|
||||
rerender({ passengers: updated, position: 26 })
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
|
||||
// Board p2
|
||||
updated = updated.map((p) => (p.id === 'p2' ? { ...p, isBoarded: true } : p))
|
||||
rerender({ passengers: updated, position: 52 })
|
||||
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
|
||||
|
||||
// Deliver p1
|
||||
updated = updated.map((p) => (p.id === 'p1' ? { ...p, isDelivered: true } : p))
|
||||
rerender({ passengers: updated, position: 53 })
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
|
||||
// All updates should have been reflected
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[1].isDelivered).toBe(false)
|
||||
})
|
||||
|
||||
test('EDGE CASE: new passengers at position 0 with old route', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 95 } }
|
||||
)
|
||||
|
||||
// At 95% - route 1 passengers
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Train exits tunnel
|
||||
rerender({ route: 1, passengers: mockPassengers, position: 110 })
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// New passengers generated but route hasn't changed yet, position resets to 0
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// CRITICAL: New passengers, old route, position = 0
|
||||
// This could trigger the second useEffect if not handled carefully
|
||||
rerender({ route: 1, passengers: newPassengers, position: 0 })
|
||||
|
||||
// Should NOT show new passengers yet (route hasn't changed)
|
||||
// But position is 0-100, so second effect might fire
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
expect(result.current.displayPassengers[0].name).toBe('Alice')
|
||||
})
|
||||
|
||||
test('EDGE CASE: passengers regenerated at position 5%', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 95 } }
|
||||
)
|
||||
|
||||
// At 95% - route 1 passengers
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// New passengers generated while train is at 5%
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// CRITICAL: New passengers array, same route, position within 0-100
|
||||
rerender({ route: 1, passengers: newPassengers, position: 5 })
|
||||
|
||||
// Should NOT show new passengers (different array reference, route hasn't changed properly)
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
})
|
||||
|
||||
test('EDGE CASE: rapid route increment with position oscillation', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { route: 1, passengers: mockPassengers, position: 50 } }
|
||||
)
|
||||
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
const route2Passengers: Passenger[] = [
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'Charlie',
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Route changes, position goes positive briefly before negative
|
||||
rerender({ route: 2, passengers: route2Passengers, position: 2 })
|
||||
|
||||
// Should still show old passengers
|
||||
expect(result.current.displayPassengers[0].id).toBe('p1')
|
||||
|
||||
// Position goes negative
|
||||
rerender({ route: 2, passengers: route2Passengers, position: -3 })
|
||||
|
||||
// NOW should show new passengers
|
||||
expect(result.current.displayPassengers[0].id).toBe('p3')
|
||||
})
|
||||
})
|
||||
@@ -1,362 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
|
||||
// Mock the landmarks module
|
||||
vi.mock('../../lib/landmarks', () => ({
|
||||
generateLandmarks: vi.fn((_route: number) => [
|
||||
{ emoji: '🌲', position: 30, offset: { x: 0, y: -50 }, size: 24 },
|
||||
{ emoji: '🏔️', position: 70, offset: { x: 0, y: -80 }, size: 32 },
|
||||
]),
|
||||
}))
|
||||
|
||||
describe('useTrackManagement', () => {
|
||||
let mockPathRef: React.RefObject<SVGPathElement>
|
||||
let mockTrackGenerator: RailroadTrackGenerator
|
||||
let mockStations: Station[]
|
||||
let mockPassengers: Passenger[]
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock path element
|
||||
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
mockPath.getTotalLength = vi.fn(() => 1000)
|
||||
mockPath.getPointAtLength = vi.fn((distance: number) => ({
|
||||
x: distance,
|
||||
y: 300,
|
||||
}))
|
||||
mockPathRef = { current: mockPath }
|
||||
|
||||
// Mock track generator
|
||||
mockTrackGenerator = {
|
||||
generateTrack: vi.fn((route: number) => ({
|
||||
referencePath: `M 0 300 L ${route * 100} 300`,
|
||||
ballastPath: `M 0 300 L ${route * 100} 300`,
|
||||
})),
|
||||
generateTiesAndRails: vi.fn(() => ({
|
||||
ties: [
|
||||
{ x1: 0, y1: 300, x2: 10, y2: 300 },
|
||||
{ x1: 20, y1: 300, x2: 30, y2: 300 },
|
||||
],
|
||||
leftRailPoints: ['0,295', '100,295'],
|
||||
rightRailPoints: ['0,305', '100,305'],
|
||||
})),
|
||||
} as unknown as RailroadTrackGenerator
|
||||
|
||||
mockStations = [
|
||||
{ id: 'station-1', name: 'Station 1', position: 20, icon: '🏭' },
|
||||
{ id: 'station-2', name: 'Station 2', position: 60, icon: '🏛️' },
|
||||
]
|
||||
|
||||
mockPassengers = [
|
||||
{
|
||||
id: 'passenger-1',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('initializes with null trackData', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
// Track data should be generated
|
||||
expect(result.current.trackData).toBeDefined()
|
||||
expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
test('generates landmarks for current route', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.landmarks).toHaveLength(2)
|
||||
expect(result.current.landmarks[0].emoji).toBe('🌲')
|
||||
expect(result.current.landmarks[1].emoji).toBe('🏔️')
|
||||
})
|
||||
|
||||
test('generates ties and rails when path is ready', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.tiesAndRails).toBeDefined()
|
||||
expect(result.current.tiesAndRails?.ties).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('calculates station positions along path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.stationPositions).toHaveLength(2)
|
||||
// Station 1 at 20% of 1000 = 200
|
||||
expect(result.current.stationPositions[0].x).toBe(200)
|
||||
// Station 2 at 60% of 1000 = 600
|
||||
expect(result.current.stationPositions[1].x).toBe(600)
|
||||
})
|
||||
|
||||
test('calculates landmark positions along path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.landmarkPositions).toHaveLength(2)
|
||||
// First landmark at 30% + offset
|
||||
expect(result.current.landmarkPositions[0].x).toBe(300) // 30% of 1000
|
||||
expect(result.current.landmarkPositions[0].y).toBe(250) // 300 + (-50)
|
||||
})
|
||||
|
||||
test('delays track update when changing routes mid-journey', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
}
|
||||
)
|
||||
|
||||
const initialTrackData = result.current.trackData
|
||||
|
||||
// Change route while train is mid-journey (position > 0)
|
||||
rerender({ route: 2, position: 50 })
|
||||
|
||||
// Track should NOT update yet (pending)
|
||||
expect(result.current.trackData).toBe(initialTrackData)
|
||||
expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
test('applies pending track when train resets to beginning', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
}
|
||||
)
|
||||
|
||||
// Change route while train is mid-journey
|
||||
rerender({ route: 2, position: 50 })
|
||||
const trackDataBeforeReset = result.current.trackData
|
||||
|
||||
// Train resets to beginning (position < 0)
|
||||
rerender({ route: 2, position: -5 })
|
||||
|
||||
// Track should now update
|
||||
expect(result.current.trackData).not.toBe(trackDataBeforeReset)
|
||||
})
|
||||
|
||||
test('immediately applies new track when train is at start', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ route, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: route,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: -5 },
|
||||
}
|
||||
)
|
||||
|
||||
const initialTrackData = result.current.trackData
|
||||
|
||||
// Change route while train is at start (position < 0)
|
||||
rerender({ route: 2, position: -5 })
|
||||
|
||||
// Track should update immediately
|
||||
expect(result.current.trackData).not.toBe(initialTrackData)
|
||||
})
|
||||
|
||||
test('delays passenger display update until all cars exit', () => {
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'passenger-2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { passengers: mockPassengers, position: 50 },
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.current.displayPassengers).toBe(mockPassengers)
|
||||
|
||||
// Change passengers while train is mid-journey
|
||||
// Locomotive at 100%, but last car at 100 - (5*7) = 65%
|
||||
rerender({ passengers: newPassengers, position: 100 })
|
||||
|
||||
// Display passengers should NOT update yet (last car hasn't exited)
|
||||
expect(result.current.displayPassengers).toBe(mockPassengers)
|
||||
})
|
||||
|
||||
test('does not update passenger display until train resets', () => {
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'passenger-2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
},
|
||||
]
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { passengers: mockPassengers, position: 50 },
|
||||
}
|
||||
)
|
||||
|
||||
// Change passengers, locomotive at position where all cars have exited
|
||||
// Last car exits at position 97%, so locomotive at 132%
|
||||
rerender({ passengers: newPassengers, position: 132 })
|
||||
|
||||
// Display passengers should NOT update yet (waiting for train reset)
|
||||
expect(result.current.displayPassengers).toBe(mockPassengers)
|
||||
|
||||
// Now train resets to beginning
|
||||
rerender({ passengers: newPassengers, position: -5 })
|
||||
|
||||
// Display passengers should update now (train reset)
|
||||
expect(result.current.displayPassengers).toBe(newPassengers)
|
||||
})
|
||||
|
||||
test('updates passengers immediately during same route', () => {
|
||||
const updatedPassengers: Passenger[] = [{ ...mockPassengers[0], isBoarded: true }]
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { passengers: mockPassengers, position: 50 },
|
||||
}
|
||||
)
|
||||
|
||||
// Update passengers (boarding) during same route
|
||||
rerender({ passengers: updatedPassengers, position: 55 })
|
||||
|
||||
// Display passengers should update immediately (same route, gameplay update)
|
||||
expect(result.current.displayPassengers).toBe(updatedPassengers)
|
||||
})
|
||||
|
||||
test('returns null when no track data', () => {
|
||||
// Create a hook where trackGenerator returns null
|
||||
const nullTrackGenerator = {
|
||||
generateTrack: vi.fn(() => null),
|
||||
} as unknown as RailroadTrackGenerator
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTrackManagement({
|
||||
currentRoute: 1,
|
||||
trainPosition: 0,
|
||||
trackGenerator: nullTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trackData).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,302 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrainTransforms } from '../useTrainTransforms'
|
||||
|
||||
describe('useTrainTransforms', () => {
|
||||
let mockPathRef: React.RefObject<SVGPathElement>
|
||||
let mockTrackGenerator: RailroadTrackGenerator
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock path element
|
||||
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
mockPathRef = { current: mockPath }
|
||||
|
||||
// Mock track generator
|
||||
mockTrackGenerator = {
|
||||
getTrainTransform: vi.fn((_path: SVGPathElement, position: number) => ({
|
||||
x: position * 10,
|
||||
y: 300,
|
||||
rotation: position / 10,
|
||||
})),
|
||||
} as unknown as RailroadTrackGenerator
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('returns default transform when pathRef is null', () => {
|
||||
const nullPathRef: React.RefObject<SVGPathElement> = { current: null }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: nullPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trainTransform).toEqual({
|
||||
x: 50,
|
||||
y: 300,
|
||||
rotation: 0,
|
||||
})
|
||||
expect(result.current.trainCars).toHaveLength(5)
|
||||
})
|
||||
|
||||
test('calculates train transform at given position', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trainTransform).toEqual({
|
||||
x: 500, // 50 * 10
|
||||
y: 300,
|
||||
rotation: 5, // 50 / 10
|
||||
})
|
||||
})
|
||||
|
||||
test('updates transform when train position changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ position }) =>
|
||||
useTrainTransforms({
|
||||
trainPosition: position,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{ initialProps: { position: 20 } }
|
||||
)
|
||||
|
||||
expect(result.current.trainTransform.x).toBe(200)
|
||||
|
||||
rerender({ position: 60 })
|
||||
expect(result.current.trainTransform.x).toBe(600)
|
||||
})
|
||||
|
||||
test('calculates correct number of train cars', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trainCars).toHaveLength(5)
|
||||
})
|
||||
|
||||
test('respects custom maxCars parameter', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trainCars).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('respects custom carSpacing parameter', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 10,
|
||||
})
|
||||
)
|
||||
|
||||
// First car should be at position 50 - 10 = 40
|
||||
expect(result.current.trainCars[0].position).toBe(40)
|
||||
})
|
||||
|
||||
test('positions cars behind locomotive with correct spacing', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 3,
|
||||
carSpacing: 10,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.trainCars[0].position).toBe(40) // 50 - 1*10
|
||||
expect(result.current.trainCars[1].position).toBe(30) // 50 - 2*10
|
||||
expect(result.current.trainCars[2].position).toBe(20) // 50 - 3*10
|
||||
})
|
||||
|
||||
test('calculates locomotive opacity correctly during fade in', () => {
|
||||
// Fade in range: 3-8%
|
||||
const { result: result1 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 3,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result1.current.locomotiveOpacity).toBe(0)
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 5.5, // Midpoint between 3 and 8
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result2.current.locomotiveOpacity).toBe(0.5)
|
||||
|
||||
const { result: result3 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 8,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result3.current.locomotiveOpacity).toBe(1)
|
||||
})
|
||||
|
||||
test('calculates locomotive opacity correctly during fade out', () => {
|
||||
// Fade out range: 92-97%
|
||||
const { result: result1 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 92,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result1.current.locomotiveOpacity).toBe(1)
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 94.5, // Midpoint between 92 and 97
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result2.current.locomotiveOpacity).toBe(0.5)
|
||||
|
||||
const { result: result3 } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 97,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
expect(result3.current.locomotiveOpacity).toBe(0)
|
||||
})
|
||||
|
||||
test('locomotive is fully visible in middle of track', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.locomotiveOpacity).toBe(1)
|
||||
})
|
||||
|
||||
test('calculates car opacity independently for each car', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 10, // Locomotive at 10%, first car at 3% (fading in)
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 2,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
// First car at position 3 should be starting to fade in
|
||||
expect(result.current.trainCars[0].position).toBe(3)
|
||||
expect(result.current.trainCars[0].opacity).toBe(0)
|
||||
|
||||
// Second car at position -4 should be invisible (not yet entered)
|
||||
expect(result.current.trainCars[1].position).toBe(0) // clamped to 0
|
||||
expect(result.current.trainCars[1].opacity).toBe(0)
|
||||
})
|
||||
|
||||
test('car positions cannot go below zero', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 5,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
// First car at 5 - 7 = -2, should be clamped to 0
|
||||
expect(result.current.trainCars[0].position).toBe(0)
|
||||
// Second car at 5 - 14 = -9, should be clamped to 0
|
||||
expect(result.current.trainCars[1].position).toBe(0)
|
||||
})
|
||||
|
||||
test('cars fade out completely past 97%', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 104, // Last car at 104 - 35 = 69% (5 cars * 7 spacing)
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
const lastCar = result.current.trainCars[4]
|
||||
expect(lastCar.position).toBe(69)
|
||||
expect(lastCar.opacity).toBe(1) // Still visible, not past 97%
|
||||
})
|
||||
|
||||
test('memoizes car transforms to avoid recalculation on same inputs', () => {
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useTrainTransforms({
|
||||
trainPosition: 50,
|
||||
trackGenerator: mockTrackGenerator,
|
||||
pathRef: mockPathRef,
|
||||
maxCars: 5,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
const firstCars = result.current.trainCars
|
||||
|
||||
// Rerender with same props
|
||||
rerender()
|
||||
|
||||
// Should be the exact same array reference (memoized)
|
||||
expect(result.current.trainCars).toBe(firstCars)
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { type CommentaryContext, getAICommentary } from '../components/AISystem/aiCommentary'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useSoundEffects } from './useSoundEffects'
|
||||
|
||||
export function useAIRacers() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { playSound } = useSoundEffects()
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isGameActive) return
|
||||
|
||||
// Update AI positions every 200ms (line 11690)
|
||||
const aiUpdateInterval = setInterval(() => {
|
||||
const newPositions = state.aiRacers.map((racer) => {
|
||||
// Base speed with random variance (0.6-1.4 range via Math.random() * 0.8 + 0.6)
|
||||
const variance = Math.random() * 0.8 + 0.6
|
||||
let speed = racer.speed * variance * state.speedMultiplier
|
||||
|
||||
// Rubber-banding: AI speeds up 2x when >10 units behind player (line 11697-11699)
|
||||
const distanceBehind = state.correctAnswers - racer.position
|
||||
if (distanceBehind > 10) {
|
||||
speed *= 2
|
||||
}
|
||||
|
||||
// Update position
|
||||
const newPosition = racer.position + speed
|
||||
|
||||
return {
|
||||
id: racer.id,
|
||||
position: newPosition,
|
||||
}
|
||||
})
|
||||
|
||||
dispatch({ type: 'UPDATE_AI_POSITIONS', positions: newPositions })
|
||||
|
||||
// Check for AI win in practice mode (line 14151)
|
||||
if (state.style === 'practice' && state.isGameActive) {
|
||||
const winningAI = state.aiRacers.find((racer, index) => {
|
||||
const updatedPosition = newPositions[index]?.position || racer.position
|
||||
return updatedPosition >= state.raceGoal
|
||||
})
|
||||
|
||||
if (winningAI) {
|
||||
// Play game over sound (line 14193)
|
||||
playSound('gameOver')
|
||||
// End the game
|
||||
dispatch({ type: 'END_RACE' })
|
||||
// Show results after a short delay
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'SHOW_RESULTS' })
|
||||
}, 1500)
|
||||
return // Exit early to prevent further updates
|
||||
}
|
||||
}
|
||||
|
||||
// Check for commentary triggers after position updates
|
||||
state.aiRacers.forEach((racer) => {
|
||||
const updatedPosition =
|
||||
newPositions.find((p) => p.id === racer.id)?.position || racer.position
|
||||
const distanceBehind = state.correctAnswers - updatedPosition
|
||||
const distanceAhead = updatedPosition - state.correctAnswers
|
||||
|
||||
// Detect passing events
|
||||
const playerJustPassed =
|
||||
racer.previousPosition > state.correctAnswers && updatedPosition < state.correctAnswers
|
||||
const aiJustPassed =
|
||||
racer.previousPosition < state.correctAnswers && updatedPosition > state.correctAnswers
|
||||
|
||||
// Determine commentary context
|
||||
let context: CommentaryContext | null = null
|
||||
|
||||
if (playerJustPassed) {
|
||||
context = 'player_passed'
|
||||
} else if (aiJustPassed) {
|
||||
context = 'ai_passed'
|
||||
} else if (distanceBehind > 20) {
|
||||
// Player has lapped the AI (more than 20 units behind)
|
||||
context = 'lapped'
|
||||
} else if (distanceBehind > 10) {
|
||||
// AI is desperate to catch up (rubber-banding active)
|
||||
context = 'desperate_catchup'
|
||||
} else if (distanceAhead > 5) {
|
||||
// AI is significantly ahead
|
||||
context = 'ahead'
|
||||
} else if (distanceBehind > 3) {
|
||||
// AI is behind
|
||||
context = 'behind'
|
||||
}
|
||||
|
||||
// Trigger commentary if context is valid
|
||||
if (context) {
|
||||
const message = getAICommentary(racer, context, state.correctAnswers, updatedPosition)
|
||||
if (message) {
|
||||
dispatch({
|
||||
type: 'TRIGGER_AI_COMMENTARY',
|
||||
racerId: racer.id,
|
||||
message,
|
||||
context,
|
||||
})
|
||||
|
||||
// Play special turbo sound when AI goes desperate (line 11941)
|
||||
if (context === 'desperate_catchup') {
|
||||
playSound('ai_turbo', 0.12)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 200)
|
||||
|
||||
return () => clearInterval(aiUpdateInterval)
|
||||
}, [
|
||||
state.isGameActive,
|
||||
state.aiRacers,
|
||||
state.correctAnswers,
|
||||
state.speedMultiplier,
|
||||
dispatch, // Play game over sound (line 14193)
|
||||
playSound,
|
||||
state.raceGoal,
|
||||
state.style,
|
||||
])
|
||||
|
||||
return {
|
||||
aiRacers: state.aiRacers,
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import type { PairPerformance } from '../lib/gameTypes'
|
||||
|
||||
export function useAdaptiveDifficulty() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
// Track performance after each answer (lines 14495-14553)
|
||||
const trackPerformance = (isCorrect: boolean, responseTime: number) => {
|
||||
if (!state.currentQuestion) return
|
||||
|
||||
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
|
||||
|
||||
// Get or create performance data for this pair
|
||||
const pairData: PairPerformance = state.difficultyTracker.pairPerformance.get(pairKey) || {
|
||||
attempts: 0,
|
||||
correct: 0,
|
||||
avgTime: 0,
|
||||
difficulty: 1,
|
||||
}
|
||||
|
||||
// Update performance data
|
||||
pairData.attempts++
|
||||
if (isCorrect) {
|
||||
pairData.correct++
|
||||
}
|
||||
|
||||
// Update average time (rolling average)
|
||||
const totalTime = pairData.avgTime * (pairData.attempts - 1) + responseTime
|
||||
pairData.avgTime = totalTime / pairData.attempts
|
||||
|
||||
// Calculate pair-specific difficulty (lines 14555-14576)
|
||||
if (pairData.attempts >= 2) {
|
||||
const accuracyRate = pairData.correct / pairData.attempts
|
||||
const avgTime = pairData.avgTime
|
||||
|
||||
let difficulty = 1
|
||||
if (accuracyRate >= 0.9 && avgTime < 1500) {
|
||||
difficulty = 1 // Very easy
|
||||
} else if (accuracyRate >= 0.8 && avgTime < 2000) {
|
||||
difficulty = 2 // Easy
|
||||
} else if (accuracyRate >= 0.7 || avgTime < 2500) {
|
||||
difficulty = 3 // Medium
|
||||
} else if (accuracyRate >= 0.5 || avgTime < 3500) {
|
||||
difficulty = 4 // Hard
|
||||
} else {
|
||||
difficulty = 5 // Very hard
|
||||
}
|
||||
|
||||
pairData.difficulty = difficulty
|
||||
}
|
||||
|
||||
// Update difficulty tracker in state
|
||||
const newPairPerformance = new Map(state.difficultyTracker.pairPerformance)
|
||||
newPairPerformance.set(pairKey, pairData)
|
||||
|
||||
// Update consecutive counters
|
||||
const newTracker = {
|
||||
...state.difficultyTracker,
|
||||
pairPerformance: newPairPerformance,
|
||||
consecutiveCorrect: isCorrect ? state.difficultyTracker.consecutiveCorrect + 1 : 0,
|
||||
consecutiveIncorrect: !isCorrect ? state.difficultyTracker.consecutiveIncorrect + 1 : 0,
|
||||
}
|
||||
|
||||
// Adapt global difficulty (lines 14578-14605)
|
||||
if (newTracker.consecutiveCorrect >= 3) {
|
||||
// Reduce time limit (increase difficulty)
|
||||
newTracker.currentTimeLimit = Math.max(
|
||||
1000,
|
||||
newTracker.currentTimeLimit - newTracker.currentTimeLimit * newTracker.adaptationRate
|
||||
)
|
||||
} else if (newTracker.consecutiveIncorrect >= 2) {
|
||||
// Increase time limit (decrease difficulty)
|
||||
newTracker.currentTimeLimit = Math.min(
|
||||
5000,
|
||||
newTracker.currentTimeLimit + newTracker.baseTimeLimit * newTracker.adaptationRate
|
||||
)
|
||||
}
|
||||
|
||||
// Update overall difficulty level
|
||||
const avgDifficulty =
|
||||
Array.from(newTracker.pairPerformance.values()).reduce(
|
||||
(sum, data) => sum + data.difficulty,
|
||||
0
|
||||
) / Math.max(1, newTracker.pairPerformance.size)
|
||||
|
||||
newTracker.difficultyLevel = Math.round(avgDifficulty)
|
||||
|
||||
// Exit learning mode after sufficient data (lines 14548-14552)
|
||||
if (
|
||||
newTracker.pairPerformance.size >= 5 &&
|
||||
Array.from(newTracker.pairPerformance.values()).some((data) => data.attempts >= 3)
|
||||
) {
|
||||
newTracker.learningMode = false
|
||||
}
|
||||
|
||||
// Dispatch update
|
||||
dispatch({ type: 'UPDATE_DIFFICULTY_TRACKER', tracker: newTracker })
|
||||
|
||||
// Adapt AI speeds based on player performance
|
||||
adaptAISpeeds(newTracker)
|
||||
}
|
||||
|
||||
// Calculate recent success rate (lines 14685-14693)
|
||||
const calculateRecentSuccessRate = (): number => {
|
||||
const recentQuestions = Math.min(10, state.totalQuestions)
|
||||
if (recentQuestions === 0) return 0.5 // Default for first question
|
||||
|
||||
// Use global tracking for recent performance
|
||||
const recentCorrect = Math.max(
|
||||
0,
|
||||
state.correctAnswers - Math.max(0, state.totalQuestions - recentQuestions)
|
||||
)
|
||||
return recentCorrect / recentQuestions
|
||||
}
|
||||
|
||||
// Calculate average response time (lines 14695-14705)
|
||||
const calculateAverageResponseTime = (): number => {
|
||||
const recentPairs = Array.from(state.difficultyTracker.pairPerformance.values())
|
||||
.filter((data) => data.attempts >= 1)
|
||||
.slice(-5) // Last 5 different pairs encountered
|
||||
|
||||
if (recentPairs.length === 0) return 3000 // Default for learning mode
|
||||
|
||||
const totalTime = recentPairs.reduce((sum, data) => sum + data.avgTime, 0)
|
||||
return totalTime / recentPairs.length
|
||||
}
|
||||
|
||||
// Adapt AI speeds based on performance (lines 14607-14683)
|
||||
const adaptAISpeeds = (tracker: typeof state.difficultyTracker) => {
|
||||
// Don't adapt during learning mode
|
||||
if (tracker.learningMode) return
|
||||
|
||||
const playerSuccessRate = calculateRecentSuccessRate()
|
||||
const avgResponseTime = calculateAverageResponseTime()
|
||||
|
||||
// Base speed multipliers for each race mode
|
||||
let baseSpeedMultiplier: number
|
||||
switch (state.style) {
|
||||
case 'practice':
|
||||
baseSpeedMultiplier = 0.7
|
||||
break
|
||||
case 'sprint':
|
||||
baseSpeedMultiplier = 0.9
|
||||
break
|
||||
case 'survival':
|
||||
baseSpeedMultiplier = state.speedMultiplier * state.survivalMultiplier
|
||||
break
|
||||
default:
|
||||
baseSpeedMultiplier = 0.7
|
||||
}
|
||||
|
||||
// Calculate adaptive multiplier based on player performance
|
||||
let adaptiveMultiplier = 1.0
|
||||
|
||||
// Success rate factor (0.5x to 1.6x based on success rate)
|
||||
if (playerSuccessRate > 0.85) {
|
||||
adaptiveMultiplier *= 1.6 // Player doing great - speed up AI significantly
|
||||
} else if (playerSuccessRate > 0.75) {
|
||||
adaptiveMultiplier *= 1.3 // Player doing well - speed up AI moderately
|
||||
} else if (playerSuccessRate > 0.6) {
|
||||
adaptiveMultiplier *= 1.0 // Player doing okay - keep AI at base speed
|
||||
} else if (playerSuccessRate > 0.45) {
|
||||
adaptiveMultiplier *= 0.75 // Player struggling - slow down AI
|
||||
} else {
|
||||
adaptiveMultiplier *= 0.5 // Player really struggling - significantly slow AI
|
||||
}
|
||||
|
||||
// Response time factor - faster players get faster AI
|
||||
if (avgResponseTime < 1500) {
|
||||
adaptiveMultiplier *= 1.2 // Very fast player
|
||||
} else if (avgResponseTime < 2500) {
|
||||
adaptiveMultiplier *= 1.1 // Fast player
|
||||
} else if (avgResponseTime > 4000) {
|
||||
adaptiveMultiplier *= 0.9 // Slow player
|
||||
}
|
||||
|
||||
// Streak bonus - players on hot streaks get more challenge
|
||||
if (state.streak >= 8) {
|
||||
adaptiveMultiplier *= 1.3
|
||||
} else if (state.streak >= 5) {
|
||||
adaptiveMultiplier *= 1.15
|
||||
}
|
||||
|
||||
// Apply bounds to prevent extreme values
|
||||
adaptiveMultiplier = Math.max(0.3, Math.min(2.0, adaptiveMultiplier))
|
||||
|
||||
// Update AI speeds with adaptive multiplier
|
||||
const finalSpeedMultiplier = baseSpeedMultiplier * adaptiveMultiplier
|
||||
|
||||
// Update AI racer speeds
|
||||
const updatedRacers = state.aiRacers.map((racer, index) => {
|
||||
if (index === 0) {
|
||||
// Swift AI (more aggressive)
|
||||
return { ...racer, speed: 0.32 * finalSpeedMultiplier }
|
||||
} else {
|
||||
// Math Bot (more consistent)
|
||||
return { ...racer, speed: 0.2 * finalSpeedMultiplier }
|
||||
}
|
||||
})
|
||||
|
||||
dispatch({ type: 'UPDATE_AI_SPEEDS', racers: updatedRacers })
|
||||
|
||||
// Debug logging for AI adaptation (every 5 questions)
|
||||
if (state.totalQuestions % 5 === 0) {
|
||||
console.log('🤖 AI Speed Adaptation:', {
|
||||
playerSuccessRate: `${Math.round(playerSuccessRate * 100)}%`,
|
||||
avgResponseTime: `${Math.round(avgResponseTime)}ms`,
|
||||
streak: state.streak,
|
||||
adaptiveMultiplier: Math.round(adaptiveMultiplier * 100) / 100,
|
||||
swiftAISpeed: updatedRacers[0] ? Math.round(updatedRacers[0].speed * 1000) / 1000 : 0,
|
||||
mathBotSpeed: updatedRacers[1] ? Math.round(updatedRacers[1].speed * 1000) / 1000 : 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get adaptive time limit for current question (lines 14740-14763)
|
||||
const getAdaptiveTimeLimit = (): number => {
|
||||
if (!state.currentQuestion) return 3000
|
||||
|
||||
let adaptiveTime: number
|
||||
|
||||
if (state.difficultyTracker.learningMode) {
|
||||
adaptiveTime = Math.max(2000, state.difficultyTracker.currentTimeLimit)
|
||||
} else {
|
||||
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
|
||||
const pairData = state.difficultyTracker.pairPerformance.get(pairKey)
|
||||
|
||||
if (pairData && pairData.attempts >= 2) {
|
||||
// Use pair-specific difficulty
|
||||
const baseTime = state.difficultyTracker.baseTimeLimit
|
||||
const difficultyMultiplier = (6 - pairData.difficulty) / 5 // Invert: difficulty 1 = more time
|
||||
adaptiveTime = Math.max(1000, baseTime * difficultyMultiplier)
|
||||
} else {
|
||||
// Default for new pairs
|
||||
adaptiveTime = state.difficultyTracker.currentTimeLimit
|
||||
}
|
||||
}
|
||||
|
||||
// Apply user timeout setting override (lines 14765-14785)
|
||||
return applyTimeoutSetting(adaptiveTime)
|
||||
}
|
||||
|
||||
// Apply timeout setting multiplier (lines 14765-14785)
|
||||
const applyTimeoutSetting = (baseTime: number): number => {
|
||||
switch (state.timeoutSetting) {
|
||||
case 'preschool':
|
||||
return Math.max(baseTime * 4, 20000) // At least 20 seconds
|
||||
case 'kindergarten':
|
||||
return Math.max(baseTime * 3, 15000) // At least 15 seconds
|
||||
case 'relaxed':
|
||||
return Math.max(baseTime * 2.4, 12000) // At least 12 seconds
|
||||
case 'slow':
|
||||
return Math.max(baseTime * 1.6, 8000) // At least 8 seconds
|
||||
case 'normal':
|
||||
return Math.max(baseTime, 5000) // At least 5 seconds
|
||||
case 'fast':
|
||||
return Math.max(baseTime * 0.6, 3000) // At least 3 seconds
|
||||
case 'expert':
|
||||
return Math.max(baseTime * 0.4, 2000) // At least 2 seconds
|
||||
default:
|
||||
return baseTime
|
||||
}
|
||||
}
|
||||
|
||||
// Get adaptive feedback message (lines 11655-11721)
|
||||
const getAdaptiveFeedbackMessage = (
|
||||
pairKey: string,
|
||||
_isCorrect: boolean,
|
||||
_responseTime: number
|
||||
): {
|
||||
message: string
|
||||
type: 'learning' | 'struggling' | 'mastered' | 'adapted'
|
||||
} | null => {
|
||||
const pairData = state.difficultyTracker.pairPerformance.get(pairKey)
|
||||
const [num1, num2, _sum] = pairKey.split('_').map(Number)
|
||||
|
||||
// Learning mode messages
|
||||
if (state.difficultyTracker.learningMode) {
|
||||
const encouragements = [
|
||||
"🧠 I'm learning your style! Keep going!",
|
||||
'📊 Building your skill profile...',
|
||||
'🎯 Every answer helps me understand you better!',
|
||||
'🚀 Analyzing your complement superpowers!',
|
||||
]
|
||||
return {
|
||||
message: encouragements[Math.floor(Math.random() * encouragements.length)],
|
||||
type: 'learning',
|
||||
}
|
||||
}
|
||||
|
||||
// After learning - provide specific feedback
|
||||
if (pairData && pairData.attempts >= 3) {
|
||||
const accuracy = pairData.correct / pairData.attempts
|
||||
const avgTime = pairData.avgTime
|
||||
|
||||
// Struggling pairs (< 60% accuracy)
|
||||
if (accuracy < 0.6) {
|
||||
const strugglingMessages = [
|
||||
`💪 ${num1}+${num2} needs practice - I'm giving you extra time!`,
|
||||
`🎯 Working on ${num1}+${num2} - you've got this!`,
|
||||
`⏰ Taking it slower with ${num1}+${num2} - no rush!`,
|
||||
`🧩 ${num1}+${num2} is getting special attention from me!`,
|
||||
]
|
||||
return {
|
||||
message: strugglingMessages[Math.floor(Math.random() * strugglingMessages.length)],
|
||||
type: 'struggling',
|
||||
}
|
||||
}
|
||||
|
||||
// Mastered pairs (> 85% accuracy and fast)
|
||||
if (accuracy > 0.85 && avgTime < 2000) {
|
||||
const masteredMessages = [
|
||||
`⚡ ${num1}+${num2} = MASTERED! Lightning mode activated!`,
|
||||
`🔥 You've conquered ${num1}+${num2} - speeding it up!`,
|
||||
`🏆 ${num1}+${num2} expert detected! Challenge mode ON!`,
|
||||
`⭐ ${num1}+${num2} is your superpower! Going faster!`,
|
||||
]
|
||||
return {
|
||||
message: masteredMessages[Math.floor(Math.random() * masteredMessages.length)],
|
||||
type: 'mastered',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show adaptation when difficulty changes
|
||||
if (state.difficultyTracker.consecutiveCorrect >= 3) {
|
||||
return {
|
||||
message: "🚀 You're on fire! Increasing the challenge!",
|
||||
type: 'adapted',
|
||||
}
|
||||
} else if (state.difficultyTracker.consecutiveIncorrect >= 2) {
|
||||
return {
|
||||
message: "🤗 Let's slow down a bit - I'm here to help!",
|
||||
type: 'adapted',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
trackPerformance,
|
||||
getAdaptiveTimeLimit,
|
||||
calculateRecentSuccessRate,
|
||||
calculateAverageResponseTime,
|
||||
getAdaptiveFeedbackMessage,
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
|
||||
export function useGameLoop() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
// Generate first question when game begins
|
||||
useEffect(() => {
|
||||
if (state.gamePhase === 'playing' && !state.currentQuestion) {
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
}
|
||||
}, [state.gamePhase, state.currentQuestion, dispatch])
|
||||
|
||||
const nextQuestion = useCallback(() => {
|
||||
if (!state.isGameActive) return
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
}, [state.isGameActive, dispatch])
|
||||
|
||||
const submitAnswer = useCallback(
|
||||
(answer: number) => {
|
||||
if (!state.currentQuestion) return
|
||||
|
||||
const isCorrect = answer === state.currentQuestion.correctAnswer
|
||||
|
||||
if (isCorrect) {
|
||||
// Update score, streak, progress
|
||||
// TODO: Will implement full scoring in next step
|
||||
dispatch({ type: 'SUBMIT_ANSWER', answer })
|
||||
|
||||
// Move to next question
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
} else {
|
||||
// Reset streak
|
||||
// TODO: Will implement incorrect answer handling
|
||||
dispatch({ type: 'SUBMIT_ANSWER', answer })
|
||||
}
|
||||
},
|
||||
[state.currentQuestion, dispatch]
|
||||
)
|
||||
|
||||
const startCountdown = useCallback(() => {
|
||||
// Trigger countdown phase
|
||||
dispatch({ type: 'START_COUNTDOWN' })
|
||||
|
||||
// Start 3-2-1-GO countdown (lines 11163-11211)
|
||||
let count = 3
|
||||
const countdownInterval = setInterval(() => {
|
||||
if (count > 0) {
|
||||
// TODO: Play countdown sound
|
||||
count--
|
||||
} else {
|
||||
// GO!
|
||||
// TODO: Play start sound
|
||||
clearInterval(countdownInterval)
|
||||
|
||||
// Start the actual game after GO animation (1 second delay)
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
}, 1000)
|
||||
}
|
||||
}, 1000)
|
||||
}, [dispatch])
|
||||
|
||||
return {
|
||||
nextQuestion,
|
||||
submitAnswer,
|
||||
startCountdown,
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
|
||||
export interface BoardingAnimation {
|
||||
passenger: Passenger
|
||||
fromX: number
|
||||
fromY: number
|
||||
toX: number
|
||||
toY: number
|
||||
carIndex: number
|
||||
startTime: number
|
||||
}
|
||||
|
||||
export interface DisembarkingAnimation {
|
||||
passenger: Passenger
|
||||
fromX: number
|
||||
fromY: number
|
||||
toX: number
|
||||
toY: number
|
||||
startTime: number
|
||||
}
|
||||
|
||||
interface UsePassengerAnimationsParams {
|
||||
passengers: Passenger[]
|
||||
stations: Station[]
|
||||
stationPositions: Array<{ x: number; y: number }>
|
||||
trainPosition: number
|
||||
trackGenerator: RailroadTrackGenerator
|
||||
pathRef: React.RefObject<SVGPathElement>
|
||||
}
|
||||
|
||||
export function usePassengerAnimations({
|
||||
passengers,
|
||||
stations,
|
||||
stationPositions,
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
}: UsePassengerAnimationsParams) {
|
||||
const [boardingAnimations, setBoardingAnimations] = useState<Map<string, BoardingAnimation>>(
|
||||
new Map()
|
||||
)
|
||||
const [disembarkingAnimations, setDisembarkingAnimations] = useState<
|
||||
Map<string, DisembarkingAnimation>
|
||||
>(new Map())
|
||||
const previousPassengersRef = useRef<Passenger[]>(passengers)
|
||||
|
||||
// Detect passengers boarding/disembarking and start animations
|
||||
useEffect(() => {
|
||||
if (!pathRef.current || stationPositions.length === 0) return
|
||||
|
||||
const previousPassengers = previousPassengersRef.current
|
||||
const currentPassengers = passengers
|
||||
|
||||
// Find newly boarded passengers
|
||||
const newlyBoarded = currentPassengers.filter((curr) => {
|
||||
const prev = previousPassengers.find((p) => p.id === curr.id)
|
||||
return curr.isBoarded && prev && !prev.isBoarded
|
||||
})
|
||||
|
||||
// Find newly delivered passengers
|
||||
const newlyDelivered = currentPassengers.filter((curr) => {
|
||||
const prev = previousPassengers.find((p) => p.id === curr.id)
|
||||
return curr.isDelivered && prev && !prev.isDelivered
|
||||
})
|
||||
|
||||
// Start animation for each newly boarded passenger
|
||||
newlyBoarded.forEach((passenger) => {
|
||||
// Find origin station
|
||||
const originStation = stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!originStation) return
|
||||
|
||||
const stationIndex = stations.indexOf(originStation)
|
||||
const stationPos = stationPositions[stationIndex]
|
||||
if (!stationPos) return
|
||||
|
||||
// Find which car this passenger will be in
|
||||
const boardedPassengers = currentPassengers.filter((p) => p.isBoarded && !p.isDelivered)
|
||||
const carIndex = boardedPassengers.indexOf(passenger)
|
||||
|
||||
// Calculate train car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7) // 7% spacing
|
||||
const carTransform = trackGenerator.getTrainTransform(pathRef.current!, carPosition)
|
||||
|
||||
// Create boarding animation
|
||||
const animation: BoardingAnimation = {
|
||||
passenger,
|
||||
fromX: stationPos.x,
|
||||
fromY: stationPos.y - 30,
|
||||
toX: carTransform.x,
|
||||
toY: carTransform.y,
|
||||
carIndex,
|
||||
startTime: Date.now(),
|
||||
}
|
||||
|
||||
setBoardingAnimations((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(passenger.id, animation)
|
||||
return next
|
||||
})
|
||||
|
||||
// Remove animation after 800ms
|
||||
setTimeout(() => {
|
||||
setBoardingAnimations((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(passenger.id)
|
||||
return next
|
||||
})
|
||||
}, 800)
|
||||
})
|
||||
|
||||
// Start animation for each newly delivered passenger
|
||||
newlyDelivered.forEach((passenger) => {
|
||||
// Find destination station
|
||||
const destinationStation = stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!destinationStation) return
|
||||
|
||||
const stationIndex = stations.indexOf(destinationStation)
|
||||
const stationPos = stationPositions[stationIndex]
|
||||
if (!stationPos) return
|
||||
|
||||
// Find which car this passenger was in (before delivery)
|
||||
const prevBoardedPassengers = previousPassengers.filter((p) => p.isBoarded && !p.isDelivered)
|
||||
const carIndex = prevBoardedPassengers.findIndex((p) => p.id === passenger.id)
|
||||
if (carIndex === -1) return
|
||||
|
||||
// Calculate train car position at time of disembarking
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7) // 7% spacing
|
||||
const carTransform = trackGenerator.getTrainTransform(pathRef.current!, carPosition)
|
||||
|
||||
// Create disembarking animation (from car to station)
|
||||
const animation: DisembarkingAnimation = {
|
||||
passenger,
|
||||
fromX: carTransform.x,
|
||||
fromY: carTransform.y,
|
||||
toX: stationPos.x,
|
||||
toY: stationPos.y - 30,
|
||||
startTime: Date.now(),
|
||||
}
|
||||
|
||||
setDisembarkingAnimations((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(passenger.id, animation)
|
||||
return next
|
||||
})
|
||||
|
||||
// Remove animation after 800ms
|
||||
setTimeout(() => {
|
||||
setDisembarkingAnimations((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(passenger.id)
|
||||
return next
|
||||
})
|
||||
}, 800)
|
||||
})
|
||||
|
||||
// Update ref
|
||||
previousPassengersRef.current = currentPassengers
|
||||
}, [passengers, stations, stationPositions, trainPosition, trackGenerator, pathRef])
|
||||
|
||||
return {
|
||||
boardingAnimations,
|
||||
disembarkingAnimations,
|
||||
}
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Web Audio API sound effects system
|
||||
* Generates retro 90s-style arcade sounds programmatically
|
||||
*
|
||||
* Based on original implementation from web_generator.py lines 14218-14490
|
||||
*/
|
||||
|
||||
interface Note {
|
||||
freq: number
|
||||
time: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
export function useSoundEffects() {
|
||||
const audioContextsRef = useRef<AudioContext[]>([])
|
||||
|
||||
/**
|
||||
* Helper function to play multi-note 90s arcade sounds
|
||||
*/
|
||||
const play90sSound = useCallback(
|
||||
(
|
||||
audioContext: AudioContext,
|
||||
notes: Note[],
|
||||
volume: number = 0.15,
|
||||
waveType: OscillatorType = 'sine'
|
||||
) => {
|
||||
notes.forEach((note) => {
|
||||
const oscillator = audioContext.createOscillator()
|
||||
const gainNode = audioContext.createGain()
|
||||
const filterNode = audioContext.createBiquadFilter()
|
||||
|
||||
// Create that classic 90s arcade sound chain
|
||||
oscillator.connect(filterNode)
|
||||
filterNode.connect(gainNode)
|
||||
gainNode.connect(audioContext.destination)
|
||||
|
||||
// Set wave type for that retro flavor
|
||||
oscillator.type = waveType
|
||||
|
||||
// Add some 90s-style filtering
|
||||
filterNode.type = 'lowpass'
|
||||
filterNode.frequency.setValueAtTime(2000, audioContext.currentTime + note.time)
|
||||
filterNode.Q.setValueAtTime(1, audioContext.currentTime + note.time)
|
||||
|
||||
// Set frequency and add vibrato for that classic arcade wobble
|
||||
oscillator.frequency.setValueAtTime(note.freq, audioContext.currentTime + note.time)
|
||||
if (waveType === 'sawtooth' || waveType === 'square') {
|
||||
// Add slight vibrato for extra 90s flavor
|
||||
oscillator.frequency.exponentialRampToValueAtTime(
|
||||
note.freq * 1.02,
|
||||
audioContext.currentTime + note.time + note.duration * 0.5
|
||||
)
|
||||
oscillator.frequency.exponentialRampToValueAtTime(
|
||||
note.freq,
|
||||
audioContext.currentTime + note.time + note.duration
|
||||
)
|
||||
}
|
||||
|
||||
// Classic arcade envelope - quick attack, moderate decay
|
||||
gainNode.gain.setValueAtTime(0, audioContext.currentTime + note.time)
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
volume,
|
||||
audioContext.currentTime + note.time + 0.01
|
||||
)
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.7,
|
||||
audioContext.currentTime + note.time + note.duration * 0.7
|
||||
)
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.001,
|
||||
audioContext.currentTime + note.time + note.duration
|
||||
)
|
||||
|
||||
oscillator.start(audioContext.currentTime + note.time)
|
||||
oscillator.stop(audioContext.currentTime + note.time + note.duration)
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Play a sound effect
|
||||
* @param type - Sound type (correct, incorrect, countdown, etc.)
|
||||
* @param volume - Volume level (0-1), default 0.15
|
||||
*/
|
||||
const playSound = useCallback(
|
||||
(
|
||||
type:
|
||||
| 'correct'
|
||||
| 'incorrect'
|
||||
| 'timeout'
|
||||
| 'countdown'
|
||||
| 'race_start'
|
||||
| 'celebration'
|
||||
| 'lap_celebration'
|
||||
| 'gameOver'
|
||||
| 'ai_turbo'
|
||||
| 'milestone'
|
||||
| 'streak'
|
||||
| 'combo'
|
||||
| 'whoosh'
|
||||
| 'train_chuff'
|
||||
| 'train_whistle'
|
||||
| 'coal_spill'
|
||||
| 'steam_hiss',
|
||||
volume: number = 0.15
|
||||
) => {
|
||||
try {
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
|
||||
// Track audio contexts for cleanup
|
||||
audioContextsRef.current.push(audioContext)
|
||||
|
||||
switch (type) {
|
||||
case 'correct':
|
||||
// Classic 90s "power-up" sound - ascending beeps
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 523, time: 0, duration: 0.08 }, // C5
|
||||
{ freq: 659, time: 0.08, duration: 0.08 }, // E5
|
||||
{ freq: 784, time: 0.16, duration: 0.12 }, // G5
|
||||
],
|
||||
volume,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'incorrect':
|
||||
// Classic arcade "error" sound - descending buzz
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 400, time: 0, duration: 0.15 },
|
||||
{ freq: 300, time: 0.05, duration: 0.15 },
|
||||
{ freq: 200, time: 0.1, duration: 0.2 },
|
||||
],
|
||||
volume * 0.8,
|
||||
'square'
|
||||
)
|
||||
break
|
||||
|
||||
case 'timeout':
|
||||
// Classic "time's up" alarm
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 800, time: 0, duration: 0.1 },
|
||||
{ freq: 600, time: 0.1, duration: 0.1 },
|
||||
{ freq: 800, time: 0.2, duration: 0.1 },
|
||||
{ freq: 600, time: 0.3, duration: 0.15 },
|
||||
],
|
||||
volume,
|
||||
'square'
|
||||
)
|
||||
break
|
||||
|
||||
case 'countdown':
|
||||
// Classic arcade countdown beep
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[{ freq: 800, time: 0, duration: 0.15 }],
|
||||
volume * 0.6,
|
||||
'sine'
|
||||
)
|
||||
break
|
||||
|
||||
case 'race_start':
|
||||
// Epic race start fanfare
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 523, time: 0, duration: 0.1 }, // C5
|
||||
{ freq: 659, time: 0.1, duration: 0.1 }, // E5
|
||||
{ freq: 784, time: 0.2, duration: 0.1 }, // G5
|
||||
{ freq: 1046, time: 0.3, duration: 0.3 }, // C6 - triumphant!
|
||||
],
|
||||
volume * 1.2,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'celebration':
|
||||
// Classic victory fanfare - like completing a level
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 523, time: 0, duration: 0.12 }, // C5
|
||||
{ freq: 659, time: 0.12, duration: 0.12 }, // E5
|
||||
{ freq: 784, time: 0.24, duration: 0.12 }, // G5
|
||||
{ freq: 1046, time: 0.36, duration: 0.24 }, // C6
|
||||
{ freq: 1318, time: 0.6, duration: 0.3 }, // E6 - epic finish!
|
||||
],
|
||||
volume * 1.5,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'lap_celebration':
|
||||
// Radical "bonus achieved" sound
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 1046, time: 0, duration: 0.08 }, // C6
|
||||
{ freq: 1318, time: 0.08, duration: 0.08 }, // E6
|
||||
{ freq: 1568, time: 0.16, duration: 0.08 }, // G6
|
||||
{ freq: 2093, time: 0.24, duration: 0.15 }, // C7 - totally rad!
|
||||
],
|
||||
volume * 1.3,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'gameOver':
|
||||
// Classic "game over" descending tones
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 400, time: 0, duration: 0.2 },
|
||||
{ freq: 350, time: 0.2, duration: 0.2 },
|
||||
{ freq: 300, time: 0.4, duration: 0.2 },
|
||||
{ freq: 250, time: 0.6, duration: 0.3 },
|
||||
{ freq: 200, time: 0.9, duration: 0.4 },
|
||||
],
|
||||
volume,
|
||||
'triangle'
|
||||
)
|
||||
break
|
||||
|
||||
case 'ai_turbo':
|
||||
// Sound when AI goes into turbo mode
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 200, time: 0, duration: 0.05 },
|
||||
{ freq: 400, time: 0.05, duration: 0.05 },
|
||||
{ freq: 600, time: 0.1, duration: 0.05 },
|
||||
{ freq: 800, time: 0.15, duration: 0.1 },
|
||||
],
|
||||
volume * 0.7,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'milestone':
|
||||
// Rad milestone sound - like collecting a power-up
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 659, time: 0, duration: 0.1 }, // E5
|
||||
{ freq: 784, time: 0.1, duration: 0.1 }, // G5
|
||||
{ freq: 880, time: 0.2, duration: 0.1 }, // A5
|
||||
{ freq: 1046, time: 0.3, duration: 0.15 }, // C6 - awesome!
|
||||
],
|
||||
volume * 1.1,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'streak':
|
||||
// Epic streak sound - getting hot!
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 880, time: 0, duration: 0.06 }, // A5
|
||||
{ freq: 1046, time: 0.06, duration: 0.06 }, // C6
|
||||
{ freq: 1318, time: 0.12, duration: 0.08 }, // E6
|
||||
{ freq: 1760, time: 0.2, duration: 0.1 }, // A6 - on fire!
|
||||
],
|
||||
volume * 1.2,
|
||||
'sawtooth'
|
||||
)
|
||||
break
|
||||
|
||||
case 'combo':
|
||||
// Gnarly combo sound - for rapid correct answers
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 1046, time: 0, duration: 0.04 }, // C6
|
||||
{ freq: 1175, time: 0.04, duration: 0.04 }, // D6
|
||||
{ freq: 1318, time: 0.08, duration: 0.04 }, // E6
|
||||
{ freq: 1480, time: 0.12, duration: 0.06 }, // F#6
|
||||
],
|
||||
volume * 0.9,
|
||||
'square'
|
||||
)
|
||||
break
|
||||
|
||||
case 'whoosh': {
|
||||
// Cool whoosh sound for fast responses
|
||||
const whooshOsc = audioContext.createOscillator()
|
||||
const whooshGain = audioContext.createGain()
|
||||
const whooshFilter = audioContext.createBiquadFilter()
|
||||
|
||||
whooshOsc.connect(whooshFilter)
|
||||
whooshFilter.connect(whooshGain)
|
||||
whooshGain.connect(audioContext.destination)
|
||||
|
||||
whooshOsc.type = 'sawtooth'
|
||||
whooshFilter.type = 'highpass'
|
||||
whooshFilter.frequency.setValueAtTime(1000, audioContext.currentTime)
|
||||
whooshFilter.frequency.exponentialRampToValueAtTime(100, audioContext.currentTime + 0.3)
|
||||
|
||||
whooshOsc.frequency.setValueAtTime(400, audioContext.currentTime)
|
||||
whooshOsc.frequency.exponentialRampToValueAtTime(800, audioContext.currentTime + 0.15)
|
||||
whooshOsc.frequency.exponentialRampToValueAtTime(200, audioContext.currentTime + 0.3)
|
||||
|
||||
whooshGain.gain.setValueAtTime(0, audioContext.currentTime)
|
||||
whooshGain.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.6,
|
||||
audioContext.currentTime + 0.02
|
||||
)
|
||||
whooshGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.3)
|
||||
|
||||
whooshOsc.start(audioContext.currentTime)
|
||||
whooshOsc.stop(audioContext.currentTime + 0.3)
|
||||
break
|
||||
}
|
||||
|
||||
case 'train_chuff': {
|
||||
// Realistic steam train chuffing sound
|
||||
const chuffOsc = audioContext.createOscillator()
|
||||
const chuffGain = audioContext.createGain()
|
||||
const chuffFilter = audioContext.createBiquadFilter()
|
||||
|
||||
chuffOsc.connect(chuffFilter)
|
||||
chuffFilter.connect(chuffGain)
|
||||
chuffGain.connect(audioContext.destination)
|
||||
|
||||
chuffOsc.type = 'sawtooth'
|
||||
chuffFilter.type = 'bandpass'
|
||||
chuffFilter.frequency.setValueAtTime(150, audioContext.currentTime)
|
||||
chuffFilter.Q.setValueAtTime(5, audioContext.currentTime)
|
||||
|
||||
chuffOsc.frequency.setValueAtTime(80, audioContext.currentTime)
|
||||
chuffOsc.frequency.exponentialRampToValueAtTime(120, audioContext.currentTime + 0.05)
|
||||
chuffOsc.frequency.exponentialRampToValueAtTime(60, audioContext.currentTime + 0.2)
|
||||
|
||||
chuffGain.gain.setValueAtTime(0, audioContext.currentTime)
|
||||
chuffGain.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.8,
|
||||
audioContext.currentTime + 0.01
|
||||
)
|
||||
chuffGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.2)
|
||||
|
||||
chuffOsc.start(audioContext.currentTime)
|
||||
chuffOsc.stop(audioContext.currentTime + 0.2)
|
||||
break
|
||||
}
|
||||
|
||||
case 'train_whistle':
|
||||
// Classic steam train whistle
|
||||
play90sSound(
|
||||
audioContext,
|
||||
[
|
||||
{ freq: 523, time: 0, duration: 0.3 }, // C5 - long whistle
|
||||
{ freq: 659, time: 0.1, duration: 0.4 }, // E5 - harmony
|
||||
{ freq: 523, time: 0.3, duration: 0.2 }, // C5 - fade out
|
||||
],
|
||||
volume * 1.2,
|
||||
'sine'
|
||||
)
|
||||
break
|
||||
|
||||
case 'coal_spill': {
|
||||
// Coal chunks spilling sound effect
|
||||
const coalOsc = audioContext.createOscillator()
|
||||
const coalGain = audioContext.createGain()
|
||||
const coalFilter = audioContext.createBiquadFilter()
|
||||
|
||||
coalOsc.connect(coalFilter)
|
||||
coalFilter.connect(coalGain)
|
||||
coalGain.connect(audioContext.destination)
|
||||
|
||||
coalOsc.type = 'square'
|
||||
coalFilter.type = 'lowpass'
|
||||
coalFilter.frequency.setValueAtTime(300, audioContext.currentTime)
|
||||
|
||||
// Simulate coal chunks falling with random frequency bursts
|
||||
coalOsc.frequency.setValueAtTime(200 + Math.random() * 100, audioContext.currentTime)
|
||||
coalOsc.frequency.exponentialRampToValueAtTime(
|
||||
100 + Math.random() * 50,
|
||||
audioContext.currentTime + 0.1
|
||||
)
|
||||
coalOsc.frequency.exponentialRampToValueAtTime(
|
||||
80 + Math.random() * 40,
|
||||
audioContext.currentTime + 0.3
|
||||
)
|
||||
|
||||
coalGain.gain.setValueAtTime(0, audioContext.currentTime)
|
||||
coalGain.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.6,
|
||||
audioContext.currentTime + 0.01
|
||||
)
|
||||
coalGain.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.3,
|
||||
audioContext.currentTime + 0.15
|
||||
)
|
||||
coalGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.4)
|
||||
|
||||
coalOsc.start(audioContext.currentTime)
|
||||
coalOsc.stop(audioContext.currentTime + 0.4)
|
||||
break
|
||||
}
|
||||
|
||||
case 'steam_hiss': {
|
||||
// Steam hissing sound for locomotive
|
||||
const steamOsc = audioContext.createOscillator()
|
||||
const steamGain = audioContext.createGain()
|
||||
const steamFilter = audioContext.createBiquadFilter()
|
||||
|
||||
steamOsc.connect(steamFilter)
|
||||
steamFilter.connect(steamGain)
|
||||
steamGain.connect(audioContext.destination)
|
||||
|
||||
steamOsc.type = 'triangle'
|
||||
steamFilter.type = 'highpass'
|
||||
steamFilter.frequency.setValueAtTime(2000, audioContext.currentTime)
|
||||
|
||||
steamOsc.frequency.setValueAtTime(4000 + Math.random() * 1000, audioContext.currentTime)
|
||||
|
||||
steamGain.gain.setValueAtTime(0, audioContext.currentTime)
|
||||
steamGain.gain.exponentialRampToValueAtTime(
|
||||
volume * 0.4,
|
||||
audioContext.currentTime + 0.02
|
||||
)
|
||||
steamGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.6)
|
||||
|
||||
steamOsc.start(audioContext.currentTime)
|
||||
steamOsc.stop(audioContext.currentTime + 0.6)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
console.log('🎵 Web Audio not supported - missing out on rad 90s sounds!')
|
||||
}
|
||||
},
|
||||
[play90sSound]
|
||||
)
|
||||
|
||||
/**
|
||||
* Stop all currently playing sounds
|
||||
*/
|
||||
const stopAllSounds = useCallback(() => {
|
||||
try {
|
||||
if (audioContextsRef.current.length > 0) {
|
||||
audioContextsRef.current.forEach((context) => {
|
||||
try {
|
||||
context.close()
|
||||
} catch (_e) {
|
||||
// Ignore errors
|
||||
}
|
||||
})
|
||||
audioContextsRef.current = []
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('🔇 Sound cleanup error:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
playSound,
|
||||
stopAllSounds,
|
||||
}
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { calculateMaxConcurrentPassengers, generatePassengers } from '../lib/passengerGenerator'
|
||||
import { useSoundEffects } from './useSoundEffects'
|
||||
|
||||
/**
|
||||
* Steam Sprint momentum system (Infinite Mode)
|
||||
*
|
||||
* Momentum mechanics:
|
||||
* - Each correct answer adds momentum (builds up steam pressure)
|
||||
* - Momentum decays over time based on skill level
|
||||
* - Train automatically advances to next route upon completion
|
||||
* - Game continues indefinitely until player quits
|
||||
* - Time-of-day cycle repeats every 60 seconds
|
||||
*
|
||||
* Skill level decay rates (momentum lost per second):
|
||||
* - Preschool: 2.0/s (very slow decay)
|
||||
* - Kindergarten: 3.5/s
|
||||
* - Relaxed: 5.0/s
|
||||
* - Slow: 7.0/s
|
||||
* - Normal: 9.0/s
|
||||
* - Fast: 11.0/s
|
||||
* - Expert: 13.0/s (rapid decay)
|
||||
*/
|
||||
|
||||
const MOMENTUM_DECAY_RATES = {
|
||||
preschool: 2.0,
|
||||
kindergarten: 3.5,
|
||||
relaxed: 5.0,
|
||||
slow: 7.0,
|
||||
normal: 9.0,
|
||||
fast: 11.0,
|
||||
expert: 13.0,
|
||||
}
|
||||
|
||||
const MOMENTUM_GAIN_PER_CORRECT = 15 // Momentum added for each correct answer
|
||||
const SPEED_MULTIPLIER = 0.15 // Convert momentum to speed (% per second at momentum=100)
|
||||
const UPDATE_INTERVAL = 50 // Update every 50ms (~20 fps)
|
||||
const GAME_DURATION = 60000 // 60 seconds in milliseconds
|
||||
|
||||
export function useSteamJourney() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { playSound } = useSoundEffects()
|
||||
const gameStartTimeRef = useRef<number>(0)
|
||||
const lastUpdateRef = useRef<number>(0)
|
||||
const routeExitThresholdRef = useRef<number>(107) // Default for 1 car: 100 + 7
|
||||
|
||||
// Initialize game start time and generate initial passengers
|
||||
useEffect(() => {
|
||||
if (state.isGameActive && state.style === 'sprint' && gameStartTimeRef.current === 0) {
|
||||
gameStartTimeRef.current = Date.now()
|
||||
lastUpdateRef.current = Date.now()
|
||||
|
||||
// Generate initial passengers if none exist
|
||||
if (state.passengers.length === 0) {
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
|
||||
// Calculate and store exit threshold for this route
|
||||
const CAR_SPACING = 7
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
const maxCars = Math.max(1, maxPassengers)
|
||||
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
|
||||
}
|
||||
}
|
||||
}, [state.isGameActive, state.style, state.stations, state.passengers.length, dispatch])
|
||||
|
||||
// Momentum decay and position update loop
|
||||
useEffect(() => {
|
||||
if (!state.isGameActive || state.style !== 'sprint') return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const elapsed = now - gameStartTimeRef.current
|
||||
const deltaTime = now - lastUpdateRef.current
|
||||
lastUpdateRef.current = now
|
||||
|
||||
// Steam Sprint is infinite - no time limit
|
||||
|
||||
// Get decay rate based on timeout setting (skill level)
|
||||
const decayRate = MOMENTUM_DECAY_RATES[state.timeoutSetting] || MOMENTUM_DECAY_RATES.normal
|
||||
|
||||
// Calculate momentum decay for this frame
|
||||
const momentumLoss = (decayRate * deltaTime) / 1000
|
||||
|
||||
// Update momentum (don't go below 0)
|
||||
const newMomentum = Math.max(0, state.momentum - momentumLoss)
|
||||
|
||||
// Calculate speed from momentum (% per second)
|
||||
const speed = newMomentum * SPEED_MULTIPLIER
|
||||
|
||||
// Update train position (accumulate, never go backward)
|
||||
// Allow position to go past 100% so entire train (including cars) can exit tunnel
|
||||
const positionDelta = (speed * deltaTime) / 1000
|
||||
const trainPosition = state.trainPosition + positionDelta
|
||||
|
||||
// Calculate pressure (0-150 PSI) - based on momentum as percentage of max
|
||||
const maxMomentum = 100 // Theoretical max momentum
|
||||
const pressure = Math.min(150, (newMomentum / maxMomentum) * 150)
|
||||
|
||||
// Update state
|
||||
dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: newMomentum,
|
||||
trainPosition,
|
||||
pressure,
|
||||
elapsedTime: elapsed,
|
||||
})
|
||||
|
||||
// Check for passengers that should board
|
||||
// Passengers board when an EMPTY car reaches their station
|
||||
const CAR_SPACING = 7 // Must match SteamTrainJourney component
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
|
||||
const maxCars = Math.max(1, maxPassengers)
|
||||
const currentBoardedPassengers = state.passengers.filter((p) => p.isBoarded && !p.isDelivered)
|
||||
|
||||
// Debug logging flag - enable when debugging passenger boarding issues
|
||||
// TO ENABLE: Change this to true, save, and the logs will appear in the browser console
|
||||
// When you see passengers getting left behind, copy the entire console log and paste into Claude Code
|
||||
const DEBUG_PASSENGER_BOARDING = true
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n'.repeat(3))
|
||||
console.log('='.repeat(80))
|
||||
console.log('🚂 PASSENGER BOARDING DEBUG LOG')
|
||||
console.log('='.repeat(80))
|
||||
console.log('ISSUE: Passengers are getting left behind at stations')
|
||||
console.log('PURPOSE: This log captures all state during boarding/delivery logic')
|
||||
console.log('USAGE: Copy this entire log and paste into Claude Code for debugging')
|
||||
console.log('='.repeat(80))
|
||||
console.log('\n📊 CURRENT FRAME STATE:')
|
||||
console.log(` Train Position: ${trainPosition.toFixed(2)}`)
|
||||
console.log(` Speed: ${speed.toFixed(2)}% per second`)
|
||||
console.log(` Momentum: ${newMomentum.toFixed(2)}`)
|
||||
console.log(` Max Cars: ${maxCars}`)
|
||||
console.log(` Car Spacing: ${CAR_SPACING}`)
|
||||
console.log(` Distance Tolerance: 5`)
|
||||
|
||||
console.log('\n🚉 STATIONS:')
|
||||
state.stations.forEach((station) => {
|
||||
console.log(` ${station.emoji} ${station.name} (ID: ${station.id})`)
|
||||
console.log(` Position: ${station.position}`)
|
||||
})
|
||||
|
||||
console.log('\n👥 ALL PASSENGERS:')
|
||||
state.passengers.forEach((p, idx) => {
|
||||
const origin = state.stations.find((s) => s.id === p.originStationId)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
console.log(` [${idx}] ${p.name} (ID: ${p.id})`)
|
||||
console.log(
|
||||
` Status: ${p.isDelivered ? 'DELIVERED' : p.isBoarded ? 'BOARDED' : 'WAITING'}`
|
||||
)
|
||||
console.log(
|
||||
` Route: ${origin?.emoji} ${origin?.name} (pos ${origin?.position}) → ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`
|
||||
)
|
||||
console.log(` Urgent: ${p.isUrgent}`)
|
||||
})
|
||||
|
||||
console.log('\n🚃 CAR POSITIONS:')
|
||||
for (let i = 0; i < maxCars; i++) {
|
||||
const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING)
|
||||
console.log(` Car ${i}: position ${carPos.toFixed(2)}`)
|
||||
}
|
||||
|
||||
console.log('\n🔍 CURRENTLY BOARDED PASSENGERS:')
|
||||
currentBoardedPassengers.forEach((p, carIndex) => {
|
||||
const carPos = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
const distToDest = Math.abs(carPos - (dest?.position || 0))
|
||||
console.log(` Car ${carIndex}: ${p.name}`)
|
||||
console.log(` Car position: ${carPos.toFixed(2)}`)
|
||||
console.log(` Destination: ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`)
|
||||
console.log(` Distance to dest: ${distToDest.toFixed(2)}`)
|
||||
console.log(` Will deliver: ${distToDest < 5 ? 'YES' : 'NO'}`)
|
||||
})
|
||||
}
|
||||
|
||||
// FIRST: Identify which passengers will be delivered in this frame
|
||||
const passengersToDeliver = new Set<string>()
|
||||
currentBoardedPassengers.forEach((passenger, carIndex) => {
|
||||
if (!passenger || passenger.isDelivered) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
// Calculate this passenger's car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If this car is at the destination station (within 5% tolerance), mark for delivery
|
||||
if (distance < 5) {
|
||||
passengersToDeliver.add(passenger.id)
|
||||
}
|
||||
})
|
||||
|
||||
// Build a map of which cars are occupied (excluding passengers being delivered this frame)
|
||||
const occupiedCars = new Map<number, (typeof currentBoardedPassengers)[0]>()
|
||||
currentBoardedPassengers.forEach((passenger, arrayIndex) => {
|
||||
// Don't count a car as occupied if its passenger is being delivered this frame
|
||||
if (!passengersToDeliver.has(passenger.id)) {
|
||||
occupiedCars.set(arrayIndex, passenger)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n📦 PASSENGERS TO DELIVER THIS FRAME:')
|
||||
if (passengersToDeliver.size === 0) {
|
||||
console.log(' None')
|
||||
} else {
|
||||
passengersToDeliver.forEach((id) => {
|
||||
const p = state.passengers.find((passenger) => passenger.id === id)
|
||||
console.log(` - ${p?.name} (ID: ${id})`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n🚗 OCCUPIED CARS (after excluding deliveries):')
|
||||
if (occupiedCars.size === 0) {
|
||||
console.log(' All cars are empty')
|
||||
} else {
|
||||
occupiedCars.forEach((passenger, carIndex) => {
|
||||
console.log(` Car ${carIndex}: ${passenger.name}`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n🔄 BOARDING ATTEMPTS:')
|
||||
}
|
||||
|
||||
// Track which cars are assigned in THIS frame to prevent double-boarding
|
||||
const carsAssignedThisFrame = new Set<number>()
|
||||
|
||||
// Find waiting passengers whose origin station has an empty car nearby
|
||||
state.passengers.forEach((passenger) => {
|
||||
if (passenger.isBoarded || passenger.isDelivered) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) return
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
`\n Passenger: ${passenger.name} waiting at ${station.emoji} ${station.name} (pos ${station.position})`
|
||||
)
|
||||
}
|
||||
|
||||
// Check if any empty car is at this station
|
||||
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
|
||||
let boarded = false
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
const isOccupied = occupiedCars.has(carIndex)
|
||||
const isAssigned = carsAssignedThisFrame.has(carIndex)
|
||||
const inRange = distance < 5
|
||||
const occupant = occupiedCars.get(carIndex)
|
||||
|
||||
console.log(` Car ${carIndex} @ pos ${carPosition.toFixed(2)}:`)
|
||||
console.log(` Distance to station: ${distance.toFixed(2)}`)
|
||||
console.log(` In range (<5): ${inRange}`)
|
||||
console.log(
|
||||
` Occupied: ${isOccupied}${isOccupied ? ` (by ${occupant?.name})` : ''}`
|
||||
)
|
||||
console.log(` Assigned this frame: ${isAssigned}`)
|
||||
console.log(` Can board: ${!isOccupied && !isAssigned && inRange}`)
|
||||
}
|
||||
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
|
||||
|
||||
const distance2 = Math.abs(carPosition - station.position)
|
||||
|
||||
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
|
||||
// Increased tolerance to ensure fast-moving trains don't miss passengers
|
||||
if (distance2 < 5) {
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(` ✅ BOARDING ${passenger.name} onto Car ${carIndex}`)
|
||||
}
|
||||
dispatch({
|
||||
type: 'BOARD_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
})
|
||||
// Mark this car as assigned in this frame
|
||||
carsAssignedThisFrame.add(carIndex)
|
||||
boarded = true
|
||||
return // Board this passenger and move on
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING && !boarded) {
|
||||
console.log(` ❌ ${passenger.name} NOT BOARDED - no suitable car found`)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n🎯 DELIVERY ATTEMPTS:')
|
||||
}
|
||||
|
||||
// Check for deliverable passengers
|
||||
// Passengers disembark when THEIR car reaches their destination
|
||||
currentBoardedPassengers.forEach((passenger, carIndex) => {
|
||||
if (!passenger || passenger.isDelivered) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
// Calculate this passenger's car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If this car is at the destination station (within 5% tolerance), deliver
|
||||
if (distance < 5) {
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
` ✅ DELIVERING ${passenger.name} from Car ${carIndex} to ${station.emoji} ${station.name}`
|
||||
)
|
||||
console.log(
|
||||
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
|
||||
)
|
||||
}
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
dispatch({
|
||||
type: 'DELIVER_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
points,
|
||||
})
|
||||
} else if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
` ⏳ ${passenger.name} in Car ${carIndex} heading to ${station.emoji} ${station.name}`
|
||||
)
|
||||
console.log(
|
||||
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(`\n${'='.repeat(80)}`)
|
||||
console.log('END OF DEBUG LOG')
|
||||
console.log('='.repeat(80))
|
||||
}
|
||||
|
||||
// Check for route completion (entire train exits tunnel)
|
||||
// Use stored threshold (stable for entire route)
|
||||
const ENTIRE_TRAIN_EXIT_THRESHOLD = routeExitThresholdRef.current
|
||||
|
||||
if (
|
||||
trainPosition >= ENTIRE_TRAIN_EXIT_THRESHOLD &&
|
||||
state.trainPosition < ENTIRE_TRAIN_EXIT_THRESHOLD
|
||||
) {
|
||||
// Play celebration whistle
|
||||
playSound('train_whistle', 0.6)
|
||||
setTimeout(() => {
|
||||
playSound('celebration', 0.4)
|
||||
}, 800)
|
||||
|
||||
// Auto-advance to next route
|
||||
const nextRoute = state.currentRoute + 1
|
||||
dispatch({
|
||||
type: 'START_NEW_ROUTE',
|
||||
routeNumber: nextRoute,
|
||||
stations: state.stations,
|
||||
})
|
||||
|
||||
// Generate new passengers
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
|
||||
// Calculate and store new exit threshold for next route
|
||||
const newMaxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
const newMaxCars = Math.max(1, newMaxPassengers)
|
||||
routeExitThresholdRef.current = 100 + newMaxCars * CAR_SPACING
|
||||
}
|
||||
}, UPDATE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
state.isGameActive,
|
||||
state.style,
|
||||
state.momentum,
|
||||
state.trainPosition,
|
||||
state.timeoutSetting,
|
||||
state.passengers,
|
||||
state.stations,
|
||||
state.currentRoute,
|
||||
dispatch,
|
||||
playSound,
|
||||
])
|
||||
|
||||
// Auto-regenerate passengers when all are delivered
|
||||
useEffect(() => {
|
||||
if (!state.isGameActive || state.style !== 'sprint') return
|
||||
|
||||
// Check if all passengers are delivered
|
||||
const allDelivered = state.passengers.length > 0 && state.passengers.every((p) => p.isDelivered)
|
||||
|
||||
if (allDelivered) {
|
||||
// Generate new passengers after a short delay
|
||||
setTimeout(() => {
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
}, 1000)
|
||||
}
|
||||
}, [state.isGameActive, state.style, state.passengers, state.stations, dispatch])
|
||||
|
||||
// Add momentum on correct answer
|
||||
useEffect(() => {
|
||||
// Only for sprint mode
|
||||
if (state.style !== 'sprint') return
|
||||
|
||||
// This effect triggers when correctAnswers increases
|
||||
// We use a ref to track previous value to detect changes
|
||||
}, [state.style])
|
||||
|
||||
// Function to boost momentum (called when answer is correct)
|
||||
const boostMomentum = () => {
|
||||
if (state.style !== 'sprint') return
|
||||
|
||||
const newMomentum = Math.min(100, state.momentum + MOMENTUM_GAIN_PER_CORRECT)
|
||||
dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: newMomentum,
|
||||
trainPosition: state.trainPosition, // Keep current position
|
||||
pressure: state.pressure,
|
||||
elapsedTime: state.elapsedTime,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate time of day period (0-5 for 6 periods, cycles infinitely)
|
||||
const getTimeOfDayPeriod = (): number => {
|
||||
if (state.elapsedTime === 0) return 0
|
||||
const periodDuration = GAME_DURATION / 6
|
||||
return Math.floor(state.elapsedTime / periodDuration) % 6
|
||||
}
|
||||
|
||||
// Get sky gradient colors based on time of day
|
||||
const getSkyGradient = (): { top: string; bottom: string } => {
|
||||
const period = getTimeOfDayPeriod()
|
||||
|
||||
// 6 periods over 60 seconds: dawn → morning → midday → afternoon → dusk → night
|
||||
const gradients = [
|
||||
{ top: '#1e3a8a', bottom: '#f59e0b' }, // Dawn - deep blue to orange
|
||||
{ top: '#3b82f6', bottom: '#fbbf24' }, // Morning - blue to yellow
|
||||
{ top: '#60a5fa', bottom: '#93c5fd' }, // Midday - bright blue
|
||||
{ top: '#3b82f6', bottom: '#f59e0b' }, // Afternoon - blue to orange
|
||||
{ top: '#7c3aed', bottom: '#f97316' }, // Dusk - purple to orange
|
||||
{ top: '#1e1b4b', bottom: '#312e81' }, // Night - dark purple
|
||||
]
|
||||
|
||||
return gradients[period] || gradients[0]
|
||||
}
|
||||
|
||||
return {
|
||||
boostMomentum,
|
||||
getTimeOfDayPeriod,
|
||||
getSkyGradient,
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
import { generateLandmarks, type Landmark } from '../lib/landmarks'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
|
||||
interface UseTrackManagementParams {
|
||||
currentRoute: number
|
||||
trainPosition: number
|
||||
trackGenerator: RailroadTrackGenerator
|
||||
pathRef: React.RefObject<SVGPathElement>
|
||||
stations: Station[]
|
||||
passengers: Passenger[]
|
||||
maxCars: number
|
||||
carSpacing: number
|
||||
}
|
||||
|
||||
export function useTrackManagement({
|
||||
currentRoute,
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
stations,
|
||||
passengers,
|
||||
maxCars: _maxCars,
|
||||
carSpacing: _carSpacing,
|
||||
}: UseTrackManagementParams) {
|
||||
const [trackData, setTrackData] = useState<ReturnType<
|
||||
typeof trackGenerator.generateTrack
|
||||
> | null>(null)
|
||||
const [tiesAndRails, setTiesAndRails] = useState<{
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
|
||||
leftRailPath: string
|
||||
rightRailPath: string
|
||||
} | null>(null)
|
||||
const [stationPositions, setStationPositions] = useState<Array<{ x: number; y: number }>>([])
|
||||
const [landmarks, setLandmarks] = useState<Landmark[]>([])
|
||||
const [landmarkPositions, setLandmarkPositions] = useState<Array<{ x: number; y: number }>>([])
|
||||
const [displayPassengers, setDisplayPassengers] = useState<Passenger[]>(passengers)
|
||||
|
||||
// Track previous route data to maintain visuals during transition
|
||||
const previousRouteRef = useRef(currentRoute)
|
||||
const [pendingTrackData, setPendingTrackData] = useState<ReturnType<
|
||||
typeof trackGenerator.generateTrack
|
||||
> | null>(null)
|
||||
const displayRouteRef = useRef(currentRoute) // Track which route's passengers are being displayed
|
||||
|
||||
// Generate landmarks when route changes
|
||||
useEffect(() => {
|
||||
const newLandmarks = generateLandmarks(currentRoute)
|
||||
setLandmarks(newLandmarks)
|
||||
}, [currentRoute])
|
||||
|
||||
// Generate track on mount and when route changes
|
||||
useEffect(() => {
|
||||
const track = trackGenerator.generateTrack(currentRoute)
|
||||
|
||||
// If we're in the middle of a route (position > 0), store as pending
|
||||
// Only apply new track when position resets to beginning (< 0)
|
||||
if (trainPosition > 0 && previousRouteRef.current !== currentRoute) {
|
||||
setPendingTrackData(track)
|
||||
} else {
|
||||
setTrackData(track)
|
||||
previousRouteRef.current = currentRoute
|
||||
setPendingTrackData(null)
|
||||
}
|
||||
}, [trackGenerator, currentRoute, trainPosition])
|
||||
|
||||
// Apply pending track when train resets to beginning
|
||||
useEffect(() => {
|
||||
if (pendingTrackData && trainPosition < 0) {
|
||||
setTrackData(pendingTrackData)
|
||||
previousRouteRef.current = currentRoute
|
||||
setPendingTrackData(null)
|
||||
}
|
||||
}, [pendingTrackData, trainPosition, currentRoute])
|
||||
|
||||
// Manage passenger display during route transitions
|
||||
useEffect(() => {
|
||||
// Only switch to new passengers when:
|
||||
// 1. Train has reset to start position (< 0) - track has changed, OR
|
||||
// 2. Same route AND train is in middle of track (10-90%) - gameplay updates like boarding/delivering
|
||||
const trainReset = trainPosition < 0
|
||||
const sameRoute = currentRoute === displayRouteRef.current
|
||||
const inMiddleOfTrack = trainPosition >= 10 && trainPosition < 90 // Avoid start/end transition zones
|
||||
|
||||
if (trainReset) {
|
||||
// Train reset - update to new route's passengers
|
||||
setDisplayPassengers(passengers)
|
||||
displayRouteRef.current = currentRoute
|
||||
} else if (sameRoute && inMiddleOfTrack) {
|
||||
// Same route and train in middle of track - update passengers for gameplay changes (boarding/delivery)
|
||||
setDisplayPassengers(passengers)
|
||||
}
|
||||
// Otherwise, keep displaying old passengers until train resets
|
||||
}, [passengers, trainPosition, currentRoute])
|
||||
|
||||
// Generate ties and rails when path is ready
|
||||
useEffect(() => {
|
||||
if (pathRef.current && trackData) {
|
||||
const result = trackGenerator.generateTiesAndRails(pathRef.current)
|
||||
setTiesAndRails(result)
|
||||
}
|
||||
}, [trackData, trackGenerator, pathRef])
|
||||
|
||||
// Calculate station positions when path is ready
|
||||
useEffect(() => {
|
||||
if (pathRef.current) {
|
||||
const positions = stations.map((station) => {
|
||||
const pathLength = pathRef.current!.getTotalLength()
|
||||
const distance = (station.position / 100) * pathLength
|
||||
const point = pathRef.current!.getPointAtLength(distance)
|
||||
return { x: point.x, y: point.y }
|
||||
})
|
||||
setStationPositions(positions)
|
||||
}
|
||||
}, [stations, pathRef])
|
||||
|
||||
// Calculate landmark positions when path is ready
|
||||
useEffect(() => {
|
||||
if (pathRef.current && landmarks.length > 0) {
|
||||
const positions = landmarks.map((landmark) => {
|
||||
const pathLength = pathRef.current!.getTotalLength()
|
||||
const distance = (landmark.position / 100) * pathLength
|
||||
const point = pathRef.current!.getPointAtLength(distance)
|
||||
return {
|
||||
x: point.x + landmark.offset.x,
|
||||
y: point.y + landmark.offset.y,
|
||||
}
|
||||
})
|
||||
setLandmarkPositions(positions)
|
||||
}
|
||||
}, [landmarks, pathRef])
|
||||
|
||||
return {
|
||||
trackData,
|
||||
tiesAndRails,
|
||||
stationPositions,
|
||||
landmarks,
|
||||
landmarkPositions,
|
||||
displayPassengers,
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
|
||||
interface TrainTransform {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
}
|
||||
|
||||
interface TrainCarTransform extends TrainTransform {
|
||||
position: number
|
||||
opacity: number
|
||||
}
|
||||
|
||||
interface UseTrainTransformsParams {
|
||||
trainPosition: number
|
||||
trackGenerator: RailroadTrackGenerator
|
||||
pathRef: React.RefObject<SVGPathElement>
|
||||
maxCars: number
|
||||
carSpacing: number
|
||||
}
|
||||
|
||||
export function useTrainTransforms({
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
maxCars,
|
||||
carSpacing,
|
||||
}: UseTrainTransformsParams) {
|
||||
const [trainTransform, setTrainTransform] = useState<TrainTransform>({
|
||||
x: 50,
|
||||
y: 300,
|
||||
rotation: 0,
|
||||
})
|
||||
|
||||
// Update train position and rotation
|
||||
useEffect(() => {
|
||||
if (pathRef.current) {
|
||||
const transform = trackGenerator.getTrainTransform(pathRef.current, trainPosition)
|
||||
setTrainTransform(transform)
|
||||
}
|
||||
}, [trainPosition, trackGenerator, pathRef])
|
||||
|
||||
// Calculate train car transforms (each car follows behind the locomotive)
|
||||
const trainCars = useMemo((): TrainCarTransform[] => {
|
||||
if (!pathRef.current) {
|
||||
return Array.from({ length: maxCars }, () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
position: 0,
|
||||
opacity: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
return Array.from({ length: maxCars }).map((_, carIndex) => {
|
||||
// Calculate position for this car (behind the locomotive)
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * carSpacing)
|
||||
|
||||
// Calculate opacity: fade in at left tunnel (3-8%), fade out at right tunnel (92-97%)
|
||||
const fadeInStart = 3
|
||||
const fadeInEnd = 8
|
||||
const fadeOutStart = 92
|
||||
const fadeOutEnd = 97
|
||||
|
||||
let opacity = 1 // Default to fully visible
|
||||
|
||||
// Fade in from left tunnel
|
||||
if (carPosition <= fadeInStart) {
|
||||
opacity = 0
|
||||
} else if (carPosition < fadeInEnd) {
|
||||
opacity = (carPosition - fadeInStart) / (fadeInEnd - fadeInStart)
|
||||
}
|
||||
// Fade out into right tunnel
|
||||
else if (carPosition >= fadeOutEnd) {
|
||||
opacity = 0
|
||||
} else if (carPosition > fadeOutStart) {
|
||||
opacity = 1 - (carPosition - fadeOutStart) / (fadeOutEnd - fadeOutStart)
|
||||
}
|
||||
|
||||
return {
|
||||
...trackGenerator.getTrainTransform(pathRef.current!, carPosition),
|
||||
position: carPosition,
|
||||
opacity,
|
||||
}
|
||||
})
|
||||
}, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing])
|
||||
|
||||
// Calculate locomotive opacity (fade in/out through tunnels)
|
||||
const locomotiveOpacity = useMemo(() => {
|
||||
const fadeInStart = 3
|
||||
const fadeInEnd = 8
|
||||
const fadeOutStart = 92
|
||||
const fadeOutEnd = 97
|
||||
|
||||
// Fade in from left tunnel
|
||||
if (trainPosition <= fadeInStart) {
|
||||
return 0
|
||||
} else if (trainPosition < fadeInEnd) {
|
||||
return (trainPosition - fadeInStart) / (fadeInEnd - fadeInStart)
|
||||
}
|
||||
// Fade out into right tunnel
|
||||
else if (trainPosition >= fadeOutEnd) {
|
||||
return 0
|
||||
} else if (trainPosition > fadeOutStart) {
|
||||
return 1 - (trainPosition - fadeOutStart) / (fadeOutEnd - fadeOutStart)
|
||||
}
|
||||
|
||||
return 1 // Default to fully visible
|
||||
}, [trainPosition])
|
||||
|
||||
return {
|
||||
trainTransform,
|
||||
trainCars,
|
||||
locomotiveOpacity,
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* Railroad Track Generator
|
||||
*
|
||||
* Generates dynamic curved railroad tracks with proper ballast, ties, and rails.
|
||||
* Based on the original Python implementation with SVG path generation.
|
||||
*/
|
||||
|
||||
export interface Waypoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface TrackElements {
|
||||
ballastPath: string
|
||||
referencePath: string
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
|
||||
leftRailPath: string
|
||||
rightRailPath: string
|
||||
}
|
||||
|
||||
export class RailroadTrackGenerator {
|
||||
private viewWidth: number
|
||||
private viewHeight: number
|
||||
|
||||
constructor(viewWidth = 800, viewHeight = 600) {
|
||||
this.viewWidth = viewWidth
|
||||
this.viewHeight = viewHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete track elements for rendering
|
||||
*/
|
||||
generateTrack(routeNumber: number = 1): TrackElements {
|
||||
const waypoints = this.generateTrackWaypoints(routeNumber)
|
||||
const pathData = this.generateSmoothPath(waypoints)
|
||||
|
||||
return {
|
||||
ballastPath: pathData,
|
||||
referencePath: pathData,
|
||||
ties: [],
|
||||
leftRailPath: '',
|
||||
rightRailPath: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeded random number generator for deterministic randomness
|
||||
*/
|
||||
private seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate waypoints for track with controlled randomness
|
||||
* Based on route number for variety across different routes
|
||||
*/
|
||||
private generateTrackWaypoints(routeNumber: number): Waypoint[] {
|
||||
// Base waypoints - tracks span from left tunnel (x=20) to right tunnel (x=780)
|
||||
// viewBox is "-50 -50 900 700", so x ranges from -50 to 850
|
||||
const baseWaypoints: Waypoint[] = [
|
||||
{ x: 20, y: 300 }, // Start at left tunnel center
|
||||
{ x: 120, y: 260 }, // Emerging from left tunnel
|
||||
{ x: 240, y: 200 }, // Climb into hills
|
||||
{ x: 380, y: 170 }, // Mountain pass
|
||||
{ x: 520, y: 220 }, // Descent to valley
|
||||
{ x: 660, y: 160 }, // Bridge over canyon
|
||||
{ x: 780, y: 300 }, // Enter right tunnel center
|
||||
]
|
||||
|
||||
// Add deterministic randomness based on route number (but keep start/end fixed)
|
||||
return baseWaypoints.map((point, index) => {
|
||||
if (index === 0 || index === baseWaypoints.length - 1) {
|
||||
return point // Keep start/end points fixed
|
||||
}
|
||||
|
||||
// Use seeded randomness for consistent track per route
|
||||
const seed1 = routeNumber * 12.9898 + index * 78.233
|
||||
const seed2 = routeNumber * 43.789 + index * 67.123
|
||||
const randomX = (this.seededRandom(seed1) - 0.5) * 60 // ±30 pixels
|
||||
const randomY = (this.seededRandom(seed2) - 0.5) * 80 // ±40 pixels
|
||||
|
||||
return {
|
||||
x: point.x + randomX,
|
||||
y: point.y + randomY,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate smooth cubic bezier curves through waypoints
|
||||
*/
|
||||
private generateSmoothPath(waypoints: Waypoint[]): string {
|
||||
if (waypoints.length < 2) return ''
|
||||
|
||||
let pathData = `M ${waypoints[0].x} ${waypoints[0].y}`
|
||||
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
const current = waypoints[i]
|
||||
const previous = waypoints[i - 1]
|
||||
|
||||
// Calculate control points for smooth curves
|
||||
const dx = current.x - previous.x
|
||||
const dy = current.y - previous.y
|
||||
|
||||
const cp1x = previous.x + dx * 0.3
|
||||
const cp1y = previous.y + dy * 0.2
|
||||
const cp2x = current.x - dx * 0.3
|
||||
const cp2y = current.y - dy * 0.2
|
||||
|
||||
pathData += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${current.x} ${current.y}`
|
||||
}
|
||||
|
||||
return pathData
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate gentle curves through densely sampled waypoints
|
||||
* Uses very gentle control points to avoid wobbles in straight sections
|
||||
*/
|
||||
private generateGentlePath(waypoints: Waypoint[]): string {
|
||||
if (waypoints.length < 2) return ''
|
||||
|
||||
let pathData = `M ${waypoints[0].x} ${waypoints[0].y}`
|
||||
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
const current = waypoints[i]
|
||||
const previous = waypoints[i - 1]
|
||||
|
||||
// Use extremely gentle control points for very dense sampling
|
||||
const dx = current.x - previous.x
|
||||
const dy = current.y - previous.y
|
||||
|
||||
const cp1x = previous.x + dx * 0.33
|
||||
const cp1y = previous.y + dy * 0.33
|
||||
const cp2x = current.x - dx * 0.33
|
||||
const cp2y = current.y - dy * 0.33
|
||||
|
||||
pathData += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${current.x} ${current.y}`
|
||||
}
|
||||
|
||||
return pathData
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate railroad ties and rails along the path
|
||||
* This requires an SVG path element to measure
|
||||
*/
|
||||
generateTiesAndRails(pathElement: SVGPathElement): {
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
|
||||
leftRailPath: string
|
||||
rightRailPath: string
|
||||
} {
|
||||
const pathLength = pathElement.getTotalLength()
|
||||
const tieSpacing = 12 // Distance between ties in pixels
|
||||
const gaugeWidth = 15 // Standard gauge (tie extends 15px each side)
|
||||
const tieCount = Math.floor(pathLength / tieSpacing)
|
||||
|
||||
const ties: Array<{ x1: number; y1: number; x2: number; y2: number }> = []
|
||||
|
||||
// Generate ties at normal spacing
|
||||
for (let i = 0; i < tieCount; i++) {
|
||||
const distance = i * tieSpacing
|
||||
const point = pathElement.getPointAtLength(distance)
|
||||
|
||||
// Calculate perpendicular angle for tie orientation
|
||||
const nextDistance = Math.min(distance + 2, pathLength)
|
||||
const nextPoint = pathElement.getPointAtLength(nextDistance)
|
||||
const angle = Math.atan2(nextPoint.y - point.y, nextPoint.x - point.x)
|
||||
const perpAngle = angle + Math.PI / 2
|
||||
|
||||
// Calculate tie end points
|
||||
const leftX = point.x + Math.cos(perpAngle) * gaugeWidth
|
||||
const leftY = point.y + Math.sin(perpAngle) * gaugeWidth
|
||||
const rightX = point.x - Math.cos(perpAngle) * gaugeWidth
|
||||
const rightY = point.y - Math.sin(perpAngle) * gaugeWidth
|
||||
|
||||
// Store tie
|
||||
ties.push({ x1: leftX, y1: leftY, x2: rightX, y2: rightY })
|
||||
}
|
||||
|
||||
// Generate rail paths as smooth curves (not polylines)
|
||||
// Sample points along the path and create offset waypoints
|
||||
const railSampling = 2 // Sample every 2 pixels for waypoints (very dense sampling for smooth curves)
|
||||
const sampleCount = Math.floor(pathLength / railSampling)
|
||||
|
||||
const leftRailWaypoints: Waypoint[] = []
|
||||
const rightRailWaypoints: Waypoint[] = []
|
||||
|
||||
for (let i = 0; i <= sampleCount; i++) {
|
||||
const distance = Math.min(i * railSampling, pathLength)
|
||||
const point = pathElement.getPointAtLength(distance)
|
||||
|
||||
// Calculate perpendicular angle with longer lookahead for smoother curves
|
||||
const nextDistance = Math.min(distance + 8, pathLength)
|
||||
const nextPoint = pathElement.getPointAtLength(nextDistance)
|
||||
const angle = Math.atan2(nextPoint.y - point.y, nextPoint.x - point.x)
|
||||
const perpAngle = angle + Math.PI / 2
|
||||
|
||||
// Calculate offset positions for rails
|
||||
const leftX = point.x + Math.cos(perpAngle) * gaugeWidth
|
||||
const leftY = point.y + Math.sin(perpAngle) * gaugeWidth
|
||||
const rightX = point.x - Math.cos(perpAngle) * gaugeWidth
|
||||
const rightY = point.y - Math.sin(perpAngle) * gaugeWidth
|
||||
|
||||
leftRailWaypoints.push({ x: leftX, y: leftY })
|
||||
rightRailWaypoints.push({ x: rightX, y: rightY })
|
||||
}
|
||||
|
||||
// Generate smooth curved paths through the rail waypoints with gentle control points
|
||||
const leftRailPath = this.generateGentlePath(leftRailWaypoints)
|
||||
const rightRailPath = this.generateGentlePath(rightRailWaypoints)
|
||||
|
||||
return { ties, leftRailPath, rightRailPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate train position and rotation along path
|
||||
*/
|
||||
getTrainTransform(
|
||||
pathElement: SVGPathElement,
|
||||
progress: number // 0-100%
|
||||
): { x: number; y: number; rotation: number } {
|
||||
const pathLength = pathElement.getTotalLength()
|
||||
const targetLength = (progress / 100) * pathLength
|
||||
|
||||
// Get exact point on curved path
|
||||
const point = pathElement.getPointAtLength(targetLength)
|
||||
|
||||
// Calculate rotation based on path direction
|
||||
const lookAheadDistance = Math.min(5, pathLength - targetLength)
|
||||
const nextPoint = pathElement.getPointAtLength(targetLength + lookAheadDistance)
|
||||
|
||||
// Calculate angle between current and next point
|
||||
const deltaX = nextPoint.x - point.x
|
||||
const deltaY = nextPoint.y - point.y
|
||||
const angleRadians = Math.atan2(deltaY, deltaX)
|
||||
const angleDegrees = angleRadians * (180 / Math.PI)
|
||||
|
||||
return {
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
rotation: angleDegrees,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
export type GameMode = 'friends5' | 'friends10' | 'mixed'
|
||||
export type GameStyle = 'practice' | 'sprint' | 'survival'
|
||||
export type TimeoutSetting =
|
||||
| 'preschool'
|
||||
| 'kindergarten'
|
||||
| 'relaxed'
|
||||
| 'slow'
|
||||
| 'normal'
|
||||
| 'fast'
|
||||
| 'expert'
|
||||
export type ComplementDisplay = 'number' | 'abacus' | 'random' // How to display the complement number
|
||||
|
||||
export interface ComplementQuestion {
|
||||
number: number
|
||||
targetSum: number
|
||||
correctAnswer: number
|
||||
showAsAbacus: boolean // For random mode, this is decided once per question
|
||||
}
|
||||
|
||||
export interface AIRacer {
|
||||
id: string
|
||||
position: number
|
||||
speed: number
|
||||
name: string
|
||||
personality: 'competitive' | 'analytical'
|
||||
icon: string
|
||||
lastComment: number
|
||||
commentCooldown: number
|
||||
previousPosition: number
|
||||
}
|
||||
|
||||
export interface DifficultyTracker {
|
||||
pairPerformance: Map<string, PairPerformance>
|
||||
baseTimeLimit: number
|
||||
currentTimeLimit: number
|
||||
difficultyLevel: number
|
||||
consecutiveCorrect: number
|
||||
consecutiveIncorrect: number
|
||||
learningMode: boolean
|
||||
adaptationRate: number
|
||||
}
|
||||
|
||||
export interface PairPerformance {
|
||||
attempts: number
|
||||
correct: number
|
||||
avgTime: number
|
||||
difficulty: number
|
||||
}
|
||||
|
||||
export interface Station {
|
||||
id: string
|
||||
name: string
|
||||
position: number // 0-100% along track
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface Passenger {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
originStationId: string
|
||||
destinationStationId: string
|
||||
isUrgent: boolean
|
||||
isBoarded: boolean
|
||||
isDelivered: boolean
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
// Game configuration
|
||||
mode: GameMode
|
||||
style: GameStyle
|
||||
timeoutSetting: TimeoutSetting
|
||||
complementDisplay: ComplementDisplay // How to display the complement number
|
||||
|
||||
// Current question
|
||||
currentQuestion: ComplementQuestion | null
|
||||
previousQuestion: ComplementQuestion | null
|
||||
|
||||
// Game progress
|
||||
score: number
|
||||
streak: number
|
||||
bestStreak: number
|
||||
totalQuestions: number
|
||||
correctAnswers: number
|
||||
|
||||
// Game status
|
||||
isGameActive: boolean
|
||||
isPaused: boolean
|
||||
gamePhase: 'intro' | 'controls' | 'countdown' | 'playing' | 'results'
|
||||
|
||||
// Timing
|
||||
gameStartTime: number | null
|
||||
questionStartTime: number
|
||||
|
||||
// Race mechanics
|
||||
raceGoal: number
|
||||
timeLimit: number | null
|
||||
speedMultiplier: number
|
||||
aiRacers: AIRacer[]
|
||||
|
||||
// Adaptive difficulty
|
||||
difficultyTracker: DifficultyTracker
|
||||
|
||||
// Survival mode specific
|
||||
playerLap: number
|
||||
aiLaps: Map<string, number>
|
||||
survivalMultiplier: number
|
||||
|
||||
// Sprint mode specific
|
||||
momentum: number
|
||||
trainPosition: number
|
||||
pressure: number // 0-150 PSI
|
||||
elapsedTime: number // milliseconds elapsed in 60-second journey
|
||||
lastCorrectAnswerTime: number
|
||||
currentRoute: number
|
||||
stations: Station[]
|
||||
passengers: Passenger[]
|
||||
deliveredPassengers: number
|
||||
cumulativeDistance: number // Total distance across all routes
|
||||
showRouteCelebration: boolean
|
||||
|
||||
// Input
|
||||
currentInput: string
|
||||
|
||||
// UI state
|
||||
showScoreModal: boolean
|
||||
activeSpeechBubbles: Map<string, string> // racerId -> message
|
||||
adaptiveFeedback: { message: string; type: string } | null
|
||||
}
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'SET_MODE'; mode: GameMode }
|
||||
| { type: 'SET_STYLE'; style: GameStyle }
|
||||
| { type: 'SET_TIMEOUT'; timeout: TimeoutSetting }
|
||||
| { type: 'SET_COMPLEMENT_DISPLAY'; display: ComplementDisplay }
|
||||
| { type: 'SHOW_CONTROLS' }
|
||||
| { type: 'START_COUNTDOWN' }
|
||||
| { type: 'BEGIN_GAME' }
|
||||
| { type: 'NEXT_QUESTION' }
|
||||
| { type: 'SUBMIT_ANSWER'; answer: number }
|
||||
| { type: 'UPDATE_INPUT'; input: string }
|
||||
| {
|
||||
type: 'UPDATE_AI_POSITIONS'
|
||||
positions: Array<{ id: string; position: number }>
|
||||
}
|
||||
| {
|
||||
type: 'TRIGGER_AI_COMMENTARY'
|
||||
racerId: string
|
||||
message: string
|
||||
context: string
|
||||
}
|
||||
| { type: 'CLEAR_AI_COMMENT'; racerId: string }
|
||||
| { type: 'UPDATE_DIFFICULTY_TRACKER'; tracker: DifficultyTracker }
|
||||
| { type: 'UPDATE_AI_SPEEDS'; racers: AIRacer[] }
|
||||
| {
|
||||
type: 'SHOW_ADAPTIVE_FEEDBACK'
|
||||
feedback: { message: string; type: string }
|
||||
}
|
||||
| { type: 'CLEAR_ADAPTIVE_FEEDBACK' }
|
||||
| { type: 'UPDATE_MOMENTUM'; momentum: number }
|
||||
| { type: 'UPDATE_TRAIN_POSITION'; position: number }
|
||||
| {
|
||||
type: 'UPDATE_STEAM_JOURNEY'
|
||||
momentum: number
|
||||
trainPosition: number
|
||||
pressure: number
|
||||
elapsedTime: number
|
||||
}
|
||||
| { type: 'COMPLETE_LAP'; racerId: string }
|
||||
| { type: 'PAUSE_RACE' }
|
||||
| { type: 'RESUME_RACE' }
|
||||
| { type: 'END_RACE' }
|
||||
| { type: 'SHOW_RESULTS' }
|
||||
| { type: 'RESET_GAME' }
|
||||
| { type: 'GENERATE_PASSENGERS'; passengers: Passenger[] }
|
||||
| { type: 'BOARD_PASSENGER'; passengerId: string }
|
||||
| { type: 'DELIVER_PASSENGER'; passengerId: string; points: number }
|
||||
| { type: 'START_NEW_ROUTE'; routeNumber: number; stations: Station[] }
|
||||
| { type: 'COMPLETE_ROUTE' }
|
||||
| { type: 'HIDE_ROUTE_CELEBRATION' }
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Geographic landmarks for Steam Train Journey
|
||||
* Landmarks add visual variety to the landscape based on route themes
|
||||
*/
|
||||
|
||||
export interface Landmark {
|
||||
emoji: string
|
||||
position: number // 0-100% along track
|
||||
offset: { x: number; y: number } // Offset from track position
|
||||
size: number // Font size multiplier
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate landmarks for a specific route
|
||||
* Different route themes have different landmark types
|
||||
*/
|
||||
export function generateLandmarks(routeNumber: number): Landmark[] {
|
||||
const seed = routeNumber * 456.789
|
||||
|
||||
// Deterministic randomness for landmark placement
|
||||
const random = (index: number) => {
|
||||
return Math.abs(Math.sin(seed + index * 2.7))
|
||||
}
|
||||
|
||||
const landmarks: Landmark[] = []
|
||||
|
||||
// Route theme determines landmark types
|
||||
const themeIndex = (routeNumber - 1) % 10
|
||||
|
||||
// Generate 4-6 landmarks along the route
|
||||
const landmarkCount = Math.floor(random(0) * 3) + 4
|
||||
|
||||
for (let i = 0; i < landmarkCount; i++) {
|
||||
const position = (i + 1) * (100 / (landmarkCount + 1))
|
||||
const offsetSide = random(i) > 0.5 ? 1 : -1
|
||||
const offsetDistance = 30 + random(i + 10) * 40
|
||||
|
||||
let emoji = '🌳' // Default tree
|
||||
let size = 24
|
||||
|
||||
// Choose emoji based on theme and position
|
||||
switch (themeIndex) {
|
||||
case 0: // Prairie Express
|
||||
emoji = random(i) > 0.6 ? '🌾' : '🌻'
|
||||
size = 20
|
||||
break
|
||||
case 1: // Mountain Climb
|
||||
emoji = random(i) > 0.5 ? '⛰️' : '🗻'
|
||||
size = 32
|
||||
break
|
||||
case 2: // Coastal Run
|
||||
emoji = random(i) > 0.7 ? '🌊' : random(i) > 0.4 ? '🏖️' : '⛵'
|
||||
size = 24
|
||||
break
|
||||
case 3: // Desert Crossing
|
||||
emoji = random(i) > 0.6 ? '🌵' : '🏜️'
|
||||
size = 28
|
||||
break
|
||||
case 4: // Forest Trail
|
||||
emoji = random(i) > 0.7 ? '🌲' : random(i) > 0.4 ? '🌳' : '🦌'
|
||||
size = 26
|
||||
break
|
||||
case 5: // Canyon Route
|
||||
emoji = random(i) > 0.5 ? '🏞️' : '🪨'
|
||||
size = 30
|
||||
break
|
||||
case 6: // River Valley
|
||||
emoji = random(i) > 0.6 ? '🌊' : random(i) > 0.3 ? '🌳' : '🦆'
|
||||
size = 24
|
||||
break
|
||||
case 7: // Highland Pass
|
||||
emoji = random(i) > 0.6 ? '🗻' : '☁️'
|
||||
size = 28
|
||||
break
|
||||
case 8: // Lakeside Journey
|
||||
emoji = random(i) > 0.7 ? '🏞️' : random(i) > 0.4 ? '🌳' : '🦢'
|
||||
size = 26
|
||||
break
|
||||
case 9: // Grand Circuit
|
||||
emoji = random(i) > 0.7 ? '🎪' : random(i) > 0.4 ? '🎡' : '🎠'
|
||||
size = 28
|
||||
break
|
||||
}
|
||||
|
||||
// Add bridges at specific positions (around 40-60%)
|
||||
if (position > 40 && position < 60 && random(i + 20) > 0.7) {
|
||||
emoji = '🌉'
|
||||
size = 36
|
||||
}
|
||||
|
||||
landmarks.push({
|
||||
emoji,
|
||||
position,
|
||||
offset: {
|
||||
x: offsetSide * offsetDistance,
|
||||
y: random(i + 5) * 20 - 10,
|
||||
},
|
||||
size,
|
||||
})
|
||||
}
|
||||
|
||||
return landmarks
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
import type { Passenger, Station } from './gameTypes'
|
||||
|
||||
// Names and avatars organized by gender presentation
|
||||
const MASCULINE_NAMES = [
|
||||
'Ahmed',
|
||||
'Bob',
|
||||
'Carlos',
|
||||
'Elias',
|
||||
'Ethan',
|
||||
'George',
|
||||
'Ian',
|
||||
'Kevin',
|
||||
'Marcus',
|
||||
'Oliver',
|
||||
'Victor',
|
||||
'Xavier',
|
||||
'Raj',
|
||||
'David',
|
||||
'Miguel',
|
||||
'Jin',
|
||||
]
|
||||
|
||||
const FEMININE_NAMES = [
|
||||
'Alice',
|
||||
'Bella',
|
||||
'Diana',
|
||||
'Devi',
|
||||
'Fatima',
|
||||
'Fiona',
|
||||
'Hannah',
|
||||
'Julia',
|
||||
'Laura',
|
||||
'Nina',
|
||||
'Petra',
|
||||
'Rosa',
|
||||
'Tessa',
|
||||
'Uma',
|
||||
'Wendy',
|
||||
'Zara',
|
||||
'Yuki',
|
||||
]
|
||||
|
||||
const GENDER_NEUTRAL_NAMES = [
|
||||
'Alex',
|
||||
'Charlie',
|
||||
'Jordan',
|
||||
'Morgan',
|
||||
'Quinn',
|
||||
'Riley',
|
||||
'Sam',
|
||||
'Taylor',
|
||||
]
|
||||
|
||||
// Masculine-presenting avatars
|
||||
const MASCULINE_AVATARS = [
|
||||
'👨',
|
||||
'👨🏻',
|
||||
'👨🏼',
|
||||
'👨🏽',
|
||||
'👨🏾',
|
||||
'👨🏿',
|
||||
'👴',
|
||||
'👴🏻',
|
||||
'👴🏼',
|
||||
'👴🏽',
|
||||
'👴🏾',
|
||||
'👴🏿',
|
||||
'👦',
|
||||
'👦🏻',
|
||||
'👦🏼',
|
||||
'👦🏽',
|
||||
'👦🏾',
|
||||
'👦🏿',
|
||||
'🧔',
|
||||
'🧔🏻',
|
||||
'🧔🏼',
|
||||
'🧔🏽',
|
||||
'🧔🏾',
|
||||
'🧔🏿',
|
||||
'👨🦱',
|
||||
'👨🏻🦱',
|
||||
'👨🏼🦱',
|
||||
'👨🏽🦱',
|
||||
'👨🏾🦱',
|
||||
'👨🏿🦱',
|
||||
'👨🦰',
|
||||
'👨🏻🦰',
|
||||
'👨🏼🦰',
|
||||
'👨🏽🦰',
|
||||
'👨🏾🦰',
|
||||
'👨🏿🦰',
|
||||
'👱',
|
||||
'👱🏻',
|
||||
'👱🏼',
|
||||
'👱🏽',
|
||||
'👱🏾',
|
||||
'👱🏿',
|
||||
]
|
||||
|
||||
// Feminine-presenting avatars
|
||||
const FEMININE_AVATARS = [
|
||||
'👩',
|
||||
'👩🏻',
|
||||
'👩🏼',
|
||||
'👩🏽',
|
||||
'👩🏾',
|
||||
'👩🏿',
|
||||
'👵',
|
||||
'👵🏻',
|
||||
'👵🏼',
|
||||
'👵🏽',
|
||||
'👵🏾',
|
||||
'👵🏿',
|
||||
'👧',
|
||||
'👧🏻',
|
||||
'👧🏼',
|
||||
'👧🏽',
|
||||
'👧🏾',
|
||||
'👧🏿',
|
||||
'👩🦱',
|
||||
'👩🏻🦱',
|
||||
'👩🏼🦱',
|
||||
'👩🏽🦱',
|
||||
'👩🏾🦱',
|
||||
'👩🏿🦱',
|
||||
'👩🦰',
|
||||
'👩🏻🦰',
|
||||
'👩🏼🦰',
|
||||
'👩🏽🦰',
|
||||
'👩🏾🦰',
|
||||
'👩🏿🦰',
|
||||
'👱♀️',
|
||||
'👱🏻♀️',
|
||||
'👱🏼♀️',
|
||||
'👱🏽♀️',
|
||||
'👱🏾♀️',
|
||||
'👱🏿♀️',
|
||||
]
|
||||
|
||||
// Gender-neutral avatars
|
||||
const NEUTRAL_AVATARS = ['🧑', '🧑🏻', '🧑🏼', '🧑🏽', '🧑🏾', '🧑🏿']
|
||||
|
||||
/**
|
||||
* Generate 3-5 passengers with random names and destinations
|
||||
* 30% chance of urgent passengers
|
||||
*/
|
||||
export function generatePassengers(stations: Station[]): Passenger[] {
|
||||
const count = Math.floor(Math.random() * 3) + 3 // 3-5 passengers
|
||||
const passengers: Passenger[] = []
|
||||
const usedCombos = new Set<string>()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let name: string
|
||||
let avatar: string
|
||||
let comboKey: string
|
||||
|
||||
// Keep trying until we get a unique name/avatar combo
|
||||
do {
|
||||
// Randomly choose a gender category
|
||||
const genderRoll = Math.random()
|
||||
let namePool: string[]
|
||||
let avatarPool: string[]
|
||||
|
||||
if (genderRoll < 0.45) {
|
||||
// 45% masculine
|
||||
namePool = MASCULINE_NAMES
|
||||
avatarPool = MASCULINE_AVATARS
|
||||
} else if (genderRoll < 0.9) {
|
||||
// 45% feminine
|
||||
namePool = FEMININE_NAMES
|
||||
avatarPool = FEMININE_AVATARS
|
||||
} else {
|
||||
// 10% neutral
|
||||
namePool = GENDER_NEUTRAL_NAMES
|
||||
avatarPool = NEUTRAL_AVATARS
|
||||
}
|
||||
|
||||
// Pick from the chosen category
|
||||
name = namePool[Math.floor(Math.random() * namePool.length)]
|
||||
avatar = avatarPool[Math.floor(Math.random() * avatarPool.length)]
|
||||
comboKey = `${name}-${avatar}`
|
||||
} while (usedCombos.has(comboKey) && usedCombos.size < 100) // Prevent infinite loop
|
||||
|
||||
usedCombos.add(comboKey)
|
||||
|
||||
// Pick random origin and destination stations (must be different)
|
||||
// Destination must be ahead of origin (higher position on track)
|
||||
// 40% chance to start at depot, 60% chance to start at other stations
|
||||
let originStation: Station
|
||||
let destination: Station
|
||||
|
||||
if (Math.random() < 0.4 || stations.length < 3) {
|
||||
// Start at depot (first station)
|
||||
originStation = stations[0]
|
||||
// Pick any station ahead as destination
|
||||
const stationsAhead = stations.slice(1)
|
||||
destination = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
} else {
|
||||
// Start at a random non-depot, non-final station
|
||||
const nonDepotStations = stations.slice(1, -1) // Exclude depot and final station
|
||||
originStation = nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)]
|
||||
|
||||
// Pick a station ahead of origin (higher position)
|
||||
const stationsAhead = stations.filter((s) => s.position > originStation.position)
|
||||
destination = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
}
|
||||
|
||||
// 30% chance of urgent
|
||||
const isUrgent = Math.random() < 0.3
|
||||
|
||||
passengers.push({
|
||||
id: `passenger-${Date.now()}-${i}`,
|
||||
name,
|
||||
avatar,
|
||||
originStationId: originStation.id,
|
||||
destinationStationId: destination.id,
|
||||
isUrgent,
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
})
|
||||
}
|
||||
|
||||
return passengers
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if train is at a station (within 3% tolerance)
|
||||
*/
|
||||
export function isTrainAtStation(trainPosition: number, stationPosition: number): boolean {
|
||||
return Math.abs(trainPosition - stationPosition) < 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Find passengers that should board at current position
|
||||
*/
|
||||
export function findBoardablePassengers(
|
||||
passengers: Passenger[],
|
||||
stations: Station[],
|
||||
trainPosition: number
|
||||
): Passenger[] {
|
||||
const boardable: Passenger[] = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
// Skip if already boarded or delivered
|
||||
if (passenger.isBoarded || passenger.isDelivered) continue
|
||||
|
||||
const station = stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) continue
|
||||
|
||||
if (isTrainAtStation(trainPosition, station.position)) {
|
||||
boardable.push(passenger)
|
||||
}
|
||||
}
|
||||
|
||||
return boardable
|
||||
}
|
||||
|
||||
/**
|
||||
* Find passengers that should be delivered at current position
|
||||
*/
|
||||
export function findDeliverablePassengers(
|
||||
passengers: Passenger[],
|
||||
stations: Station[],
|
||||
trainPosition: number
|
||||
): Array<{ passenger: Passenger; station: Station; points: number }> {
|
||||
const deliverable: Array<{
|
||||
passenger: Passenger
|
||||
station: Station
|
||||
points: number
|
||||
}> = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
// Only check boarded passengers
|
||||
if (!passenger.isBoarded || passenger.isDelivered) continue
|
||||
|
||||
const station = stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) continue
|
||||
|
||||
if (isTrainAtStation(trainPosition, station.position)) {
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
deliverable.push({ passenger, station, points })
|
||||
}
|
||||
}
|
||||
|
||||
return deliverable
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the maximum number of passengers that will be on the train
|
||||
* concurrently at any given moment during the route
|
||||
*/
|
||||
export function calculateMaxConcurrentPassengers(
|
||||
passengers: Passenger[],
|
||||
stations: Station[]
|
||||
): number {
|
||||
// Create events for boarding and delivery
|
||||
interface StationEvent {
|
||||
position: number
|
||||
isBoarding: boolean // true = board, false = delivery
|
||||
}
|
||||
|
||||
const events: StationEvent[] = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
const originStation = stations.find((s) => s.id === passenger.originStationId)
|
||||
const destStation = stations.find((s) => s.id === passenger.destinationStationId)
|
||||
|
||||
if (originStation && destStation) {
|
||||
events.push({ position: originStation.position, isBoarding: true })
|
||||
events.push({ position: destStation.position, isBoarding: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Sort events by position, with deliveries before boardings at the same position
|
||||
events.sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
// At same position, deliveries happen before boarding
|
||||
return a.isBoarding ? 1 : -1
|
||||
})
|
||||
|
||||
// Track current passenger count and maximum
|
||||
let currentCount = 0
|
||||
let maxCount = 0
|
||||
|
||||
for (const event of events) {
|
||||
if (event.isBoarding) {
|
||||
currentCount++
|
||||
maxCount = Math.max(maxCount, currentCount)
|
||||
} else {
|
||||
currentCount--
|
||||
}
|
||||
}
|
||||
|
||||
return maxCount
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Route themes for Steam Train Journey
|
||||
* Each route has a unique name and emoji to make the journey feel varied
|
||||
*/
|
||||
|
||||
export const ROUTE_THEMES = [
|
||||
{ name: 'Prairie Express', emoji: '🌾' },
|
||||
{ name: 'Mountain Climb', emoji: '⛰️' },
|
||||
{ name: 'Coastal Run', emoji: '🌊' },
|
||||
{ name: 'Desert Crossing', emoji: '🏜️' },
|
||||
{ name: 'Forest Trail', emoji: '🌲' },
|
||||
{ name: 'Canyon Route', emoji: '🏞️' },
|
||||
{ name: 'River Valley', emoji: '🏞️' },
|
||||
{ name: 'Highland Pass', emoji: '🗻' },
|
||||
{ name: 'Lakeside Journey', emoji: '🏔️' },
|
||||
{ name: 'Grand Circuit', emoji: '🎪' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Get route theme for a given route number
|
||||
* Cycles through themes if route number exceeds available themes
|
||||
*/
|
||||
export function getRouteTheme(routeNumber: number): {
|
||||
name: string
|
||||
emoji: string
|
||||
} {
|
||||
const index = (routeNumber - 1) % ROUTE_THEMES.length
|
||||
return ROUTE_THEMES[index]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from './components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from './context/ComplementRaceContext'
|
||||
|
||||
export default function ComplementRacePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Speed Complement Race" navEmoji="🏁" gameName="complement-race">
|
||||
<ComplementRaceProvider>
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
|
||||
export default function PracticeModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Practice Mode" navEmoji="🏁" gameName="complement-race">
|
||||
<ComplementRaceProvider initialStyle="practice">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
|
||||
export default function SprintModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Steam Sprint" navEmoji="🚂" gameName="complement-race">
|
||||
<ComplementRaceProvider initialStyle="sprint">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
|
||||
export default function SurvivalModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Survival Mode" navEmoji="🔄" gameName="complement-race">
|
||||
<ComplementRaceProvider initialStyle="survival">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,15 +18,6 @@ function GamesPageContent() {
|
||||
// Get all players sorted by creation time
|
||||
const allPlayers = getAllPlayers().sort((a, b) => a.createdAt - b.createdAt)
|
||||
|
||||
const _handleGameClick = (gameType: string) => {
|
||||
// Navigate directly to games using the centralized game mode with Next.js router
|
||||
// Note: battle-arena has been removed - now handled by game registry as "matching"
|
||||
console.log('🔄 GamesPage: Navigating with Next.js router (no page reload)')
|
||||
if (gameType === 'memory-quiz') {
|
||||
router.push('/games/memory-quiz')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
password: roomPassword,
|
||||
})
|
||||
// Navigate to the game
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join room')
|
||||
setIsJoining(false)
|
||||
@@ -261,7 +261,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
|
||||
// If user is already in this exact room, just navigate to game
|
||||
if (roomData && roomData.id === room.id) {
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push('/arcade/room') // Stay in current room
|
||||
router.push('/arcade') // Stay in current room
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = () => {
|
||||
|
||||
@@ -3,133 +3,400 @@
|
||||
import Link from 'next/link'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, hstack, stack } from '../../styled-system/patterns'
|
||||
import { container, grid, hstack, stack } from '../../styled-system/patterns'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Soroban Flashcards" navEmoji="🧮">
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
bg: 'gradient-to-br from-brand.50 to-brand.100',
|
||||
})}
|
||||
>
|
||||
{/* Hero Section */}
|
||||
<main className={container({ maxW: '6xl', px: '4' })}>
|
||||
<div
|
||||
className={stack({
|
||||
gap: '12',
|
||||
py: '16',
|
||||
align: 'center',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{/* Hero Content */}
|
||||
<div className={stack({ gap: '6', maxW: '4xl' })}>
|
||||
<PageWithNav navTitle="Soroban Mastery Platform" navEmoji="🧮">
|
||||
<div className={css({ bg: 'gray.50', minHeight: '100vh' })}>
|
||||
{/* Compact Hero */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
py: { base: '8', md: '12' },
|
||||
})}
|
||||
>
|
||||
<div className={container({ maxW: '6xl', px: '4' })}>
|
||||
<div className={css({ textAlign: 'center', maxW: '4xl', mx: 'auto' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '4xl', md: '6xl' },
|
||||
fontSize: { base: '3xl', md: '5xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '4',
|
||||
lineHeight: 'tight',
|
||||
})}
|
||||
>
|
||||
Beautiful Soroban <span className={css({ color: 'brand.600' })}>Flashcards</span>
|
||||
Master Soroban Through{' '}
|
||||
<span className={css({ color: 'yellow.300' })}>Play & Practice</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
color: 'gray.600',
|
||||
maxW: '2xl',
|
||||
mx: 'auto',
|
||||
})}
|
||||
>
|
||||
Create stunning, educational flashcards with authentic Japanese abacus
|
||||
representations. Perfect for teachers, students, and mental math enthusiasts.
|
||||
<p className={css({ fontSize: { base: 'md', md: 'lg' }, opacity: 0.95, mb: '6' })}>
|
||||
Interactive tutorials, multiplayer games, and beautiful flashcards—your complete
|
||||
soroban learning ecosystem
|
||||
</p>
|
||||
|
||||
<div className={hstack({ gap: '4', justify: 'center', mt: '8' })}>
|
||||
<div className={hstack({ gap: '3', justify: 'center', flexWrap: 'wrap' })}>
|
||||
<Link
|
||||
href="/create"
|
||||
href="/arcade"
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'xl',
|
||||
shadow: 'card',
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: 'yellow.400',
|
||||
color: 'gray.900',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
_hover: { bg: 'yellow.300', transform: 'translateY(-2px)' },
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
bg: 'brand.700',
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: 'modal',
|
||||
},
|
||||
})}
|
||||
>
|
||||
✨ Start Creating →
|
||||
🎮 Play Games
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/guide"
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: 'white',
|
||||
color: 'brand.700',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'xl',
|
||||
shadow: 'card',
|
||||
border: '2px solid',
|
||||
borderColor: 'brand.200',
|
||||
color: 'purple.700',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
_hover: { bg: 'gray.100', transform: 'translateY(-2px)' },
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
borderColor: 'brand.400',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
📚 Learn Soroban
|
||||
📚 Learn
|
||||
</Link>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: 'purple.600',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
_hover: { bg: 'purple.700', transform: 'translateY(-2px)' },
|
||||
transition: 'all',
|
||||
})}
|
||||
>
|
||||
🎨 Create
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div
|
||||
{/* Main Content Grid */}
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '8' })}>
|
||||
<div className={stack({ gap: '8' })}>
|
||||
{/* Arcade Games Section */}
|
||||
<section>
|
||||
<div className={hstack({ justify: 'space-between', mb: '4' })}>
|
||||
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'gray.900' })}>
|
||||
🕹️ Multiplayer Arcade
|
||||
</h2>
|
||||
<Link
|
||||
href="/arcade"
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'purple.600',
|
||||
fontWeight: 'semibold',
|
||||
_hover: { color: 'purple.700' },
|
||||
})}
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '4' })}>
|
||||
<GameCard
|
||||
icon="🧠"
|
||||
title="Memory Lightning"
|
||||
description="Memorize soroban numbers"
|
||||
players="1-8 players"
|
||||
tags={['Co-op', 'Competitive']}
|
||||
/>
|
||||
<GameCard
|
||||
icon="⚔️"
|
||||
title="Matching Pairs"
|
||||
description="Turn-based card battles"
|
||||
players="1-4 players"
|
||||
tags={['Pattern Recognition']}
|
||||
/>
|
||||
<GameCard
|
||||
icon="🏁"
|
||||
title="Speed Race"
|
||||
description="Race AI with complements"
|
||||
players="1-4 players + AI"
|
||||
tags={['Practice', 'Sprint', 'Survival']}
|
||||
/>
|
||||
<GameCard
|
||||
icon="🔢"
|
||||
title="Card Sorting"
|
||||
description="Arrange cards visually"
|
||||
players="Solo challenge"
|
||||
tags={['Visual Literacy']}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '6' })}>
|
||||
{/* Interactive Learning */}
|
||||
<section
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
📚 Interactive Learning
|
||||
</h2>
|
||||
<div className={stack({ gap: '3' })}>
|
||||
<FeatureItem
|
||||
icon="🔍"
|
||||
title="Reading Numbers"
|
||||
description="Visual tutorials on interpreting bead positions"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon="🧮"
|
||||
title="Arithmetic Operations"
|
||||
description="Step-by-step interactive practice: +, −, ×, ÷"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon="🎯"
|
||||
title="Guided Tutorials"
|
||||
description="Hands-on exercises with instant feedback"
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/guide"
|
||||
className={css({
|
||||
display: 'block',
|
||||
mt: '4',
|
||||
py: '2',
|
||||
textAlign: 'center',
|
||||
bg: 'purple.50',
|
||||
color: 'purple.700',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'lg',
|
||||
_hover: { bg: 'purple.100' },
|
||||
})}
|
||||
>
|
||||
Start Learning →
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Flashcard Creator */}
|
||||
<section
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
🎨 Flashcard Creator
|
||||
</h2>
|
||||
<div className={stack({ gap: '3' })}>
|
||||
<FeatureItem
|
||||
icon="📄"
|
||||
title="Multiple Formats"
|
||||
description="PDF, PNG, SVG, interactive HTML"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon="🎨"
|
||||
title="Custom Styling"
|
||||
description="Bead shapes, color schemes, fonts, layouts"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon="📐"
|
||||
title="Paper Options"
|
||||
description="A3, A4, A5, US Letter • Portrait/Landscape"
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
display: 'block',
|
||||
mt: '4',
|
||||
py: '2',
|
||||
textAlign: 'center',
|
||||
bg: 'blue.50',
|
||||
color: 'blue.700',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'lg',
|
||||
_hover: { bg: 'blue.100' },
|
||||
})}
|
||||
>
|
||||
Create Flashcards →
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Multiplayer Features */}
|
||||
<section>
|
||||
<h2
|
||||
className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'gray.900', mb: '4' })}
|
||||
>
|
||||
🌐 Multiplayer Features
|
||||
</h2>
|
||||
<div className={grid({ columns: { base: 1, sm: 2, md: 4 }, gap: '4' })}>
|
||||
<FeatureCard
|
||||
icon="🎭"
|
||||
title="Player Characters"
|
||||
description="Custom names, emojis, and colors for each player"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="🏠"
|
||||
title="Private Rooms"
|
||||
description="Create rooms with codes, passwords, or approval-only access"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="⚡"
|
||||
title="Real-time Play"
|
||||
description="Socket.io powered instant multiplayer sync"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="📊"
|
||||
title="Stats & Progress"
|
||||
description="Track wins, accuracy, and performance across games"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<section
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1', md: '3' },
|
||||
gap: '8',
|
||||
mt: '16',
|
||||
w: 'full',
|
||||
bg: 'gradient-to-r',
|
||||
gradientFrom: 'purple.600',
|
||||
gradientTo: 'indigo.600',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
<FeatureCard
|
||||
icon="🎨"
|
||||
title="Beautiful Design"
|
||||
description="Vector graphics, color schemes, authentic bead positioning"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="⚡"
|
||||
title="Instant Generation"
|
||||
description="Create PDFs, interactive HTML, PNGs, and SVGs in seconds"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="🎯"
|
||||
title="Educational Focus"
|
||||
description="Perfect for teachers, students, and soroban enthusiasts"
|
||||
/>
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
mb: '4',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Complete Soroban Learning Platform
|
||||
</h2>
|
||||
<div className={grid({ columns: { base: 2, md: 4 }, gap: '6', textAlign: 'center' })}>
|
||||
<StatItem number="4" label="Arcade Games" />
|
||||
<StatItem number="8" label="Max Players" />
|
||||
<StatItem number="3" label="Learning Modes" />
|
||||
<StatItem number="4+" label="Export Formats" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
function GameCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
players,
|
||||
tags,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
players: string
|
||||
tags: string[]
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
transition: 'all',
|
||||
_hover: { shadow: 'md', transform: 'translateY(-2px)' },
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '2xl', mb: '2' })}>{icon}</div>
|
||||
<h3 className={css({ fontSize: 'md', fontWeight: 'bold', color: 'gray.900', mb: '1' })}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={css({ fontSize: 'sm', color: 'gray.600', mb: '2' })}>{description}</p>
|
||||
<p className={css({ fontSize: 'xs', color: 'gray.500', mb: '2' })}>{players}</p>
|
||||
<div className={hstack({ gap: '1', flexWrap: 'wrap' })}>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
px: '2',
|
||||
py: '0.5',
|
||||
bg: 'purple.100',
|
||||
color: 'purple.700',
|
||||
rounded: 'full',
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureItem({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className={hstack({ gap: '3', alignItems: 'flex-start' })}>
|
||||
<div className={css({ fontSize: 'xl', flexShrink: 0 })}>{icon}</div>
|
||||
<div>
|
||||
<h4 className={css({ fontSize: 'sm', fontWeight: 'semibold', color: 'gray.900' })}>
|
||||
{title}
|
||||
</h4>
|
||||
<p className={css({ fontSize: 'xs', color: 'gray.600' })}>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
icon,
|
||||
title,
|
||||
@@ -142,44 +409,31 @@ function FeatureCard({
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
p: '8',
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
textAlign: 'center',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: 'modal',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '3',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '2xl', mb: '2' })}>{icon}</div>
|
||||
<h3 className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'gray.900', mb: '1' })}>
|
||||
{title}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
lineHeight: 'relaxed',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: 'xs', color: 'gray.600', lineHeight: 'relaxed' })}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatItem({ number, label }: { number: string; label: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className={css({ fontSize: '3xl', fontWeight: 'bold', mb: '1' })}>{number}</div>
|
||||
<div className={css({ fontSize: 'sm', opacity: 0.9 })}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
563
apps/web/src/arcade-games/card-sorting/Provider.tsx
Normal file
563
apps/web/src/arcade-games/card-sorting/Provider.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useCallback, useMemo, createContext, useContext, useState } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { buildPlayerMetadata as buildPlayerMetadataUtil } from '@/lib/arcade/player-ownership.client'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { generateRandomCards, shuffleCards } from './utils/cardGeneration'
|
||||
import type { CardSortingState, CardSortingMove, SortingCard, CardSortingConfig } from './types'
|
||||
|
||||
// Context value interface
|
||||
interface CardSortingContextValue {
|
||||
state: CardSortingState
|
||||
// Actions
|
||||
startGame: () => void
|
||||
placeCard: (cardId: string, position: number) => void
|
||||
insertCard: (cardId: string, insertPosition: number) => void
|
||||
removeCard: (position: number) => void
|
||||
checkSolution: () => void
|
||||
revealNumbers: () => void
|
||||
goToSetup: () => void
|
||||
resumeGame: () => void
|
||||
setConfig: (field: 'cardCount' | 'showNumbers' | 'timeLimit', value: unknown) => void
|
||||
exitSession: () => void
|
||||
// Computed
|
||||
canCheckSolution: boolean
|
||||
placedCount: number
|
||||
elapsedTime: number
|
||||
hasConfigChanged: boolean
|
||||
canResumeGame: boolean
|
||||
// UI state
|
||||
selectedCardId: string | null
|
||||
selectCard: (cardId: string | null) => void
|
||||
}
|
||||
|
||||
// Create context
|
||||
const CardSortingContext = createContext<CardSortingContextValue | null>(null)
|
||||
|
||||
// Initial state matching validator's getInitialState
|
||||
const createInitialState = (config: Partial<CardSortingConfig>): CardSortingState => ({
|
||||
cardCount: config.cardCount ?? 8,
|
||||
showNumbers: config.showNumbers ?? true,
|
||||
timeLimit: config.timeLimit ?? null,
|
||||
gamePhase: 'setup',
|
||||
playerId: '',
|
||||
playerMetadata: {
|
||||
id: '',
|
||||
name: '',
|
||||
emoji: '',
|
||||
userId: '',
|
||||
},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
selectedCards: [],
|
||||
correctOrder: [],
|
||||
availableCards: [],
|
||||
placedCards: new Array(config.cardCount ?? 8).fill(null),
|
||||
selectedCardId: null,
|
||||
numbersRevealed: false,
|
||||
scoreBreakdown: null,
|
||||
})
|
||||
|
||||
/**
|
||||
* Optimistic move application (client-side prediction)
|
||||
*/
|
||||
function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardSortingState {
|
||||
const typedMove = move as CardSortingMove
|
||||
|
||||
switch (typedMove.type) {
|
||||
case 'START_GAME': {
|
||||
const selectedCards = typedMove.data.selectedCards as SortingCard[]
|
||||
const correctOrder = [...selectedCards].sort((a, b) => a.number - b.number)
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
playerId: typedMove.playerId,
|
||||
playerMetadata: typedMove.data.playerMetadata,
|
||||
gameStartTime: Date.now(),
|
||||
selectedCards,
|
||||
correctOrder,
|
||||
availableCards: shuffleCards(selectedCards),
|
||||
placedCards: new Array(state.cardCount).fill(null),
|
||||
numbersRevealed: false,
|
||||
// Save original config for pause/resume
|
||||
originalConfig: {
|
||||
cardCount: state.cardCount,
|
||||
showNumbers: state.showNumbers,
|
||||
timeLimit: state.timeLimit,
|
||||
},
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'PLACE_CARD': {
|
||||
const { cardId, position } = typedMove.data
|
||||
const card = state.availableCards.find((c) => c.id === cardId)
|
||||
if (!card) return state
|
||||
|
||||
// Simple replacement (can leave gaps)
|
||||
const newPlaced = [...state.placedCards]
|
||||
const replacedCard = newPlaced[position]
|
||||
newPlaced[position] = card
|
||||
|
||||
// Remove card from available
|
||||
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
|
||||
|
||||
// If slot was occupied, add replaced card back to available
|
||||
if (replacedCard) {
|
||||
newAvailable = [...newAvailable, replacedCard]
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: newPlaced,
|
||||
}
|
||||
}
|
||||
|
||||
case 'INSERT_CARD': {
|
||||
const { cardId, insertPosition } = typedMove.data
|
||||
const card = state.availableCards.find((c) => c.id === cardId)
|
||||
if (!card) return state
|
||||
|
||||
// Insert with shift and compact (no gaps)
|
||||
const newPlaced = new Array(state.cardCount).fill(null)
|
||||
|
||||
// Copy existing cards, shifting those at/after insert position
|
||||
for (let i = 0; i < state.placedCards.length; i++) {
|
||||
if (state.placedCards[i] !== null) {
|
||||
if (i < insertPosition) {
|
||||
newPlaced[i] = state.placedCards[i]
|
||||
} else {
|
||||
// Cards at or after insert position shift right by 1
|
||||
// Card will be collected during compaction if it falls off the end
|
||||
newPlaced[i + 1] = state.placedCards[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Place new card at insert position
|
||||
newPlaced[insertPosition] = card
|
||||
|
||||
// Compact to remove gaps
|
||||
const compacted: SortingCard[] = []
|
||||
for (const c of newPlaced) {
|
||||
if (c !== null) {
|
||||
compacted.push(c)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill final array (no gaps)
|
||||
const finalPlaced = new Array(state.cardCount).fill(null)
|
||||
for (let i = 0; i < Math.min(compacted.length, state.cardCount); i++) {
|
||||
finalPlaced[i] = compacted[i]
|
||||
}
|
||||
|
||||
// Remove from available
|
||||
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
|
||||
|
||||
// Any excess cards go back to available
|
||||
if (compacted.length > state.cardCount) {
|
||||
const excess = compacted.slice(state.cardCount)
|
||||
newAvailable = [...newAvailable, ...excess]
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: finalPlaced,
|
||||
}
|
||||
}
|
||||
|
||||
case 'REMOVE_CARD': {
|
||||
const { position } = typedMove.data
|
||||
const card = state.placedCards[position]
|
||||
if (!card) return state
|
||||
|
||||
const newPlaced = [...state.placedCards]
|
||||
newPlaced[position] = null
|
||||
const newAvailable = [...state.availableCards, card]
|
||||
|
||||
return {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: newPlaced,
|
||||
}
|
||||
}
|
||||
|
||||
case 'REVEAL_NUMBERS': {
|
||||
return {
|
||||
...state,
|
||||
numbersRevealed: true,
|
||||
}
|
||||
}
|
||||
|
||||
case 'CHECK_SOLUTION': {
|
||||
// Server will calculate score - just transition to results optimistically
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
gameEndTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
case 'GO_TO_SETUP': {
|
||||
const isPausingGame = state.gamePhase === 'playing'
|
||||
|
||||
return {
|
||||
...createInitialState({
|
||||
cardCount: state.cardCount,
|
||||
showNumbers: state.showNumbers,
|
||||
timeLimit: state.timeLimit,
|
||||
}),
|
||||
// Save paused state if coming from active game
|
||||
originalConfig: state.originalConfig,
|
||||
pausedGamePhase: isPausingGame ? 'playing' : undefined,
|
||||
pausedGameState: isPausingGame
|
||||
? {
|
||||
selectedCards: state.selectedCards,
|
||||
availableCards: state.availableCards,
|
||||
placedCards: state.placedCards,
|
||||
gameStartTime: state.gameStartTime || Date.now(),
|
||||
numbersRevealed: state.numbersRevealed,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
const { field, value } = typedMove.data
|
||||
const clearPausedGame = !!state.pausedGamePhase
|
||||
|
||||
return {
|
||||
...state,
|
||||
[field]: value,
|
||||
// Update placedCards array size if cardCount changes
|
||||
...(field === 'cardCount' ? { placedCards: new Array(value as number).fill(null) } : {}),
|
||||
// Clear paused game if config changed
|
||||
...(clearPausedGame
|
||||
? {
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
originalConfig: undefined,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
case 'RESUME_GAME': {
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return state
|
||||
}
|
||||
|
||||
const correctOrder = [...state.pausedGameState.selectedCards].sort(
|
||||
(a, b) => a.number - b.number
|
||||
)
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: state.pausedGamePhase,
|
||||
selectedCards: state.pausedGameState.selectedCards,
|
||||
correctOrder,
|
||||
availableCards: state.pausedGameState.availableCards,
|
||||
placedCards: state.pausedGameState.placedCards,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
numbersRevealed: state.pausedGameState.numbersRevealed,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Card Sorting Provider - Single Player Pattern Recognition Game
|
||||
*/
|
||||
export function CardSortingProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Local UI state (not synced to server)
|
||||
const [selectedCardId, setSelectedCardId] = useState<string | null>(null)
|
||||
|
||||
// Get local player (single player game)
|
||||
const localPlayerId = useMemo(() => {
|
||||
return Array.from(activePlayers).find((id) => {
|
||||
const player = players.get(id)
|
||||
return player?.isLocal !== false
|
||||
})
|
||||
}, [activePlayers, players])
|
||||
|
||||
// Merge saved config from room data
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null
|
||||
const savedConfig = gameConfig?.['card-sorting'] as Partial<CardSortingConfig> | undefined
|
||||
|
||||
return createInitialState(savedConfig || {})
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState: mergedInitialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Build player metadata for the single local player
|
||||
const buildPlayerMetadata = useCallback(() => {
|
||||
if (!localPlayerId) {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
emoji: '',
|
||||
userId: '',
|
||||
}
|
||||
}
|
||||
|
||||
const playerOwnership: Record<string, string> = {}
|
||||
if (viewerId) {
|
||||
playerOwnership[localPlayerId] = viewerId
|
||||
}
|
||||
|
||||
const metadata = buildPlayerMetadataUtil(
|
||||
[localPlayerId],
|
||||
playerOwnership,
|
||||
players,
|
||||
viewerId ?? undefined
|
||||
)
|
||||
|
||||
return metadata[localPlayerId] || { id: '', name: '', emoji: '', userId: '' }
|
||||
}, [localPlayerId, players, viewerId])
|
||||
|
||||
// Computed values
|
||||
const canCheckSolution = useMemo(
|
||||
() => state.placedCards.every((c) => c !== null),
|
||||
[state.placedCards]
|
||||
)
|
||||
|
||||
const placedCount = useMemo(
|
||||
() => state.placedCards.filter((c) => c !== null).length,
|
||||
[state.placedCards]
|
||||
)
|
||||
|
||||
const elapsedTime = useMemo(() => {
|
||||
if (!state.gameStartTime) return 0
|
||||
const now = state.gameEndTime || Date.now()
|
||||
return Math.floor((now - state.gameStartTime) / 1000)
|
||||
}, [state.gameStartTime, state.gameEndTime])
|
||||
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
if (!state.originalConfig) return false
|
||||
return (
|
||||
state.cardCount !== state.originalConfig.cardCount ||
|
||||
state.showNumbers !== state.originalConfig.showNumbers ||
|
||||
state.timeLimit !== state.originalConfig.timeLimit
|
||||
)
|
||||
}, [state.cardCount, state.showNumbers, state.timeLimit, state.originalConfig])
|
||||
|
||||
const canResumeGame = useMemo(() => {
|
||||
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
|
||||
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
if (!localPlayerId) {
|
||||
console.error('[CardSortingProvider] No local player available')
|
||||
return
|
||||
}
|
||||
|
||||
const playerMetadata = buildPlayerMetadata()
|
||||
const selectedCards = generateRandomCards(state.cardCount)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: {
|
||||
playerMetadata,
|
||||
selectedCards,
|
||||
},
|
||||
})
|
||||
}, [localPlayerId, state.cardCount, buildPlayerMetadata, sendMove, viewerId])
|
||||
|
||||
const placeCard = useCallback(
|
||||
(cardId: string, position: number) => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'PLACE_CARD',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { cardId, position },
|
||||
})
|
||||
|
||||
// Clear selection
|
||||
setSelectedCardId(null)
|
||||
},
|
||||
[localPlayerId, sendMove, viewerId]
|
||||
)
|
||||
|
||||
const insertCard = useCallback(
|
||||
(cardId: string, insertPosition: number) => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'INSERT_CARD',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { cardId, insertPosition },
|
||||
})
|
||||
|
||||
// Clear selection
|
||||
setSelectedCardId(null)
|
||||
},
|
||||
[localPlayerId, sendMove, viewerId]
|
||||
)
|
||||
|
||||
const removeCard = useCallback(
|
||||
(position: number) => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'REMOVE_CARD',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { position },
|
||||
})
|
||||
},
|
||||
[localPlayerId, sendMove, viewerId]
|
||||
)
|
||||
|
||||
const checkSolution = useCallback(() => {
|
||||
if (!localPlayerId) return
|
||||
if (!canCheckSolution) {
|
||||
console.warn('[CardSortingProvider] Cannot check - not all cards placed')
|
||||
return
|
||||
}
|
||||
|
||||
sendMove({
|
||||
type: 'CHECK_SOLUTION',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [localPlayerId, canCheckSolution, sendMove, viewerId])
|
||||
|
||||
const revealNumbers = useCallback(() => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'REVEAL_NUMBERS',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [localPlayerId, sendMove, viewerId])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [localPlayerId, sendMove, viewerId])
|
||||
|
||||
const resumeGame = useCallback(() => {
|
||||
if (!localPlayerId || !canResumeGame) {
|
||||
console.warn('[CardSortingProvider] Cannot resume - no paused game or config changed')
|
||||
return
|
||||
}
|
||||
|
||||
sendMove({
|
||||
type: 'RESUME_GAME',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [localPlayerId, canResumeGame, sendMove, viewerId])
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'cardCount' | 'showNumbers' | 'timeLimit', value: unknown) => {
|
||||
if (!localPlayerId) return
|
||||
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Persist to database
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
|
||||
const currentCardSortingConfig =
|
||||
(currentGameConfig['card-sorting'] as Record<string, unknown>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
'card-sorting': {
|
||||
...currentCardSortingConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
}
|
||||
},
|
||||
[localPlayerId, sendMove, viewerId, roomData, updateGameConfig]
|
||||
)
|
||||
|
||||
const contextValue: CardSortingContextValue = {
|
||||
state,
|
||||
// Actions
|
||||
startGame,
|
||||
placeCard,
|
||||
insertCard,
|
||||
removeCard,
|
||||
checkSolution,
|
||||
revealNumbers,
|
||||
goToSetup,
|
||||
resumeGame,
|
||||
setConfig,
|
||||
exitSession,
|
||||
// Computed
|
||||
canCheckSolution,
|
||||
placedCount,
|
||||
elapsedTime,
|
||||
hasConfigChanged,
|
||||
canResumeGame,
|
||||
// UI state
|
||||
selectedCardId,
|
||||
selectCard: setSelectedCardId,
|
||||
}
|
||||
|
||||
return <CardSortingContext.Provider value={contextValue}>{children}</CardSortingContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access Card Sorting context
|
||||
*/
|
||||
export function useCardSorting() {
|
||||
const context = useContext(CardSortingContext)
|
||||
if (!context) {
|
||||
throw new Error('useCardSorting must be used within CardSortingProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
466
apps/web/src/arcade-games/card-sorting/Validator.ts
Normal file
466
apps/web/src/arcade-games/card-sorting/Validator.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import type {
|
||||
GameValidator,
|
||||
ValidationContext,
|
||||
ValidationResult,
|
||||
} from '@/lib/arcade/validation/types'
|
||||
import type { CardSortingConfig, CardSortingMove, CardSortingState } from './types'
|
||||
import { calculateScore } from './utils/scoringAlgorithm'
|
||||
import { placeCardAtPosition, insertCardAtPosition, removeCardAtPosition } from './utils/validation'
|
||||
|
||||
export class CardSortingValidator implements GameValidator<CardSortingState, CardSortingMove> {
|
||||
validateMove(
|
||||
state: CardSortingState,
|
||||
move: CardSortingMove,
|
||||
context: ValidationContext
|
||||
): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data, move.playerId)
|
||||
case 'PLACE_CARD':
|
||||
return this.validatePlaceCard(state, move.data.cardId, move.data.position)
|
||||
case 'INSERT_CARD':
|
||||
return this.validateInsertCard(state, move.data.cardId, move.data.insertPosition)
|
||||
case 'REMOVE_CARD':
|
||||
return this.validateRemoveCard(state, move.data.position)
|
||||
case 'REVEAL_NUMBERS':
|
||||
return this.validateRevealNumbers(state)
|
||||
case 'CHECK_SOLUTION':
|
||||
return this.validateCheckSolution(state)
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state)
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
case 'RESUME_GAME':
|
||||
return this.validateResumeGame(state)
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unknown move type: ${(move as CardSortingMove).type}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGame(
|
||||
state: CardSortingState,
|
||||
data: { playerMetadata: unknown; selectedCards: unknown },
|
||||
playerId: string
|
||||
): ValidationResult {
|
||||
// Must be in setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only start game from setup phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate selectedCards
|
||||
if (!Array.isArray(data.selectedCards)) {
|
||||
return { valid: false, error: 'selectedCards must be an array' }
|
||||
}
|
||||
|
||||
if (data.selectedCards.length !== state.cardCount) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Must provide exactly ${state.cardCount} cards`,
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCards = data.selectedCards as unknown[]
|
||||
|
||||
// Create correct order (sorted)
|
||||
const correctOrder = [...selectedCards].sort((a: unknown, b: unknown) => {
|
||||
const cardA = a as { number: number }
|
||||
const cardB = b as { number: number }
|
||||
return cardA.number - cardB.number
|
||||
})
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
playerId,
|
||||
playerMetadata: data.playerMetadata,
|
||||
gameStartTime: Date.now(),
|
||||
selectedCards: selectedCards as typeof state.selectedCards,
|
||||
correctOrder: correctOrder as typeof state.correctOrder,
|
||||
availableCards: selectedCards as typeof state.availableCards,
|
||||
placedCards: new Array(state.cardCount).fill(null),
|
||||
numbersRevealed: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validatePlaceCard(
|
||||
state: CardSortingState,
|
||||
cardId: string,
|
||||
position: number
|
||||
): ValidationResult {
|
||||
// Must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Can only place cards during playing phase' }
|
||||
}
|
||||
|
||||
// Card must exist in availableCards
|
||||
const card = state.availableCards.find((c) => c.id === cardId)
|
||||
if (!card) {
|
||||
return { valid: false, error: 'Card not found in available cards' }
|
||||
}
|
||||
|
||||
// Position must be valid (0 to cardCount-1)
|
||||
if (position < 0 || position >= state.cardCount) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid position: must be between 0 and ${state.cardCount - 1}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Place the card using utility function (simple replacement)
|
||||
const { placedCards: newPlaced, replacedCard } = placeCardAtPosition(
|
||||
state.placedCards,
|
||||
card,
|
||||
position
|
||||
)
|
||||
|
||||
// Remove card from available
|
||||
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
|
||||
|
||||
// If slot was occupied, add replaced card back to available
|
||||
if (replacedCard) {
|
||||
newAvailable = [...newAvailable, replacedCard]
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: newPlaced,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateInsertCard(
|
||||
state: CardSortingState,
|
||||
cardId: string,
|
||||
insertPosition: number
|
||||
): ValidationResult {
|
||||
// Must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Can only insert cards during playing phase' }
|
||||
}
|
||||
|
||||
// Card must exist in availableCards
|
||||
const card = state.availableCards.find((c) => c.id === cardId)
|
||||
if (!card) {
|
||||
return { valid: false, error: 'Card not found in available cards' }
|
||||
}
|
||||
|
||||
// Position must be valid (0 to cardCount, inclusive - can insert after last position)
|
||||
if (insertPosition < 0 || insertPosition > state.cardCount) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid insert position: must be between 0 and ${state.cardCount}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the card using utility function (with shift and compact)
|
||||
const { placedCards: newPlaced, excessCards } = insertCardAtPosition(
|
||||
state.placedCards,
|
||||
card,
|
||||
insertPosition,
|
||||
state.cardCount
|
||||
)
|
||||
|
||||
// Remove card from available
|
||||
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
|
||||
|
||||
// Add any excess cards back to available (shouldn't normally happen)
|
||||
if (excessCards.length > 0) {
|
||||
newAvailable = [...newAvailable, ...excessCards]
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: newPlaced,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateRemoveCard(state: CardSortingState, position: number): ValidationResult {
|
||||
// Must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only remove cards during playing phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Position must be valid
|
||||
if (position < 0 || position >= state.cardCount) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid position: must be between 0 and ${state.cardCount - 1}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Card must exist at position
|
||||
if (state.placedCards[position] === null) {
|
||||
return { valid: false, error: 'No card at this position' }
|
||||
}
|
||||
|
||||
// Remove the card using utility function
|
||||
const { placedCards: newPlaced, removedCard } = removeCardAtPosition(
|
||||
state.placedCards,
|
||||
position
|
||||
)
|
||||
|
||||
if (!removedCard) {
|
||||
return { valid: false, error: 'Failed to remove card' }
|
||||
}
|
||||
|
||||
// Add back to available
|
||||
const newAvailable = [...state.availableCards, removedCard]
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
availableCards: newAvailable,
|
||||
placedCards: newPlaced,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateRevealNumbers(state: CardSortingState): ValidationResult {
|
||||
// Must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only reveal numbers during playing phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Must be enabled in config
|
||||
if (!state.showNumbers) {
|
||||
return { valid: false, error: 'Reveal numbers is not enabled' }
|
||||
}
|
||||
|
||||
// Already revealed
|
||||
if (state.numbersRevealed) {
|
||||
return { valid: false, error: 'Numbers already revealed' }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
numbersRevealed: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateCheckSolution(state: CardSortingState): ValidationResult {
|
||||
// Must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only check solution during playing phase',
|
||||
}
|
||||
}
|
||||
|
||||
// All slots must be filled
|
||||
if (state.placedCards.some((c) => c === null)) {
|
||||
return { valid: false, error: 'Must place all cards before checking' }
|
||||
}
|
||||
|
||||
// Calculate score using scoring algorithms
|
||||
const userSequence = state.placedCards.map((c) => c!.number)
|
||||
const correctSequence = state.correctOrder.map((c) => c.number)
|
||||
|
||||
const scoreBreakdown = calculateScore(
|
||||
userSequence,
|
||||
correctSequence,
|
||||
state.gameStartTime || Date.now(),
|
||||
state.numbersRevealed
|
||||
)
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
gameEndTime: Date.now(),
|
||||
scoreBreakdown,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateGoToSetup(state: CardSortingState): ValidationResult {
|
||||
// Save current game state for resume (if in playing phase)
|
||||
if (state.gamePhase === 'playing') {
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...this.getInitialState({
|
||||
cardCount: state.cardCount,
|
||||
showNumbers: state.showNumbers,
|
||||
timeLimit: state.timeLimit,
|
||||
}),
|
||||
originalConfig: {
|
||||
cardCount: state.cardCount,
|
||||
showNumbers: state.showNumbers,
|
||||
timeLimit: state.timeLimit,
|
||||
},
|
||||
pausedGamePhase: 'playing',
|
||||
pausedGameState: {
|
||||
selectedCards: state.selectedCards,
|
||||
availableCards: state.availableCards,
|
||||
placedCards: state.placedCards,
|
||||
gameStartTime: state.gameStartTime || Date.now(),
|
||||
numbersRevealed: state.numbersRevealed,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Just go to setup
|
||||
return {
|
||||
valid: true,
|
||||
newState: this.getInitialState({
|
||||
cardCount: state.cardCount,
|
||||
showNumbers: state.showNumbers,
|
||||
timeLimit: state.timeLimit,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
private validateSetConfig(
|
||||
state: CardSortingState,
|
||||
field: string,
|
||||
value: unknown
|
||||
): ValidationResult {
|
||||
// Must be in setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only change config in setup phase' }
|
||||
}
|
||||
|
||||
// Validate field and value
|
||||
switch (field) {
|
||||
case 'cardCount':
|
||||
if (![5, 8, 12, 15].includes(value as number)) {
|
||||
return { valid: false, error: 'cardCount must be 5, 8, 12, or 15' }
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
cardCount: value as 5 | 8 | 12 | 15,
|
||||
placedCards: new Array(value as number).fill(null),
|
||||
// Clear pause state if config changed
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
case 'showNumbers':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: 'showNumbers must be a boolean' }
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
showNumbers: value,
|
||||
// Clear pause state if config changed
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
case 'timeLimit':
|
||||
if (value !== null && (typeof value !== 'number' || value < 30)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'timeLimit must be null or a number >= 30',
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
timeLimit: value as number | null,
|
||||
// Clear pause state if config changed
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
default:
|
||||
return { valid: false, error: `Unknown config field: ${field}` }
|
||||
}
|
||||
}
|
||||
|
||||
private validateResumeGame(state: CardSortingState): ValidationResult {
|
||||
// Must be in setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only resume from setup phase' }
|
||||
}
|
||||
|
||||
// Must have paused game state
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return { valid: false, error: 'No paused game to resume' }
|
||||
}
|
||||
|
||||
// Restore paused state
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: state.pausedGamePhase,
|
||||
selectedCards: state.pausedGameState.selectedCards,
|
||||
correctOrder: [...state.pausedGameState.selectedCards].sort((a, b) => a.number - b.number),
|
||||
availableCards: state.pausedGameState.availableCards,
|
||||
placedCards: state.pausedGameState.placedCards,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
numbersRevealed: state.pausedGameState.numbersRevealed,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
isGameComplete(state: CardSortingState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
getInitialState(config: CardSortingConfig): CardSortingState {
|
||||
return {
|
||||
cardCount: config.cardCount,
|
||||
showNumbers: config.showNumbers,
|
||||
timeLimit: config.timeLimit,
|
||||
gamePhase: 'setup',
|
||||
playerId: '',
|
||||
playerMetadata: {
|
||||
id: '',
|
||||
name: '',
|
||||
emoji: '',
|
||||
userId: '',
|
||||
},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
selectedCards: [],
|
||||
correctOrder: [],
|
||||
availableCards: [],
|
||||
placedCards: new Array(config.cardCount).fill(null),
|
||||
selectedCardId: null,
|
||||
numbersRevealed: false,
|
||||
scoreBreakdown: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cardSortingValidator = new CardSortingValidator()
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { StandardGameLayout } from '@/components/StandardGameLayout'
|
||||
import { useFullscreen } from '@/contexts/FullscreenContext'
|
||||
import { useCardSorting } from '../Provider'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
import { PlayingPhase } from './PlayingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, startGame, goToSetup } = useCardSorting()
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const gameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Register fullscreen element
|
||||
if (gameRef.current) {
|
||||
setFullscreenElement(gameRef.current)
|
||||
}
|
||||
}, [setFullscreenElement])
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Card Sorting"
|
||||
navEmoji="🔢"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
onExitSession={() => {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onSetup={
|
||||
goToSetup
|
||||
? () => {
|
||||
goToSetup()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onNewGame={() => {
|
||||
startGame()
|
||||
}}
|
||||
>
|
||||
<StandardGameLayout>
|
||||
<div
|
||||
ref={gameRef}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: { base: '12px', sm: '16px', md: '20px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
<main
|
||||
className={css({
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: { base: '12px', md: '20px' },
|
||||
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</main>
|
||||
</div>
|
||||
</StandardGameLayout>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useCardSorting } from '../Provider'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function PlayingPhase() {
|
||||
const {
|
||||
state,
|
||||
selectedCardId,
|
||||
selectCard,
|
||||
placeCard,
|
||||
insertCard,
|
||||
removeCard,
|
||||
checkSolution,
|
||||
revealNumbers,
|
||||
goToSetup,
|
||||
canCheckSolution,
|
||||
placedCount,
|
||||
elapsedTime,
|
||||
} = useCardSorting()
|
||||
|
||||
// Status message (mimics Python updateSortingStatus)
|
||||
const [statusMessage, setStatusMessage] = useState(
|
||||
`Arrange the ${state.cardCount} cards in ascending order (smallest to largest)`
|
||||
)
|
||||
|
||||
// Update status message based on state
|
||||
useEffect(() => {
|
||||
if (state.gamePhase !== 'playing') return
|
||||
|
||||
if (selectedCardId) {
|
||||
const card = state.availableCards.find((c) => c.id === selectedCardId)
|
||||
if (card) {
|
||||
setStatusMessage(
|
||||
`Selected card with value ${card.number}. Click a position or + button to place it.`
|
||||
)
|
||||
}
|
||||
} else if (placedCount === state.cardCount) {
|
||||
setStatusMessage('All cards placed! Click "Check My Solution" to see how you did.')
|
||||
} else {
|
||||
setStatusMessage(
|
||||
`${placedCount}/${state.cardCount} cards placed. Select ${placedCount === 0 ? 'a' : 'another'} card to continue.`
|
||||
)
|
||||
}
|
||||
}, [selectedCardId, placedCount, state.cardCount, state.gamePhase, state.availableCards])
|
||||
|
||||
// Format time display
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Calculate gradient for position slots (darker = smaller, lighter = larger)
|
||||
const getSlotGradient = (position: number, total: number) => {
|
||||
const intensity = position / (total - 1 || 1)
|
||||
const lightness = 30 + intensity * 45 // 30% to 75%
|
||||
return {
|
||||
background: `hsl(220, 8%, ${lightness}%)`,
|
||||
color: lightness > 60 ? '#2c3e50' : '#ffffff',
|
||||
borderColor: lightness > 60 ? '#2c5f76' : 'rgba(255,255,255,0.4)',
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardClick = (cardId: string) => {
|
||||
if (selectedCardId === cardId) {
|
||||
selectCard(null) // Deselect
|
||||
} else {
|
||||
selectCard(cardId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSlotClick = (position: number) => {
|
||||
if (!selectedCardId) {
|
||||
// No card selected - if slot has a card, move it back and auto-select
|
||||
if (state.placedCards[position]) {
|
||||
const cardToMove = state.placedCards[position]!
|
||||
removeCard(position)
|
||||
// Auto-select the card that was moved back
|
||||
selectCard(cardToMove.id)
|
||||
} else {
|
||||
setStatusMessage('Select a card first, or click a placed card to move it back.')
|
||||
}
|
||||
} else {
|
||||
// Card is selected - place it (replaces existing card if any)
|
||||
placeCard(selectedCardId, position)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInsertClick = (insertPosition: number) => {
|
||||
if (!selectedCardId) {
|
||||
setStatusMessage('Please select a card first, then click where to insert it.')
|
||||
return
|
||||
}
|
||||
insertCard(selectedCardId, insertPosition)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
height: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Status message */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '0.75rem 1rem',
|
||||
background: '#e3f2fd',
|
||||
borderLeft: '4px solid #2c5f76',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '500',
|
||||
color: '#2c3e50',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{statusMessage}
|
||||
</div>
|
||||
|
||||
{/* Header with timer and actions */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
background: 'teal.50',
|
||||
borderRadius: '0.5rem',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', gap: '2rem' })}>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
fontWeight: '600',
|
||||
})}
|
||||
>
|
||||
Time
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'teal.700',
|
||||
})}
|
||||
>
|
||||
{formatTime(elapsedTime)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
fontWeight: '600',
|
||||
})}
|
||||
>
|
||||
Progress
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'teal.700',
|
||||
})}
|
||||
>
|
||||
{placedCount}/{state.cardCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({ display: 'flex', gap: '0.5rem' })}>
|
||||
{state.showNumbers && !state.numbersRevealed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={revealNumbers}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'orange.500',
|
||||
color: 'white',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
background: 'orange.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Reveal Numbers
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkSolution}
|
||||
disabled={!canCheckSolution}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: canCheckSolution ? 'teal.600' : 'gray.300',
|
||||
color: 'white',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: canCheckSolution ? 'pointer' : 'not-allowed',
|
||||
opacity: canCheckSolution ? 1 : 0.6,
|
||||
_hover: {
|
||||
background: canCheckSolution ? 'teal.700' : 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Check Solution
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSetup}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'gray.600',
|
||||
color: 'white',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
background: 'gray.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
End Game
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main game area */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '2rem',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{/* Available cards */}
|
||||
<div className={css({ flex: 1, minWidth: '200px' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Available Cards
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px',
|
||||
justifyContent: 'center',
|
||||
padding: '15px',
|
||||
background: 'rgba(255,255,255,0.5)',
|
||||
borderRadius: '8px',
|
||||
minHeight: '120px',
|
||||
border: '2px dashed #2c5f76',
|
||||
})}
|
||||
>
|
||||
{state.availableCards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
onClick={() => handleCardClick(card.id)}
|
||||
className={css({
|
||||
width: '90px',
|
||||
height: '90px',
|
||||
padding: '8px',
|
||||
border: '2px solid',
|
||||
borderColor: selectedCardId === card.id ? '#1976d2' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
background: selectedCardId === card.id ? '#e3f2fd' : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.1)',
|
||||
_hover: {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0 8px 16px rgba(0,0,0,0.2)',
|
||||
borderColor: '#2c5f76',
|
||||
},
|
||||
})}
|
||||
style={
|
||||
selectedCardId === card.id
|
||||
? {
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: '0 6px 20px rgba(25, 118, 210, 0.3)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: card.svgContent }}
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'& svg': {
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{state.numbersRevealed && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '5px',
|
||||
right: '5px',
|
||||
background: '#ffc107',
|
||||
color: '#333',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position slots with insert buttons */}
|
||||
<div className={css({ flex: 2, minWidth: '300px' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Sort Positions (Smallest → Largest)
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '15px',
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
borderRadius: '8px',
|
||||
border: '2px dashed #2c5f76',
|
||||
})}
|
||||
>
|
||||
{/* Insert button before first position */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInsertClick(0)}
|
||||
disabled={!selectedCardId}
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '50px',
|
||||
background: selectedCardId ? '#1976d2' : '#2c5f76',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '20px',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
cursor: selectedCardId ? 'pointer' : 'default',
|
||||
opacity: selectedCardId ? 1 : 0.3,
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
_hover: {
|
||||
opacity: 1,
|
||||
background: '#1976d2',
|
||||
transform: selectedCardId ? 'scale(1.1)' : 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
||||
{/* Render each position slot followed by an insert button */}
|
||||
{state.placedCards.map((card, index) => {
|
||||
const gradientStyle = getSlotGradient(index, state.cardCount)
|
||||
const isEmpty = card === null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Position slot */}
|
||||
<div
|
||||
key={`slot-${index}`}
|
||||
onClick={() => handleSlotClick(index)}
|
||||
className={css({
|
||||
width: '90px',
|
||||
height: '110px',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.25rem',
|
||||
position: 'relative',
|
||||
_hover: {
|
||||
transform: selectedCardId && isEmpty ? 'scale(1.05)' : 'none',
|
||||
boxShadow:
|
||||
selectedCardId && isEmpty ? '0 4px 12px rgba(0,0,0,0.15)' : 'none',
|
||||
},
|
||||
})}
|
||||
style={
|
||||
isEmpty
|
||||
? {
|
||||
...gradientStyle,
|
||||
// Active state: add slight glow when card is selected
|
||||
boxShadow: selectedCardId
|
||||
? '0 0 0 2px #1976d2, 0 2px 8px rgba(25, 118, 210, 0.3)'
|
||||
: 'none',
|
||||
}
|
||||
: {
|
||||
background: '#fff',
|
||||
color: '#333',
|
||||
borderColor: '#2c5f76',
|
||||
}
|
||||
}
|
||||
>
|
||||
{card ? (
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: card.svgContent,
|
||||
}}
|
||||
className={css({
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'& svg': {
|
||||
maxWidth: '70px',
|
||||
maxHeight: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
opacity: 0.7,
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
← Click to move back
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
style={{ color: gradientStyle.color }}
|
||||
>
|
||||
{index === 0 ? 'Smallest' : index === state.cardCount - 1 ? 'Largest' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Insert button after this position */}
|
||||
<button
|
||||
key={`insert-${index + 1}`}
|
||||
type="button"
|
||||
onClick={() => handleInsertClick(index + 1)}
|
||||
disabled={!selectedCardId}
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '50px',
|
||||
background: selectedCardId ? '#1976d2' : '#2c5f76',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '20px',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
cursor: selectedCardId ? 'pointer' : 'default',
|
||||
opacity: selectedCardId ? 1 : 0.3,
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
_hover: {
|
||||
opacity: 1,
|
||||
background: '#1976d2',
|
||||
transform: selectedCardId ? 'scale(1.1)' : 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useCardSorting } from '../Provider'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, startGame, goToSetup, exitSession } = useCardSorting()
|
||||
const { scoreBreakdown } = state
|
||||
|
||||
if (!scoreBreakdown) {
|
||||
return (
|
||||
<div className={css({ textAlign: 'center', padding: '2rem' })}>
|
||||
<p>No score data available</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getMessage = (score: number) => {
|
||||
if (score === 100) return '🎉 Perfect! All cards in correct order!'
|
||||
if (score >= 80) return '👍 Excellent! Very close to perfect!'
|
||||
if (score >= 60) return '👍 Good job! You understand the pattern!'
|
||||
return '💪 Keep practicing! Focus on reading each abacus carefully.'
|
||||
}
|
||||
|
||||
const getEmoji = (score: number) => {
|
||||
if (score === 100) return '🏆'
|
||||
if (score >= 80) return '⭐'
|
||||
if (score >= 60) return '👍'
|
||||
return '📈'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2rem',
|
||||
padding: '1rem',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{/* Score Display */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: '4rem', marginBottom: '0.5rem' })}>
|
||||
{getEmoji(scoreBreakdown.finalScore)}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '0.5rem',
|
||||
color: 'gray.800',
|
||||
})}
|
||||
>
|
||||
Your Score: {scoreBreakdown.finalScore}%
|
||||
</h2>
|
||||
<p className={css({ fontSize: 'lg', color: 'gray.600' })}>
|
||||
{getMessage(scoreBreakdown.finalScore)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Score Breakdown */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
color: 'gray.800',
|
||||
})}
|
||||
>
|
||||
Score Breakdown
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1rem' })}>
|
||||
{/* Exact Position Matches */}
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>
|
||||
Exact Position Matches (30%)
|
||||
</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{scoreBreakdown.exactMatches}/{state.cardCount} cards
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '1.5rem',
|
||||
background: 'gray.200',
|
||||
borderRadius: '9999px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
background: 'teal.500',
|
||||
transition: 'width 0.5s ease',
|
||||
})}
|
||||
style={{ width: `${scoreBreakdown.exactPositionScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relative Order */}
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>
|
||||
Relative Order (50%)
|
||||
</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{scoreBreakdown.lcsLength}/{state.cardCount} in sequence
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '1.5rem',
|
||||
background: 'gray.200',
|
||||
borderRadius: '9999px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
background: 'teal.500',
|
||||
transition: 'width 0.5s ease',
|
||||
})}
|
||||
style={{ width: `${scoreBreakdown.relativeOrderScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization */}
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>Organization (20%)</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{scoreBreakdown.inversions} out-of-order pairs
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '1.5rem',
|
||||
background: 'gray.200',
|
||||
borderRadius: '9999px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
background: 'teal.500',
|
||||
transition: 'width 0.5s ease',
|
||||
})}
|
||||
style={{ width: `${scoreBreakdown.inversionScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Taken */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: '0.5rem',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>Time Taken</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{Math.floor(scoreBreakdown.elapsedTime / 60)}:
|
||||
{(scoreBreakdown.elapsedTime % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{scoreBreakdown.numbersRevealed && (
|
||||
<div
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
background: 'orange.50',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'orange.200',
|
||||
fontSize: 'sm',
|
||||
color: 'orange.700',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
⚠️ Numbers were revealed during play
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
color: 'gray.800',
|
||||
})}
|
||||
>
|
||||
Comparison
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1.5rem' })}>
|
||||
{/* User's Answer */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.5rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Your Answer:
|
||||
</h4>
|
||||
<div className={css({ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' })}>
|
||||
{state.placedCards.map((card, i) => {
|
||||
if (!card) return null
|
||||
const isCorrect = card.number === state.correctOrder[i]?.number
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
border: '2px solid',
|
||||
borderColor: isCorrect ? 'green.500' : 'red.500',
|
||||
borderRadius: '0.375rem',
|
||||
background: isCorrect ? 'green.50' : 'red.50',
|
||||
textAlign: 'center',
|
||||
minWidth: '60px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.600',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
#{i + 1}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
color: isCorrect ? 'green.700' : 'red.700',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
{isCorrect ? (
|
||||
<div className={css({ fontSize: 'xs' })}>✓</div>
|
||||
) : (
|
||||
<div className={css({ fontSize: 'xs' })}>✗</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correct Order */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.5rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Correct Order:
|
||||
</h4>
|
||||
<div className={css({ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' })}>
|
||||
{state.correctOrder.map((card, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'gray.50',
|
||||
textAlign: 'center',
|
||||
minWidth: '60px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.600',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
#{i + 1}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
maxWidth: '400px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'teal.600',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: 'lg',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: 'teal.700',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
New Game (Same Settings)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSetup}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'gray.600',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: 'lg',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: 'gray.700',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Change Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitSession}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'white',
|
||||
color: 'gray.700',
|
||||
fontWeight: '600',
|
||||
fontSize: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: 'gray.400',
|
||||
background: 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Exit to Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
apps/web/src/arcade-games/card-sorting/components/SetupPhase.tsx
Normal file
194
apps/web/src/arcade-games/card-sorting/components/SetupPhase.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useCardSorting } from '../Provider'
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, setConfig, startGame, resumeGame, canResumeGame } = useCardSorting()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2rem',
|
||||
padding: '2rem',
|
||||
})}
|
||||
>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Card Sorting Challenge
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'md', md: 'lg' },
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Arrange abacus cards in order using only visual patterns
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card Count Selection */}
|
||||
<div className={css({ width: '100%', maxWidth: '400px' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.5rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Number of Cards
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '4',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{([5, 8, 12, 15] as const).map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
type="button"
|
||||
onClick={() => setConfig('cardCount', count)}
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '2px solid',
|
||||
borderColor: state.cardCount === count ? 'teal.500' : 'gray.300',
|
||||
background: state.cardCount === count ? 'teal.50' : 'white',
|
||||
color: state.cardCount === count ? 'teal.700' : 'gray.700',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: 'teal.400',
|
||||
background: 'teal.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show Numbers Toggle */}
|
||||
<div className={css({ width: '100%', maxWidth: '400px' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '1rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
background: 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.showNumbers}
|
||||
onChange={(e) => setConfig('showNumbers', e.target.checked)}
|
||||
className={css({
|
||||
width: '1.25rem',
|
||||
height: '1.25rem',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: '600',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Allow "Reveal Numbers" button
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
Show numeric values during gameplay
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
marginTop: '1rem',
|
||||
})}
|
||||
>
|
||||
{canResumeGame && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resumeGame}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'teal.600',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: 'lg',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: 'teal.700',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Resume Game
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: canResumeGame ? 'gray.600' : 'teal.600',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: 'lg',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: canResumeGame ? 'gray.700' : 'teal.700',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{canResumeGame ? 'Start New Game' : 'Start Game'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
apps/web/src/arcade-games/card-sorting/index.ts
Normal file
74
apps/web/src/arcade-games/card-sorting/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Card Sorting Challenge Game Definition
|
||||
*
|
||||
* A single-player pattern recognition game where players arrange abacus cards
|
||||
* in ascending order using only visual patterns (no numbers shown).
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { CardSortingProvider } from './Provider'
|
||||
import type { CardSortingConfig, CardSortingMove, CardSortingState } from './types'
|
||||
import { cardSortingValidator } from './Validator'
|
||||
|
||||
const manifest: GameManifest = {
|
||||
name: 'card-sorting',
|
||||
displayName: 'Card Sorting Challenge',
|
||||
icon: '🔢',
|
||||
description: 'Sort abacus cards using pattern recognition',
|
||||
longDescription:
|
||||
'Challenge your abacus reading skills! Arrange cards in ascending order using only ' +
|
||||
'the visual patterns - no numbers shown. Perfect for practicing number recognition and ' +
|
||||
'developing mental math intuition.',
|
||||
maxPlayers: 1, // Single player only
|
||||
difficulty: 'Intermediate',
|
||||
chips: ['🧠 Pattern Recognition', '🎯 Solo Challenge', '📊 Smart Scoring'],
|
||||
color: 'teal',
|
||||
gradient: 'linear-gradient(135deg, #99f6e4, #5eead4)',
|
||||
borderColor: 'teal.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
const defaultConfig: CardSortingConfig = {
|
||||
cardCount: 8,
|
||||
showNumbers: true,
|
||||
timeLimit: null,
|
||||
}
|
||||
|
||||
// Config validation function
|
||||
function validateCardSortingConfig(config: unknown): config is CardSortingConfig {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const c = config as Record<string, unknown>
|
||||
|
||||
// Validate cardCount
|
||||
if (!('cardCount' in c) || ![5, 8, 12, 15].includes(c.cardCount as number)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate showNumbers
|
||||
if (!('showNumbers' in c) || typeof c.showNumbers !== 'boolean') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate timeLimit
|
||||
if ('timeLimit' in c) {
|
||||
if (c.timeLimit !== null && (typeof c.timeLimit !== 'number' || c.timeLimit < 30)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const cardSortingGame = defineGame<CardSortingConfig, CardSortingState, CardSortingMove>({
|
||||
manifest,
|
||||
Provider: CardSortingProvider,
|
||||
GameComponent,
|
||||
validator: cardSortingValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateCardSortingConfig,
|
||||
})
|
||||
208
apps/web/src/arcade-games/card-sorting/types.ts
Normal file
208
apps/web/src/arcade-games/card-sorting/types.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types'
|
||||
|
||||
// ============================================================================
|
||||
// Player Metadata
|
||||
// ============================================================================
|
||||
|
||||
export interface PlayerMetadata {
|
||||
id: string // Player ID (UUID)
|
||||
name: string
|
||||
emoji: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
export interface CardSortingConfig extends GameConfig {
|
||||
cardCount: 5 | 8 | 12 | 15 // Difficulty (number of cards)
|
||||
showNumbers: boolean // Allow reveal numbers button
|
||||
timeLimit: number | null // Optional time limit (seconds), null = unlimited
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Data Types
|
||||
// ============================================================================
|
||||
|
||||
export type GamePhase = 'setup' | 'playing' | 'results'
|
||||
|
||||
export interface SortingCard {
|
||||
id: string // Unique ID for this card instance
|
||||
number: number // The abacus value (0-99+)
|
||||
svgContent: string // Serialized AbacusReact SVG
|
||||
}
|
||||
|
||||
export interface PlacedCard {
|
||||
card: SortingCard // The card data
|
||||
position: number // Which slot it's in (0-indexed)
|
||||
}
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
finalScore: number // 0-100 weighted average
|
||||
exactMatches: number // Cards in exactly correct position
|
||||
lcsLength: number // Longest common subsequence length
|
||||
inversions: number // Number of out-of-order pairs
|
||||
relativeOrderScore: number // 0-100 based on LCS
|
||||
exactPositionScore: number // 0-100 based on exact matches
|
||||
inversionScore: number // 0-100 based on inversions
|
||||
elapsedTime: number // Seconds taken
|
||||
numbersRevealed: boolean // Whether player used reveal
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Game State
|
||||
// ============================================================================
|
||||
|
||||
export interface CardSortingState extends GameState {
|
||||
// Configuration
|
||||
cardCount: 5 | 8 | 12 | 15
|
||||
showNumbers: boolean
|
||||
timeLimit: number | null
|
||||
|
||||
// Game phase
|
||||
gamePhase: GamePhase
|
||||
|
||||
// Player & timing
|
||||
playerId: string // Single player ID
|
||||
playerMetadata: PlayerMetadata // Player display info
|
||||
gameStartTime: number | null
|
||||
gameEndTime: number | null
|
||||
|
||||
// Cards
|
||||
selectedCards: SortingCard[] // The N cards for this game
|
||||
correctOrder: SortingCard[] // Sorted by number (answer key)
|
||||
availableCards: SortingCard[] // Cards not yet placed
|
||||
placedCards: (SortingCard | null)[] // Array of N slots (null = empty)
|
||||
|
||||
// UI state (client-only, not in server state)
|
||||
selectedCardId: string | null // Currently selected card
|
||||
numbersRevealed: boolean // If player revealed numbers
|
||||
|
||||
// Results
|
||||
scoreBreakdown: ScoreBreakdown | null // Final score details
|
||||
|
||||
// Pause/Resume (standard pattern)
|
||||
originalConfig?: CardSortingConfig
|
||||
pausedGamePhase?: GamePhase
|
||||
pausedGameState?: {
|
||||
selectedCards: SortingCard[]
|
||||
availableCards: SortingCard[]
|
||||
placedCards: (SortingCard | null)[]
|
||||
gameStartTime: number
|
||||
numbersRevealed: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Game Moves
|
||||
// ============================================================================
|
||||
|
||||
export type CardSortingMove =
|
||||
| {
|
||||
type: 'START_GAME'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
playerMetadata: PlayerMetadata
|
||||
selectedCards: SortingCard[] // Pre-selected random cards
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'PLACE_CARD'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
cardId: string // Which card to place
|
||||
position: number // Which slot (0-indexed)
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'INSERT_CARD'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
cardId: string // Which card to insert
|
||||
insertPosition: number // Where to insert (0-indexed, can be 0 to cardCount)
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'REMOVE_CARD'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
position: number // Which slot to remove from
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'REVEAL_NUMBERS'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'CHECK_SOLUTION'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'GO_TO_SETUP'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'SET_CONFIG'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
field: 'cardCount' | 'showNumbers' | 'timeLimit'
|
||||
value: unknown
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'RESUME_GAME'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Props
|
||||
// ============================================================================
|
||||
|
||||
export interface SortingCardProps {
|
||||
card: SortingCard
|
||||
isSelected: boolean
|
||||
isPlaced: boolean
|
||||
isCorrect?: boolean // After checking solution
|
||||
onClick: () => void
|
||||
showNumber: boolean // If revealed
|
||||
}
|
||||
|
||||
export interface PositionSlotProps {
|
||||
position: number
|
||||
card: SortingCard | null
|
||||
isActive: boolean // If slot is clickable
|
||||
isCorrect?: boolean // After checking solution
|
||||
gradientStyle: React.CSSProperties
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface ScoreDisplayProps {
|
||||
breakdown: ScoreBreakdown
|
||||
correctOrder: SortingCard[]
|
||||
userOrder: SortingCard[]
|
||||
onNewGame: () => void
|
||||
onExit: () => void
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import type { SortingCard } from '../types'
|
||||
|
||||
/**
|
||||
* Generate random cards for sorting game
|
||||
* @param count Number of cards to generate
|
||||
* @param minValue Minimum abacus value (default 0)
|
||||
* @param maxValue Maximum abacus value (default 99)
|
||||
*/
|
||||
export function generateRandomCards(count: number, minValue = 0, maxValue = 99): SortingCard[] {
|
||||
// Generate pool of unique random numbers
|
||||
const numbers = new Set<number>()
|
||||
while (numbers.size < count) {
|
||||
const num = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
|
||||
numbers.add(num)
|
||||
}
|
||||
|
||||
// Convert to sorted array (for answer key)
|
||||
const sortedNumbers = Array.from(numbers).sort((a, b) => a - b)
|
||||
|
||||
// Create card objects with SVG content
|
||||
return sortedNumbers.map((number, index) => {
|
||||
// Render AbacusReact to SVG string
|
||||
const svgContent = renderToString(
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns="auto"
|
||||
scaleFactor={1.0}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
/>
|
||||
)
|
||||
|
||||
return {
|
||||
id: `card-${index}-${number}`,
|
||||
number,
|
||||
svgContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle array for random order
|
||||
*/
|
||||
export function shuffleCards(cards: SortingCard[]): SortingCard[] {
|
||||
const shuffled = [...cards]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
return shuffled
|
||||
}
|
||||
100
apps/web/src/arcade-games/card-sorting/utils/scoringAlgorithm.ts
Normal file
100
apps/web/src/arcade-games/card-sorting/utils/scoringAlgorithm.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { ScoreBreakdown } from '../types'
|
||||
|
||||
/**
|
||||
* Calculate Longest Common Subsequence length
|
||||
* Measures how many cards are in correct relative order
|
||||
*/
|
||||
export function longestCommonSubsequence(seq1: number[], seq2: number[]): number {
|
||||
const m = seq1.length
|
||||
const n = seq2.length
|
||||
const dp: number[][] = Array(m + 1)
|
||||
.fill(0)
|
||||
.map(() => Array(n + 1).fill(0))
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (seq1[i - 1] === seq2[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m][n]
|
||||
}
|
||||
|
||||
/**
|
||||
* Count inversions (out-of-order pairs)
|
||||
* Measures how scrambled the sequence is
|
||||
*/
|
||||
export function countInversions(userSeq: number[], correctSeq: number[]): number {
|
||||
// Create mapping from value to correct position
|
||||
const correctPositions: Record<number, number> = {}
|
||||
for (let idx = 0; idx < correctSeq.length; idx++) {
|
||||
correctPositions[correctSeq[idx]] = idx
|
||||
}
|
||||
|
||||
// Convert user sequence to correct-position sequence
|
||||
const userCorrectPositions = userSeq.map((val) => correctPositions[val])
|
||||
|
||||
// Count inversions
|
||||
let inversions = 0
|
||||
for (let i = 0; i < userCorrectPositions.length; i++) {
|
||||
for (let j = i + 1; j < userCorrectPositions.length; j++) {
|
||||
if (userCorrectPositions[i] > userCorrectPositions[j]) {
|
||||
inversions++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inversions
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate comprehensive score breakdown
|
||||
*/
|
||||
export function calculateScore(
|
||||
userSequence: number[],
|
||||
correctSequence: number[],
|
||||
startTime: number,
|
||||
numbersRevealed: boolean
|
||||
): ScoreBreakdown {
|
||||
// LCS-based score (relative order)
|
||||
const lcsLength = longestCommonSubsequence(userSequence, correctSequence)
|
||||
const relativeOrderScore = (lcsLength / correctSequence.length) * 100
|
||||
|
||||
// Exact position matches
|
||||
let exactMatches = 0
|
||||
for (let i = 0; i < userSequence.length; i++) {
|
||||
if (userSequence[i] === correctSequence[i]) {
|
||||
exactMatches++
|
||||
}
|
||||
}
|
||||
const exactPositionScore = (exactMatches / correctSequence.length) * 100
|
||||
|
||||
// Inversion-based score (organization)
|
||||
const inversions = countInversions(userSequence, correctSequence)
|
||||
const maxInversions = (correctSequence.length * (correctSequence.length - 1)) / 2
|
||||
const inversionScore = Math.max(0, ((maxInversions - inversions) / maxInversions) * 100)
|
||||
|
||||
// Weighted final score
|
||||
// - 50% for relative order (LCS)
|
||||
// - 30% for exact positions
|
||||
// - 20% for organization (inversions)
|
||||
const finalScore = Math.round(
|
||||
relativeOrderScore * 0.5 + exactPositionScore * 0.3 + inversionScore * 0.2
|
||||
)
|
||||
|
||||
return {
|
||||
finalScore,
|
||||
exactMatches,
|
||||
lcsLength,
|
||||
inversions,
|
||||
relativeOrderScore: Math.round(relativeOrderScore),
|
||||
exactPositionScore: Math.round(exactPositionScore),
|
||||
inversionScore: Math.round(inversionScore),
|
||||
elapsedTime: Math.floor((Date.now() - startTime) / 1000),
|
||||
numbersRevealed,
|
||||
}
|
||||
}
|
||||
102
apps/web/src/arcade-games/card-sorting/utils/validation.ts
Normal file
102
apps/web/src/arcade-games/card-sorting/utils/validation.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { SortingCard } from '../types'
|
||||
|
||||
/**
|
||||
* Place a card at a specific position (simple replacement, can leave gaps)
|
||||
* This is used when clicking directly on a slot
|
||||
* Returns old card if slot was occupied
|
||||
*/
|
||||
export function placeCardAtPosition(
|
||||
placedCards: (SortingCard | null)[],
|
||||
cardToPlace: SortingCard,
|
||||
position: number
|
||||
): { placedCards: (SortingCard | null)[]; replacedCard: SortingCard | null } {
|
||||
const newPlaced = [...placedCards]
|
||||
const replacedCard = newPlaced[position]
|
||||
newPlaced[position] = cardToPlace
|
||||
return { placedCards: newPlaced, replacedCard }
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a card at a specific position, shifting existing cards and compacting
|
||||
* This is used when clicking a + (insert) button
|
||||
* Returns new placedCards array with no gaps
|
||||
*/
|
||||
export function insertCardAtPosition(
|
||||
placedCards: (SortingCard | null)[],
|
||||
cardToPlace: SortingCard,
|
||||
insertPosition: number,
|
||||
totalSlots: number
|
||||
): { placedCards: (SortingCard | null)[]; excessCards: SortingCard[] } {
|
||||
// Create working array
|
||||
const newPlaced = new Array(totalSlots).fill(null)
|
||||
|
||||
// Copy existing cards, shifting those at/after position
|
||||
for (let i = 0; i < placedCards.length; i++) {
|
||||
if (placedCards[i] !== null) {
|
||||
if (i < insertPosition) {
|
||||
// Before insert position - stays same
|
||||
newPlaced[i] = placedCards[i]
|
||||
} else {
|
||||
// At or after position - shift right
|
||||
if (i + 1 < totalSlots) {
|
||||
newPlaced[i + 1] = placedCards[i]
|
||||
} else {
|
||||
// Card would fall off, will be handled by compaction
|
||||
newPlaced[i + 1] = placedCards[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Place new card at insert position
|
||||
newPlaced[insertPosition] = cardToPlace
|
||||
|
||||
// Compact to remove gaps (shift all cards left)
|
||||
const compacted: SortingCard[] = []
|
||||
for (const card of newPlaced) {
|
||||
if (card !== null) {
|
||||
compacted.push(card)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill final array with compacted cards (no gaps)
|
||||
const result = new Array(totalSlots).fill(null)
|
||||
for (let i = 0; i < Math.min(compacted.length, totalSlots); i++) {
|
||||
result[i] = compacted[i]
|
||||
}
|
||||
|
||||
// Any excess cards are returned
|
||||
const excess = compacted.slice(totalSlots)
|
||||
|
||||
return { placedCards: result, excessCards: excess }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove card at position
|
||||
*/
|
||||
export function removeCardAtPosition(
|
||||
placedCards: (SortingCard | null)[],
|
||||
position: number
|
||||
): { placedCards: (SortingCard | null)[]; removedCard: SortingCard | null } {
|
||||
const removedCard = placedCards[position]
|
||||
|
||||
if (!removedCard) {
|
||||
return { placedCards, removedCard: null }
|
||||
}
|
||||
|
||||
// Remove card and compact
|
||||
const compacted: SortingCard[] = []
|
||||
for (let i = 0; i < placedCards.length; i++) {
|
||||
if (i !== position && placedCards[i] !== null) {
|
||||
compacted.push(placedCards[i] as SortingCard)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill new array
|
||||
const newPlaced = new Array(placedCards.length).fill(null)
|
||||
for (let i = 0; i < compacted.length; i++) {
|
||||
newPlaced[i] = compacted[i]
|
||||
}
|
||||
|
||||
return { placedCards: newPlaced, removedCard }
|
||||
}
|
||||
@@ -38,6 +38,7 @@ interface CompatibleGameState {
|
||||
style: string
|
||||
timeoutSetting: string
|
||||
complementDisplay: string
|
||||
maxConcurrentPassengers: number
|
||||
|
||||
// Current question (extracted from currentQuestions[localPlayerId])
|
||||
currentQuestion: any | null
|
||||
@@ -102,7 +103,7 @@ interface ComplementRaceContextValue {
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
submitAnswer: (answer: number, responseTime: number) => void
|
||||
claimPassenger: (passengerId: string) => void
|
||||
claimPassenger: (passengerId: string, carIndex: number) => void
|
||||
deliverPassenger: (passengerId: string) => void
|
||||
nextQuestion: () => void
|
||||
endGame: () => void
|
||||
@@ -111,6 +112,7 @@ interface ComplementRaceContextValue {
|
||||
setConfig: (field: keyof ComplementRaceConfig, value: unknown) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
boostMomentum: (correct: boolean) => void // Client-side momentum boost/reduce
|
||||
}
|
||||
|
||||
const ComplementRaceContext = createContext<ComplementRaceContextValue | null>(null)
|
||||
@@ -128,12 +130,71 @@ export function useComplementRace() {
|
||||
|
||||
/**
|
||||
* Optimistic move application (client-side prediction)
|
||||
* For now, just return current state - server will validate and send back authoritative state
|
||||
* Apply moves immediately on client for responsive UI, server will confirm or reject
|
||||
*/
|
||||
function applyMoveOptimistically(state: ComplementRaceState, move: GameMove): ComplementRaceState {
|
||||
// Simple optimistic updates can be added here later
|
||||
// For now, rely on server validation
|
||||
return state
|
||||
const typedMove = move as ComplementRaceMove
|
||||
|
||||
switch (typedMove.type) {
|
||||
case 'CLAIM_PASSENGER': {
|
||||
// Optimistically mark passenger as claimed and assign to car
|
||||
const passengerId = typedMove.data.passengerId
|
||||
const carIndex = typedMove.data.carIndex
|
||||
const updatedPassengers = state.passengers.map((p) =>
|
||||
p.id === passengerId ? { ...p, claimedBy: typedMove.playerId, carIndex } : p
|
||||
)
|
||||
|
||||
// Optimistically add to player's passenger list
|
||||
const updatedPlayers = { ...state.players }
|
||||
const player = updatedPlayers[typedMove.playerId]
|
||||
if (player) {
|
||||
updatedPlayers[typedMove.playerId] = {
|
||||
...player,
|
||||
passengers: [...player.passengers, passengerId],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
passengers: updatedPassengers,
|
||||
players: updatedPlayers,
|
||||
}
|
||||
}
|
||||
|
||||
case 'DELIVER_PASSENGER': {
|
||||
// Optimistically mark passenger as delivered and award points
|
||||
const passengerId = typedMove.data.passengerId
|
||||
const passenger = state.passengers.find((p) => p.id === passengerId)
|
||||
if (!passenger) return state
|
||||
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
const updatedPassengers = state.passengers.map((p) =>
|
||||
p.id === passengerId ? { ...p, deliveredBy: typedMove.playerId } : p
|
||||
)
|
||||
|
||||
// Optimistically remove from player's passenger list and update score
|
||||
const updatedPlayers = { ...state.players }
|
||||
const player = updatedPlayers[typedMove.playerId]
|
||||
if (player) {
|
||||
updatedPlayers[typedMove.playerId] = {
|
||||
...player,
|
||||
passengers: player.passengers.filter((id) => id !== passengerId),
|
||||
deliveredPassengers: player.deliveredPassengers + 1,
|
||||
score: player.score + points,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
passengers: updatedPassengers,
|
||||
players: updatedPlayers,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// For other moves, rely on server validation
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,10 +313,41 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
// Debug logging ref (track last logged values)
|
||||
const lastLogRef = useState({ key: '', count: 0 })[0]
|
||||
|
||||
// Client-side smooth movement state
|
||||
// Client-side game state (NOT synced to server - purely visual/gameplay)
|
||||
const [clientMomentum, setClientMomentum] = useState(10) // Start at 10 for gentle push
|
||||
const [clientPosition, setClientPosition] = useState(0)
|
||||
const [clientPressure, setClientPressure] = useState(0)
|
||||
const [clientAIRacers, setClientAIRacers] = useState<
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
position: number
|
||||
speed: number
|
||||
personality: 'competitive' | 'analytical'
|
||||
icon: string
|
||||
lastComment: number
|
||||
commentCooldown: number
|
||||
previousPosition: number
|
||||
}>
|
||||
>([])
|
||||
const lastUpdateRef = useRef(Date.now())
|
||||
const gameStartTimeRef = useRef(0)
|
||||
|
||||
// Decay rates based on skill level (momentum lost per second)
|
||||
const MOMENTUM_DECAY_RATES = {
|
||||
preschool: 2.0,
|
||||
kindergarten: 3.5,
|
||||
relaxed: 5.0,
|
||||
slow: 7.0,
|
||||
normal: 9.0,
|
||||
fast: 11.0,
|
||||
expert: 13.0,
|
||||
}
|
||||
|
||||
const MOMENTUM_GAIN_PER_CORRECT = 15
|
||||
const MOMENTUM_LOSS_PER_WRONG = 10
|
||||
const SPEED_MULTIPLIER = 0.15 // momentum * 0.15 = % per second
|
||||
const UPDATE_INTERVAL = 50 // 50ms = ~20fps
|
||||
|
||||
// Transform multiplayer state to look like single-player state
|
||||
const compatibleState = useMemo((): CompatibleGameState => {
|
||||
@@ -281,6 +373,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
style: multiplayerState.config.style,
|
||||
timeoutSetting: multiplayerState.config.timeoutSetting,
|
||||
complementDisplay: multiplayerState.config.complementDisplay,
|
||||
maxConcurrentPassengers: multiplayerState.config.maxConcurrentPassengers,
|
||||
|
||||
// Current question
|
||||
currentQuestion: localPlayerId
|
||||
@@ -307,23 +400,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
// Race mechanics
|
||||
raceGoal: multiplayerState.config.raceGoal,
|
||||
timeLimit: multiplayerState.config.timeLimit ?? null,
|
||||
speedMultiplier: 1.0,
|
||||
aiRacers: multiplayerState.aiOpponents.map((ai) => ({
|
||||
id: ai.id,
|
||||
name: ai.name,
|
||||
position: ai.position,
|
||||
speed: ai.speed,
|
||||
personality: ai.personality,
|
||||
icon: ai.personality === 'competitive' ? '🏃♂️' : '🏃',
|
||||
lastComment: ai.lastCommentTime,
|
||||
commentCooldown: 0,
|
||||
previousPosition: ai.position,
|
||||
})),
|
||||
speedMultiplier:
|
||||
multiplayerState.config.style === 'practice'
|
||||
? 0.7
|
||||
: multiplayerState.config.style === 'sprint'
|
||||
? 0.9
|
||||
: 1.0, // Base speed multipliers by mode
|
||||
aiRacers: clientAIRacers, // Use client-side AI state
|
||||
|
||||
// Sprint mode specific
|
||||
momentum: localPlayer?.momentum || 0,
|
||||
trainPosition: clientPosition, // Use client-calculated smooth position
|
||||
pressure: clientPressure, // Use client-calculated smooth pressure
|
||||
// Sprint mode specific (all client-side for smooth movement)
|
||||
momentum: clientMomentum, // Client-only state with continuous decay
|
||||
trainPosition: clientPosition, // Client-calculated from momentum
|
||||
pressure: clientPressure, // Client-calculated from momentum (0-150 PSI)
|
||||
elapsedTime: multiplayerState.gameStartTime ? Date.now() - multiplayerState.gameStartTime : 0,
|
||||
lastCorrectAnswerTime: localPlayer?.lastAnswerTime || Date.now(),
|
||||
currentRoute: multiplayerState.currentRoute,
|
||||
@@ -345,70 +433,132 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
adaptiveFeedback: localUIState.adaptiveFeedback,
|
||||
difficultyTracker: localUIState.difficultyTracker,
|
||||
}
|
||||
}, [multiplayerState, localPlayerId, localUIState, clientPosition, clientPressure])
|
||||
}, [
|
||||
multiplayerState,
|
||||
localPlayerId,
|
||||
localUIState,
|
||||
clientPosition,
|
||||
clientPressure,
|
||||
clientMomentum,
|
||||
clientAIRacers,
|
||||
])
|
||||
|
||||
// Client-side game loop for smooth train movement
|
||||
// Initialize game start time when game becomes active
|
||||
useEffect(() => {
|
||||
if (compatibleState.style !== 'sprint' || !compatibleState.isGameActive) return
|
||||
if (compatibleState.isGameActive && compatibleState.style === 'sprint') {
|
||||
if (gameStartTimeRef.current === 0) {
|
||||
gameStartTimeRef.current = Date.now()
|
||||
lastUpdateRef.current = Date.now()
|
||||
// Reset client state for new game
|
||||
setClientMomentum(10) // Start with gentle push
|
||||
setClientPosition(0)
|
||||
setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum
|
||||
}
|
||||
} else {
|
||||
// Reset when game ends
|
||||
gameStartTimeRef.current = 0
|
||||
}
|
||||
}, [compatibleState.isGameActive, compatibleState.style])
|
||||
|
||||
const UPDATE_INTERVAL = 50 // 50ms = ~20fps
|
||||
const SPEED_MULTIPLIER = 0.15 // speed = momentum * 0.15 (% per second)
|
||||
// Initialize AI racers when game starts
|
||||
useEffect(() => {
|
||||
if (compatibleState.isGameActive && multiplayerState.config.enableAI) {
|
||||
const count = multiplayerState.config.aiOpponentCount
|
||||
if (count > 0 && clientAIRacers.length === 0) {
|
||||
const aiNames = ['Swift AI', 'Math Bot', 'Speed Demon', 'Brain Bot']
|
||||
const personalities: Array<'competitive' | 'analytical'> = ['competitive', 'analytical']
|
||||
|
||||
const newAI = []
|
||||
for (let i = 0; i < Math.min(count, aiNames.length); i++) {
|
||||
// Use original balanced speeds: 0.32 for Swift AI, 0.2 for Math Bot
|
||||
const baseSpeed = i === 0 ? 0.32 : 0.2
|
||||
newAI.push({
|
||||
id: `ai-${i}`,
|
||||
name: aiNames[i],
|
||||
personality: personalities[i % personalities.length] as 'competitive' | 'analytical',
|
||||
position: 0,
|
||||
speed: baseSpeed, // Balanced speed from original single-player version
|
||||
icon: personalities[i % personalities.length] === 'competitive' ? '🏃♂️' : '🏃',
|
||||
lastComment: 0,
|
||||
commentCooldown: 0,
|
||||
previousPosition: 0,
|
||||
})
|
||||
}
|
||||
setClientAIRacers(newAI)
|
||||
}
|
||||
} else if (!compatibleState.isGameActive) {
|
||||
// Clear AI when game ends
|
||||
setClientAIRacers([])
|
||||
}
|
||||
}, [
|
||||
compatibleState.isGameActive,
|
||||
multiplayerState.config.enableAI,
|
||||
multiplayerState.config.aiOpponentCount,
|
||||
clientAIRacers.length,
|
||||
])
|
||||
|
||||
// Main client-side game loop: momentum decay and position calculation
|
||||
useEffect(() => {
|
||||
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint') return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const deltaTime = now - lastUpdateRef.current
|
||||
lastUpdateRef.current = now
|
||||
|
||||
// Get server momentum (authoritative)
|
||||
const serverMomentum = compatibleState.momentum
|
||||
// Get decay rate based on skill level
|
||||
const decayRate =
|
||||
MOMENTUM_DECAY_RATES[compatibleState.timeoutSetting as keyof typeof MOMENTUM_DECAY_RATES] ||
|
||||
MOMENTUM_DECAY_RATES.normal
|
||||
|
||||
// Calculate speed from momentum
|
||||
const speed = serverMomentum * SPEED_MULTIPLIER
|
||||
setClientMomentum((prevMomentum) => {
|
||||
// Calculate momentum decay for this frame
|
||||
const momentumLoss = (decayRate * deltaTime) / 1000
|
||||
|
||||
// Update position continuously based on momentum
|
||||
const positionDelta = (speed * deltaTime) / 1000
|
||||
setClientPosition((prev) => prev + positionDelta)
|
||||
// Update momentum (don't go below 0)
|
||||
const newMomentum = Math.max(0, prevMomentum - momentumLoss)
|
||||
|
||||
// Calculate pressure from momentum (0-150 PSI)
|
||||
const pressure = Math.min(150, (serverMomentum / 100) * 150)
|
||||
setClientPressure(pressure)
|
||||
// Calculate speed from momentum (% per second)
|
||||
const speed = newMomentum * SPEED_MULTIPLIER
|
||||
|
||||
// Update position (accumulate, never go backward)
|
||||
const positionDelta = (speed * deltaTime) / 1000
|
||||
setClientPosition((prev) => prev + positionDelta)
|
||||
|
||||
// Calculate pressure (0-150 PSI)
|
||||
const pressure = Math.min(150, (newMomentum / 100) * 150)
|
||||
setClientPressure(pressure)
|
||||
|
||||
return newMomentum
|
||||
})
|
||||
}, UPDATE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [compatibleState.style, compatibleState.isGameActive, compatibleState.momentum])
|
||||
|
||||
// Sync client position with server position on route changes/resets
|
||||
useEffect(() => {
|
||||
const serverPosition = multiplayerState.players[localPlayerId || '']?.position || 0
|
||||
// Only sync if there's a significant jump (route change)
|
||||
if (Math.abs(serverPosition - clientPosition) > 10) {
|
||||
setClientPosition(serverPosition)
|
||||
}
|
||||
}, [multiplayerState.players, localPlayerId, clientPosition])
|
||||
|
||||
// Debug logging: only log on answer submission or significant events
|
||||
useEffect(() => {
|
||||
if (compatibleState.style === 'sprint' && compatibleState.isGameActive) {
|
||||
const key = `${compatibleState.correctAnswers}`
|
||||
|
||||
// Only log on new answers (not every frame)
|
||||
if (lastLogRef.key !== key) {
|
||||
console.log(
|
||||
`🚂 Answer #${compatibleState.correctAnswers}: momentum=${compatibleState.momentum} pos=${Math.floor(compatibleState.trainPosition)} pressure=${compatibleState.pressure} streak=${compatibleState.streak}`
|
||||
)
|
||||
lastLogRef.key = key
|
||||
}
|
||||
}
|
||||
}, [
|
||||
compatibleState.correctAnswers,
|
||||
compatibleState.momentum,
|
||||
compatibleState.trainPosition,
|
||||
compatibleState.pressure,
|
||||
compatibleState.streak,
|
||||
compatibleState.style,
|
||||
compatibleState.isGameActive,
|
||||
compatibleState.style,
|
||||
compatibleState.timeoutSetting,
|
||||
MOMENTUM_DECAY_RATES,
|
||||
SPEED_MULTIPLIER,
|
||||
UPDATE_INTERVAL,
|
||||
])
|
||||
|
||||
// Reset client position when route changes
|
||||
useEffect(() => {
|
||||
const currentRoute = multiplayerState.currentRoute
|
||||
// When route changes, reset position and give starting momentum
|
||||
if (currentRoute > 1 && compatibleState.style === 'sprint') {
|
||||
console.log(
|
||||
`[Provider] Route changed to ${currentRoute}, resetting position. Passengers: ${multiplayerState.passengers.length}`
|
||||
)
|
||||
setClientPosition(0)
|
||||
setClientMomentum(10) // Reset to starting momentum (gentle push)
|
||||
}
|
||||
}, [multiplayerState.currentRoute, compatibleState.style, multiplayerState.passengers.length])
|
||||
|
||||
// Keep lastLogRef for future debugging needs
|
||||
// (removed debug logging)
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
if (activePlayers.length === 0) {
|
||||
@@ -453,7 +603,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
|
||||
const claimPassenger = useCallback(
|
||||
(passengerId: string) => {
|
||||
(passengerId: string, carIndex: number) => {
|
||||
const currentPlayerId = activePlayers.find((id) => {
|
||||
const player = players.get(id)
|
||||
return player?.isLocal
|
||||
@@ -465,7 +615,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
type: 'CLAIM_PASSENGER',
|
||||
playerId: currentPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { passengerId },
|
||||
data: { passengerId, carIndex },
|
||||
} as ComplementRaceMove)
|
||||
},
|
||||
[activePlayers, players, viewerId, sendMove]
|
||||
@@ -607,8 +757,8 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
break
|
||||
case 'BOARD_PASSENGER':
|
||||
case 'CLAIM_PASSENGER':
|
||||
if (action.passengerId !== undefined) {
|
||||
claimPassenger(action.passengerId)
|
||||
if (action.passengerId !== undefined && action.carIndex !== undefined) {
|
||||
claimPassenger(action.passengerId, action.carIndex)
|
||||
}
|
||||
break
|
||||
case 'DELIVER_PASSENGER':
|
||||
@@ -616,6 +766,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
deliverPassenger(action.passengerId)
|
||||
}
|
||||
break
|
||||
case 'START_NEW_ROUTE':
|
||||
// Send route progression to server
|
||||
if (action.routeNumber !== undefined) {
|
||||
console.log(`[Provider] Dispatching START_NEW_ROUTE for route ${action.routeNumber}`)
|
||||
sendMove({
|
||||
type: 'START_NEW_ROUTE',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: { routeNumber: action.routeNumber },
|
||||
} as ComplementRaceMove)
|
||||
}
|
||||
break
|
||||
// Local UI state actions
|
||||
case 'UPDATE_INPUT':
|
||||
setLocalUIState((prev) => ({ ...prev, currentInput: action.input || '' }))
|
||||
@@ -638,6 +800,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
newBubbles.set(action.racerId, action.message)
|
||||
return { ...prev, activeSpeechBubbles: newBubbles }
|
||||
})
|
||||
// Update racer's lastComment time and cooldown to prevent spam
|
||||
setClientAIRacers((prevRacers) =>
|
||||
prevRacers.map((racer) =>
|
||||
racer.id === action.racerId
|
||||
? {
|
||||
...racer,
|
||||
lastComment: Date.now(),
|
||||
commentCooldown: Math.random() * 4000 + 2000, // 2-6 seconds
|
||||
}
|
||||
: racer
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'CLEAR_AI_COMMENT': {
|
||||
@@ -648,15 +822,55 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'UPDATE_AI_POSITIONS': {
|
||||
// Update client-side AI positions
|
||||
if (action.positions && Array.isArray(action.positions)) {
|
||||
setClientAIRacers((prevRacers) =>
|
||||
prevRacers.map((racer) => {
|
||||
const update = action.positions.find(
|
||||
(p: { id: string; position: number }) => p.id === racer.id
|
||||
)
|
||||
return update
|
||||
? {
|
||||
...racer,
|
||||
previousPosition: racer.position,
|
||||
position: update.position,
|
||||
}
|
||||
: racer
|
||||
})
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'UPDATE_AI_SPEEDS': {
|
||||
// Update client-side AI speeds (adaptive difficulty)
|
||||
if (action.racers && Array.isArray(action.racers)) {
|
||||
setClientAIRacers((prevRacers) =>
|
||||
prevRacers.map((racer) => {
|
||||
const update = action.racers.find(
|
||||
(r: { id: string; speed: number }) => r.id === racer.id
|
||||
)
|
||||
return update
|
||||
? {
|
||||
...racer,
|
||||
speed: update.speed,
|
||||
}
|
||||
: racer
|
||||
})
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'UPDATE_DIFFICULTY_TRACKER': {
|
||||
// Update local difficulty tracker state
|
||||
setLocalUIState((prev) => ({ ...prev, difficultyTracker: action.tracker }))
|
||||
break
|
||||
}
|
||||
// Other local actions that don't affect UI (can be ignored for now)
|
||||
case 'UPDATE_AI_POSITIONS':
|
||||
case 'UPDATE_MOMENTUM':
|
||||
case 'UPDATE_TRAIN_POSITION':
|
||||
case 'UPDATE_STEAM_JOURNEY':
|
||||
case 'UPDATE_DIFFICULTY_TRACKER':
|
||||
case 'UPDATE_AI_SPEEDS':
|
||||
case 'GENERATE_PASSENGERS':
|
||||
case 'START_NEW_ROUTE':
|
||||
case 'GENERATE_PASSENGERS': // Passengers generated server-side when route starts
|
||||
case 'COMPLETE_ROUTE':
|
||||
case 'HIDE_ROUTE_CELEBRATION':
|
||||
case 'COMPLETE_LAP':
|
||||
@@ -676,9 +890,28 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
claimPassenger,
|
||||
deliverPassenger,
|
||||
multiplayerState.questionStartTime,
|
||||
sendMove,
|
||||
activePlayers,
|
||||
viewerId,
|
||||
]
|
||||
)
|
||||
|
||||
// Client-side momentum boost/reduce (sprint mode only)
|
||||
const boostMomentum = useCallback(
|
||||
(correct: boolean) => {
|
||||
if (compatibleState.style !== 'sprint') return
|
||||
|
||||
setClientMomentum((prevMomentum) => {
|
||||
if (correct) {
|
||||
return Math.min(100, prevMomentum + MOMENTUM_GAIN_PER_CORRECT)
|
||||
} else {
|
||||
return Math.max(0, prevMomentum - MOMENTUM_LOSS_PER_WRONG)
|
||||
}
|
||||
})
|
||||
},
|
||||
[compatibleState.style, MOMENTUM_GAIN_PER_CORRECT, MOMENTUM_LOSS_PER_WRONG]
|
||||
)
|
||||
|
||||
const contextValue: ComplementRaceContextValue = {
|
||||
state: compatibleState, // Use transformed state
|
||||
dispatch,
|
||||
@@ -694,6 +927,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
boostMomentum, // Client-side momentum control
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -76,12 +76,6 @@ export class ComplementRaceValidator
|
||||
implements GameValidator<ComplementRaceState, ComplementRaceMove>
|
||||
{
|
||||
validateMove(state: ComplementRaceState, move: ComplementRaceMove): ValidationResult {
|
||||
console.log('[ComplementRace] Validating move:', {
|
||||
type: move.type,
|
||||
playerId: move.playerId,
|
||||
gamePhase: state.gamePhase,
|
||||
})
|
||||
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
@@ -104,7 +98,12 @@ export class ComplementRaceValidator
|
||||
return this.validateUpdateInput(state, move.playerId, move.data.input)
|
||||
|
||||
case 'CLAIM_PASSENGER':
|
||||
return this.validateClaimPassenger(state, move.playerId, move.data.passengerId)
|
||||
return this.validateClaimPassenger(
|
||||
state,
|
||||
move.playerId,
|
||||
move.data.passengerId,
|
||||
move.data.carIndex
|
||||
)
|
||||
|
||||
case 'DELIVER_PASSENGER':
|
||||
return this.validateDeliverPassenger(state, move.playerId, move.data.passengerId)
|
||||
@@ -168,8 +167,7 @@ export class ComplementRaceValidator
|
||||
bestStreak: 0,
|
||||
correctAnswers: 0,
|
||||
totalQuestions: 0,
|
||||
position: 0,
|
||||
momentum: 50, // Start with some momentum (position/pressure calculated client-side)
|
||||
position: 0, // Only used for practice/survival; sprint mode is client-side
|
||||
isReady: false,
|
||||
isActive: true,
|
||||
currentAnswer: null,
|
||||
@@ -191,8 +189,25 @@ export class ComplementRaceValidator
|
||||
? this.generatePassengers(state.config.passengerCount, state.stations)
|
||||
: []
|
||||
|
||||
// Calculate maxConcurrentPassengers based on initial passenger layout (sprint mode only)
|
||||
let updatedConfig = state.config
|
||||
if (state.config.style === 'sprint' && passengers.length > 0) {
|
||||
const maxConcurrentPassengers = Math.max(
|
||||
1,
|
||||
this.calculateMaxConcurrentPassengers(passengers, state.stations)
|
||||
)
|
||||
console.log(
|
||||
`[Game Start] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${passengers.length} passengers`
|
||||
)
|
||||
updatedConfig = {
|
||||
...state.config,
|
||||
maxConcurrentPassengers,
|
||||
}
|
||||
}
|
||||
|
||||
const newState: ComplementRaceState = {
|
||||
...state,
|
||||
config: updatedConfig,
|
||||
gamePhase: 'playing', // Go directly to playing (countdown can be added later)
|
||||
activePlayers,
|
||||
playerMetadata: playerMetadata as typeof state.playerMetadata,
|
||||
@@ -203,6 +218,7 @@ export class ComplementRaceValidator
|
||||
routeStartTime: state.config.style === 'sprint' ? Date.now() : null,
|
||||
raceStartTime: Date.now(), // Race starts immediately
|
||||
gameStartTime: Date.now(),
|
||||
aiOpponents: [], // AI handled client-side
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
@@ -317,15 +333,9 @@ export class ComplementRaceValidator
|
||||
updatedPlayer.position = Math.min(100, player.position + 100 / state.config.raceGoal)
|
||||
}
|
||||
} else if (state.config.style === 'sprint') {
|
||||
// Sprint: Update momentum only (position calculated client-side for smooth movement)
|
||||
if (correct) {
|
||||
updatedPlayer.momentum = Math.min(100, player.momentum + 15)
|
||||
} else {
|
||||
updatedPlayer.momentum = Math.max(0, player.momentum - 10)
|
||||
}
|
||||
|
||||
// Position is calculated client-side continuously based on momentum
|
||||
// This allows for smooth 20fps movement instead of discrete jumps per answer
|
||||
// Sprint: All momentum/position handled client-side for smooth 20fps movement
|
||||
// Server only tracks scoring, passengers, and game progression
|
||||
// No server-side position updates needed
|
||||
} else if (state.config.style === 'survival') {
|
||||
// Survival: Always move forward, speed based on accuracy
|
||||
const moveDistance = correct ? 5 : 2
|
||||
@@ -394,7 +404,8 @@ export class ComplementRaceValidator
|
||||
private validateClaimPassenger(
|
||||
state: ComplementRaceState,
|
||||
playerId: string,
|
||||
passengerId: string
|
||||
passengerId: string,
|
||||
carIndex: number
|
||||
): ValidationResult {
|
||||
if (state.config.style !== 'sprint') {
|
||||
return { valid: false, error: 'Passengers only available in sprint mode' }
|
||||
@@ -421,22 +432,27 @@ export class ComplementRaceValidator
|
||||
return { valid: false, error: 'Passenger already claimed' }
|
||||
}
|
||||
|
||||
// Check if player is at the origin station (within 5% tolerance)
|
||||
const originStation = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!originStation) {
|
||||
return { valid: false, error: 'Origin station not found' }
|
||||
// Sprint mode: Position is client-side, trust client's spatial checking
|
||||
// (Client checks position in useSteamJourney before sending CLAIM move)
|
||||
// Other modes: Validate position server-side
|
||||
if (state.config.style !== 'sprint') {
|
||||
const originStation = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!originStation) {
|
||||
return { valid: false, error: 'Origin station not found' }
|
||||
}
|
||||
|
||||
const distance = Math.abs(player.position - originStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at origin station' }
|
||||
}
|
||||
}
|
||||
|
||||
const distance = Math.abs(player.position - originStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at origin station' }
|
||||
}
|
||||
|
||||
// Claim passenger
|
||||
// Claim passenger and assign to physical car
|
||||
const updatedPassengers = [...state.passengers]
|
||||
updatedPassengers[passengerIndex] = {
|
||||
...passenger,
|
||||
claimedBy: playerId,
|
||||
carIndex, // Store which physical car (0-N) the passenger is seated in
|
||||
}
|
||||
|
||||
const newState: ComplementRaceState = {
|
||||
@@ -484,15 +500,19 @@ export class ComplementRaceValidator
|
||||
return { valid: false, error: 'Passenger already delivered' }
|
||||
}
|
||||
|
||||
// Check if player is at destination station (within 5% tolerance)
|
||||
const destStation = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!destStation) {
|
||||
return { valid: false, error: 'Destination station not found' }
|
||||
}
|
||||
// Sprint mode: Position is client-side, trust client's spatial checking
|
||||
// (Client checks position in useSteamJourney before sending DELIVER move)
|
||||
// Other modes: Validate position server-side
|
||||
if (state.config.style !== 'sprint') {
|
||||
const destStation = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!destStation) {
|
||||
return { valid: false, error: 'Destination station not found' }
|
||||
}
|
||||
|
||||
const distance = Math.abs(player.position - destStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at destination station' }
|
||||
const distance = Math.abs(player.position - destStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at destination station' }
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver passenger and award points
|
||||
@@ -525,13 +545,12 @@ export class ComplementRaceValidator
|
||||
return { valid: false, error: 'Routes only available in sprint mode' }
|
||||
}
|
||||
|
||||
// Reset all player positions to 0 for new route
|
||||
// Reset all player positions to 0 for new route (client handles momentum reset)
|
||||
const resetPlayers: Record<string, PlayerState> = {}
|
||||
for (const [playerId, player] of Object.entries(state.players)) {
|
||||
resetPlayers[playerId] = {
|
||||
...player,
|
||||
position: 0,
|
||||
momentum: 50, // Reset momentum to starting value
|
||||
position: 0, // Server position not used in sprint; client will reset
|
||||
passengers: [], // Clear any remaining passengers
|
||||
}
|
||||
}
|
||||
@@ -539,12 +558,26 @@ export class ComplementRaceValidator
|
||||
// Generate new passengers
|
||||
const newPassengers = this.generatePassengers(state.config.passengerCount, state.stations)
|
||||
|
||||
// Calculate maxConcurrentPassengers based on the new route's passenger layout
|
||||
const maxConcurrentPassengers = Math.max(
|
||||
1,
|
||||
this.calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
)
|
||||
|
||||
console.log(
|
||||
`[Route ${routeNumber}] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${newPassengers.length} passengers`
|
||||
)
|
||||
|
||||
const newState: ComplementRaceState = {
|
||||
...state,
|
||||
currentRoute: routeNumber,
|
||||
routeStartTime: Date.now(),
|
||||
players: resetPlayers,
|
||||
passengers: newPassengers,
|
||||
config: {
|
||||
...state.config,
|
||||
maxConcurrentPassengers, // Update config with calculated value
|
||||
},
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
@@ -665,37 +698,126 @@ export class ComplementRaceValidator
|
||||
|
||||
private generatePassengers(count: number, stations: Station[]): Passenger[] {
|
||||
const passengers: Passenger[] = []
|
||||
const usedCombos = new Set<string>()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Pick random origin and destination (must be different)
|
||||
const originIndex = Math.floor(Math.random() * stations.length)
|
||||
let destIndex = Math.floor(Math.random() * stations.length)
|
||||
while (destIndex === originIndex) {
|
||||
destIndex = Math.floor(Math.random() * stations.length)
|
||||
let name: string
|
||||
let avatar: string
|
||||
let comboKey: string
|
||||
|
||||
// Keep trying until we get a unique name/avatar combo
|
||||
do {
|
||||
const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length)
|
||||
const avatarIndex = Math.floor(Math.random() * PASSENGER_AVATARS.length)
|
||||
name = PASSENGER_NAMES[nameIndex]
|
||||
avatar = PASSENGER_AVATARS[avatarIndex]
|
||||
comboKey = `${name}-${avatar}`
|
||||
} while (usedCombos.has(comboKey) && usedCombos.size < 100) // Prevent infinite loop
|
||||
|
||||
usedCombos.add(comboKey)
|
||||
|
||||
// Pick origin and destination stations
|
||||
// KEY: Destination must be AHEAD of origin (higher position on track)
|
||||
// This ensures passengers travel forward, creating better overlap
|
||||
let originStation: Station
|
||||
let destinationStation: Station
|
||||
|
||||
if (Math.random() < 0.4 || stations.length < 3) {
|
||||
// 40% chance to start at depot (first station)
|
||||
originStation = stations[0]
|
||||
// Pick any station ahead as destination
|
||||
const stationsAhead = stations.slice(1)
|
||||
destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
} else {
|
||||
// Start at a random non-depot, non-final station
|
||||
const nonDepotStations = stations.slice(1, -1) // Exclude depot and final station
|
||||
originStation = nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)]
|
||||
// Pick a station ahead of origin (higher position)
|
||||
const stationsAhead = stations.filter((s) => s.position > originStation.position)
|
||||
destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
}
|
||||
|
||||
const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length)
|
||||
const avatarIndex = Math.floor(Math.random() * PASSENGER_AVATARS.length)
|
||||
// 30% chance of urgent
|
||||
const isUrgent = Math.random() < 0.3
|
||||
|
||||
passengers.push({
|
||||
const passenger = {
|
||||
id: `p-${Date.now()}-${i}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: PASSENGER_NAMES[nameIndex],
|
||||
avatar: PASSENGER_AVATARS[avatarIndex],
|
||||
originStationId: stations[originIndex].id,
|
||||
destinationStationId: stations[destIndex].id,
|
||||
isUrgent: Math.random() < 0.3, // 30% chance of urgent
|
||||
name,
|
||||
avatar,
|
||||
originStationId: originStation.id,
|
||||
destinationStationId: destinationStation.id,
|
||||
isUrgent,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null, // Not boarded yet
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
passengers.push(passenger)
|
||||
|
||||
console.log(
|
||||
`[Passenger ${i + 1}/${count}] ${name} waiting at ${originStation.emoji} ${originStation.name} (pos ${originStation.position}) → ${destinationStation.emoji} ${destinationStation.name} (pos ${destinationStation.position}) ${isUrgent ? '⚡ URGENT' : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[Generated ${passengers.length} passengers total]`)
|
||||
return passengers
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the maximum number of passengers that will be on the train
|
||||
* concurrently at any given moment during the route
|
||||
*/
|
||||
private calculateMaxConcurrentPassengers(passengers: Passenger[], stations: Station[]): number {
|
||||
// Create events for boarding and delivery
|
||||
interface StationEvent {
|
||||
position: number
|
||||
isBoarding: boolean // true = board, false = delivery
|
||||
}
|
||||
|
||||
const events: StationEvent[] = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
const originStation = stations.find((s) => s.id === passenger.originStationId)
|
||||
const destStation = stations.find((s) => s.id === passenger.destinationStationId)
|
||||
|
||||
if (originStation && destStation) {
|
||||
events.push({ position: originStation.position, isBoarding: true })
|
||||
events.push({ position: destStation.position, isBoarding: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Sort events by position, with deliveries before boardings at the same position
|
||||
events.sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
// At same position, deliveries happen before boarding
|
||||
return a.isBoarding ? 1 : -1
|
||||
})
|
||||
|
||||
// Track current passenger count and maximum
|
||||
let currentCount = 0
|
||||
let maxCount = 0
|
||||
|
||||
for (const event of events) {
|
||||
if (event.isBoarding) {
|
||||
currentCount++
|
||||
maxCount = Math.max(maxCount, currentCount)
|
||||
} else {
|
||||
currentCount--
|
||||
}
|
||||
}
|
||||
|
||||
return maxCount
|
||||
}
|
||||
|
||||
private checkWinCondition(state: ComplementRaceState): string | null {
|
||||
const { config, players } = state
|
||||
|
||||
// Infinite mode: Never end the game
|
||||
if (config.winCondition === 'infinite') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Practice mode: First to reach goal
|
||||
if (config.style === 'practice') {
|
||||
for (const [playerId, player] of Object.entries(players)) {
|
||||
@@ -703,6 +825,7 @@ export class ComplementRaceValidator
|
||||
return playerId
|
||||
}
|
||||
}
|
||||
// AI wins handled client-side via useAIRacers hook
|
||||
}
|
||||
|
||||
// Sprint mode: Check route-based, score-based, or time-based win conditions
|
||||
@@ -754,12 +877,15 @@ export class ComplementRaceValidator
|
||||
// Find player with highest position (most laps)
|
||||
let maxPosition = 0
|
||||
let winner: string | null = null
|
||||
|
||||
for (const [playerId, player] of Object.entries(players)) {
|
||||
if (player.position > maxPosition) {
|
||||
maxPosition = player.position
|
||||
winner = playerId
|
||||
}
|
||||
}
|
||||
// AI wins handled client-side via useAIRacers hook
|
||||
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ const defaultConfig: ComplementRaceConfig = {
|
||||
passengerCount: 6,
|
||||
maxConcurrentPassengers: 3,
|
||||
raceGoal: 20,
|
||||
winCondition: 'route-based',
|
||||
winCondition: 'infinite', // Sprint mode is infinite by default (Steam Sprint)
|
||||
routeCount: 3,
|
||||
targetScore: 100,
|
||||
timeLimit: 300,
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface Passenger {
|
||||
isUrgent: boolean // Urgent passengers worth 2x points
|
||||
claimedBy: string | null // playerId who picked up this passenger (null = unclaimed)
|
||||
deliveredBy: string | null // playerId who delivered (null = not delivered yet)
|
||||
carIndex: number | null // Physical car index (0-N) where passenger is seated (null = not boarded)
|
||||
timestamp: number // When passenger spawned
|
||||
}
|
||||
|
||||
@@ -61,8 +62,7 @@ export interface PlayerState {
|
||||
totalQuestions: number
|
||||
|
||||
// Position & Progress
|
||||
position: number // 0-100% for practice/sprint, lap count for survival
|
||||
momentum: number // 0-100 (sprint mode only, position/pressure calculated client-side)
|
||||
position: number // 0-100% for practice/survival only (sprint mode: client-side)
|
||||
|
||||
// Current state
|
||||
isReady: boolean
|
||||
@@ -143,7 +143,7 @@ export type ComplementRaceMove = BaseGameMove &
|
||||
// Playing phase
|
||||
| { type: 'SUBMIT_ANSWER'; data: { answer: number; responseTime: number } }
|
||||
| { type: 'UPDATE_INPUT'; data: { input: string } } // Show "thinking" indicator
|
||||
| { type: 'CLAIM_PASSENGER'; data: { passengerId: string } } // Sprint mode: pickup
|
||||
| { type: 'CLAIM_PASSENGER'; data: { passengerId: string; carIndex: number } } // Sprint mode: pickup
|
||||
| { type: 'DELIVER_PASSENGER'; data: { passengerId: string } } // Sprint mode: delivery
|
||||
|
||||
// Game flow
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Provider
|
||||
*
|
||||
* Context provider for Math Sprint game state management.
|
||||
* Demonstrates free-for-all gameplay with TEAM_MOVE pattern.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
||||
import {
|
||||
buildPlayerMetadata,
|
||||
useArcadeSession,
|
||||
useGameMode,
|
||||
useRoomData,
|
||||
useUpdateGameConfig,
|
||||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
|
||||
import type { Difficulty, MathSprintState } from './types'
|
||||
|
||||
/**
|
||||
* Context value provided to child components
|
||||
*/
|
||||
interface MathSprintContextValue {
|
||||
state: MathSprintState
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
submitAnswer: (answer: number) => void
|
||||
nextQuestion: () => void
|
||||
resetGame: () => void
|
||||
setConfig: (field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
const MathSprintContext = createContext<MathSprintContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Hook to access Math Sprint context
|
||||
*/
|
||||
export function useMathSprint() {
|
||||
const context = useContext(MathSprintContext)
|
||||
if (!context) {
|
||||
throw new Error('useMathSprint must be used within MathSprintProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Math Sprint Provider Component
|
||||
*/
|
||||
export function MathSprintProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active players as array (keep Set iteration order)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Merge saved config from room with defaults
|
||||
const gameConfig = useMemo(() => {
|
||||
const allGameConfigs = roomData?.gameConfig as Record<string, unknown> | null | undefined
|
||||
const savedConfig = allGameConfigs?.['math-sprint'] as Record<string, unknown> | undefined
|
||||
return {
|
||||
difficulty: (savedConfig?.difficulty as Difficulty) || 'medium',
|
||||
questionsPerRound: (savedConfig?.questionsPerRound as number) || 10,
|
||||
timePerQuestion: (savedConfig?.timePerQuestion as number) || 30,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Initial state with merged config
|
||||
const initialState = useMemo<MathSprintState>(
|
||||
() => ({
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
difficulty: gameConfig.difficulty,
|
||||
questionsPerRound: gameConfig.questionsPerRound,
|
||||
timePerQuestion: gameConfig.timePerQuestion,
|
||||
currentQuestionIndex: 0,
|
||||
questions: [],
|
||||
scores: {},
|
||||
correctAnswersCount: {},
|
||||
answers: [],
|
||||
questionStartTime: 0,
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}),
|
||||
[gameConfig]
|
||||
)
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession, lastError, clearError } = useArcadeSession<MathSprintState>(
|
||||
{
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: (state) => state, // Server handles all state updates
|
||||
}
|
||||
)
|
||||
|
||||
// Action: Start game
|
||||
const startGame = useCallback(() => {
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: TEAM_MOVE, // Free-for-all: no specific turn owner
|
||||
userId: viewerId || '',
|
||||
data: { activePlayers, playerMetadata },
|
||||
})
|
||||
}, [activePlayers, players, viewerId, sendMove])
|
||||
|
||||
// Action: Submit answer
|
||||
const submitAnswer = useCallback(
|
||||
(answer: number) => {
|
||||
// Find this user's player ID from game state
|
||||
const myPlayerId = state.activePlayers.find((pid) => {
|
||||
return state.playerMetadata[pid]?.userId === viewerId
|
||||
})
|
||||
|
||||
if (!myPlayerId) {
|
||||
console.error('[MathSprint] No player found for current user')
|
||||
return
|
||||
}
|
||||
|
||||
sendMove({
|
||||
type: 'SUBMIT_ANSWER',
|
||||
playerId: myPlayerId, // Specific player answering
|
||||
userId: viewerId || '',
|
||||
data: { answer },
|
||||
})
|
||||
},
|
||||
[state.activePlayers, state.playerMetadata, viewerId, sendMove]
|
||||
)
|
||||
|
||||
// Action: Next question
|
||||
const nextQuestion = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'NEXT_QUESTION',
|
||||
playerId: TEAM_MOVE, // Any player can advance
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
// Action: Reset game
|
||||
const resetGame = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'RESET_GAME',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
// Action: Set config
|
||||
const setConfig = useCallback(
|
||||
(field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => {
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Persist to database for next session
|
||||
if (roomData?.id) {
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: {
|
||||
...roomData.gameConfig,
|
||||
'math-sprint': {
|
||||
...(roomData.gameConfig?.['math-sprint'] || {}),
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[viewerId, sendMove, updateGameConfig, roomData]
|
||||
)
|
||||
|
||||
const contextValue: MathSprintContextValue = {
|
||||
state,
|
||||
lastError,
|
||||
startGame,
|
||||
submitAnswer,
|
||||
nextQuestion,
|
||||
resetGame,
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
}
|
||||
|
||||
return <MathSprintContext.Provider value={contextValue}>{children}</MathSprintContext.Provider>
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Validator
|
||||
*
|
||||
* Server-side validation for Math Sprint game.
|
||||
* Generates questions, validates answers, awards points.
|
||||
*/
|
||||
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
|
||||
import type {
|
||||
Difficulty,
|
||||
MathSprintConfig,
|
||||
MathSprintMove,
|
||||
MathSprintState,
|
||||
Operation,
|
||||
Question,
|
||||
} from './types'
|
||||
|
||||
export class MathSprintValidator implements GameValidator<MathSprintState, MathSprintMove> {
|
||||
/**
|
||||
* Validate a game move
|
||||
*/
|
||||
validateMove(
|
||||
state: MathSprintState,
|
||||
move: MathSprintMove,
|
||||
context?: { userId?: string }
|
||||
): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
|
||||
case 'SUBMIT_ANSWER':
|
||||
return this.validateSubmitAnswer(
|
||||
state,
|
||||
move.playerId,
|
||||
Number(move.data.answer),
|
||||
move.timestamp
|
||||
)
|
||||
|
||||
case 'NEXT_QUESTION':
|
||||
return this.validateNextQuestion(state)
|
||||
|
||||
case 'RESET_GAME':
|
||||
return this.validateResetGame(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
|
||||
default:
|
||||
return { valid: false, error: 'Unknown move type' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if game is complete
|
||||
*/
|
||||
isGameComplete(state: MathSprintState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initial state for new game
|
||||
*/
|
||||
getInitialState(config: unknown): MathSprintState {
|
||||
const { difficulty, questionsPerRound, timePerQuestion } = config as MathSprintConfig
|
||||
|
||||
return {
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
difficulty: difficulty || 'medium',
|
||||
questionsPerRound: questionsPerRound || 10,
|
||||
timePerQuestion: timePerQuestion || 30,
|
||||
currentQuestionIndex: 0,
|
||||
questions: [],
|
||||
scores: {},
|
||||
correctAnswersCount: {},
|
||||
answers: [],
|
||||
questionStartTime: 0,
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Methods
|
||||
// ============================================================================
|
||||
|
||||
private validateStartGame(
|
||||
state: MathSprintState,
|
||||
activePlayers: string[],
|
||||
playerMetadata: Record<string, any>
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Game already started' }
|
||||
}
|
||||
|
||||
if (activePlayers.length < 2) {
|
||||
return { valid: false, error: 'Need at least 2 players' }
|
||||
}
|
||||
|
||||
// Generate questions
|
||||
const questions = this.generateQuestions(state.difficulty, state.questionsPerRound)
|
||||
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
questions,
|
||||
currentQuestionIndex: 0,
|
||||
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
correctAnswersCount: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
answers: [],
|
||||
questionStartTime: Date.now(),
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSubmitAnswer(
|
||||
state: MathSprintState,
|
||||
playerId: string,
|
||||
answer: number,
|
||||
timestamp: number
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Game not in progress' }
|
||||
}
|
||||
|
||||
if (!state.activePlayers.includes(playerId)) {
|
||||
return { valid: false, error: 'Player not in game' }
|
||||
}
|
||||
|
||||
if (state.questionAnswered) {
|
||||
return { valid: false, error: 'Question already answered correctly' }
|
||||
}
|
||||
|
||||
// Check if player already answered this question
|
||||
const alreadyAnswered = state.answers.some((a) => a.playerId === playerId)
|
||||
if (alreadyAnswered) {
|
||||
return { valid: false, error: 'You already answered this question' }
|
||||
}
|
||||
|
||||
const currentQuestion = state.questions[state.currentQuestionIndex]
|
||||
const correct = answer === currentQuestion.correctAnswer
|
||||
|
||||
const answerRecord = {
|
||||
playerId,
|
||||
answer,
|
||||
timestamp,
|
||||
correct,
|
||||
}
|
||||
|
||||
const newAnswers = [...state.answers, answerRecord]
|
||||
let newState = { ...state, answers: newAnswers }
|
||||
|
||||
// If correct, award points and mark question as answered
|
||||
if (correct) {
|
||||
newState = {
|
||||
...newState,
|
||||
questionAnswered: true,
|
||||
winnerId: playerId,
|
||||
scores: {
|
||||
...state.scores,
|
||||
[playerId]: state.scores[playerId] + 10,
|
||||
},
|
||||
correctAnswersCount: {
|
||||
...state.correctAnswersCount,
|
||||
[playerId]: state.correctAnswersCount[playerId] + 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateNextQuestion(state: MathSprintState): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Game not in progress' }
|
||||
}
|
||||
|
||||
if (!state.questionAnswered) {
|
||||
return { valid: false, error: 'Current question not answered yet' }
|
||||
}
|
||||
|
||||
const isLastQuestion = state.currentQuestionIndex >= state.questions.length - 1
|
||||
|
||||
if (isLastQuestion) {
|
||||
// Game complete, go to results
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
}
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// Move to next question
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
currentQuestionIndex: state.currentQuestionIndex + 1,
|
||||
answers: [],
|
||||
questionStartTime: Date.now(),
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateResetGame(state: MathSprintState): ValidationResult {
|
||||
const newState = this.getInitialState({
|
||||
difficulty: state.difficulty,
|
||||
questionsPerRound: state.questionsPerRound,
|
||||
timePerQuestion: state.timePerQuestion,
|
||||
})
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSetConfig(state: MathSprintState, field: string, value: any): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Cannot change config during game' }
|
||||
}
|
||||
|
||||
const newState = {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Question Generation
|
||||
// ============================================================================
|
||||
|
||||
private generateQuestions(difficulty: Difficulty, count: number): Question[] {
|
||||
const questions: Question[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const operation = this.randomOperation()
|
||||
const question = this.generateQuestion(difficulty, operation, `q-${i}`)
|
||||
questions.push(question)
|
||||
}
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
private generateQuestion(difficulty: Difficulty, operation: Operation, id: string): Question {
|
||||
let operand1: number
|
||||
let operand2: number
|
||||
let correctAnswer: number
|
||||
|
||||
switch (difficulty) {
|
||||
case 'easy':
|
||||
operand1 = this.randomInt(1, 10)
|
||||
operand2 = this.randomInt(1, 10)
|
||||
break
|
||||
case 'medium':
|
||||
operand1 = this.randomInt(10, 50)
|
||||
operand2 = this.randomInt(1, 20)
|
||||
break
|
||||
case 'hard':
|
||||
operand1 = this.randomInt(10, 100)
|
||||
operand2 = this.randomInt(10, 50)
|
||||
break
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'addition':
|
||||
correctAnswer = operand1 + operand2
|
||||
break
|
||||
case 'subtraction':
|
||||
// Ensure positive result
|
||||
if (operand1 < operand2) {
|
||||
;[operand1, operand2] = [operand2, operand1]
|
||||
}
|
||||
correctAnswer = operand1 - operand2
|
||||
break
|
||||
case 'multiplication':
|
||||
// Smaller numbers for multiplication
|
||||
if (difficulty === 'hard') {
|
||||
operand1 = this.randomInt(2, 20)
|
||||
operand2 = this.randomInt(2, 12)
|
||||
} else {
|
||||
operand1 = this.randomInt(2, 10)
|
||||
operand2 = this.randomInt(2, 10)
|
||||
}
|
||||
correctAnswer = operand1 * operand2
|
||||
break
|
||||
}
|
||||
|
||||
const operationSymbol = this.getOperationSymbol(operation)
|
||||
const displayText = `${operand1} ${operationSymbol} ${operand2} = ?`
|
||||
|
||||
return {
|
||||
id,
|
||||
operand1,
|
||||
operand2,
|
||||
operation,
|
||||
correctAnswer,
|
||||
displayText,
|
||||
}
|
||||
}
|
||||
|
||||
private randomOperation(): Operation {
|
||||
const operations: Operation[] = ['addition', 'subtraction', 'multiplication']
|
||||
return operations[Math.floor(Math.random() * operations.length)]
|
||||
}
|
||||
|
||||
private randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
private getOperationSymbol(operation: Operation): string {
|
||||
switch (operation) {
|
||||
case 'addition':
|
||||
return '+'
|
||||
case 'subtraction':
|
||||
return '−'
|
||||
case 'multiplication':
|
||||
return '×'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mathSprintValidator = new MathSprintValidator()
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Game Component
|
||||
*
|
||||
* Main wrapper component with navigation and phase routing.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useMathSprint } from '../Provider'
|
||||
import { PlayingPhase } from './PlayingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, resetGame } = useMathSprint()
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Math Sprint"
|
||||
navEmoji="🧮"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
// No currentPlayerId - free-for-all game, everyone can act simultaneously
|
||||
playerScores={state.scores}
|
||||
onExitSession={() => {
|
||||
exitSession?.()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onNewGame={() => {
|
||||
resetGame()
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Playing Phase
|
||||
*
|
||||
* Main gameplay: show question, accept answers, show feedback.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
|
||||
export function PlayingPhase() {
|
||||
const { state, submitAnswer, nextQuestion, lastError, clearError } = useMathSprint()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const currentQuestion = state.questions[state.currentQuestionIndex]
|
||||
const progress = `${state.currentQuestionIndex + 1} / ${state.questions.length}`
|
||||
|
||||
// Find if current user answered
|
||||
const myPlayerId = Object.keys(state.playerMetadata).find(
|
||||
(pid) => state.playerMetadata[pid]?.userId === viewerId
|
||||
)
|
||||
const myAnswer = state.answers.find((a) => a.playerId === myPlayerId)
|
||||
|
||||
// Auto-clear error after 3 seconds
|
||||
useEffect(() => {
|
||||
if (lastError) {
|
||||
const timeout = setTimeout(() => clearError(), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [lastError, clearError])
|
||||
|
||||
// Clear input after question changes
|
||||
useEffect(() => {
|
||||
setInputValue('')
|
||||
}, [state.currentQuestionIndex])
|
||||
|
||||
const handleSubmit = () => {
|
||||
const answer = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(answer)) return
|
||||
|
||||
submitAnswer(answer)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '700px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: 'semibold' })}>
|
||||
Question {progress}
|
||||
</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
background: 'gray.200',
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(90deg, #a78bfa, #8b5cf6)',
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 0.3s',
|
||||
})}
|
||||
style={{
|
||||
width: `${((state.currentQuestionIndex + 1) / state.questions.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{lastError && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
|
||||
border: '2px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span>⚠️</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'red.700' })}>{lastError}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
padding: '4px 8px',
|
||||
background: 'red.100',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
_hover: { background: 'red.200' },
|
||||
})}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question Display */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
|
||||
border: '2px solid',
|
||||
borderColor: 'purple.300',
|
||||
borderRadius: '16px',
|
||||
padding: '48px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
>
|
||||
{currentQuestion.displayText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Answer Input */}
|
||||
{!state.questionAnswered && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: myAnswer ? 'gray.300' : 'purple.500',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
{myAnswer ? (
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Your answer: <strong>{myAnswer.answer}</strong>
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
|
||||
Waiting for others or correct answer...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Your Answer
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: '12px' })}>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your answer..."
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
fontSize: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
borderColor: 'purple.500',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
padding: '12px 24px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: inputValue ? 'purple.600' : 'gray.400',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: inputValue ? 'pointer' : 'not-allowed',
|
||||
_hover: {
|
||||
background: inputValue ? 'purple.700' : 'gray.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Winner Display */}
|
||||
{state.questionAnswered && state.winnerId && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #d1fae5, #a7f3d0)',
|
||||
border: '2px solid',
|
||||
borderColor: 'green.400',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '3xl', marginBottom: '8px' })}>🎉</div>
|
||||
<div className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'green.700' })}>
|
||||
{state.playerMetadata[state.winnerId]?.name || 'Someone'} got it right!
|
||||
</div>
|
||||
<div className={css({ fontSize: 'md', color: 'green.600', marginTop: '4px' })}>
|
||||
Answer: {currentQuestion.correctAnswer}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextQuestion}
|
||||
className={css({
|
||||
marginTop: '16px',
|
||||
padding: '12px 32px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: 'green.600',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
_hover: { background: 'green.700' },
|
||||
})}
|
||||
>
|
||||
Next Question →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
Scores
|
||||
</h3>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
|
||||
{Object.entries(state.scores)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([playerId, score]) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
background: 'gray.50',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span className={css({ fontSize: 'xl' })}>{player?.emoji}</span>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: 'medium' })}>
|
||||
{player?.name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'purple.600' })}
|
||||
>
|
||||
{score} pts
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Results Phase
|
||||
*
|
||||
* Show final scores and winner.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, resetGame } = useMathSprint()
|
||||
|
||||
// Sort players by score
|
||||
const sortedPlayers = Object.entries(state.scores)
|
||||
.map(([playerId, score]) => ({
|
||||
playerId,
|
||||
score,
|
||||
correct: state.correctAnswersCount[playerId] || 0,
|
||||
player: state.playerMetadata[playerId],
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
const winner = sortedPlayers[0]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Winner Announcement */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
|
||||
border: '2px solid',
|
||||
borderColor: 'yellow.400',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '4xl', marginBottom: '12px' })}>🏆</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'yellow.800',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{winner.player?.name} Wins!
|
||||
</h2>
|
||||
<div className={css({ fontSize: 'lg', color: 'yellow.700' })}>
|
||||
{winner.score} points • {winner.correct} correct
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final Scores */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Final Scores
|
||||
</h3>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
|
||||
{sortedPlayers.map((item, index) => (
|
||||
<div
|
||||
key={item.playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
background: index === 0 ? 'linear-gradient(135deg, #fef3c7, #fde68a)' : 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: index === 0 ? 'yellow.300' : 'gray.200',
|
||||
borderRadius: '12px',
|
||||
})}
|
||||
>
|
||||
{/* Rank */}
|
||||
<div
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: index === 0 ? 'yellow.500' : 'gray.300',
|
||||
color: index === 0 ? 'white' : 'gray.700',
|
||||
borderRadius: '50%',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Player Info */}
|
||||
<div className={css({ flex: 1 })}>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span className={css({ fontSize: 'xl' })}>{item.player?.emoji}</span>
|
||||
<span className={css({ fontSize: 'md', fontWeight: 'semibold' })}>
|
||||
{item.player?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ fontSize: 'xs', color: 'gray.600', marginTop: '2px' })}>
|
||||
{item.correct} / {state.questions.length} correct
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: index === 0 ? 'yellow.700' : 'purple.600',
|
||||
})}
|
||||
>
|
||||
{item.score}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
|
||||
border: '1px solid',
|
||||
borderColor: 'purple.300',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
|
||||
{state.questions.length}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Questions</div>
|
||||
</div>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
|
||||
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Difficulty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Play Again Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetGame}
|
||||
className={css({
|
||||
padding: '14px 28px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: 'purple.600',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
_hover: {
|
||||
background: 'purple.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Setup Phase
|
||||
*
|
||||
* Configure game settings before starting.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
import type { Difficulty } from '../types'
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, startGame, setConfig } = useMathSprint()
|
||||
|
||||
const handleDifficultyChange = (difficulty: Difficulty) => {
|
||||
setConfig('difficulty', difficulty)
|
||||
}
|
||||
|
||||
const handleQuestionsChange = (questions: number) => {
|
||||
setConfig('questionsPerRound', questions)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Game Title */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
🧮 Math Sprint
|
||||
</h1>
|
||||
<p className={css({ color: 'gray.600' })}>
|
||||
Race to solve math problems! First correct answer wins points.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Settings Card */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Game Settings
|
||||
</h2>
|
||||
|
||||
{/* Difficulty */}
|
||||
<div className={css({ marginBottom: '20px' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Difficulty
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: '8px' })}>
|
||||
{(['easy', 'medium', 'hard'] as Difficulty[]).map((diff) => (
|
||||
<button
|
||||
key={diff}
|
||||
type="button"
|
||||
onClick={() => handleDifficultyChange(diff)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid',
|
||||
borderColor: state.difficulty === diff ? 'purple.500' : 'gray.300',
|
||||
background: state.difficulty === diff ? 'purple.50' : 'white',
|
||||
color: state.difficulty === diff ? 'purple.700' : 'gray.700',
|
||||
fontWeight: state.difficulty === diff ? 'semibold' : 'normal',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: 'purple.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{diff.charAt(0).toUpperCase() + diff.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className={css({ fontSize: 'xs', color: 'gray.500', marginTop: '4px' })}>
|
||||
{state.difficulty === 'easy' && 'Numbers 1-10, simple operations'}
|
||||
{state.difficulty === 'medium' && 'Numbers 1-50, varied operations'}
|
||||
{state.difficulty === 'hard' && 'Numbers 1-100, harder calculations'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Questions Per Round */}
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Questions: {state.questionsPerRound}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="20"
|
||||
step="5"
|
||||
value={state.questionsPerRound}
|
||||
onChange={(e) => handleQuestionsChange(Number(e.target.value))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({ display: 'flex', justifyContent: 'space-between', fontSize: 'xs' })}
|
||||
>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
<span>15</span>
|
||||
<span>20</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.300',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3 className={css({ fontSize: 'sm', fontWeight: 'semibold', marginBottom: '8px' })}>
|
||||
How to Play
|
||||
</h3>
|
||||
<ul className={css({ fontSize: 'sm', color: 'gray.700', paddingLeft: '20px' })}>
|
||||
<li>Solve math problems as fast as you can</li>
|
||||
<li>First correct answer earns 10 points</li>
|
||||
<li>Everyone can answer at the same time</li>
|
||||
<li>Most points wins!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Start Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
disabled={state.activePlayers.length < 2}
|
||||
className={css({
|
||||
padding: '14px 28px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.600',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
cursor: state.activePlayers.length < 2 ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.length < 2
|
||||
? `Need ${2 - state.activePlayers.length} more player(s)`
|
||||
: 'Start Game'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
name: math-sprint
|
||||
displayName: Math Sprint
|
||||
icon: 🧮
|
||||
description: Fast-paced math racing game
|
||||
longDescription: Race against other players to solve math problems! Answer questions quickly to earn points. First person to answer correctly wins the round. Features multiple difficulty levels and customizable question counts.
|
||||
maxPlayers: 6
|
||||
difficulty: Beginner
|
||||
chips:
|
||||
- 👥 Multiplayer
|
||||
- ⚡ Free-for-All
|
||||
- 🧮 Math Skills
|
||||
- 🏃 Speed
|
||||
color: purple
|
||||
gradient: linear-gradient(135deg, #ddd6fe, #c4b5fd)
|
||||
borderColor: purple.200
|
||||
available: true
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Game Definition
|
||||
*
|
||||
* A free-for-all math game demonstrating the TEAM_MOVE pattern.
|
||||
* Players race to solve math problems - first correct answer wins points.
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { MathSprintProvider } from './Provider'
|
||||
import type { MathSprintConfig, MathSprintMove, MathSprintState } from './types'
|
||||
import { mathSprintValidator } from './Validator'
|
||||
|
||||
const manifest: GameManifest = {
|
||||
name: 'math-sprint',
|
||||
displayName: 'Math Sprint',
|
||||
icon: '🧮',
|
||||
description: 'Race to solve math problems!',
|
||||
longDescription:
|
||||
'A fast-paced free-for-all game where players compete to solve math problems. First correct answer earns points. Choose your difficulty and test your mental math skills!',
|
||||
maxPlayers: 8,
|
||||
difficulty: 'Beginner',
|
||||
chips: ['👥 Multiplayer', '⚡ Fast-Paced', '🧠 Mental Math'],
|
||||
color: 'purple',
|
||||
gradient: 'linear-gradient(135deg, #ddd6fe, #c4b5fd)',
|
||||
borderColor: 'purple.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
const defaultConfig: MathSprintConfig = {
|
||||
difficulty: 'medium',
|
||||
questionsPerRound: 10,
|
||||
timePerQuestion: 30,
|
||||
}
|
||||
|
||||
// Config validation function
|
||||
function validateMathSprintConfig(config: unknown): config is MathSprintConfig {
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
'difficulty' in config &&
|
||||
'questionsPerRound' in config &&
|
||||
'timePerQuestion' in config &&
|
||||
['easy', 'medium', 'hard'].includes((config as any).difficulty) &&
|
||||
typeof (config as any).questionsPerRound === 'number' &&
|
||||
typeof (config as any).timePerQuestion === 'number' &&
|
||||
(config as any).questionsPerRound >= 5 &&
|
||||
(config as any).questionsPerRound <= 20 &&
|
||||
(config as any).timePerQuestion >= 10
|
||||
)
|
||||
}
|
||||
|
||||
export const mathSprintGame = defineGame<MathSprintConfig, MathSprintState, MathSprintMove>({
|
||||
manifest,
|
||||
Provider: MathSprintProvider,
|
||||
GameComponent,
|
||||
validator: mathSprintValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateMathSprintConfig,
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Game Types
|
||||
*
|
||||
* A free-for-all game where players race to solve math problems.
|
||||
* Demonstrates the TEAM_MOVE pattern (no specific turn owner).
|
||||
*/
|
||||
|
||||
import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk'
|
||||
|
||||
/**
|
||||
* Difficulty levels for math problems
|
||||
*/
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard'
|
||||
|
||||
/**
|
||||
* Math operation types
|
||||
*/
|
||||
export type Operation = 'addition' | 'subtraction' | 'multiplication'
|
||||
|
||||
/**
|
||||
* Game configuration (persisted to database)
|
||||
*/
|
||||
export interface MathSprintConfig extends GameConfig {
|
||||
difficulty: Difficulty
|
||||
questionsPerRound: number
|
||||
timePerQuestion: number // seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* A math question
|
||||
*/
|
||||
export interface Question {
|
||||
id: string
|
||||
operand1: number
|
||||
operand2: number
|
||||
operation: Operation
|
||||
correctAnswer: number
|
||||
displayText: string // e.g., "5 + 3 = ?"
|
||||
}
|
||||
|
||||
/**
|
||||
* Player answer submission
|
||||
*/
|
||||
export interface Answer {
|
||||
playerId: string
|
||||
answer: number
|
||||
timestamp: number
|
||||
correct: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Game state (synchronized across all clients)
|
||||
*/
|
||||
export interface MathSprintState extends GameState {
|
||||
gamePhase: 'setup' | 'playing' | 'results'
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
|
||||
|
||||
// Configuration
|
||||
difficulty: Difficulty
|
||||
questionsPerRound: number
|
||||
timePerQuestion: number
|
||||
|
||||
// Game progress
|
||||
currentQuestionIndex: number
|
||||
questions: Question[]
|
||||
|
||||
// Scoring
|
||||
scores: Record<string, number> // playerId -> score
|
||||
correctAnswersCount: Record<string, number> // playerId -> count
|
||||
|
||||
// Current question state
|
||||
answers: Answer[] // All answers for current question
|
||||
questionStartTime: number // Timestamp when question was shown
|
||||
questionAnswered: boolean // True if someone got it right
|
||||
winnerId: string | null // Winner of current question (first correct)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move types for Math Sprint
|
||||
*/
|
||||
export type MathSprintMove =
|
||||
| StartGameMove
|
||||
| SubmitAnswerMove
|
||||
| NextQuestionMove
|
||||
| ResetGameMove
|
||||
| SetConfigMove
|
||||
|
||||
export interface StartGameMove extends GameMove {
|
||||
type: 'START_GAME'
|
||||
data: {
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubmitAnswerMove extends GameMove {
|
||||
type: 'SUBMIT_ANSWER'
|
||||
data: {
|
||||
answer: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface NextQuestionMove extends GameMove {
|
||||
type: 'NEXT_QUESTION'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface ResetGameMove extends GameMove {
|
||||
type: 'RESET_GAME'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface SetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion'
|
||||
value: Difficulty | number
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Number Guesser Provider
|
||||
* Manages game state using the Arcade SDK
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
||||
import {
|
||||
type GameMove,
|
||||
buildPlayerMetadata,
|
||||
useArcadeSession,
|
||||
useGameMode,
|
||||
useRoomData,
|
||||
useUpdateGameConfig,
|
||||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import type { NumberGuesserState } from './types'
|
||||
|
||||
/**
|
||||
* Context value interface
|
||||
*/
|
||||
interface NumberGuesserContextValue {
|
||||
state: NumberGuesserState
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
chooseNumber: (number: number) => void
|
||||
makeGuess: (guess: number) => void
|
||||
nextRound: () => void
|
||||
goToSetup: () => void
|
||||
setConfig: (field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
const NumberGuesserContext = createContext<NumberGuesserContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Hook to access Number Guesser context
|
||||
*/
|
||||
export function useNumberGuesser() {
|
||||
const context = useContext(NumberGuesserContext)
|
||||
if (!context) {
|
||||
throw new Error('useNumberGuesser must be used within NumberGuesserProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic move application
|
||||
*/
|
||||
function applyMoveOptimistically(state: NumberGuesserState, move: GameMove): NumberGuesserState {
|
||||
// For simplicity, just return current state
|
||||
// Server will send back the validated new state
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Number Guesser Provider Component
|
||||
*/
|
||||
export function NumberGuesserProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active players as array (keep Set iteration order to match UI display)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Merge saved config from room
|
||||
const initialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null | undefined
|
||||
const savedConfig = gameConfig?.['number-guesser'] as Record<string, unknown> | undefined
|
||||
|
||||
return {
|
||||
minNumber: (savedConfig?.minNumber as number) || 1,
|
||||
maxNumber: (savedConfig?.maxNumber as number) || 100,
|
||||
roundsToWin: (savedConfig?.roundsToWin as number) || 3,
|
||||
gamePhase: 'setup' as const,
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession, lastError, clearError } =
|
||||
useArcadeSession<NumberGuesserState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
if (activePlayers.length < 2) {
|
||||
console.error('Need at least 2 players to start')
|
||||
return
|
||||
}
|
||||
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: activePlayers[0],
|
||||
userId: viewerId || '',
|
||||
data: {
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [activePlayers, players, viewerId, sendMove])
|
||||
|
||||
const chooseNumber = useCallback(
|
||||
(secretNumber: number) => {
|
||||
sendMove({
|
||||
type: 'CHOOSE_NUMBER',
|
||||
playerId: state.chooser,
|
||||
userId: viewerId || '',
|
||||
data: { secretNumber },
|
||||
})
|
||||
},
|
||||
[state.chooser, viewerId, sendMove]
|
||||
)
|
||||
|
||||
const makeGuess = useCallback(
|
||||
(guess: number) => {
|
||||
const playerName = state.playerMetadata[state.currentGuesser]?.name || 'Unknown'
|
||||
|
||||
sendMove({
|
||||
type: 'MAKE_GUESS',
|
||||
playerId: state.currentGuesser,
|
||||
userId: viewerId || '',
|
||||
data: { guess, playerName },
|
||||
})
|
||||
},
|
||||
[state.currentGuesser, state.playerMetadata, viewerId, sendMove]
|
||||
)
|
||||
|
||||
const nextRound = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'NEXT_ROUND',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, viewerId, sendMove])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId: activePlayers[0] || state.chooser || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, state.chooser, viewerId, sendMove])
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => {
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Persist to database
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
|
||||
const currentNumberGuesserConfig =
|
||||
(currentGameConfig['number-guesser'] as Record<string, unknown>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
'number-guesser': {
|
||||
...currentNumberGuesserConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
}
|
||||
},
|
||||
[activePlayers, viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const contextValue: NumberGuesserContextValue = {
|
||||
state,
|
||||
lastError,
|
||||
startGame,
|
||||
chooseNumber,
|
||||
makeGuess,
|
||||
nextRound,
|
||||
goToSetup,
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberGuesserContext.Provider value={contextValue}>{children}</NumberGuesserContext.Provider>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user