Compare commits
135 Commits
abacus-rea
...
abacus-rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93f4cb0b11 | ||
|
|
c5ebc635af | ||
|
|
491b299e28 | ||
|
|
bb9959f7fb | ||
|
|
88ac0b9bcb | ||
|
|
2ed7b2cbf8 | ||
|
|
63b0b552a8 | ||
|
|
b7233f9e4a | ||
|
|
db56ce89ee | ||
|
|
4f79c08d73 | ||
|
|
46d4af2bda | ||
|
|
6c90a68c49 | ||
|
|
9ea15535d1 | ||
|
|
65dafc9215 | ||
|
|
e06a750454 | ||
|
|
5d2083903e | ||
|
|
a9e0d19734 | ||
|
|
fe9ea67f56 | ||
|
|
78d5234a79 | ||
|
|
5ae22e4645 | ||
|
|
d9acc0efea | ||
|
|
05bb035db5 | ||
|
|
a1f2b9736a | ||
|
|
a2512d5738 | ||
|
|
32abde107c | ||
|
|
fad8636763 | ||
|
|
f2e71657dc | ||
|
|
e704a28524 | ||
|
|
96782b0e7a | ||
|
|
01766944f0 | ||
|
|
93cb070ca5 | ||
|
|
1613912740 | ||
|
|
651bc21583 | ||
|
|
8ad3144d2d | ||
|
|
076c97abac | ||
|
|
227cfabf11 | ||
|
|
1b11031598 | ||
|
|
4ac8758957 | ||
|
|
db52e14dfe | ||
|
|
8c8b8e08b4 | ||
|
|
ee48417abf | ||
|
|
99cdfa8a0b | ||
|
|
7488bb3803 | ||
|
|
1a5fa2873b | ||
|
|
f7419bc6a0 | ||
|
|
eadd7da6db | ||
|
|
05a3ddb086 | ||
|
|
c928e90785 | ||
|
|
ebc6894746 | ||
|
|
0bcd7a30d4 | ||
|
|
5c5954be74 | ||
|
|
863a2e1319 | ||
|
|
4b6888af05 | ||
|
|
32c3a35eab | ||
|
|
3ea88d7a5a | ||
|
|
1a8093416e | ||
|
|
12c54b27b7 | ||
|
|
73a59745a5 | ||
|
|
3920bbad33 | ||
|
|
80e33e25b3 | ||
|
|
600bc35bc3 | ||
|
|
920aaa6398 | ||
|
|
1ff9695f69 | ||
|
|
a08f0535bf | ||
|
|
f3bc2f6d92 | ||
|
|
8c3a855239 | ||
|
|
90ba86640c | ||
|
|
90ad789ff1 | ||
|
|
159990489f | ||
|
|
4bbdabc3b5 | ||
|
|
23a9016245 | ||
|
|
582bce411f | ||
|
|
d8b4e425bf | ||
|
|
2c88b6b5f3 | ||
|
|
b2a21b79ad | ||
|
|
616a50e234 | ||
|
|
72f8dee183 | ||
|
|
d0a3bc7dc1 | ||
|
|
9a3fa93e53 | ||
|
|
982fa45c08 | ||
|
|
b58bcd92ee | ||
|
|
5c8c18cbb8 | ||
|
|
edfdd81227 | ||
|
|
6049a7f6b7 | ||
|
|
b85968bcb6 | ||
|
|
4f2a661494 | ||
|
|
31898328a3 | ||
|
|
98cfa5645b | ||
|
|
e3f552d8f5 | ||
|
|
66f52234e1 | ||
|
|
57a72e34a5 | ||
|
|
28495767a9 | ||
|
|
d67315f771 | ||
|
|
fa1cf96789 | ||
|
|
8792393956 | ||
|
|
386c88a3c0 | ||
|
|
a854fe3dc9 | ||
|
|
402724c80e | ||
|
|
c77e880be3 | ||
|
|
6c0bf7b0f7 | ||
|
|
ae4b71b986 | ||
|
|
bf046c999b | ||
|
|
183706dade | ||
|
|
54ff20c755 | ||
|
|
7a3e34b4fa | ||
|
|
a935e5aed8 | ||
|
|
dd9f688a32 | ||
|
|
0b9bfed12d | ||
|
|
af7725622e | ||
|
|
18af9730ff | ||
|
|
beff64652c | ||
|
|
c4d6691715 | ||
|
|
dcefa74902 | ||
|
|
8973241297 | ||
|
|
7f8c90acea | ||
|
|
111c0ced71 | ||
|
|
876ace50ec | ||
|
|
d58053fad3 | ||
|
|
899fc6975f | ||
|
|
c95be1df6d | ||
|
|
a387b030fa | ||
|
|
90b9ffa0d8 | ||
|
|
3fa314aaa5 | ||
|
|
0c46f3a7ba | ||
|
|
1fe12c4837 | ||
|
|
b7e7c4beff | ||
|
|
423ba55350 | ||
|
|
885fc725dc | ||
|
|
0311b0fe03 | ||
|
|
cea5fadbe4 | ||
|
|
9191b12493 | ||
|
|
3dcff2ff88 | ||
|
|
301e65dfa6 | ||
|
|
00bfcbcdee | ||
|
|
0a4bf1765c |
@@ -151,7 +151,27 @@
|
||||
"Bash(git pull:*)",
|
||||
"WebFetch(domain:antialias.github.io)",
|
||||
"Bash(open http://localhost:3006/games/matching)",
|
||||
"Bash(gh api:*)"
|
||||
"Bash(gh api:*)",
|
||||
"Bash(npx playwright test:*)",
|
||||
"Bash(open http://localhost:3001/games/matching)",
|
||||
"Bash(open http://localhost:3001/games/memory-quiz)",
|
||||
"Bash(open http://localhost:3002)",
|
||||
"Bash(open \"data:text/html,<script>console.log(''Current localStorage:'', localStorage.getItem(''soroban-abacus-display-config'')); localStorage.setItem(''soroban-abacus-display-config'', JSON.stringify({colorScheme: ''place-value'', beadShape: ''diamond'', hideInactiveBeads: false, coloredNumerals: false, scaleFactor: 1.0, soundEnabled: true, soundVolume: 0.8})); console.log(''After test save:'', localStorage.getItem(''soroban-abacus-display-config''));</script><h1>Check browser console</h1>\")",
|
||||
"Bash(npx playwright:*)",
|
||||
"Bash(open \"data:text/html,<script>\nconsole.log(''Current localStorage:'', localStorage.getItem(''soroban-abacus-display-config'')); \nlocalStorage.setItem(''soroban-abacus-display-config'', JSON.stringify({\n colorScheme: ''place-value'', \n beadShape: ''diamond'', \n hideInactiveBeads: false, \n coloredNumerals: false, \n scaleFactor: 1.0, \n soundEnabled: true, \n soundVolume: 0.8\n})); \nconsole.log(''After test save:'', localStorage.getItem(''soroban-abacus-display-config''));\n</script><h1>Check browser console</h1>\")",
|
||||
"Bash(xargs sed:*)",
|
||||
"Bash(open http://localhost:3003/games/matching)",
|
||||
"Bash(open http://localhost:3003/arcade/matching)",
|
||||
"Bash(open http://localhost:3000)",
|
||||
"Bash(open http://localhost:3003/games/memory-quiz)",
|
||||
"Bash(open http://localhost:3001)",
|
||||
"Bash(open http://localhost:3001/arcade)",
|
||||
"Bash(open http://localhost:6006)",
|
||||
"Bash(open http://localhost:3002/games/matching)",
|
||||
"Bash(open http://localhost:3002/create)",
|
||||
"Bash(open http://localhost:3002/games/complement-race/practice)",
|
||||
"Bash(open http://localhost:3002/games/complement-race)",
|
||||
"Bash(npx vitest run:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
11
.github/workflows/publish-abacus-react.yml
vendored
11
.github/workflows/publish-abacus-react.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8.0.0
|
||||
version: 8.15.6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
@@ -52,7 +52,10 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: |
|
||||
echo "Installing dependencies..."
|
||||
pnpm install
|
||||
echo "Dependencies installed successfully"
|
||||
|
||||
- name: Build abacus-react package
|
||||
run: pnpm --filter @soroban/abacus-react build
|
||||
@@ -69,7 +72,7 @@ jobs:
|
||||
- name: Configure npm for GitHub Packages
|
||||
working-directory: packages/abacus-react
|
||||
run: |
|
||||
echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" > .npmrc
|
||||
echo "//npm.pkg.github.com/:_authToken=${{ secrets.NPM_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}" > .npmrc
|
||||
echo "@soroban:registry=https://npm.pkg.github.com" >> .npmrc
|
||||
echo "registry=https://npm.pkg.github.com" >> .npmrc
|
||||
|
||||
@@ -144,7 +147,7 @@ jobs:
|
||||
|
||||
# Set authentication and registry for GitHub Packages
|
||||
echo "Publishing with explicit authentication..."
|
||||
NPM_CONFIG_USERCONFIG=.npmrc NODE_AUTH_TOKEN="${{ secrets.GITHUB_TOKEN }}" npm publish --registry=https://npm.pkg.github.com
|
||||
NPM_CONFIG_USERCONFIG=.npmrc NODE_AUTH_TOKEN="${{ secrets.NPM_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}" npm publish --registry=https://npm.pkg.github.com
|
||||
else
|
||||
echo "No new abacus-react version tag found, skipping publish"
|
||||
fi
|
||||
14
README.md
14
README.md
@@ -773,4 +773,16 @@ make verify-examples
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
This project uses DejaVu Sans font (included), which is released under a free license.
|
||||
This project uses DejaVu Sans font (included), which is released under a free license.
|
||||
---
|
||||
|
||||
## 🚀 Active Development Projects
|
||||
|
||||
### Speed Complement Race Port (In Progress)
|
||||
**Status**: Planning Complete, Ready to Implement
|
||||
**Plan Document**: [`apps/web/COMPLEMENT_RACE_PORT_PLAN.md`](./apps/web/COMPLEMENT_RACE_PORT_PLAN.md)
|
||||
**Source**: `packages/core/src/web_generator.py` (lines 10956-15113)
|
||||
**Target**: `apps/web/src/app/games/complement-race/`
|
||||
|
||||
A comprehensive port of the sophisticated Speed Complement Race game from standalone HTML to Next.js. Features 3 game modes, 2 AI personalities with 82 unique commentary messages, adaptive difficulty, and multiple visualization systems.
|
||||
|
||||
|
||||
1170
apps/web/COMPLEMENT_RACE_PORT_PLAN.md
Normal file
1170
apps/web/COMPLEMENT_RACE_PORT_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
111
apps/web/e2e/mini-nav-persistence.spec.ts
Normal file
111
apps/web/e2e/mini-nav-persistence.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Mini Navigation Game Name Persistence', () => {
|
||||
test('should not show game name when navigating back to games page from a specific game', async ({ page }) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
// Start at home page
|
||||
await page.goto(baseURL)
|
||||
|
||||
// Navigate to games page - should not have game name in mini nav
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// Check that mini nav doesn't show game name initially
|
||||
const initialGameName = page.locator('[data-testid="mini-nav-game-name"]')
|
||||
await expect(initialGameName).not.toBeVisible()
|
||||
|
||||
// Navigate to Memory Pairs game
|
||||
await page.click('a[href="/games/matching"]')
|
||||
await page.waitForURL('/games/matching')
|
||||
|
||||
// Verify game name appears in mini nav
|
||||
const memoryPairsName = page.locator('text=🧩 Memory Pairs')
|
||||
await expect(memoryPairsName).toBeVisible()
|
||||
|
||||
// Navigate back to games page using mini nav
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// BUG: Game name should disappear but it persists
|
||||
// This test should FAIL initially, demonstrating the bug
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Also test with Memory Lightning game
|
||||
await page.click('a[href="/games/memory-quiz"]')
|
||||
await page.waitForURL('/games/memory-quiz')
|
||||
|
||||
// Verify Memory Lightning name appears
|
||||
const memoryLightningName = page.locator('text=🧠 Memory Lightning')
|
||||
await expect(memoryLightningName).toBeVisible()
|
||||
|
||||
// Navigate back to games page
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// Game name should disappear
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should show correct game name when switching between different games', async ({ page }) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
// Start at Memory Pairs
|
||||
await page.goto(`${baseURL}/games/matching`)
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).toBeVisible()
|
||||
|
||||
// Switch to Memory Lightning
|
||||
await page.click('a[href="/games/memory-quiz"]')
|
||||
await page.waitForURL('/games/memory-quiz')
|
||||
|
||||
// Should show Memory Lightning and NOT Memory Pairs
|
||||
await expect(page.locator('text=🧠 Memory Lightning')).toBeVisible()
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).not.toBeVisible()
|
||||
|
||||
// Switch back to Memory Pairs
|
||||
await page.click('a[href="/games/matching"]')
|
||||
await page.waitForURL('/games/matching')
|
||||
|
||||
// Should show Memory Pairs and NOT Memory Lightning
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).toBeVisible()
|
||||
await expect(page.locator('text=🧠 Memory Lightning')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not persist game name when navigating through intermediate pages', async ({ page }) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
// Start at Memory Pairs game - should show game name
|
||||
await page.goto(`${baseURL}/games/matching`)
|
||||
const memoryPairsName = page.locator('text=🧩 Memory Pairs')
|
||||
await expect(memoryPairsName).toBeVisible()
|
||||
|
||||
// Navigate to Guide page - game name should disappear
|
||||
await page.click('a[href="/guide"]')
|
||||
await page.waitForURL('/guide')
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Navigate to Games page - game name should still be gone
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Test another path: Game -> Create -> Games
|
||||
await page.goto(`${baseURL}/games/memory-quiz`)
|
||||
const memoryLightningName = page.locator('text=🧠 Memory Lightning')
|
||||
await expect(memoryLightningName).toBeVisible()
|
||||
|
||||
// Navigate to Create page
|
||||
await page.click('a[href="/create"]')
|
||||
await page.waitForURL('/create')
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
|
||||
// Navigate to Games page - should not show any game name
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
73
apps/web/e2e/nav-slot.spec.ts
Normal file
73
apps/web/e2e/nav-slot.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Game navigation slots', () => {
|
||||
test('should show Memory Pairs game name in nav when navigating to matching game', async ({ page }) => {
|
||||
await page.goto('/games/matching')
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for the game name in the navigation
|
||||
const gameNav = page.locator('[data-testid="nav-slot"], h1:has-text("Memory Pairs")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Pairs')
|
||||
})
|
||||
|
||||
test('should show Memory Lightning game name in nav when navigating to memory quiz', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for the game name in the navigation
|
||||
const gameNav = page.locator('[data-testid="nav-slot"], h1:has-text("Memory Lightning")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Lightning')
|
||||
})
|
||||
|
||||
test('should maintain game name in nav after page reload', async ({ page }) => {
|
||||
// Navigate to matching game
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Verify game name appears
|
||||
const gameNav = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
|
||||
// Reload the page
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Verify game name still appears after reload
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Pairs')
|
||||
})
|
||||
|
||||
test('should show different game names when navigating between games', async ({ page }) => {
|
||||
// Start with matching game
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const matchingNav = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(matchingNav).toBeVisible()
|
||||
|
||||
// Navigate to memory quiz
|
||||
await page.goto('/games/memory-quiz')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const quizNav = page.locator('h1:has-text("Memory Lightning")')
|
||||
await expect(quizNav).toBeVisible()
|
||||
|
||||
// Verify the matching game name is gone
|
||||
await expect(matchingNav).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show game name on non-game pages', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should not see any game names on the home page
|
||||
const gameNavs = page.locator('h1:has-text("Memory Pairs"), h1:has-text("Memory Lightning")')
|
||||
await expect(gameNavs).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
119
apps/web/e2e/sound-settings-persistence.spec.ts
Normal file
119
apps/web/e2e/sound-settings-persistence.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Sound Settings Persistence', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear localStorage before each test
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
})
|
||||
|
||||
test('should persist sound enabled setting to localStorage', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Open style dropdown
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
// Find and toggle the sound switch (should be off by default)
|
||||
const soundSwitch = page.locator('[role="switch"]').filter({ hasText: /sound/i }).or(
|
||||
page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })
|
||||
).or(
|
||||
page.getByLabel(/sound/i)
|
||||
).or(
|
||||
page.locator('button').filter({ hasText: /sound/i })
|
||||
).first()
|
||||
|
||||
await soundSwitch.click()
|
||||
|
||||
// Check localStorage was updated
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
|
||||
// Reload page and verify setting persists
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
const soundSwitchAfterReload = page.locator('[role="switch"]').filter({ hasText: /sound/i }).or(
|
||||
page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })
|
||||
).or(
|
||||
page.getByLabel(/sound/i)
|
||||
).or(
|
||||
page.locator('button').filter({ hasText: /sound/i })
|
||||
).first()
|
||||
|
||||
await expect(soundSwitchAfterReload).toBeChecked()
|
||||
})
|
||||
|
||||
test('should persist sound volume setting to localStorage', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Open style dropdown
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
// Find volume slider
|
||||
const volumeSlider = page.locator('input[type="range"]').or(
|
||||
page.locator('[role="slider"]')
|
||||
).first()
|
||||
|
||||
// Set volume to a specific value (e.g., 0.6)
|
||||
await volumeSlider.fill('60') // Assuming 0-100 range
|
||||
|
||||
// Check localStorage was updated
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundVolume).toBeCloseTo(0.6, 1)
|
||||
|
||||
// Reload page and verify setting persists
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
const volumeSliderAfterReload = page.locator('input[type="range"]').or(
|
||||
page.locator('[role="slider"]')
|
||||
).first()
|
||||
|
||||
const volumeValue = await volumeSliderAfterReload.inputValue()
|
||||
expect(parseFloat(volumeValue)).toBeCloseTo(60, 0) // Allow for some variance
|
||||
})
|
||||
|
||||
test('should load default sound settings when localStorage is empty', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Check that default settings are loaded
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
// Should have default values: soundEnabled: true, soundVolume: 0.8
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
expect(storedConfig.soundVolume).toBe(0.8)
|
||||
})
|
||||
|
||||
test('should handle invalid localStorage data gracefully', async ({ page }) => {
|
||||
// Set invalid localStorage data
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('soroban-abacus-display-config', 'invalid-json')
|
||||
})
|
||||
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Should fall back to defaults and not crash
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
expect(storedConfig.soundVolume).toBe(0.8)
|
||||
})
|
||||
})
|
||||
@@ -48,21 +48,23 @@
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"emojibase-data": "^16.0.3",
|
||||
"lucide-react": "^0.294.0",
|
||||
"make-plural": "^7.4.0",
|
||||
"next": "^14.2.32",
|
||||
"python-bridge": "^1.1.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable-layout": "^0.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@storybook/addon-docs": "^9.1.7",
|
||||
"@storybook/addon-onboarding": "^9.1.7",
|
||||
"@storybook/nextjs": "^9.1.7",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"concurrently": "^8.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
|
||||
@@ -47,6 +47,60 @@ export default defineConfig({
|
||||
shadows: {
|
||||
card: { value: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)' },
|
||||
modal: { value: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' }
|
||||
},
|
||||
animations: {
|
||||
// Shake animation for errors (web_generator.py line 3419)
|
||||
shake: { value: 'shake 0.5s ease-in-out' },
|
||||
// Pulse animation for success feedback (line 2004)
|
||||
successPulse: { value: 'successPulse 0.5s ease' },
|
||||
pulse: { value: 'pulse 2s infinite' },
|
||||
// Error shake with larger amplitude (line 2009)
|
||||
errorShake: { value: 'errorShake 0.5s ease' },
|
||||
// Bounce animations (line 6271, 5065)
|
||||
bounce: { value: 'bounce 1s infinite alternate' },
|
||||
bounceIn: { value: 'bounceIn 1s ease-out' },
|
||||
// Glow animation (line 6260)
|
||||
glow: { value: 'glow 1s ease-in-out infinite alternate' }
|
||||
}
|
||||
},
|
||||
keyframes: {
|
||||
// Shake - horizontal oscillation for errors (line 3419)
|
||||
shake: {
|
||||
'0%, 100%': { transform: 'translateX(0)' },
|
||||
'25%': { transform: 'translateX(-5px)' },
|
||||
'75%': { transform: 'translateX(5px)' }
|
||||
},
|
||||
// Success pulse - gentle scale for correct answers (line 2004)
|
||||
successPulse: {
|
||||
'0%, 100%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.05)' }
|
||||
},
|
||||
// Pulse - continuous breathing effect (line 6255)
|
||||
pulse: {
|
||||
'0%, 100%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.05)' }
|
||||
},
|
||||
// Error shake - stronger horizontal oscillation (line 2009)
|
||||
errorShake: {
|
||||
'0%, 100%': { transform: 'translateX(0)' },
|
||||
'25%': { transform: 'translateX(-10px)' },
|
||||
'75%': { transform: 'translateX(10px)' }
|
||||
},
|
||||
// Bounce - vertical oscillation (line 6271)
|
||||
bounce: {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-10px)' }
|
||||
},
|
||||
// Bounce in - entry animation with scale and rotate (line 6265)
|
||||
bounceIn: {
|
||||
'0%': { transform: 'scale(0.3) rotate(-10deg)', opacity: '0' },
|
||||
'50%': { transform: 'scale(1.1) rotate(5deg)' },
|
||||
'100%': { transform: 'scale(1) rotate(0deg)', opacity: '1' }
|
||||
},
|
||||
// Glow - expanding box shadow (line 6260)
|
||||
glow: {
|
||||
'0%': { boxShadow: '0 0 5px rgba(255, 255, 255, 0.5)' },
|
||||
'100%': { boxShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
apps/web/playwright.config.ts
Normal file
27
apps/web/playwright.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3002',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:3002',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
62
apps/web/src/app/__tests__/layout.nav.test.tsx
Normal file
62
apps/web/src/app/__tests__/layout.nav.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import RootLayout from '../layout'
|
||||
|
||||
// Mock AppNavBar to verify it receives the nav prop
|
||||
const MockAppNavBar = ({ navSlot }: { navSlot?: React.ReactNode }) => (
|
||||
<div data-testid="app-nav-bar">
|
||||
{navSlot && <div data-testid="nav-slot-content">{navSlot}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
jest.mock('../../components/AppNavBar', () => ({
|
||||
AppNavBar: MockAppNavBar,
|
||||
}))
|
||||
|
||||
// Mock all context providers
|
||||
jest.mock('../../contexts/AbacusDisplayContext', () => ({
|
||||
AbacusDisplayProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('../../contexts/UserProfileContext', () => ({
|
||||
UserProfileProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('../../contexts/GameModeContext', () => ({
|
||||
GameModeProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('../../contexts/FullscreenContext', () => ({
|
||||
FullscreenProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('RootLayout with nav slot', () => {
|
||||
it('passes nav slot to AppNavBar', () => {
|
||||
const navContent = <div>Memory Lightning</div>
|
||||
const pageContent = <div>Page content</div>
|
||||
|
||||
render(
|
||||
<RootLayout nav={navContent}>
|
||||
{pageContent}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('app-nav-bar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('nav-slot-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('Memory Lightning')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('works without nav slot', () => {
|
||||
const pageContent = <div>Page content</div>
|
||||
|
||||
render(
|
||||
<RootLayout nav={null}>
|
||||
{pageContent}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('app-nav-bar')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-slot-content')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Page content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { EnhancedChampionArena } from '../../components/EnhancedChampionArena'
|
||||
import { FullscreenProvider, useFullscreen } from '../../contexts/FullscreenContext'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
|
||||
function ArcadeContent() {
|
||||
const router = useRouter()
|
||||
@@ -31,12 +32,13 @@ function ArcadeContent() {
|
||||
<div
|
||||
ref={arcadeRef}
|
||||
className={css({
|
||||
height: '100vh',
|
||||
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'
|
||||
flexDirection: 'column',
|
||||
py: { base: '4', md: '6' }
|
||||
})}>
|
||||
{/* Animated background elements */}
|
||||
<div className={css({
|
||||
@@ -53,45 +55,11 @@ function ArcadeContent() {
|
||||
animation: 'arcadeFloat 20s ease-in-out infinite'
|
||||
})} />
|
||||
|
||||
{/* Compact Header - only shows in fullscreen */}
|
||||
<div className={css({
|
||||
flexShrink: 0,
|
||||
textAlign: 'center',
|
||||
py: { base: '3', md: '4' },
|
||||
px: '4',
|
||||
position: 'relative',
|
||||
zIndex: 2
|
||||
})}>
|
||||
<h1 className={css({
|
||||
fontSize: { base: '2xl', sm: '3xl', md: '4xl', lg: '5xl' },
|
||||
fontWeight: 'black',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
mb: { base: '1', md: '2' },
|
||||
textShadow: '0 0 30px rgba(96, 165, 250, 0.5)',
|
||||
lineHeight: '1.1'
|
||||
})}>
|
||||
🏟️ CHAMPION ARENA
|
||||
</h1>
|
||||
|
||||
<p className={css({
|
||||
fontSize: { base: 'sm', md: 'lg', lg: 'xl' },
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
maxW: '2xl',
|
||||
mx: 'auto',
|
||||
display: { base: 'none', sm: 'block' }
|
||||
})}>
|
||||
Select your champions and dive into epic mathematical battles!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Champion Arena - takes remaining space */}
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
px: { base: '2', md: '4' },
|
||||
pb: { base: '2', md: '4' },
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
minHeight: 0 // Important for flex children
|
||||
@@ -117,7 +85,9 @@ function ArcadeContent() {
|
||||
export default function ArcadePage() {
|
||||
return (
|
||||
<FullscreenProvider>
|
||||
<ArcadeContent />
|
||||
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizeGameContext={true}>
|
||||
<ArcadeContent />
|
||||
</PageWithNav>
|
||||
</FullscreenProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import { useState } from 'react'
|
||||
import { useForm } from '@tanstack/react-form'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { container, stack, hstack, grid } from '../../../styled-system/patterns'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import Link from 'next/link'
|
||||
import { ConfigurationForm } from '@/components/ConfigurationForm'
|
||||
import { ConfigurationFormWithoutGenerate } from '@/components/ConfigurationFormWithoutGenerate'
|
||||
import { LivePreview } from '@/components/LivePreview'
|
||||
import { GenerationProgress } from '@/components/GenerationProgress'
|
||||
import { StyleControls } from '@/components/StyleControls'
|
||||
import { useAbacusConfig } from '@/contexts/AbacusDisplayContext'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
|
||||
// Complete, validated configuration ready for generation
|
||||
export interface FlashcardConfig {
|
||||
@@ -185,7 +186,8 @@ export default function CreatePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
|
||||
<PageWithNav navTitle="Create Flashcards" navEmoji="✨">
|
||||
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '8' })}>
|
||||
@@ -378,6 +380,7 @@ export default function CreatePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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)]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { GameIntro } from './GameIntro'
|
||||
import { GameControls } from './GameControls'
|
||||
import { GameCountdown } from './GameCountdown'
|
||||
import { GameDisplay } from './GameDisplay'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import type { GameMode, GameStyle, TimeoutSetting, ComplementDisplay } 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useGameLoop } from '../hooks/useGameLoop'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useAIRacers } from '../hooks/useAIRacers'
|
||||
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
|
||||
import { useSteamJourney } from '../hooks/useSteamJourney'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
import { LinearTrack } from './RaceTrack/LinearTrack'
|
||||
import { CircularTrack } from './RaceTrack/CircularTrack'
|
||||
import { SteamTrainJourney } from './RaceTrack/SteamTrainJourney'
|
||||
import { RouteCelebration } from './RouteCelebration'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
import { generatePassengers } from '../lib/passengerGenerator'
|
||||
|
||||
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)
|
||||
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])
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
120
apps/web/src/app/games/complement-race/components/GameIntro.tsx
Normal file
120
apps/web/src/app/games/complement-race/components/GameIntro.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'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'
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'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>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { useSpring, animated } 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
import { SpeechBubble } from '../AISystem/SpeechBubble'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useSoundEffects } from '../../hooks/useSoundEffects'
|
||||
|
||||
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 } = useUserProfile()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [celebrationCooldown, setCelebrationCooldown] = useState<Set<string>>(new Set())
|
||||
|
||||
// Get the first active player's emoji from UserProfileContext (same as nav bar)
|
||||
const activePlayer = players.find(p => p.isActive)
|
||||
const playerEmoji = activePlayer
|
||||
? (activePlayer.id === 1 ? profile.player1Emoji :
|
||||
activePlayer.id === 2 ? profile.player2Emoji :
|
||||
activePlayer.id === 3 ? profile.player3Emoji :
|
||||
activePlayer.id === 4 ? profile.player4Emoji : '👤')
|
||||
: '👤'
|
||||
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])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Station, Passenger, ComplementQuestion } from '../../lib/gameTypes'
|
||||
import { PassengerCard } from '../PassengerCard'
|
||||
import { PressureGauge } from '../PressureGauge'
|
||||
import { AbacusTarget } from '../AbacusTarget'
|
||||
|
||||
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'
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
import { SpeechBubble } from '../AISystem/SpeechBubble'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
|
||||
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 } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji from UserProfileContext (same as nav bar)
|
||||
const activePlayer = players.find(p => p.isActive)
|
||||
const playerEmoji = activePlayer
|
||||
? (activePlayer.id === 1 ? profile.player1Emoji :
|
||||
activePlayer.id === 2 ? profile.player2Emoji :
|
||||
activePlayer.id === 3 ? profile.player3Emoji :
|
||||
activePlayer.id === 4 ? profile.player4Emoji : '👤')
|
||||
: '👤'
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Station, Passenger } 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 && tiesAndRails.leftRailPath && (
|
||||
<path
|
||||
d={tiesAndRails.leftRailPath}
|
||||
fill="none"
|
||||
stroke="#C0C0C0"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right rail */}
|
||||
{tiesAndRails && 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'
|
||||
@@ -0,0 +1,298 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState, useMemo, memo } from 'react'
|
||||
import { useSpring, animated } from '@react-spring/web'
|
||||
import { useSteamJourney } from '../../hooks/useSteamJourney'
|
||||
import { usePassengerAnimations, type BoardingAnimation, type DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
|
||||
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
|
||||
import { useTrackManagement } from '../../hooks/useTrackManagement'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { getRouteTheme } from '../../lib/routeThemes'
|
||||
import { calculateMaxConcurrentPassengers } from '../../lib/passengerGenerator'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { TrainTerrainBackground } from './TrainTerrainBackground'
|
||||
import { RailroadTrackPath } from './RailroadTrackPath'
|
||||
import { TrainAndCars } from './TrainAndCars'
|
||||
import { GameHUD } from './GameHUD'
|
||||
|
||||
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: { number: number; targetSum: number; correctAnswer: number } | 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 } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji from UserProfileContext (same as nav bar)
|
||||
const activePlayer = players.find(p => p.isActive)
|
||||
const playerEmoji = activePlayer
|
||||
? (activePlayer.id === 1 ? profile.player1Emoji :
|
||||
activePlayer.id === 2 ? profile.player2Emoji :
|
||||
activePlayer.id === 3 ? profile.player3Emoji :
|
||||
activePlayer.id === 4 ? profile.player4Emoji : '👤')
|
||||
: '👤'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'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'
|
||||
@@ -0,0 +1,198 @@
|
||||
'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'
|
||||
@@ -0,0 +1,165 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import { GameHUD } from '../GameHUD'
|
||||
import type { Station, Passenger } from '../../../lib/gameTypes'
|
||||
|
||||
// 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
|
||||
},
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, test, expect } 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useReducer, ReactNode } from 'react'
|
||||
import type { GameState, GameAction, AIRacer, DifficultyTracker, Station, Passenger } 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.20, // 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
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
||||
import { usePassengerAnimations } from '../usePassengerAnimations'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,350 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, 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)
|
||||
let 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))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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 { renderHook, act } from '@testing-library/react'
|
||||
import { ReactNode } from 'react'
|
||||
import { ComplementRaceProvider } from '../../context/ComplementRaceContext'
|
||||
import { useSteamJourney } from '../useSteamJourney'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
|
||||
// Mock sound effects
|
||||
jest.mock('../useSoundEffects', () => ({
|
||||
useSoundEffects: () => ({
|
||||
playSound: jest.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(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers()
|
||||
jest.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(() => {
|
||||
jest.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(() => {
|
||||
jest.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
|
||||
})
|
||||
jest.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(() => {
|
||||
jest.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(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Passenger should be delivered
|
||||
const deliveredPassenger = result.current.race.state.passengers.find(p => p.id === 'p1')
|
||||
expect(deliveredPassenger?.isDelivered).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,506 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import type { Station, Passenger } from '../../lib/gameTypes'
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,364 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import type { Station, Passenger } from '../../lib/gameTypes'
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
||||
import { useTrainTransforms } from '../useTrainTransforms'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
114
apps/web/src/app/games/complement-race/hooks/useAIRacers.ts
Normal file
114
apps/web/src/app/games/complement-race/hooks/useAIRacers.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { getAICommentary, type CommentaryContext } from '../components/AISystem/aiCommentary'
|
||||
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])
|
||||
|
||||
return {
|
||||
aiRacers: state.aiRacers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useEffect } from 'react'
|
||||
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
|
||||
let 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.60) {
|
||||
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.20 * 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
|
||||
}
|
||||
}
|
||||
67
apps/web/src/app/games/complement-race/hooks/useGameLoop.ts
Normal file
67
apps/web/src/app/games/complement-race/hooks/useGameLoop.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
354
apps/web/src/app/games/complement-race/hooks/useSoundEffects.ts
Normal file
354
apps/web/src/app/games/complement-race/hooks/useSoundEffects.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
281
apps/web/src/app/games/complement-race/hooks/useSteamJourney.ts
Normal file
281
apps/web/src/app/games/complement-race/hooks/useSteamJourney.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useEffect, useRef, useMemo } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { generatePassengers, calculateMaxConcurrentPassengers } 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)
|
||||
|
||||
// Build a map of which cars are occupied (car index -> passenger)
|
||||
const occupiedCars = new Map<number, typeof currentBoardedPassengers[0]>()
|
||||
currentBoardedPassengers.forEach((passenger, arrayIndex) => {
|
||||
occupiedCars.set(arrayIndex, passenger)
|
||||
})
|
||||
|
||||
// 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
|
||||
|
||||
// Check if any empty car is at this station
|
||||
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (occupiedCars.has(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 or near station (within 5% tolerance for fast trains), board this passenger
|
||||
// Increased tolerance to ensure fast-moving trains don't miss passengers
|
||||
if (distance < 5) {
|
||||
dispatch({
|
||||
type: 'BOARD_PASSENGER',
|
||||
passengerId: passenger.id
|
||||
})
|
||||
// Mark this car as assigned in this frame
|
||||
carsAssignedThisFrame.add(carIndex)
|
||||
return // Board this passenger and move on
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 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) {
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
dispatch({
|
||||
type: 'DELIVER_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
points
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 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.pressure, state.elapsedTime, 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.correctAnswers, 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
import type { Station, Passenger } from '../lib/gameTypes'
|
||||
import { generateLandmarks, type Landmark } from '../lib/landmarks'
|
||||
|
||||
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,
|
||||
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)
|
||||
}
|
||||
}, [trackData, 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)
|
||||
}
|
||||
}, [trackData, landmarks, pathRef])
|
||||
|
||||
return {
|
||||
trackData,
|
||||
tiesAndRails,
|
||||
stationPositions,
|
||||
landmarks,
|
||||
landmarkPositions,
|
||||
displayPassengers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState, useMemo } 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 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: [],
|
||||
leftRailPoints: [],
|
||||
rightRailPoints: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
156
apps/web/src/app/games/complement-race/lib/gameTypes.ts
Normal file
156
apps/web/src/app/games/complement-race/lib/gameTypes.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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' }
|
||||
103
apps/web/src/app/games/complement-race/lib/landmarks.ts
Normal file
103
apps/web/src/app/games/complement-race/lib/landmarks.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
232
apps/web/src/app/games/complement-race/lib/passengerGenerator.ts
Normal file
232
apps/web/src/app/games/complement-race/lib/passengerGenerator.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
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
|
||||
}
|
||||
26
apps/web/src/app/games/complement-race/lib/routeThemes.ts
Normal file
26
apps/web/src/app/games/complement-race/lib/routeThemes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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]
|
||||
}
|
||||
15
apps/web/src/app/games/complement-race/page.tsx
Normal file
15
apps/web/src/app/games/complement-race/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceProvider } from './context/ComplementRaceContext'
|
||||
import { ComplementRaceGame } from './components/ComplementRaceGame'
|
||||
|
||||
export default function ComplementRacePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Speed Complement Race" navEmoji="🏁">
|
||||
<ComplementRaceProvider>
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/app/games/complement-race/practice/page.tsx
Normal file
15
apps/web/src/app/games/complement-race/practice/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
|
||||
export default function PracticeModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Practice Mode" navEmoji="🏁">
|
||||
<ComplementRaceProvider initialStyle="practice">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/app/games/complement-race/sprint/page.tsx
Normal file
15
apps/web/src/app/games/complement-race/sprint/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
|
||||
export default function SprintModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Steam Sprint" navEmoji="🚂">
|
||||
<ComplementRaceProvider initialStyle="sprint">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/app/games/complement-race/survival/page.tsx
Normal file
15
apps/web/src/app/games/complement-race/survival/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
|
||||
export default function SurvivalModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Survival Mode" navEmoji="🔄">
|
||||
<ComplementRaceProvider initialStyle="survival">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -24,11 +24,24 @@ interface EmojiPickerProps {
|
||||
currentEmoji: string
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
playerNumber: 1 | 2
|
||||
playerNumber: 1 | 2 | 3 | 4
|
||||
}
|
||||
|
||||
// Create a map of emoji to their searchable data
|
||||
const emojiMap = new Map<string, { keywords: string[] }>()
|
||||
// Emoji group categories from emojibase (matching Unicode CLDR group IDs)
|
||||
const EMOJI_GROUPS = {
|
||||
0: { name: 'Smileys & Emotion', icon: '😀' },
|
||||
1: { name: 'People & Body', icon: '👤' },
|
||||
3: { name: 'Animals & Nature', icon: '🐶' },
|
||||
4: { name: 'Food & Drink', icon: '🍎' },
|
||||
5: { name: 'Travel & Places', icon: '🚗' },
|
||||
6: { name: 'Activities', icon: '⚽' },
|
||||
7: { name: 'Objects', icon: '💡' },
|
||||
8: { name: 'Symbols', icon: '❤️' },
|
||||
9: { name: 'Flags', icon: '🏁' }
|
||||
} as const
|
||||
|
||||
// Create a map of emoji to their searchable data and group
|
||||
const emojiMap = new Map<string, { keywords: string[], group: number }>()
|
||||
;(emojiData as EmojibaseEmoji[]).forEach((emoji) => {
|
||||
if (emoji.emoji) {
|
||||
// Handle emoticon field which can be string, array, or undefined
|
||||
@@ -46,7 +59,8 @@ const emojiMap = new Map<string, { keywords: string[] }>()
|
||||
emoji.label?.toLowerCase(),
|
||||
...(emoji.tags || []).map((tag: string) => tag.toLowerCase()),
|
||||
...emoticons
|
||||
].filter(Boolean)
|
||||
].filter(Boolean),
|
||||
group: emoji.group
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -71,13 +85,43 @@ function getEmojiKeywords(emoji: string): string[] {
|
||||
|
||||
export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber }: EmojiPickerProps) {
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<number | null>(null)
|
||||
const [hoveredEmoji, setHoveredEmoji] = useState<string | null>(null)
|
||||
const [hoverPosition, setHoverPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
// Enhanced search functionality - clear separation between default and search
|
||||
const isSearching = searchFilter.trim().length > 0
|
||||
const isCategoryFiltered = selectedCategory !== null && !isSearching
|
||||
|
||||
// Calculate which categories have emojis
|
||||
const availableCategories = useMemo(() => {
|
||||
const categoryCounts: Record<number, number> = {}
|
||||
PLAYER_EMOJIS.forEach(emoji => {
|
||||
const data = emojiMap.get(emoji)
|
||||
if (data && data.group !== undefined) {
|
||||
categoryCounts[data.group] = (categoryCounts[data.group] || 0) + 1
|
||||
}
|
||||
})
|
||||
return Object.keys(EMOJI_GROUPS)
|
||||
.map(Number)
|
||||
.filter(groupId => categoryCounts[groupId] > 0)
|
||||
}, [])
|
||||
|
||||
const displayEmojis = useMemo(() => {
|
||||
// Start with all emojis
|
||||
let emojis = PLAYER_EMOJIS
|
||||
|
||||
// Apply category filter first (unless searching)
|
||||
if (isCategoryFiltered) {
|
||||
emojis = emojis.filter(emoji => {
|
||||
const data = emojiMap.get(emoji)
|
||||
return data && data.group === selectedCategory
|
||||
})
|
||||
}
|
||||
|
||||
// Then apply search filter
|
||||
if (!isSearching) {
|
||||
return PLAYER_EMOJIS
|
||||
return emojis
|
||||
}
|
||||
|
||||
const searchTerm = searchFilter.toLowerCase().trim()
|
||||
@@ -116,7 +160,7 @@ export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber
|
||||
})
|
||||
|
||||
return sortedResults
|
||||
}, [searchFilter, isSearching])
|
||||
}, [searchFilter, isSearching, selectedCategory, isCategoryFiltered])
|
||||
|
||||
return (
|
||||
<div className={css({
|
||||
@@ -195,7 +239,11 @@ export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber
|
||||
padding: '8px 12px',
|
||||
background: playerNumber === 1
|
||||
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
|
||||
: 'linear-gradient(135deg, #fd79a8, #e84393)',
|
||||
: playerNumber === 2
|
||||
? 'linear-gradient(135deg, #fd79a8, #e84393)'
|
||||
: playerNumber === 3
|
||||
? 'linear-gradient(135deg, #a78bfa, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
borderRadius: '12px',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
@@ -247,6 +295,74 @@ export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
{!isSearching && (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
overflowX: 'auto',
|
||||
paddingBottom: '8px',
|
||||
marginBottom: '12px',
|
||||
flexShrink: 0,
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '6px'
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#cbd5e1',
|
||||
borderRadius: '3px'
|
||||
}
|
||||
})}>
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: selectedCategory === null ? '2px solid #3b82f6' : '2px solid #e5e7eb',
|
||||
background: selectedCategory === null ? '#eff6ff' : 'white',
|
||||
color: selectedCategory === null ? '#1e40af' : '#6b7280',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: {
|
||||
background: selectedCategory === null ? '#dbeafe' : '#f9fafb',
|
||||
transform: 'translateY(-1px)'
|
||||
}
|
||||
})}
|
||||
>
|
||||
✨ All
|
||||
</button>
|
||||
{availableCategories.map((groupId) => {
|
||||
const group = EMOJI_GROUPS[groupId as keyof typeof EMOJI_GROUPS]
|
||||
return (
|
||||
<button
|
||||
key={groupId}
|
||||
onClick={() => setSelectedCategory(Number(groupId))}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: selectedCategory === Number(groupId) ? '2px solid #3b82f6' : '2px solid #e5e7eb',
|
||||
background: selectedCategory === Number(groupId) ? '#eff6ff' : 'white',
|
||||
color: selectedCategory === Number(groupId) ? '#1e40af' : '#6b7280',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: {
|
||||
background: selectedCategory === Number(groupId) ? '#dbeafe' : '#f9fafb',
|
||||
transform: 'translateY(-1px)'
|
||||
}
|
||||
})}
|
||||
>
|
||||
{group.icon} {group.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Mode Header */}
|
||||
{isSearching && displayEmojis.length > 0 && (
|
||||
<div className={css({
|
||||
@@ -292,13 +408,15 @@ export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber
|
||||
color: 'gray.700',
|
||||
marginBottom: '4px'
|
||||
})}>
|
||||
📝 All Available Characters
|
||||
{selectedCategory !== null
|
||||
? `${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].icon} ${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].name}`
|
||||
: '📝 All Available Characters'}
|
||||
</div>
|
||||
<div className={css({
|
||||
fontSize: '12px',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
{PLAYER_EMOJIS.length} characters available • Use search to find specific emojis
|
||||
{displayEmojis.length} emojis {selectedCategory !== null ? 'in category' : 'available'} • Use search to find specific emojis
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -307,37 +425,72 @@ export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber
|
||||
{displayEmojis.length > 0 && (
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(16, 1fr)',
|
||||
gap: '4px',
|
||||
alignContent: 'start',
|
||||
'@media (max-width: 1200px)': {
|
||||
gridTemplateColumns: 'repeat(14, 1fr)'
|
||||
overflowY: 'auto',
|
||||
minHeight: 0,
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '10px'
|
||||
},
|
||||
'@media (max-width: 1000px)': {
|
||||
gridTemplateColumns: 'repeat(12, 1fr)'
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#f1f5f9',
|
||||
borderRadius: '5px'
|
||||
},
|
||||
'@media (max-width: 800px)': {
|
||||
gridTemplateColumns: 'repeat(10, 1fr)'
|
||||
},
|
||||
'@media (max-width: 600px)': {
|
||||
gridTemplateColumns: 'repeat(8, 1fr)'
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#cbd5e1',
|
||||
borderRadius: '5px',
|
||||
'&:hover': {
|
||||
background: '#94a3b8'
|
||||
}
|
||||
}
|
||||
})}>
|
||||
<div className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(16, 1fr)',
|
||||
gap: '4px',
|
||||
padding: '4px',
|
||||
'@media (max-width: 1200px)': {
|
||||
gridTemplateColumns: 'repeat(14, 1fr)'
|
||||
},
|
||||
'@media (max-width: 1000px)': {
|
||||
gridTemplateColumns: 'repeat(12, 1fr)'
|
||||
},
|
||||
'@media (max-width: 800px)': {
|
||||
gridTemplateColumns: 'repeat(10, 1fr)'
|
||||
},
|
||||
'@media (max-width: 600px)': {
|
||||
gridTemplateColumns: 'repeat(8, 1fr)'
|
||||
}
|
||||
})}>
|
||||
{displayEmojis.map(emoji => {
|
||||
const isSelected = emoji === currentEmoji
|
||||
const getSelectedBg = () => {
|
||||
if (!isSelected) return 'transparent'
|
||||
if (playerNumber === 1) return 'blue.100'
|
||||
if (playerNumber === 2) return 'pink.100'
|
||||
if (playerNumber === 3) return 'purple.100'
|
||||
return 'yellow.100'
|
||||
}
|
||||
const getSelectedBorder = () => {
|
||||
if (!isSelected) return 'transparent'
|
||||
if (playerNumber === 1) return 'blue.400'
|
||||
if (playerNumber === 2) return 'pink.400'
|
||||
if (playerNumber === 3) return 'purple.400'
|
||||
return 'yellow.400'
|
||||
}
|
||||
const getHoverBg = () => {
|
||||
if (!isSelected) return 'gray.100'
|
||||
if (playerNumber === 1) return 'blue.200'
|
||||
if (playerNumber === 2) return 'pink.200'
|
||||
if (playerNumber === 3) return 'purple.200'
|
||||
return 'yellow.200'
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={emoji}
|
||||
className={css({
|
||||
aspectRatio: '1',
|
||||
background: isSelected
|
||||
? (playerNumber === 1 ? 'blue.100' : 'pink.100')
|
||||
: 'transparent',
|
||||
background: getSelectedBg(),
|
||||
border: '2px solid',
|
||||
borderColor: isSelected
|
||||
? (playerNumber === 1 ? 'blue.400' : 'pink.400')
|
||||
: 'transparent',
|
||||
borderColor: getSelectedBorder(),
|
||||
borderRadius: '6px',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
@@ -346,14 +499,21 @@ export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
_hover: {
|
||||
background: isSelected
|
||||
? (playerNumber === 1 ? 'blue.200' : 'pink.200')
|
||||
: 'gray.100',
|
||||
background: getHoverBg(),
|
||||
transform: 'scale(1.15)',
|
||||
zIndex: 1,
|
||||
fontSize: '24px'
|
||||
}
|
||||
})}
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setHoveredEmoji(emoji)
|
||||
setHoverPosition({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top
|
||||
})
|
||||
}}
|
||||
onMouseLeave={() => setHoveredEmoji(null)}
|
||||
onClick={() => {
|
||||
onEmojiSelect(emoji)
|
||||
}}
|
||||
@@ -362,6 +522,7 @@ export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -414,6 +575,137 @@ export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber
|
||||
💡 Powered by emojibase-data • Try: "face", "smart", "heart", "animal", "food" • Click to select
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Magnifying Glass Preview - SUPER POWERED! */}
|
||||
{hoveredEmoji && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${hoverPosition.x}px`,
|
||||
top: `${hoverPosition.y - 120}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10000,
|
||||
animation: 'magnifyIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||
}}
|
||||
>
|
||||
{/* Outer glow ring */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-20px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.3) 0%, transparent 70%)',
|
||||
animation: 'pulseGlow 2s ease-in-out infinite'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main preview card */}
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.4), 0 0 0 4px rgba(59, 130, 246, 0.6), inset 0 2px 4px rgba(255,255,255,0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '120px',
|
||||
lineHeight: 1,
|
||||
minWidth: '160px',
|
||||
minHeight: '160px',
|
||||
position: 'relative',
|
||||
animation: 'emojiFloat 3s ease-in-out infinite'
|
||||
}}
|
||||
>
|
||||
{/* Sparkle effects */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
fontSize: '20px',
|
||||
animation: 'sparkle 1.5s ease-in-out infinite',
|
||||
animationDelay: '0s'
|
||||
}}>✨</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '15px',
|
||||
left: '15px',
|
||||
fontSize: '16px',
|
||||
animation: 'sparkle 1.5s ease-in-out infinite',
|
||||
animationDelay: '0.5s'
|
||||
}}>✨</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
left: '20px',
|
||||
fontSize: '12px',
|
||||
animation: 'sparkle 1.5s ease-in-out infinite',
|
||||
animationDelay: '1s'
|
||||
}}>✨</div>
|
||||
|
||||
{hoveredEmoji}
|
||||
</div>
|
||||
|
||||
{/* Arrow pointing down with glow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '14px solid transparent',
|
||||
borderRight: '14px solid transparent',
|
||||
borderTop: '14px solid white',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add magnifying animations */}
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@keyframes magnifyIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.5);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
@keyframes emojiFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
@keyframes sparkle {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(180deg);
|
||||
}
|
||||
}
|
||||
` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useAbacusConfig } from '../../../../contexts/AbacusDisplayContext'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useUserProfile } from '../../../../contexts/UserProfileContext'
|
||||
import type { GameCardProps } from '../context/types'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { MemoryGrid } from './MemoryGrid'
|
||||
import { PlayerStatusBar } from './PlayerStatusBar'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { pluralizeWord } from '../../../../utils/pluralization'
|
||||
|
||||
export function GamePhase() {
|
||||
const { state, resetGame, activePlayers } = useMemoryPairs()
|
||||
@@ -22,170 +24,84 @@ export function GamePhase() {
|
||||
flexDirection: 'column'
|
||||
})}>
|
||||
|
||||
{/* Game Header - Compact on mobile */}
|
||||
{/* Minimal Game Header */}
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(118, 75, 162, 0.1))',
|
||||
padding: { base: '8px 12px', sm: '12px 16px', md: '16px 20px' },
|
||||
borderRadius: { base: '8px', md: '12px' },
|
||||
marginBottom: { base: '8px', sm: '12px', md: '16px' },
|
||||
border: '1px solid rgba(102, 126, 234, 0.2)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: { base: '8px 12px', sm: '10px 16px', md: '12px 20px' },
|
||||
background: 'linear-gradient(135deg, rgba(102, 126, 234, 0.08), rgba(118, 75, 162, 0.08))',
|
||||
borderRadius: '12px',
|
||||
marginBottom: { base: '12px', sm: '16px', md: '20px' },
|
||||
border: '1px solid rgba(102, 126, 234, 0.15)',
|
||||
flexShrink: 0
|
||||
})}>
|
||||
|
||||
{/* Game Mode Indicator - Compact */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: { base: '8px', sm: '12px', md: '16px' }
|
||||
gap: '8px',
|
||||
fontSize: { base: '14px', sm: '15px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
|
||||
{/* Game Type & Difficulty Info - Hidden on mobile */}
|
||||
<div className={css({
|
||||
display: { base: 'none', sm: 'flex' },
|
||||
alignItems: 'center',
|
||||
gap: { base: '8px', md: '16px' }
|
||||
})}>
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
})}>
|
||||
<span className={css({ fontSize: '20px' })}>
|
||||
{state.gameType === 'abacus-numeral' ? '🧮🔢' : '🤝➕'}
|
||||
</span>
|
||||
<span className={css({ fontWeight: 'bold', color: 'gray.700' })}>
|
||||
{state.gameType === 'abacus-numeral' ? 'Abacus-Numeral' : 'Complement Pairs'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
})}>
|
||||
<span className={css({ fontSize: '20px' })}>
|
||||
{state.difficulty === 6 ? '🌱' : state.difficulty === 8 ? '⚡' : state.difficulty === 12 ? '🔥' : '💀'}
|
||||
</span>
|
||||
<span className={css({ fontWeight: 'bold', color: 'gray.700' })}>
|
||||
{state.difficulty} pairs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{state.gameMode === 'multiplayer' && (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
})}>
|
||||
<span className={css({ fontSize: '20px' })}>⚔️</span>
|
||||
<span className={css({ fontWeight: 'bold', color: 'gray.700' })}>
|
||||
{activePlayers.length} Players
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Game Controls */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
})}>
|
||||
|
||||
{/* Restart Button */}
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ffeaa7, #fab1a0)',
|
||||
color: '#2d3436',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '10px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 8px rgba(255, 234, 167, 0.4)',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(255, 234, 167, 0.6)',
|
||||
background: 'linear-gradient(135deg, #fdcb6e, #e17055)'
|
||||
}
|
||||
})}
|
||||
onClick={resetGame}
|
||||
>
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
})}>
|
||||
<span>🔄</span>
|
||||
<span>New Game</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Timer (if multiplayer mode) */}
|
||||
{state.gameMode === 'multiplayer' && (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
})}>
|
||||
<span className={css({ fontSize: '16px' })}>⏰</span>
|
||||
<span className={css({ fontWeight: 'bold', color: 'gray.700' })}>
|
||||
{state.turnTimer}s per turn
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={css({ fontSize: { base: '16px', sm: '18px' } })}>
|
||||
{state.gameType === 'abacus-numeral' ? '🧮' : '🤝'}
|
||||
</span>
|
||||
<span className={css({ display: { base: 'none', sm: 'inline' } })}>
|
||||
{state.gameType === 'abacus-numeral' ? 'Abacus Match' : 'Complement Pairs'}
|
||||
</span>
|
||||
{state.gameMode === 'multiplayer' && (
|
||||
<>
|
||||
<span className={css({ color: 'gray.400' })}>•</span>
|
||||
<span>⚔️ {activePlayers.length}{pluralizeWord(activePlayers.length, 'P')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Player Indicator (Multiplayer Mode) - Compact on mobile */}
|
||||
{state.gameMode === 'multiplayer' && currentPlayerData && (
|
||||
<div className={css({
|
||||
marginTop: { base: '8px', md: '12px' },
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<div className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '6px', sm: '8px', md: '12px' },
|
||||
padding: { base: '6px 12px', sm: '8px 16px', md: '12px 24px' },
|
||||
background: `linear-gradient(135deg, ${currentPlayerData.color}, ${currentPlayerData.color}dd)`,
|
||||
color: 'white',
|
||||
borderRadius: { base: '12px', md: '20px' },
|
||||
fontSize: { base: '12px', sm: '14px', md: '16px' },
|
||||
{/* Game Controls */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
})}>
|
||||
{/* New Game Button */}
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ffeaa7, #fab1a0)',
|
||||
color: '#2d3436',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
padding: { base: '8px 12px', sm: '10px 16px' },
|
||||
fontSize: { base: '13px', sm: '14px' },
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)'
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 6px rgba(255, 234, 167, 0.3)',
|
||||
_hover: {
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 3px 8px rgba(255, 234, 167, 0.5)',
|
||||
background: 'linear-gradient(135deg, #fdcb6e, #e17055)'
|
||||
}
|
||||
})}
|
||||
onClick={resetGame}
|
||||
>
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
})}>
|
||||
<span className={css({ fontSize: { base: '20px', sm: '28px', md: '36px' } })}>
|
||||
{currentPlayerData.emoji}
|
||||
</span>
|
||||
<span className={css({ display: { base: 'none', sm: 'inline' } })}>{currentPlayerData.name}'s Turn</span>
|
||||
<span className={css({ display: { base: 'inline', sm: 'none' } })}>Turn</span>
|
||||
<span className={css({ fontSize: { base: '16px', sm: '20px', md: '24px' } })}>
|
||||
🎯
|
||||
</span>
|
||||
<span>🔄</span>
|
||||
<span>New Game</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Status Bar */}
|
||||
<PlayerStatusBar />
|
||||
|
||||
{/* Memory Grid - The main game area */}
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
@@ -197,39 +113,31 @@ export function GamePhase() {
|
||||
<MemoryGrid />
|
||||
</div>
|
||||
|
||||
{/* Helpful Instructions - Hidden on mobile */}
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
marginTop: { base: '8px', md: '16px' },
|
||||
padding: { base: '8px', md: '12px' },
|
||||
background: 'rgba(248, 250, 252, 0.8)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(226, 232, 240, 0.8)',
|
||||
display: { base: 'none', md: 'block' },
|
||||
flexShrink: 0
|
||||
})}>
|
||||
<p className={css({
|
||||
fontSize: '14px',
|
||||
color: 'gray.600',
|
||||
margin: 0,
|
||||
lineHeight: '1.4'
|
||||
{/* Quick Tip - Only show when game is starting and on larger screens */}
|
||||
{state.moves === 0 && (
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
marginTop: '12px',
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(248, 250, 252, 0.7)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(226, 232, 240, 0.6)',
|
||||
display: { base: 'none', lg: 'block' },
|
||||
flexShrink: 0
|
||||
})}>
|
||||
{state.gameType === 'abacus-numeral'
|
||||
? 'Match abacus representations with their numerical values! Look for patterns and remember card positions.'
|
||||
: 'Find pairs of numbers that add up to 5 or 10! These are called "complement pairs" or "number friends".'
|
||||
}
|
||||
</p>
|
||||
|
||||
{state.gameMode === 'multiplayer' && (
|
||||
<p className={css({
|
||||
fontSize: '12px',
|
||||
color: 'gray.500',
|
||||
margin: '6px 0 0 0'
|
||||
fontSize: '13px',
|
||||
color: 'gray.600',
|
||||
margin: 0,
|
||||
fontWeight: 'medium'
|
||||
})}>
|
||||
Take turns finding matches. The player with the most pairs wins!
|
||||
💡 {state.gameType === 'abacus-numeral'
|
||||
? 'Match abacus beads with numbers'
|
||||
: 'Find pairs that add to 5 or 10'
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { useUserProfile } from '../../../../contexts/UserProfileContext'
|
||||
import { GameCard } from './GameCard'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import { getGridConfiguration } from '../utils/cardGeneration'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { gamePlurals } from '../../../../utils/pluralization'
|
||||
|
||||
// Helper function to calculate optimal grid dimensions
|
||||
function calculateOptimalGrid(cards: number, aspectRatio: number, config: any) {
|
||||
@@ -81,14 +80,12 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
|
||||
|
||||
export function MemoryGrid() {
|
||||
const { state, flipCard } = useMemoryPairs()
|
||||
const { profile, updatePlayerEmoji } = useUserProfile()
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<{ player: 1 | 2 } | null>(null)
|
||||
|
||||
if (!state.gameCards.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const gridConfig = getGridConfiguration(state.difficulty)
|
||||
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
|
||||
const gridDimensions = useGridDimensions(gridConfig, state.gameCards.length)
|
||||
|
||||
|
||||
@@ -96,16 +93,6 @@ export function MemoryGrid() {
|
||||
flipCard(cardId)
|
||||
}
|
||||
|
||||
const handlePlayerClick = (player: 1 | 2) => {
|
||||
setShowEmojiPicker({ player })
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
if (showEmojiPicker) {
|
||||
updatePlayerEmoji(showEmojiPicker.player, emoji)
|
||||
setShowEmojiPicker(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css({
|
||||
@@ -116,166 +103,6 @@ export function MemoryGrid() {
|
||||
gap: { base: '12px', sm: '16px', md: '20px' }
|
||||
})}>
|
||||
|
||||
{/* Game Info Header */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
padding: { base: '12px 16px', sm: '14px 20px', md: '16px 24px' },
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.9), rgba(248,250,252,0.9))',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
border: '1px solid rgba(255,255,255,0.8)'
|
||||
})}>
|
||||
|
||||
<div className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: 'repeat(3, 1fr)', sm: 'repeat(3, auto)' },
|
||||
gap: { base: '8px', sm: '12px', md: '20px' },
|
||||
justifyContent: { base: 'stretch', sm: 'center' },
|
||||
width: { base: '100%', sm: 'auto' }
|
||||
})}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: { base: '18px', sm: '20px', md: '24px' }, fontWeight: 'bold', color: 'blue.600' })}>
|
||||
{state.matchedPairs}
|
||||
</div>
|
||||
<div className={css({ fontSize: { base: '10px', sm: '11px', md: '12px' }, color: 'gray.600' })}>
|
||||
Matched
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: { base: '18px', sm: '20px', md: '24px' }, fontWeight: 'bold', color: 'purple.600' })}>
|
||||
{state.moves}
|
||||
</div>
|
||||
<div className={css({ fontSize: { base: '10px', sm: '11px', md: '12px' }, color: 'gray.600' })}>
|
||||
Moves
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: { base: '18px', sm: '20px', md: '24px' }, fontWeight: 'bold', color: 'green.600' })}>
|
||||
{state.totalPairs}
|
||||
</div>
|
||||
<div className={css({ fontSize: { base: '10px', sm: '11px', md: '12px' }, color: 'gray.600' })}>
|
||||
Total Pairs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multiplayer Scores */}
|
||||
{state.gameMode === 'multiplayer' && (
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '24px' })}>
|
||||
<button
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '16px',
|
||||
background: state.currentPlayer === 1 ? 'blue.100' : 'gray.100',
|
||||
border: '3px solid',
|
||||
borderColor: state.currentPlayer === 1 ? 'blue.400' : 'gray.300',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}
|
||||
})}
|
||||
onClick={() => handlePlayerClick(1)}
|
||||
>
|
||||
<div className={css({
|
||||
fontSize: '40px',
|
||||
marginBottom: '4px',
|
||||
transition: 'transform 0.2s ease',
|
||||
_hover: { transform: 'scale(1.1)' }
|
||||
})}>
|
||||
{profile.player1Emoji}
|
||||
</div>
|
||||
<div className={css({ fontSize: '28px', fontWeight: 'bold', color: 'blue.600' })}>
|
||||
{state.scores[1] || 0}
|
||||
</div>
|
||||
<div className={css({ fontSize: '12px', color: 'gray.600', marginTop: '4px' })}>
|
||||
Click to change character
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className={css({
|
||||
fontSize: '24px',
|
||||
color: 'gray.500',
|
||||
fontWeight: 'bold'
|
||||
})}>
|
||||
VS
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '16px',
|
||||
background: state.currentPlayer === 2 ? 'red.100' : 'gray.100',
|
||||
border: '3px solid',
|
||||
borderColor: state.currentPlayer === 2 ? 'red.400' : 'gray.300',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}
|
||||
})}
|
||||
onClick={() => handlePlayerClick(2)}
|
||||
>
|
||||
<div className={css({
|
||||
fontSize: '40px',
|
||||
marginBottom: '4px',
|
||||
transition: 'transform 0.2s ease',
|
||||
_hover: { transform: 'scale(1.1)' }
|
||||
})}>
|
||||
{profile.player2Emoji}
|
||||
</div>
|
||||
<div className={css({ fontSize: '28px', fontWeight: 'bold', color: 'red.600' })}>
|
||||
{state.scores[2] || 0}
|
||||
</div>
|
||||
<div className={css({ fontSize: '12px', color: 'gray.600', marginTop: '4px' })}>
|
||||
Click to change character
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single Player Progress */}
|
||||
{state.gameMode === 'single' && (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
})}>
|
||||
<div className={css({
|
||||
width: '120px',
|
||||
height: '8px',
|
||||
background: 'gray.200',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
})}>
|
||||
<div className={css({
|
||||
width: `${(state.matchedPairs / state.totalPairs) * 100}%`,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2)',
|
||||
transition: 'width 0.3s ease',
|
||||
borderRadius: '4px'
|
||||
})} />
|
||||
</div>
|
||||
<span className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
{Math.round((state.matchedPairs / state.totalPairs) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cards Grid - Consistent r×c Layout */}
|
||||
<div
|
||||
@@ -390,15 +217,6 @@ export function MemoryGrid() {
|
||||
})} />
|
||||
)}
|
||||
|
||||
{/* Emoji Picker Modal */}
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker
|
||||
currentEmoji={showEmojiPicker.player === 1 ? profile.player1Emoji : profile.player2Emoji}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
onClose={() => setShowEmojiPicker(null)}
|
||||
playerNumber={showEmojiPicker.player}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { useFullscreen } from '../../../../contexts/FullscreenContext'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
import { GamePhase } from './GamePhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
export function MemoryPairsGame() {
|
||||
@@ -23,12 +24,16 @@ export function MemoryPairsGame() {
|
||||
}, [setFullscreenElement])
|
||||
|
||||
return (
|
||||
<StandardGameLayout>
|
||||
<PageWithNav
|
||||
navTitle="Memory Pairs"
|
||||
navEmoji="🧩"
|
||||
emphasizeGameContext={state.gamePhase === 'setup'}
|
||||
>
|
||||
<StandardGameLayout>
|
||||
<div
|
||||
ref={gameRef}
|
||||
className={css({
|
||||
flex: 1,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: { base: '12px', sm: '16px', md: '20px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -38,23 +43,6 @@ export function MemoryPairsGame() {
|
||||
})}>
|
||||
{/* Note: Fullscreen restore prompt removed - client-side navigation preserves fullscreen */}
|
||||
|
||||
<header className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: { base: '8px', sm: '12px', md: '16px' },
|
||||
px: { base: '4', md: '0' },
|
||||
display: { base: 'none', sm: 'block' }
|
||||
})}>
|
||||
<h1 className={css({
|
||||
fontSize: { base: '16px', sm: '20px', md: '24px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
textShadow: '1px 1px 2px rgba(0,0,0,0.3)',
|
||||
marginBottom: 0
|
||||
})}>
|
||||
Memory Pairs
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<main className={css({
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
@@ -73,5 +61,6 @@ export function MemoryPairsGame() {
|
||||
</main>
|
||||
</div>
|
||||
</StandardGameLayout>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { gamePlurals } from '../../../../utils/pluralization'
|
||||
|
||||
// Inject the celebration animations for Storybook
|
||||
const celebrationAnimations = `
|
||||
@keyframes gentle-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.3), 0 12px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.5), 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-sway {
|
||||
0%, 100% { transform: rotate(-2deg) scale(1); }
|
||||
50% { transform: rotate(2deg) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes turn-entrance {
|
||||
0% {
|
||||
transform: scale(0.8) rotate(-10deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes streak-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes great-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.12) translateY(-6px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 8px #22c55e60, 0 15px 35px rgba(34,197,94,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes epic-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes legendary-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.2) translateY(-12px) rotate(5deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.18) translateY(-10px) rotate(-3deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 10px #a855f7, 0 20px 45px rgba(168,85,247,0.4);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.22) translateY(-14px) rotate(3deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-1deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 8px #a855f7, 0 18px 40px rgba(168,85,247,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Component to inject animations
|
||||
const AnimationProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined' && !document.getElementById('celebration-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'celebration-animations'
|
||||
style.textContent = celebrationAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Games/Matching/PlayerStatusBar',
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The PlayerStatusBar component displays the current state of players in the matching game.
|
||||
It shows different layouts for single player vs multiplayer modes and includes escalating
|
||||
celebration effects for consecutive matching pairs.
|
||||
|
||||
## Features
|
||||
- Single player mode with epic styling
|
||||
- Multiplayer mode with competitive grid layout
|
||||
- Escalating celebration animations based on consecutive matches:
|
||||
- 2+ matches: Great celebration (green)
|
||||
- 3+ matches: Epic celebration (orange)
|
||||
- 5+ matches: Legendary celebration (purple with gold accents)
|
||||
- Real-time turn indicators
|
||||
- Score tracking and progress display
|
||||
- Responsive design for mobile and desktop
|
||||
|
||||
## Animation Preview
|
||||
The animations demonstrate different celebration levels that activate when players get consecutive matches.
|
||||
`
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AnimationProvider>
|
||||
<div className={css({
|
||||
width: '800px',
|
||||
maxWidth: '90vw',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
|
||||
minHeight: '400px'
|
||||
})}>
|
||||
<Story />
|
||||
</div>
|
||||
</AnimationProvider>
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Create a mock player card component that showcases the animations
|
||||
const MockPlayerCard = ({
|
||||
emoji,
|
||||
name,
|
||||
score,
|
||||
consecutiveMatches,
|
||||
isCurrentPlayer = true,
|
||||
celebrationLevel
|
||||
}: {
|
||||
emoji: string
|
||||
name: string
|
||||
score: number
|
||||
consecutiveMatches: number
|
||||
isCurrentPlayer?: boolean
|
||||
celebrationLevel: 'normal' | 'great' | 'epic' | 'legendary'
|
||||
}) => {
|
||||
const playerColor = celebrationLevel === 'legendary' ? '#a855f7' :
|
||||
celebrationLevel === 'epic' ? '#f97316' :
|
||||
celebrationLevel === 'great' ? '#22c55e' : '#3b82f6'
|
||||
|
||||
return (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '3', md: '4' },
|
||||
p: isCurrentPlayer ? { base: '4', md: '6' } : { base: '2', md: '3' },
|
||||
rounded: isCurrentPlayer ? '2xl' : 'lg',
|
||||
background: isCurrentPlayer
|
||||
? `linear-gradient(135deg, ${playerColor}15, ${playerColor}25, ${playerColor}15)`
|
||||
: 'white',
|
||||
border: isCurrentPlayer ? '4px solid' : '2px solid',
|
||||
borderColor: isCurrentPlayer ? playerColor : 'gray.200',
|
||||
boxShadow: isCurrentPlayer
|
||||
? `0 0 0 2px white, 0 0 0 6px ${playerColor}40, 0 12px 32px rgba(0,0,0,0.2)`
|
||||
: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
position: 'relative',
|
||||
transform: isCurrentPlayer ? 'scale(1.08) translateY(-4px)' : 'scale(1)',
|
||||
zIndex: isCurrentPlayer ? 10 : 1,
|
||||
animation: isCurrentPlayer
|
||||
? (celebrationLevel === 'legendary' ? 'legendary-celebration 0.8s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'epic' ? 'epic-celebration 0.7s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'great' ? 'great-celebration 0.6s ease-out, turn-entrance 0.6s ease-out'
|
||||
: 'turn-entrance 0.6s ease-out')
|
||||
: 'none'
|
||||
})}>
|
||||
|
||||
{/* Player emoji */}
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: '3xl', md: '5xl' } : { base: 'lg', md: 'xl' },
|
||||
flexShrink: 0,
|
||||
animation: isCurrentPlayer
|
||||
? 'float 3s ease-in-out infinite'
|
||||
: 'breathe 5s ease-in-out infinite',
|
||||
transform: isCurrentPlayer ? 'scale(1.2)' : 'scale(1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
textShadow: isCurrentPlayer ? '0 0 20px currentColor' : 'none'
|
||||
})}>
|
||||
{emoji}
|
||||
</div>
|
||||
|
||||
{/* Player info */}
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: 'md', md: 'lg' } : { base: 'xs', md: 'sm' },
|
||||
fontWeight: 'black',
|
||||
color: isCurrentPlayer ? 'gray.900' : 'gray.700',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
|
||||
})}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'md' } : { base: '2xs', md: 'xs' },
|
||||
color: isCurrentPlayer ? playerColor : 'gray.500',
|
||||
fontWeight: isCurrentPlayer ? 'black' : 'semibold'
|
||||
})}>
|
||||
{gamePlurals.pair(score)}
|
||||
{isCurrentPlayer && (
|
||||
<span className={css({
|
||||
color: 'red.600',
|
||||
fontWeight: 'black',
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
|
||||
textShadow: '0 0 15px currentColor'
|
||||
})}>
|
||||
{' • Your turn'}
|
||||
</span>
|
||||
)}
|
||||
{consecutiveMatches > 1 && (
|
||||
<div className={css({
|
||||
fontSize: { base: '2xs', md: 'xs' },
|
||||
color: celebrationLevel === 'legendary' ? 'purple.600' :
|
||||
celebrationLevel === 'epic' ? 'orange.600' :
|
||||
celebrationLevel === 'great' ? 'green.600' : 'gray.500',
|
||||
fontWeight: 'black',
|
||||
animation: isCurrentPlayer ? 'streak-pulse 1s ease-in-out infinite' : 'none',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
|
||||
})}>
|
||||
🔥 {consecutiveMatches} streak!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Epic score display */}
|
||||
{isCurrentPlayer && (
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
|
||||
color: 'white',
|
||||
px: { base: '3', md: '4' },
|
||||
py: { base: '2', md: '3' },
|
||||
rounded: 'xl',
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
fontWeight: 'black',
|
||||
boxShadow: '0 4px 15px rgba(238, 90, 36, 0.4)',
|
||||
animation: 'gentle-bounce 1.5s ease-in-out infinite',
|
||||
textShadow: '0 0 10px rgba(255,255,255,0.8)'
|
||||
})}>
|
||||
⚡{score}⚡
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Normal celebration level
|
||||
export const NormalPlayer: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="🚀"
|
||||
name="Solo Champion"
|
||||
score={3}
|
||||
consecutiveMatches={0}
|
||||
celebrationLevel="normal"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Great celebration level
|
||||
export const GreatStreak: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="🎯"
|
||||
name="Streak Master"
|
||||
score={5}
|
||||
consecutiveMatches={2}
|
||||
celebrationLevel="great"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Epic celebration level
|
||||
export const EpicStreak: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="🔥"
|
||||
name="Epic Matcher"
|
||||
score={7}
|
||||
consecutiveMatches={4}
|
||||
celebrationLevel="epic"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Legendary celebration level
|
||||
export const LegendaryStreak: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="👑"
|
||||
name="Legend"
|
||||
score={8}
|
||||
consecutiveMatches={6}
|
||||
celebrationLevel="legendary"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// All levels showcase
|
||||
export const AllCelebrationLevels: Story = {
|
||||
render: () => (
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '20px' })}>
|
||||
<h3 className={css({ textAlign: 'center', fontSize: '24px', fontWeight: 'bold', marginBottom: '20px' })}>
|
||||
Consecutive Match Celebration Levels
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))', gap: '20px' })}>
|
||||
{/* Normal */}
|
||||
<div>
|
||||
<h4 className={css({ textAlign: 'center', marginBottom: '10px', fontSize: '16px', fontWeight: 'bold' })}>
|
||||
Normal (0-1 matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="🚀"
|
||||
name="Solo Champion"
|
||||
score={3}
|
||||
consecutiveMatches={0}
|
||||
celebrationLevel="normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Great */}
|
||||
<div>
|
||||
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'green.600', fontSize: '16px', fontWeight: 'bold' })}>
|
||||
Great (2+ matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="🎯"
|
||||
name="Streak Master"
|
||||
score={5}
|
||||
consecutiveMatches={2}
|
||||
celebrationLevel="great"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Epic */}
|
||||
<div>
|
||||
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'orange.600', fontSize: '16px', fontWeight: 'bold' })}>
|
||||
Epic (3+ matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="🔥"
|
||||
name="Epic Matcher"
|
||||
score={7}
|
||||
consecutiveMatches={4}
|
||||
celebrationLevel="epic"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legendary */}
|
||||
<div>
|
||||
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'purple.600', fontSize: '16px', fontWeight: 'bold' })}>
|
||||
Legendary (5+ matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="👑"
|
||||
name="Legend"
|
||||
score={8}
|
||||
consecutiveMatches={6}
|
||||
celebrationLevel="legendary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
marginTop: '20px',
|
||||
padding: '16px',
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(0,0,0,0.1)'
|
||||
})}>
|
||||
<p className={css({ fontSize: '14px', color: 'gray.700', margin: 0 })}>
|
||||
These animations trigger when a player gets consecutive matching pairs in the memory matching game.
|
||||
The celebrations get more intense as the streak grows, providing visual feedback and excitement!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
}
|
||||
}
|
||||
458
apps/web/src/app/games/matching/components/PlayerStatusBar.tsx
Normal file
458
apps/web/src/app/games/matching/components/PlayerStatusBar.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useUserProfile } from '../../../../contexts/UserProfileContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { gamePlurals } from '../../../../utils/pluralization'
|
||||
|
||||
interface PlayerStatusBarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
const { players } = useGameMode()
|
||||
const { profile } = useUserProfile()
|
||||
const { state } = useMemoryPairs()
|
||||
|
||||
// Get active players with their profile data
|
||||
const activePlayers = players
|
||||
.filter(player => player.isActive)
|
||||
.map(player => ({
|
||||
...player,
|
||||
displayName: player.id === 1 ? profile.player1Name :
|
||||
player.id === 2 ? profile.player2Name :
|
||||
player.name,
|
||||
displayEmoji: player.id === 1 ? profile.player1Emoji :
|
||||
player.id === 2 ? profile.player2Emoji :
|
||||
player.emoji,
|
||||
score: state.scores[player.id] || 0,
|
||||
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0
|
||||
}))
|
||||
|
||||
// Get celebration level based on consecutive matches
|
||||
const getCelebrationLevel = (consecutiveMatches: number) => {
|
||||
if (consecutiveMatches >= 5) return 'legendary'
|
||||
if (consecutiveMatches >= 3) return 'epic'
|
||||
if (consecutiveMatches >= 2) return 'great'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
if (activePlayers.length <= 1) {
|
||||
// Simple single player indicator
|
||||
return (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: 'white',
|
||||
rounded: 'lg',
|
||||
p: { base: '2', md: '3' },
|
||||
border: '2px solid',
|
||||
borderColor: 'blue.200',
|
||||
mb: { base: '2', md: '3' },
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
})}
|
||||
className={className}>
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '2', md: '3' }
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: { base: 'xl', md: '2xl' }
|
||||
})}>
|
||||
{activePlayers[0]?.displayEmoji || '🚀'}
|
||||
</div>
|
||||
<div className={css({
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700'
|
||||
})}>
|
||||
{activePlayers[0]?.displayName || 'Player 1'}
|
||||
</div>
|
||||
<div className={css({
|
||||
fontSize: { base: 'xs', md: 'sm' },
|
||||
color: 'blue.600',
|
||||
fontWeight: 'medium'
|
||||
})}>
|
||||
{gamePlurals.pair(state.matchedPairs)} of {state.totalPairs} • {gamePlurals.move(state.moves)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For multiplayer, show competitive status bar
|
||||
return (
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
|
||||
rounded: 'xl',
|
||||
p: { base: '2', md: '3' },
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.200',
|
||||
mb: { base: '3', md: '4' }
|
||||
})}
|
||||
className={className}>
|
||||
<div className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: activePlayers.length <= 2
|
||||
? 'repeat(2, 1fr)'
|
||||
: activePlayers.length === 3
|
||||
? 'repeat(3, 1fr)'
|
||||
: 'repeat(2, 1fr) repeat(2, 1fr)',
|
||||
gap: { base: '2', md: '3' },
|
||||
alignItems: 'center'
|
||||
})}>
|
||||
{activePlayers.map((player, index) => {
|
||||
const isCurrentPlayer = player.id === state.currentPlayer
|
||||
const isLeading = player.score === Math.max(...activePlayers.map(p => p.score)) && player.score > 0
|
||||
const celebrationLevel = getCelebrationLevel(player.consecutiveMatches)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={player.id}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '2', md: '3' },
|
||||
p: isCurrentPlayer ? { base: '3', md: '4' } : { base: '2', md: '2' },
|
||||
rounded: isCurrentPlayer ? '2xl' : 'lg',
|
||||
background: isCurrentPlayer
|
||||
? `linear-gradient(135deg, ${player.color || '#3b82f6'}15, ${player.color || '#3b82f6'}25, ${player.color || '#3b82f6'}15)`
|
||||
: 'white',
|
||||
border: isCurrentPlayer ? '4px solid' : '2px solid',
|
||||
borderColor: isCurrentPlayer
|
||||
? (player.color || '#3b82f6')
|
||||
: 'gray.200',
|
||||
boxShadow: isCurrentPlayer
|
||||
? '0 0 0 2px white, 0 0 0 6px ' + (player.color || '#3b82f6') + '40, 0 12px 32px rgba(0,0,0,0.2)'
|
||||
: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
position: 'relative',
|
||||
transform: isCurrentPlayer ? 'scale(1.08) translateY(-4px)' : 'scale(1)',
|
||||
zIndex: isCurrentPlayer ? 10 : 1,
|
||||
animation: isCurrentPlayer
|
||||
? (celebrationLevel === 'legendary' ? 'legendary-celebration 0.8s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'epic' ? 'epic-celebration 0.7s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'great' ? 'great-celebration 0.6s ease-out, turn-entrance 0.6s ease-out'
|
||||
: 'turn-entrance 0.6s ease-out')
|
||||
: 'none'
|
||||
})}
|
||||
>
|
||||
|
||||
{/* Leading crown with sparkle */}
|
||||
{isLeading && (
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
top: isCurrentPlayer ? '-3' : '-1',
|
||||
right: isCurrentPlayer ? '-3' : '-1',
|
||||
background: 'linear-gradient(135deg, #ffd700, #ffaa00)',
|
||||
rounded: 'full',
|
||||
w: isCurrentPlayer ? '10' : '6',
|
||||
h: isCurrentPlayer ? '10' : '6',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: isCurrentPlayer ? 'lg' : 'xs',
|
||||
zIndex: 10,
|
||||
animation: 'none',
|
||||
boxShadow: '0 0 20px rgba(255, 215, 0, 0.6)'
|
||||
})}>
|
||||
👑
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle turn indicator */}
|
||||
{isCurrentPlayer && (
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
top: '-2',
|
||||
left: '-2',
|
||||
background: player.color || '#3b82f6',
|
||||
rounded: 'full',
|
||||
w: '4',
|
||||
h: '4',
|
||||
animation: 'gentle-sway 2s ease-in-out infinite',
|
||||
zIndex: 5
|
||||
})} />
|
||||
)}
|
||||
|
||||
{/* Living, breathing player emoji */}
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: '2xl', md: '3xl' } : { base: 'lg', md: 'xl' },
|
||||
flexShrink: 0,
|
||||
animation: isCurrentPlayer
|
||||
? 'float 3s ease-in-out infinite'
|
||||
: 'breathe 5s ease-in-out infinite',
|
||||
transform: isCurrentPlayer ? 'scale(1.2)' : 'scale(1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
textShadow: isCurrentPlayer ? '0 0 20px currentColor' : 'none',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
transform: isCurrentPlayer ? 'scale(1.3)' : 'scale(1.1)',
|
||||
animation: 'gentle-sway 1s ease-in-out infinite'
|
||||
}
|
||||
})}>
|
||||
{player.displayEmoji}
|
||||
</div>
|
||||
|
||||
{/* Enhanced player info */}
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: 'md', md: 'lg' } : { base: 'xs', md: 'sm' },
|
||||
fontWeight: 'black',
|
||||
color: isCurrentPlayer ? 'gray.900' : 'gray.700',
|
||||
animation: 'none',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
|
||||
})}>
|
||||
{player.displayName}
|
||||
</div>
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'md' } : { base: '2xs', md: 'xs' },
|
||||
color: isCurrentPlayer ? (player.color || '#3b82f6') : 'gray.500',
|
||||
fontWeight: isCurrentPlayer ? 'black' : 'semibold',
|
||||
animation: 'none'
|
||||
})}>
|
||||
{gamePlurals.pair(player.score)}
|
||||
{isCurrentPlayer && (
|
||||
<span className={css({
|
||||
color: 'red.600',
|
||||
fontWeight: 'black',
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
|
||||
animation: 'none',
|
||||
textShadow: '0 0 15px currentColor'
|
||||
})}>
|
||||
{' • Your turn'}
|
||||
</span>
|
||||
)}
|
||||
{player.consecutiveMatches > 1 && (
|
||||
<div className={css({
|
||||
fontSize: { base: '2xs', md: 'xs' },
|
||||
color: celebrationLevel === 'legendary' ? 'purple.600' :
|
||||
celebrationLevel === 'epic' ? 'orange.600' :
|
||||
celebrationLevel === 'great' ? 'green.600' : 'gray.500',
|
||||
fontWeight: 'black',
|
||||
animation: isCurrentPlayer ? 'streak-pulse 1s ease-in-out infinite' : 'none',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
|
||||
})}>
|
||||
🔥 {player.consecutiveMatches} streak!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simple score display for current player */}
|
||||
{isCurrentPlayer && (
|
||||
<div className={css({
|
||||
background: 'blue.500',
|
||||
color: 'white',
|
||||
px: { base: '2', md: '3' },
|
||||
py: { base: '1', md: '2' },
|
||||
rounded: 'md',
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
fontWeight: 'bold'
|
||||
})}>
|
||||
{player.score}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Epic animations for extreme emphasis
|
||||
const epicAnimations = `
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.3), 0 12px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.5), 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-sway {
|
||||
0%, 100% { transform: rotate(-2deg) scale(1); }
|
||||
50% { transform: rotate(2deg) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes turn-entrance {
|
||||
0% {
|
||||
transform: scale(0.8) rotate(-10deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes turn-exit {
|
||||
0% {
|
||||
transform: scale(1.08);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spotlight {
|
||||
0%, 100% {
|
||||
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.3) 50%, transparent 70%);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.6) 50%, transparent 70%);
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes neon-flicker {
|
||||
0%, 100% {
|
||||
text-shadow: 0 0 5px currentColor, 0 0 10px currentColor, 0 0 15px currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 2px currentColor, 0 0 5px currentColor, 0 0 8px currentColor;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes crown-sparkle {
|
||||
0%, 100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
filter: brightness(1);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-5deg) scale(1.1);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(5deg) scale(1.1);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes streak-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes great-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.12) translateY(-6px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 8px #22c55e60, 0 15px 35px rgba(34,197,94,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes epic-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes legendary-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.2) translateY(-12px) rotate(5deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.18) translateY(-10px) rotate(-3deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 10px #a855f7, 0 20px 45px rgba(168,85,247,0.4);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.22) translateY(-14px) rotate(3deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-1deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 8px #a855f7, 0 18px 40px rgba(168,85,247,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Inject animation styles
|
||||
if (typeof document !== 'undefined' && !document.getElementById('player-status-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'player-status-animations'
|
||||
style.textContent = epicAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useUserProfile } from '../../../../contexts/UserProfileContext'
|
||||
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
@@ -10,9 +11,20 @@ export function ResultsPhase() {
|
||||
const router = useRouter()
|
||||
const { state, resetGame, activePlayers } = useMemoryPairs()
|
||||
const { players } = useGameMode()
|
||||
const { profile } = useUserProfile()
|
||||
|
||||
// Get active player data
|
||||
const activePlayerData = players.filter(p => activePlayers.includes(p.id))
|
||||
// Get active player data with profile information
|
||||
const activePlayerData = players
|
||||
.filter(p => activePlayers.includes(p.id))
|
||||
.map(player => ({
|
||||
...player,
|
||||
displayName: player.id === 1 ? profile.player1Name :
|
||||
player.id === 2 ? profile.player2Name :
|
||||
player.name,
|
||||
displayEmoji: player.id === 1 ? profile.player1Emoji :
|
||||
player.id === 2 ? profile.player2Emoji :
|
||||
player.emoji
|
||||
}))
|
||||
|
||||
const gameTime = state.gameEndTime && state.gameStartTime
|
||||
? state.gameEndTime - state.gameStartTime
|
||||
@@ -64,7 +76,7 @@ export function ResultsPhase() {
|
||||
color: 'blue.600',
|
||||
fontWeight: 'bold'
|
||||
})}>
|
||||
🏆 {activePlayerData.find(p => p.id === multiplayerResult.winners[0])?.name || `Player ${multiplayerResult.winners[0]}`} Wins!
|
||||
🏆 {activePlayerData.find(p => p.id === multiplayerResult.winners[0])?.displayName || `Player ${multiplayerResult.winners[0]}`} Wins!
|
||||
</p>
|
||||
) : (
|
||||
<p className={css({
|
||||
@@ -192,10 +204,10 @@ export function ResultsPhase() {
|
||||
minWidth: '150px'
|
||||
})}>
|
||||
<div className={css({ fontSize: '48px', marginBottom: '8px' })}>
|
||||
{player.emoji}
|
||||
{player.displayEmoji}
|
||||
</div>
|
||||
<div className={css({ fontSize: '14px', marginBottom: '4px', opacity: 0.9 })}>
|
||||
{player.name}
|
||||
{player.displayName}
|
||||
</div>
|
||||
<div className={css({ fontSize: '36px', fontWeight: 'bold' })}>
|
||||
{score}
|
||||
|
||||
@@ -138,25 +138,6 @@ export function SetupPhase() {
|
||||
minHeight: 0, // Allow shrinking
|
||||
overflow: 'auto' // Enable scrolling if needed
|
||||
})}>
|
||||
<h2 className={css({
|
||||
fontSize: { base: '18px', sm: '20px', md: '24px' },
|
||||
marginBottom: { base: '8px', md: '12px' },
|
||||
color: 'gray.800',
|
||||
fontWeight: 'bold'
|
||||
})}>
|
||||
Game Setup
|
||||
</h2>
|
||||
|
||||
<p className={css({
|
||||
fontSize: { base: '13px', sm: '14px', md: '16px' },
|
||||
color: 'gray.600',
|
||||
marginBottom: { base: '12px', sm: '16px', md: '20px' },
|
||||
lineHeight: '1.4',
|
||||
display: { base: 'none', sm: 'block' }
|
||||
})}>
|
||||
Configure your memory challenge. Choose your preferred mode, game type, and difficulty level.
|
||||
</p>
|
||||
|
||||
<div className={css({
|
||||
display: 'grid',
|
||||
gap: { base: '8px', sm: '12px', md: '16px' },
|
||||
@@ -165,54 +146,21 @@ export function SetupPhase() {
|
||||
minHeight: 0 // Allow shrinking
|
||||
})}>
|
||||
|
||||
{/* Current Player Setup */}
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, #f3f4f6, #e5e7eb)',
|
||||
rounded: { base: 'lg', md: 'xl' },
|
||||
p: { base: '3', sm: '4', md: '5' },
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300'
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: { base: '14px', sm: '16px', md: '18px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
mb: { base: '1', sm: '2', md: '2' },
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
🎮 Current Setup
|
||||
</h3>
|
||||
{/* Warning if no players */}
|
||||
{activePlayerCount === 0 && (
|
||||
<div className={css({
|
||||
fontSize: { base: '12px', sm: '13px', md: '14px' },
|
||||
color: 'gray.700',
|
||||
p: '4',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '2px solid',
|
||||
borderColor: 'red.300',
|
||||
rounded: 'xl',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<p>
|
||||
<strong>{activePlayerCount}</strong> player{activePlayerCount !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
<p className={css({ fontSize: { base: '11px', sm: '12px', md: '13px' }, color: 'gray.600', mt: '1', display: { base: 'none', sm: 'block' } })}>
|
||||
{activePlayerCount === 1
|
||||
? 'Solo challenge mode - focus & memory'
|
||||
: `${activePlayerCount}-player battle mode - compete for the most pairs`
|
||||
}
|
||||
<p className={css({ color: 'red.700', fontSize: { base: '14px', md: '16px' }, fontWeight: 'bold' })}>
|
||||
⚠️ Go back to the arcade to select players before starting the game
|
||||
</p>
|
||||
</div>
|
||||
{activePlayerCount === 0 && (
|
||||
<div className={css({
|
||||
mt: '3',
|
||||
p: '3',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.200',
|
||||
rounded: 'lg',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<p className={css({ color: 'red.700', fontSize: '14px' })}>
|
||||
⚠️ Go back to select players before starting the game
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Game Type Selection */}
|
||||
<div>
|
||||
@@ -462,36 +410,6 @@ export function SetupPhase() {
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Game Preview - Hidden on mobile and small screens */}
|
||||
<div className={css({
|
||||
background: 'gray.50',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginTop: '16px',
|
||||
display: { base: 'none', lg: 'block' } // Only show on large screens
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: 'gray.700'
|
||||
})}>
|
||||
Game Preview
|
||||
</h3>
|
||||
<div className={css({
|
||||
fontSize: '12px',
|
||||
color: 'gray.600',
|
||||
lineHeight: '1.4'
|
||||
})}>
|
||||
<p><strong>Mode:</strong> {activePlayerCount === 1 ? 'Single Player' : `${activePlayerCount} Players`}</p>
|
||||
<p><strong>Type:</strong> {state.gameType === 'abacus-numeral' ? 'Abacus-Numeral Matching' : 'Complement Pairs'}</p>
|
||||
<p><strong>Difficulty:</strong> {state.difficulty} pairs ({state.difficulty * 2} cards)</p>
|
||||
{activePlayerCount > 1 && (
|
||||
<p><strong>Turn Timer:</strong> {state.turnTimer} seconds</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -34,6 +34,7 @@ const initialState: MemoryPairsState = {
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
consecutiveMatches: {},
|
||||
|
||||
// Timing
|
||||
gameStartTime: null,
|
||||
@@ -73,10 +74,12 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
}
|
||||
|
||||
case 'START_GAME':
|
||||
// Initialize scores for all active players
|
||||
// Initialize scores and consecutive matches for all active players
|
||||
const scores: PlayerScore = {}
|
||||
const consecutiveMatches: { [playerId: number]: number } = {}
|
||||
action.activePlayers.forEach(playerId => {
|
||||
scores[playerId] = 0
|
||||
consecutiveMatches[playerId] = 0
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -88,6 +91,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores,
|
||||
consecutiveMatches,
|
||||
activePlayers: action.activePlayers,
|
||||
currentPlayer: action.activePlayers[0] || 1,
|
||||
gameStartTime: Date.now(),
|
||||
@@ -135,6 +139,11 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1
|
||||
}
|
||||
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1
|
||||
}
|
||||
|
||||
// Check if game is complete
|
||||
const isGameComplete = newMatchedPairs === state.totalPairs
|
||||
|
||||
@@ -143,6 +152,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
gameCards: updatedCards,
|
||||
matchedPairs: newMatchedPairs,
|
||||
scores: newScores,
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
flippedCards: [],
|
||||
moves: state.moves + 1,
|
||||
lastMatchedPair: action.cardIds,
|
||||
@@ -169,9 +179,17 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
// Cycle through all active players
|
||||
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
|
||||
const nextIndex = (currentIndex + 1) % state.activePlayers.length
|
||||
|
||||
// Reset consecutive matches for the player who failed
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: 0
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentPlayer: state.activePlayers[nextIndex] || state.activePlayers[0]
|
||||
currentPlayer: state.activePlayers[nextIndex] || state.activePlayers[0],
|
||||
consecutiveMatches: newConsecutiveMatches
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface MemoryPairsState {
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
consecutiveMatches: { [playerId: number]: number } // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
gameStartTime: number | null
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { MemoryPairsProvider } from './context/MemoryPairsContext'
|
||||
import { UserProfileProvider } from '../../../contexts/UserProfileContext'
|
||||
import { GameModeProvider } from '../../../contexts/GameModeContext'
|
||||
import { FullscreenProvider } from '../../../contexts/FullscreenContext'
|
||||
import { MemoryPairsGame } from './components/MemoryPairsGame'
|
||||
|
||||
export default function MatchingPage() {
|
||||
return (
|
||||
<FullscreenProvider>
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<MemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</MemoryPairsProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
</FullscreenProvider>
|
||||
<MemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</MemoryPairsProvider>
|
||||
)
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import Link from 'next/link'
|
||||
import React, { useEffect, useReducer, useRef, useCallback, useMemo, useState } from 'react'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useAbacusConfig } from '../../../contexts/AbacusDisplayContext'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { isPrefix } from '../../../lib/memory-quiz-utils'
|
||||
import { StandardGameLayout } from '../../../components/StandardGameLayout'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
|
||||
|
||||
interface QuizCard {
|
||||
@@ -223,6 +223,8 @@ const generateQuizCards = (count: number, difficulty: DifficultyLevel, appConfig
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
soundEnabled={appConfig.soundEnabled}
|
||||
soundVolume={appConfig.soundVolume}
|
||||
/>,
|
||||
element: null
|
||||
}))
|
||||
@@ -262,9 +264,6 @@ function SetupPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<h2 style={{ color: '#374151', margin: '0 0 8px 0', fontSize: '18px' }}>🧠 Speed Memory Quiz</h2>
|
||||
<p style={{ color: '#6b7280', margin: '0 0 16px 0', fontSize: '14px' }}>Test your soroban reading skills! Cards will be shown briefly, then you'll enter the numbers you remember.</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
@@ -1731,23 +1730,24 @@ export default function MemoryQuizPage() {
|
||||
}, [state.prefixAcceptanceTimeout])
|
||||
|
||||
return (
|
||||
<StandardGameLayout>
|
||||
<PageWithNav navTitle="Memory Lightning" navEmoji="🧠">
|
||||
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom right, #f0fdf4, #eff6ff)',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto'
|
||||
overflow: 'auto',
|
||||
padding: '20px 8px',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
padding: '0 8px',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
@@ -1799,6 +1799,6 @@ export default function MemoryQuizPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StandardGameLayout>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { grid } from '../../../styled-system/patterns'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useUserProfile } from '../../contexts/UserProfileContext'
|
||||
import { useGameMode } from '../../contexts/GameModeContext'
|
||||
import { FullscreenProvider, useFullscreen } from '../../contexts/FullscreenContext'
|
||||
@@ -1158,7 +1159,11 @@ const globalAnimations = `
|
||||
`
|
||||
|
||||
export default function GamesPage() {
|
||||
return <GamesPageContent />
|
||||
return (
|
||||
<PageWithNav navTitle="Soroban Arcade" navEmoji="🕹️">
|
||||
<GamesPageContent />
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Inject refined animations into the page
|
||||
|
||||
486
apps/web/src/app/guide/components/ArithmeticOperationsGuide.tsx
Normal file
486
apps/web/src/app/guide/components/ArithmeticOperationsGuide.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { grid } from '../../../../styled-system/patterns'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
|
||||
import { getTutorialForEditor } from '@/utils/tutorialConverter'
|
||||
|
||||
export function ArithmeticOperationsGuide() {
|
||||
const appConfig = useAbacusConfig()
|
||||
|
||||
return (
|
||||
<div className={css({ maxW: '4xl', mx: 'auto' })}>
|
||||
<div className={css({
|
||||
bg: 'gradient-to-br',
|
||||
gradientFrom: 'purple.600',
|
||||
gradientTo: 'indigo.700',
|
||||
color: 'white',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
mb: '8',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<h2 className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
mb: '4'
|
||||
})}>
|
||||
🧮 Arithmetic Operations
|
||||
</h2>
|
||||
<p className={css({
|
||||
fontSize: 'lg',
|
||||
opacity: '0.9'
|
||||
})}>
|
||||
Master addition, subtraction, multiplication, and division on the soroban
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Addition Section */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
mb: '6',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200'
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'green.700',
|
||||
mb: '4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2'
|
||||
})}>
|
||||
<span>➕</span> Addition
|
||||
</h3>
|
||||
|
||||
<p className={css({ mb: '6', color: 'gray.700' })}>
|
||||
Addition on the soroban follows the principle of moving beads toward the bar to increase values.
|
||||
</p>
|
||||
|
||||
<div className={css({ mb: '6' })}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
mb: '3',
|
||||
color: 'green.600'
|
||||
})}>
|
||||
Basic Steps:
|
||||
</h4>
|
||||
<ol className={css({
|
||||
pl: '6',
|
||||
gap: '2',
|
||||
color: 'gray.700'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>1. Set the first number on the soroban</li>
|
||||
<li className={css({ mb: '2' })}>2. Add the second number by moving beads toward the bar</li>
|
||||
<li className={css({ mb: '2' })}>3. Handle carries when a column exceeds 9</li>
|
||||
<li>4. Read the final result</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'green.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'green.200',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
mb: '4'
|
||||
})}>
|
||||
<h5 className={css({
|
||||
fontWeight: 'semibold',
|
||||
color: 'green.800',
|
||||
mb: '2'
|
||||
})}>
|
||||
Example: 3 + 4 = 7
|
||||
</h5>
|
||||
<div className={grid({ columns: 3, gap: '4', alignItems: 'center' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<p className={css({ fontSize: 'sm', mb: '2', color: 'green.700' })}>Start: 3</p>
|
||||
<div className={css({
|
||||
width: '160px',
|
||||
height: '240px',
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
mx: 'auto'
|
||||
})}>
|
||||
<AbacusReact
|
||||
value={3}
|
||||
columns={1}
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={true}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css({ textAlign: 'center', fontSize: '2xl' })}>+</div>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<p className={css({ fontSize: 'sm', mb: '2', color: 'green.700' })}>Result: 7</p>
|
||||
<div className={css({
|
||||
width: '160px',
|
||||
height: '240px',
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
mx: 'auto'
|
||||
})}>
|
||||
<AbacusReact
|
||||
value={7}
|
||||
columns={1}
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={true}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guided Addition Tutorial */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
mb: '6',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200'
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.700',
|
||||
mb: '4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2'
|
||||
})}>
|
||||
<span>🎯</span> Guided Addition Tutorial
|
||||
</h3>
|
||||
|
||||
<p className={css({ mb: '6', color: 'gray.700' })}>
|
||||
Learn addition step-by-step with interactive guidance, tooltips, and error correction.
|
||||
</p>
|
||||
|
||||
<div className={css({
|
||||
bg: 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
rounded: 'lg',
|
||||
p: 4,
|
||||
mb: 4
|
||||
})}>
|
||||
<p className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'blue.700',
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2
|
||||
})}>
|
||||
<span>✏️</span>
|
||||
<strong>This tutorial is now editable!</strong>
|
||||
</p>
|
||||
<p className={css({ fontSize: 'xs', color: 'blue.600' })}>
|
||||
You can customize this tutorial using our new tutorial editor system.{' '}
|
||||
<a
|
||||
href="/tutorial-editor"
|
||||
className={css({
|
||||
color: 'blue.700',
|
||||
textDecoration: 'underline',
|
||||
_hover: { color: 'blue.800' }
|
||||
})}
|
||||
>
|
||||
Open in Editor →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TutorialPlayer
|
||||
tutorial={getTutorialForEditor()}
|
||||
isDebugMode={false}
|
||||
showDebugPanel={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtraction Section */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
mb: '6',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200'
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'red.700',
|
||||
mb: '4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2'
|
||||
})}>
|
||||
<span>➖</span> Subtraction
|
||||
</h3>
|
||||
|
||||
<p className={css({ mb: '6', color: 'gray.700' })}>
|
||||
Subtraction involves moving beads away from the bar to decrease values.
|
||||
</p>
|
||||
|
||||
<div className={css({ mb: '6' })}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
mb: '3',
|
||||
color: 'red.600'
|
||||
})}>
|
||||
Basic Steps:
|
||||
</h4>
|
||||
<ol className={css({
|
||||
pl: '6',
|
||||
gap: '2',
|
||||
color: 'gray.700'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>1. Set the minuend (first number) on the soroban</li>
|
||||
<li className={css({ mb: '2' })}>2. Subtract by moving beads away from the bar</li>
|
||||
<li className={css({ mb: '2' })}>3. Handle borrowing when needed</li>
|
||||
<li>4. Read the final result</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'red.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.200',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
mb: '4'
|
||||
})}>
|
||||
<h5 className={css({
|
||||
fontWeight: 'semibold',
|
||||
color: 'red.800',
|
||||
mb: '2'
|
||||
})}>
|
||||
Example: 8 - 3 = 5
|
||||
</h5>
|
||||
<div className={grid({ columns: 3, gap: '4', alignItems: 'center' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<p className={css({ fontSize: 'sm', mb: '2', color: 'red.700' })}>Start: 8</p>
|
||||
<div className={css({
|
||||
width: '160px',
|
||||
height: '240px',
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
mx: 'auto'
|
||||
})}>
|
||||
<AbacusReact
|
||||
value={8}
|
||||
columns={1}
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={true}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css({ textAlign: 'center', fontSize: '2xl' })}>-</div>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<p className={css({ fontSize: 'sm', mb: '2', color: 'red.700' })}>Result: 5</p>
|
||||
<div className={css({
|
||||
width: '160px',
|
||||
height: '240px',
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
mx: 'auto'
|
||||
})}>
|
||||
<AbacusReact
|
||||
value={5}
|
||||
columns={1}
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={true}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multiplication & Division Section */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
mb: '6',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200'
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
mb: '4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2'
|
||||
})}>
|
||||
<span>✖️➗</span> Multiplication & Division
|
||||
</h3>
|
||||
|
||||
<p className={css({ mb: '6', color: 'gray.700' })}>
|
||||
Advanced operations that combine addition/subtraction with position shifting.
|
||||
</p>
|
||||
|
||||
<div className={grid({ columns: { base: 1, md: 2 }, gap: '6' })}>
|
||||
<div className={css({
|
||||
bg: 'purple.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'purple.200',
|
||||
rounded: 'lg',
|
||||
p: '4'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'purple.800',
|
||||
mb: '3'
|
||||
})}>
|
||||
Multiplication
|
||||
</h4>
|
||||
<ul className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'purple.700',
|
||||
pl: '4'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>• Break down into repeated addition</li>
|
||||
<li className={css({ mb: '2' })}>• Use position shifts for place values</li>
|
||||
<li className={css({ mb: '2' })}>• Master multiplication tables</li>
|
||||
<li>• Practice with single digits first</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'indigo.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'indigo.200',
|
||||
rounded: 'lg',
|
||||
p: '4'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'indigo.800',
|
||||
mb: '3'
|
||||
})}>
|
||||
Division
|
||||
</h4>
|
||||
<ul className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'indigo.700',
|
||||
pl: '4'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>• Use repeated subtraction method</li>
|
||||
<li className={css({ mb: '2' })}>• Estimate quotients carefully</li>
|
||||
<li className={css({ mb: '2' })}>• Handle remainders properly</li>
|
||||
<li>• Check results by multiplication</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Practice Tips */}
|
||||
<div className={css({
|
||||
bg: 'gradient-to-r',
|
||||
gradientFrom: 'purple.600',
|
||||
gradientTo: 'indigo.600',
|
||||
color: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
mb: '3'
|
||||
})}>
|
||||
💡 Master the Fundamentals
|
||||
</h4>
|
||||
<p className={css({
|
||||
mb: '4',
|
||||
opacity: '0.9'
|
||||
})}>
|
||||
Start with simple problems and gradually increase complexity
|
||||
</p>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
display: 'inline-block',
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: 'white',
|
||||
color: 'purple.600',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'lg',
|
||||
textDecoration: 'none',
|
||||
transition: 'all',
|
||||
_hover: { transform: 'translateY(-1px)', shadow: 'lg' }
|
||||
})}
|
||||
>
|
||||
Practice Arithmetic Operations →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
702
apps/web/src/app/guide/components/ReadingNumbersGuide.tsx
Normal file
702
apps/web/src/app/guide/components/ReadingNumbersGuide.tsx
Normal file
@@ -0,0 +1,702 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { stack, hstack, grid } from '../../../../styled-system/patterns'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
|
||||
export function ReadingNumbersGuide() {
|
||||
const appConfig = useAbacusConfig()
|
||||
|
||||
return (
|
||||
<div className={stack({ gap: '12' })}>
|
||||
{/* Section Introduction */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2 className={css({
|
||||
fontSize: '3xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '4'
|
||||
})}>
|
||||
🔍 Learning to Read Soroban Numbers
|
||||
</h2>
|
||||
<p className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
maxW: '3xl',
|
||||
mx: 'auto',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
Master the fundamentals of reading numbers on the soroban with step-by-step visual tutorials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Basic Structure */}
|
||||
<div className={css({
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'xl',
|
||||
p: '8'
|
||||
})}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<div className={hstack({ gap: '4', alignItems: 'center' })}>
|
||||
<div className={css({
|
||||
w: '12',
|
||||
h: '12',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg'
|
||||
})}>
|
||||
1
|
||||
</div>
|
||||
<h3 className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Understanding the Structure
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<p className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.700',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
The soroban consists of two main sections divided by a horizontal bar. Understanding this structure is fundamental to reading any number.
|
||||
</p>
|
||||
|
||||
<div className={grid({ columns: { base: 1, md: 2 }, gap: '8' })}>
|
||||
<div className={css({
|
||||
bg: 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'blue.800',
|
||||
mb: '3'
|
||||
})}>
|
||||
🌅 Heaven Beads (Top)
|
||||
</h4>
|
||||
<ul className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'blue.700',
|
||||
lineHeight: 'relaxed',
|
||||
pl: '4'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>• Located above the horizontal bar</li>
|
||||
<li className={css({ mb: '2' })}>• Each bead represents 5</li>
|
||||
<li className={css({ mb: '2' })}>• Only one bead per column</li>
|
||||
<li>• When pushed down = active/counted</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'green.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'green.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'green.800',
|
||||
mb: '3'
|
||||
})}>
|
||||
🌍 Earth Beads (Bottom)
|
||||
</h4>
|
||||
<ul className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'green.700',
|
||||
lineHeight: 'relaxed',
|
||||
pl: '4'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>• Located below the horizontal bar</li>
|
||||
<li className={css({ mb: '2' })}>• Each bead represents 1</li>
|
||||
<li className={css({ mb: '2' })}>• Four beads per column</li>
|
||||
<li>• When pushed up = active/counted</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'yellow.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.300',
|
||||
rounded: 'xl',
|
||||
p: '4',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<p className={css({
|
||||
fontSize: 'md',
|
||||
color: 'yellow.800',
|
||||
fontWeight: 'medium'
|
||||
})}>
|
||||
💡 Key Concept: Active beads are those touching the horizontal bar
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Single Digits */}
|
||||
<div className={css({
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'xl',
|
||||
p: '8'
|
||||
})}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<div className={hstack({ gap: '4', alignItems: 'center' })}>
|
||||
<div className={css({
|
||||
w: '12',
|
||||
h: '12',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg'
|
||||
})}>
|
||||
2
|
||||
</div>
|
||||
<h3 className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Reading Single Digits (1-9)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.700',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
Let's learn to read single digits by understanding how heaven and earth beads combine to represent numbers 1 through 9.
|
||||
</p>
|
||||
|
||||
<div className={grid({ columns: { base: 1, lg: 5 }, gap: '6' })}>
|
||||
{[
|
||||
{ num: 0, desc: 'No beads active - all away from bar' },
|
||||
{ num: 1, desc: 'One earth bead pushed up' },
|
||||
{ num: 3, desc: 'Three earth beads pushed up' },
|
||||
{ num: 5, desc: 'Heaven bead pushed down' },
|
||||
{ num: 7, desc: 'Heaven bead + two earth beads' }
|
||||
].map((example) => (
|
||||
<div key={example.num} className={css({
|
||||
bg: 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center'
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'brand.600',
|
||||
mb: '3'
|
||||
})}>
|
||||
{example.num}
|
||||
</div>
|
||||
|
||||
{/* Aspect ratio container for soroban - roughly 1:3 ratio */}
|
||||
<div className={css({
|
||||
width: '100%',
|
||||
aspectRatio: '1/2.8',
|
||||
maxW: '120px',
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
})}>
|
||||
<AbacusReact
|
||||
value={example.num}
|
||||
columns={1}
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={0.8}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={css({
|
||||
fontSize: '2xs',
|
||||
color: 'gray.600',
|
||||
lineHeight: 'tight',
|
||||
textAlign: 'center',
|
||||
mt: 'auto'
|
||||
})}>
|
||||
{example.desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Multi-digit Numbers */}
|
||||
<div className={css({
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'xl',
|
||||
p: '8'
|
||||
})}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<div className={hstack({ gap: '4', alignItems: 'center' })}>
|
||||
<div className={css({
|
||||
w: '12',
|
||||
h: '12',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg'
|
||||
})}>
|
||||
3
|
||||
</div>
|
||||
<h3 className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Multi-Digit Numbers
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.700',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
Reading larger numbers is simply a matter of reading each column from left to right, with each column representing a different place value.
|
||||
</p>
|
||||
|
||||
<div className={css({
|
||||
bg: 'purple.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'purple.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'purple.800',
|
||||
mb: '4',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
📍 Reading Direction & Place Values
|
||||
</h4>
|
||||
<div className={grid({ columns: { base: 1, md: 2 }, gap: '6' })}>
|
||||
<div>
|
||||
<h5 className={css({ fontWeight: 'semibold', mb: '2', color: 'purple.800' })}>Reading Order:</h5>
|
||||
<ul className={css({ fontSize: 'sm', color: 'purple.700', pl: '4' })}>
|
||||
<li className={css({ mb: '1' })}>• Always read from LEFT to RIGHT</li>
|
||||
<li className={css({ mb: '1' })}>• Each column is one digit</li>
|
||||
<li>• Combine digits to form the complete number</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className={css({ fontWeight: 'semibold', mb: '2', color: 'purple.800' })}>Place Values:</h5>
|
||||
<ul className={css({ fontSize: 'sm', color: 'purple.700', pl: '4' })}>
|
||||
<li className={css({ mb: '1' })}>• Rightmost = Ones (1s)</li>
|
||||
<li className={css({ mb: '1' })}>• Next left = Tens (10s)</li>
|
||||
<li>• Continue for hundreds, thousands, etc.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-digit Examples */}
|
||||
<div className={css({
|
||||
bg: 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'blue.800',
|
||||
mb: '4',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
🔢 Multi-Digit Examples
|
||||
</h4>
|
||||
|
||||
<div className={grid({ columns: { base: 1, md: 3 }, gap: '8' })}>
|
||||
{[
|
||||
{ num: 23, desc: 'Two-digit: 2 in tens place + 3 in ones place' },
|
||||
{ num: 58, desc: 'Heaven bead in tens (5) + heaven + earth beads in ones (8)' },
|
||||
{ num: 147, desc: 'Three-digit: 1 hundred + 4 tens + 7 ones' }
|
||||
].map((example) => (
|
||||
<div key={example.num} className={css({
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.300',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center'
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.600',
|
||||
mb: '3'
|
||||
})}>
|
||||
{example.num}
|
||||
</div>
|
||||
|
||||
{/* Larger container for multi-digit numbers */}
|
||||
<div className={css({
|
||||
width: '100%',
|
||||
aspectRatio: '3/4',
|
||||
maxW: '180px',
|
||||
bg: 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
})}>
|
||||
<AbacusReact
|
||||
value={example.num}
|
||||
columns={'auto'}
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={0.9}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'blue.700',
|
||||
lineHeight: 'relaxed',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
{example.desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Practice Tips */}
|
||||
<div className={css({
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'xl',
|
||||
p: '8'
|
||||
})}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<div className={hstack({ gap: '4', alignItems: 'center' })}>
|
||||
<div className={css({
|
||||
w: '12',
|
||||
h: '12',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg'
|
||||
})}>
|
||||
4
|
||||
</div>
|
||||
<h3 className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Practice Strategy
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, md: 2 }, gap: '6' })}>
|
||||
<div className={css({
|
||||
bg: 'green.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'green.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'green.800',
|
||||
mb: '4'
|
||||
})}>
|
||||
🎯 Learning Tips
|
||||
</h4>
|
||||
<ul className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'green.700',
|
||||
lineHeight: 'relaxed',
|
||||
pl: '4'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>• Start with single digits (0-9)</li>
|
||||
<li className={css({ mb: '2' })}>• Practice identifying active vs. inactive beads</li>
|
||||
<li className={css({ mb: '2' })}>• Work on speed recognition</li>
|
||||
<li>• Progress to multi-digit numbers gradually</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'orange.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'orange.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'orange.800',
|
||||
mb: '4'
|
||||
})}>
|
||||
⚡ Quick Recognition
|
||||
</h4>
|
||||
<ul className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'orange.700',
|
||||
lineHeight: 'relaxed',
|
||||
pl: '4'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>• Numbers 1-4: Only earth beads</li>
|
||||
<li className={css({ mb: '2' })}>• Number 5: Only heaven bead</li>
|
||||
<li className={css({ mb: '2' })}>• Numbers 6-9: Heaven + earth beads</li>
|
||||
<li>• Zero: All beads away from bar</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'blue.600',
|
||||
color: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
mb: '3'
|
||||
})}>
|
||||
🚀 Ready to Practice?
|
||||
</h4>
|
||||
<p className={css({
|
||||
mb: '4',
|
||||
opacity: '0.9'
|
||||
})}>
|
||||
Test your newfound knowledge with interactive flashcards
|
||||
</p>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
display: 'inline-block',
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: 'white',
|
||||
color: 'blue.600',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'lg',
|
||||
textDecoration: 'none',
|
||||
transition: 'all',
|
||||
_hover: { transform: 'translateY(-1px)', shadow: 'lg' }
|
||||
})}
|
||||
>
|
||||
Create Practice Flashcards →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 5: Interactive Practice */}
|
||||
<div className={css({
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'xl',
|
||||
p: '8'
|
||||
})}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<div className={hstack({ gap: '4', alignItems: 'center' })}>
|
||||
<div className={css({
|
||||
w: '12',
|
||||
h: '12',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg'
|
||||
})}>
|
||||
5
|
||||
</div>
|
||||
<h3 className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Interactive Practice
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.700',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
Try the interactive abacus below! Click on the beads to activate them and watch the number change in real-time.
|
||||
</p>
|
||||
|
||||
<div className={css({
|
||||
bg: 'orange.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'orange.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'orange.800',
|
||||
mb: '4',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
🎮 How to Use the Interactive Abacus
|
||||
</h4>
|
||||
<div className={grid({ columns: { base: 1, md: 2 }, gap: '6' })}>
|
||||
<div>
|
||||
<h5 className={css({ fontWeight: 'semibold', mb: '2', color: 'orange.800' })}>Heaven Beads (Top):</h5>
|
||||
<ul className={css({ fontSize: 'sm', color: 'orange.700', pl: '4' })}>
|
||||
<li className={css({ mb: '1' })}>• Worth 5 points each</li>
|
||||
<li className={css({ mb: '1' })}>• Click to toggle on/off</li>
|
||||
<li>• Blue when active, gray when inactive</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className={css({ fontWeight: 'semibold', mb: '2', color: 'orange.800' })}>Earth Beads (Bottom):</h5>
|
||||
<ul className={css({ fontSize: 'sm', color: 'orange.700', pl: '4' })}>
|
||||
<li className={css({ mb: '1' })}>• Worth 1 point each</li>
|
||||
<li className={css({ mb: '1' })}>• Click to activate groups</li>
|
||||
<li>• Green when active, gray when inactive</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Abacus Component */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'brand.200',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.08)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '100%'
|
||||
})}>
|
||||
<AbacusReact
|
||||
value={0}
|
||||
columns={3}
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={1.5}
|
||||
interactive={true}
|
||||
showNumbers={true}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'blue.600',
|
||||
color: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
mb: '3'
|
||||
})}>
|
||||
🚀 Ready to Practice?
|
||||
</h4>
|
||||
<p className={css({
|
||||
mb: '4',
|
||||
opacity: '0.9'
|
||||
})}>
|
||||
Test your newfound knowledge with interactive flashcards
|
||||
</p>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
display: 'inline-block',
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: 'white',
|
||||
color: 'blue.600',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'lg',
|
||||
textDecoration: 'none',
|
||||
transition: 'all',
|
||||
_hover: { transform: 'translateY(-1px)', shadow: 'lg' }
|
||||
})}
|
||||
>
|
||||
Create Practice Flashcards →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,17 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import './globals.css'
|
||||
import { AbacusDisplayProvider } from '@/contexts/AbacusDisplayContext'
|
||||
import { UserProfileProvider } from '@/contexts/UserProfileContext'
|
||||
import { GameModeProvider } from '@/contexts/GameModeContext'
|
||||
import { FullscreenProvider } from '@/contexts/FullscreenContext'
|
||||
import { GameThemeProvider } from '@/contexts/GameThemeContext'
|
||||
import { AppNavBar } from '@/components/AppNavBar'
|
||||
import { ClientProviders } from '@/components/ClientProviders'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Soroban Flashcard Generator',
|
||||
description: 'Create beautiful, educational soroban flashcards with authentic Japanese abacus representations',
|
||||
viewport: {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -26,18 +22,9 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<AbacusDisplayProvider>
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<FullscreenProvider>
|
||||
<GameThemeProvider>
|
||||
<AppNavBar />
|
||||
{children}
|
||||
</GameThemeProvider>
|
||||
</FullscreenProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
</AbacusDisplayProvider>
|
||||
<ClientProviders>
|
||||
{children}
|
||||
</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, stack, hstack } from '../../styled-system/patterns'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className={css({ minHeight: '100vh', bg: 'gradient-to-br from-brand.50 to-brand.100' })}>
|
||||
<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' })}>
|
||||
@@ -109,7 +111,8 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as RadioGroup from '@radix-ui/react-radio-group'
|
||||
import * as Switch from '@radix-ui/react-switch'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { stack, hstack } from '../../styled-system/patterns'
|
||||
import { useAbacusDisplay, ColorScheme, BeadShape } from '@/contexts/AbacusDisplayContext'
|
||||
import { useAbacusDisplay, ColorScheme, BeadShape } from '@soroban/abacus-react'
|
||||
|
||||
interface AbacusDisplayDropdownProps {
|
||||
isFullscreen?: boolean
|
||||
@@ -169,6 +169,62 @@ export function AbacusDisplayDropdown({ isFullscreen = false }: AbacusDisplayDro
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Sound Effects" isFullscreen={isFullscreen}>
|
||||
<SwitchField
|
||||
checked={config.soundEnabled}
|
||||
onCheckedChange={(checked) => updateConfig({ soundEnabled: checked })}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{config.soundEnabled && (
|
||||
<FormField label={`Volume: ${Math.round(config.soundVolume * 100)}%`} isFullscreen={isFullscreen}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={config.soundVolume}
|
||||
onChange={(e) => updateConfig({ soundVolume: parseFloat(e.target.value) })}
|
||||
className={css({
|
||||
w: 'full',
|
||||
h: '2',
|
||||
bg: isFullscreen ? 'rgba(255, 255, 255, 0.2)' : 'gray.200',
|
||||
rounded: 'full',
|
||||
appearance: 'none',
|
||||
cursor: 'pointer',
|
||||
_focusVisible: {
|
||||
outline: 'none',
|
||||
ring: '2px',
|
||||
ringColor: isFullscreen ? 'blue.400' : 'brand.500'
|
||||
},
|
||||
'&::-webkit-slider-thumb': {
|
||||
appearance: 'none',
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
bg: isFullscreen ? 'blue.500' : 'brand.700',
|
||||
transform: 'scale(1.1)'
|
||||
}
|
||||
},
|
||||
'&::-moz-range-thumb': {
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent dropdown close
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { css } from '../../styled-system/css'
|
||||
@@ -9,15 +10,18 @@ import { useFullscreen } from '../contexts/FullscreenContext'
|
||||
|
||||
interface AppNavBarProps {
|
||||
variant?: 'full' | 'minimal'
|
||||
navSlot?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
|
||||
export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const isGamePage = pathname?.startsWith('/games')
|
||||
const isArcadePage = pathname?.startsWith('/arcade')
|
||||
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
|
||||
|
||||
|
||||
|
||||
// Auto-detect variant based on context
|
||||
const actualVariant = variant === 'full' && (isGamePage || isArcadePage) ? 'minimal' : variant
|
||||
|
||||
@@ -34,58 +38,53 @@ export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
|
||||
transition: 'all 0.3s ease'
|
||||
})}>
|
||||
<div className={hstack({ gap: '2' })}>
|
||||
{/* Arcade branding (fullscreen only) */}
|
||||
{isFullscreen && (isArcadePage || isGamePage) && (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3',
|
||||
px: '4',
|
||||
py: '2',
|
||||
bg: 'rgba(0, 0, 0, 0.85)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
backdropFilter: 'blur(15px)'
|
||||
})}>
|
||||
<h1 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent'
|
||||
})}>
|
||||
🕹️ {isArcadePage ? 'Arcade' : 'Game'}
|
||||
</h1>
|
||||
<div className={css({
|
||||
px: '2',
|
||||
py: '1',
|
||||
background: 'rgba(34, 197, 94, 0.2)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
rounded: 'full',
|
||||
fontSize: 'xs',
|
||||
color: 'green.300',
|
||||
fontWeight: 'semibold'
|
||||
})}>
|
||||
✨ FULLSCREEN
|
||||
</div>
|
||||
{/* Game branding from slot */}
|
||||
{navSlot && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '8px 16px',
|
||||
background: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: isFullscreen ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
}}
|
||||
>
|
||||
{navSlot}
|
||||
{isFullscreen && (
|
||||
<div className={css({
|
||||
px: '2',
|
||||
py: '1',
|
||||
background: 'rgba(34, 197, 94, 0.2)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
rounded: 'full',
|
||||
fontSize: 'xs',
|
||||
color: 'green.300',
|
||||
fontWeight: 'semibold'
|
||||
})}>
|
||||
✨ FULLSCREEN
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isFullscreen ? 'rgba(255, 255, 255, 0.1)' : 'gray.200',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
})}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
background: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: isFullscreen ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className={css({
|
||||
@@ -122,19 +121,19 @@ export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Controls */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isFullscreen ? 'rgba(255, 255, 255, 0.1)' : 'gray.200',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
})}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
background: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: isFullscreen ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ChampionArenaProps {
|
||||
|
||||
export function ChampionArena({ onGameModeChange, onConfigurePlayer, className }: ChampionArenaProps) {
|
||||
const { profile, updatePlayerEmoji, updatePlayerName } = useUserProfile()
|
||||
const { gameMode, players, setGameMode, updatePlayer, activePlayerCount } = useGameMode()
|
||||
const { gameMode, players, updatePlayer, activePlayerCount } = useGameMode()
|
||||
const [draggedPlayer, setDraggedPlayer] = useState<number | null>(null)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [isRosterDragOver, setIsRosterDragOver] = useState(false)
|
||||
@@ -69,7 +69,7 @@ export function ChampionArena({ onGameModeChange, onConfigurePlayer, className }
|
||||
newMode = 'tournament'
|
||||
}
|
||||
|
||||
setGameMode(newMode)
|
||||
// gameMode is now computed from active player count
|
||||
onGameModeChange?.(newMode)
|
||||
setDraggedPlayer(null)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export function ChampionArena({ onGameModeChange, onConfigurePlayer, className }
|
||||
console.log('New mode will be:', newMode, 'with', newActiveCount, 'active players')
|
||||
|
||||
// Update game mode
|
||||
setGameMode(newMode)
|
||||
// gameMode is now computed from active player count
|
||||
onGameModeChange?.(newMode)
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ export function ChampionArena({ onGameModeChange, onConfigurePlayer, className }
|
||||
// Note: Allow arena to be completely empty - user can drag champions back in
|
||||
|
||||
// Update game mode
|
||||
setGameMode(newMode)
|
||||
// gameMode is now computed from active player count
|
||||
onGameModeChange?.(newMode)
|
||||
}
|
||||
|
||||
|
||||
25
apps/web/src/components/ClientProviders.tsx
Normal file
25
apps/web/src/components/ClientProviders.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import { AbacusDisplayProvider } from '@soroban/abacus-react'
|
||||
import { UserProfileProvider } from '@/contexts/UserProfileContext'
|
||||
import { GameModeProvider } from '@/contexts/GameModeContext'
|
||||
import { FullscreenProvider } from '@/contexts/FullscreenContext'
|
||||
|
||||
interface ClientProvidersProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ClientProviders({ children }: ClientProvidersProps) {
|
||||
return (
|
||||
<AbacusDisplayProvider>
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<FullscreenProvider>
|
||||
{children}
|
||||
</FullscreenProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
</AbacusDisplayProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragStartEvent,
|
||||
DragOverEvent,
|
||||
DragEndEvent,
|
||||
useDroppable,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import {
|
||||
useSortable,
|
||||
SortableContext as SortableContextType,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useSpring, animated, config } from '@react-spring/web'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useUserProfile } from '../contexts/UserProfileContext'
|
||||
import { useGameMode } from '../contexts/GameModeContext'
|
||||
import { GameSelector } from './GameSelector'
|
||||
|
||||
interface EnhancedChampionArenaProps {
|
||||
@@ -39,691 +9,28 @@ interface EnhancedChampionArenaProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface DraggablePlayer {
|
||||
id: number
|
||||
name: string
|
||||
emoji: string
|
||||
color: string
|
||||
isActive: boolean
|
||||
level: number
|
||||
}
|
||||
|
||||
// We'll handle animations within ChampionCard to avoid ref conflicts
|
||||
|
||||
// Animated Champion Card Component
|
||||
function ChampionCard({
|
||||
player,
|
||||
isOverlay = false,
|
||||
onConfigure,
|
||||
onToggleArena,
|
||||
zone,
|
||||
crossZoneDrag = false
|
||||
}: {
|
||||
player: DraggablePlayer
|
||||
isOverlay?: boolean
|
||||
onConfigure?: (id: number) => void
|
||||
onToggleArena?: (id: number) => void
|
||||
zone: 'roster' | 'arena'
|
||||
crossZoneDrag?: boolean
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: player.id })
|
||||
|
||||
// React Spring animations with subtle entry effect and cross-zone "making way" animations
|
||||
const cardStyle = useSpring({
|
||||
from: { opacity: 0, transform: 'translateY(5px)' },
|
||||
to: {
|
||||
opacity: isDragging && !isOverlay ? 0.7 : 1,
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0) rotateZ(${isDragging ? (Math.random() - 0.5) * 2 : 0}deg)`
|
||||
: crossZoneDrag
|
||||
? `translate3d(1px, 0px, 0) scale(0.98) rotateZ(0.5deg)`
|
||||
: `translateY(0px)`
|
||||
},
|
||||
config: config.gentle,
|
||||
})
|
||||
|
||||
const glowStyle = useSpring({
|
||||
boxShadow: zone === 'arena'
|
||||
? `0 0 ${isDragging ? '30px' : '20px'} ${player.color}${isDragging ? '80' : '40'}`
|
||||
: `0 ${isDragging ? '12px 30px' : '8px 20px'} rgba(0, 0, 0, ${isDragging ? '0.25' : '0.15'})`,
|
||||
config: config.gentle,
|
||||
})
|
||||
|
||||
const emojiStyle = useSpring({
|
||||
transform: isDragging ? 'rotate(10deg)' : 'rotate(0deg)',
|
||||
config: config.wobbly,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
// Only handle click if not dragging and we have the toggle handler
|
||||
if (!isDragging && onToggleArena) {
|
||||
e.stopPropagation()
|
||||
onToggleArena(player.id)
|
||||
}
|
||||
}}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
background: 'white',
|
||||
rounded: { base: 'md', md: 'lg' },
|
||||
p: { base: '1', md: '1.5' },
|
||||
textAlign: 'center',
|
||||
cursor: isDragging ? 'grabbing' : 'pointer',
|
||||
border: { base: '2px solid', md: '2px solid' },
|
||||
borderColor: player.color,
|
||||
width: { base: '50px', md: '60px', lg: '70px' },
|
||||
minWidth: { base: '50px', md: '60px', lg: '70px' },
|
||||
flexShrink: 0,
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
transition: 'border-color 0.3s ease, transform 0.2s ease',
|
||||
zIndex: isDragging ? 1000 : 1,
|
||||
transformOrigin: 'center',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
|
||||
}
|
||||
})}
|
||||
style={cardStyle}
|
||||
>
|
||||
<div style={glowStyle} className={css({
|
||||
position: 'absolute',
|
||||
top: '-3px',
|
||||
left: '-3px',
|
||||
right: '-3px',
|
||||
bottom: '-3px',
|
||||
rounded: '2xl',
|
||||
pointerEvents: 'none',
|
||||
})} />
|
||||
|
||||
{/* Configure Button */}
|
||||
{onConfigure && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfigure(player.id)
|
||||
}}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '2',
|
||||
right: '2',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
rounded: 'full',
|
||||
w: '6',
|
||||
h: '6',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 'xs',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
zIndex: 10,
|
||||
_hover: {
|
||||
background: 'white',
|
||||
borderColor: player.color,
|
||||
transform: 'scale(1.1)'
|
||||
}
|
||||
})}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Remove Button for Arena */}
|
||||
{zone === 'arena' && onToggleArena && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleArena(player.id)
|
||||
}}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '-2',
|
||||
right: '-2',
|
||||
w: '6',
|
||||
h: '6',
|
||||
background: 'red.500',
|
||||
rounded: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 'xs',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
zIndex: 10,
|
||||
_hover: {
|
||||
background: 'red.600',
|
||||
transform: 'scale(1.1)'
|
||||
}
|
||||
})}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={emojiStyle}
|
||||
className={css({
|
||||
fontSize: { base: 'md', md: 'lg' },
|
||||
mb: { base: '0', md: '0.5' },
|
||||
})}
|
||||
>
|
||||
{player.emoji}
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
fontSize: { base: '2xs', md: 'xs' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
lineHeight: '1.1'
|
||||
})}>
|
||||
{player.name}
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
fontSize: { base: '3xs', md: '2xs' },
|
||||
color: zone === 'arena' ? 'green.700' : 'gray.600',
|
||||
fontWeight: zone === 'arena' ? 'semibold' : 'normal',
|
||||
mt: { base: '0.5', md: '0.5' }
|
||||
})}>
|
||||
{zone === 'arena' ? 'READY! 🔥' : `Level ${player.level}`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Droppable Zone Component with animations
|
||||
function DroppableZone({
|
||||
id,
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
isEmpty
|
||||
}: {
|
||||
id: string
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
subtitle: string
|
||||
isEmpty: boolean
|
||||
}) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: id,
|
||||
})
|
||||
|
||||
const zoneStyle = useSpring({
|
||||
background: isOver
|
||||
? (id === 'arena'
|
||||
? 'linear-gradient(135deg, #dcfce7, #bbf7d0)'
|
||||
: 'linear-gradient(135deg, #fef3c7, #fde68a)')
|
||||
: (id === 'arena'
|
||||
? 'linear-gradient(135deg, #fef3c7, #fde68a)'
|
||||
: 'linear-gradient(135deg, #f8fafc, #f1f5f9)'),
|
||||
borderColor: isOver ? (id === 'arena' ? '#4ade80' : '#fbbf24') : '#d1d5db',
|
||||
scale: isOver ? 1.02 : 1,
|
||||
config: config.gentle,
|
||||
})
|
||||
|
||||
const emptyStateStyle = useSpring({
|
||||
opacity: isEmpty ? (isOver ? 1 : 0.6) : 0,
|
||||
transform: isEmpty ? (isOver ? 'scale(1.1)' : 'scale(1)') : 'scale(0.8)',
|
||||
config: config.wobbly,
|
||||
})
|
||||
|
||||
export function EnhancedChampionArena({ className }: EnhancedChampionArenaProps) {
|
||||
return (
|
||||
<div className={css({
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
rounded: { base: 'xl', md: '2xl' },
|
||||
padding: { base: '2', sm: '3', md: '4' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
mb: { base: '0.5', md: '1' },
|
||||
textAlign: 'center',
|
||||
flexShrink: 0
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}) + (className ? ` ${className}` : '')}>
|
||||
{/* Game Selector - takes full height */}
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
})}>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={zoneStyle}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: { base: '1', md: '1.5' },
|
||||
justifyContent: 'center',
|
||||
alignContent: 'flex-start',
|
||||
p: { base: '1.5', md: '2' },
|
||||
rounded: id === 'arena' ? '2xl' : 'xl',
|
||||
border: { base: '2px dashed', md: '3px dashed' },
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
transition: 'all 0.3s ease',
|
||||
overflow: 'auto',
|
||||
minHeight: { base: '30px', md: '40px' }
|
||||
})}
|
||||
>
|
||||
{isEmpty && (
|
||||
<div
|
||||
style={emptyStateStyle}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
>
|
||||
<div className={css({
|
||||
fontSize: { base: 'xl', md: '2xl' },
|
||||
mb: { base: '1', md: '2' },
|
||||
})}>
|
||||
{isOver ? '✨' : (id === 'arena' ? '🏟️' : '🎯')}
|
||||
</div>
|
||||
<p className={css({
|
||||
color: 'gray.700',
|
||||
fontWeight: 'semibold',
|
||||
fontSize: { base: 'xs', md: 'sm' }
|
||||
})}>
|
||||
{isOver ? `Drop to ${id === 'arena' ? 'enter' : 'return'}!` : subtitle}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
<GameSelector
|
||||
variant="detailed"
|
||||
showHeader={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnhancedChampionArena({ onGameModeChange, onConfigurePlayer, className }: EnhancedChampionArenaProps) {
|
||||
const { profile } = useUserProfile()
|
||||
const { gameMode, players, setGameMode, updatePlayer } = useGameMode()
|
||||
const [activeId, setActiveId] = useState<number | null>(null)
|
||||
const [dragOverZone, setDragOverZone] = useState<'roster' | 'arena' | null>(null)
|
||||
|
||||
// Transform players into draggable format
|
||||
const availablePlayers = useMemo(() =>
|
||||
players
|
||||
.filter(player => !player.isActive)
|
||||
.map(player => ({
|
||||
id: player.id,
|
||||
name: player.id === 1 ? profile.player1Name : player.id === 2 ? profile.player2Name : player.name,
|
||||
emoji: player.id === 1 ? profile.player1Emoji : player.id === 2 ? profile.player2Emoji : player.emoji,
|
||||
color: player.color,
|
||||
isActive: false,
|
||||
level: Math.floor((profile.gamesPlayed || 0) / 5) + 1,
|
||||
})),
|
||||
[players, profile]
|
||||
)
|
||||
|
||||
const arenaPlayers = useMemo(() =>
|
||||
players
|
||||
.filter(player => player.isActive)
|
||||
.map(player => ({
|
||||
id: player.id,
|
||||
name: player.id === 1 ? profile.player1Name : player.id === 2 ? profile.player2Name : player.name,
|
||||
emoji: player.id === 1 ? profile.player1Emoji : player.id === 2 ? profile.player2Emoji : player.emoji,
|
||||
color: player.color,
|
||||
isActive: true,
|
||||
level: Math.floor((profile.gamesPlayed || 0) / 5) + 1,
|
||||
})),
|
||||
[players, profile]
|
||||
)
|
||||
|
||||
// Enhanced sensors for better touch and mouse support
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
// Use default collision detection
|
||||
const collisionDetection = closestCenter
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as number)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (!over) {
|
||||
setDragOverZone(null)
|
||||
return
|
||||
}
|
||||
|
||||
const activeId = active.id as number
|
||||
const overId = over.id
|
||||
|
||||
// Find which containers the active and over items belong to
|
||||
const activePlayer = [...availablePlayers, ...arenaPlayers].find(p => p.id === activeId)
|
||||
const overPlayer = [...availablePlayers, ...arenaPlayers].find(p => p.id === overId)
|
||||
|
||||
if (!activePlayer) return
|
||||
|
||||
// Determine which zone is being hovered
|
||||
let hoveredZone: 'roster' | 'arena' | null = null
|
||||
if (overId === 'roster' || (overPlayer && !overPlayer.isActive)) {
|
||||
hoveredZone = 'roster'
|
||||
} else if (overId === 'arena' || (overPlayer && overPlayer.isActive)) {
|
||||
hoveredZone = 'arena'
|
||||
}
|
||||
|
||||
// Set drag over zone if it's different from the active player's current zone
|
||||
if (hoveredZone && ((hoveredZone === 'arena' && !activePlayer.isActive) || (hoveredZone === 'roster' && activePlayer.isActive))) {
|
||||
setDragOverZone(hoveredZone)
|
||||
} else {
|
||||
setDragOverZone(null)
|
||||
}
|
||||
|
||||
// If we're dragging over a player in a different zone, move to that zone
|
||||
if (overPlayer && activePlayer.isActive !== overPlayer.isActive) {
|
||||
const shouldActivate = overPlayer.isActive
|
||||
updatePlayer(activeId, { isActive: shouldActivate })
|
||||
|
||||
// Update game mode
|
||||
const newArenaCount = shouldActivate
|
||||
? arenaPlayers.length + (activePlayer.isActive ? 0 : 1)
|
||||
: arenaPlayers.length - (activePlayer.isActive ? 1 : 0)
|
||||
|
||||
let newMode: 'single' | 'battle' | 'tournament' = 'single'
|
||||
if (newArenaCount === 1) newMode = 'single'
|
||||
else if (newArenaCount === 2) newMode = 'battle'
|
||||
else if (newArenaCount >= 3) newMode = 'tournament'
|
||||
|
||||
setGameMode(newMode)
|
||||
onGameModeChange?.(newMode)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
setActiveId(null)
|
||||
setDragOverZone(null)
|
||||
|
||||
if (!over) return
|
||||
|
||||
const playerId = active.id as number
|
||||
const overId = over.id
|
||||
|
||||
// Check if we're dragging over a player (for reordering within same zone)
|
||||
const overPlayer = [...availablePlayers, ...arenaPlayers].find(p => p.id === overId)
|
||||
|
||||
if (overPlayer) {
|
||||
// Reordering within the same zone
|
||||
const activePlayer = [...availablePlayers, ...arenaPlayers].find(p => p.id === playerId)
|
||||
if (activePlayer && activePlayer.isActive === overPlayer.isActive) {
|
||||
// Same zone reordering - this is handled automatically by SortableContext
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle moving between zones (when dropping on zone itself)
|
||||
const targetZone = overId as string
|
||||
if (targetZone === 'arena' || targetZone === 'roster') {
|
||||
const shouldActivate = targetZone === 'arena'
|
||||
|
||||
// Don't update if already in correct state
|
||||
const currentPlayer = [...availablePlayers, ...arenaPlayers].find(p => p.id === playerId)
|
||||
if (currentPlayer && currentPlayer.isActive === shouldActivate) {
|
||||
return
|
||||
}
|
||||
|
||||
updatePlayer(playerId, { isActive: shouldActivate })
|
||||
|
||||
// Update game mode based on arena players count
|
||||
const currentArenaPlayer = arenaPlayers.find(p => p.id === playerId)
|
||||
const newArenaCount = shouldActivate
|
||||
? arenaPlayers.length + 1
|
||||
: arenaPlayers.length - (currentArenaPlayer ? 1 : 0)
|
||||
|
||||
let newMode: 'single' | 'battle' | 'tournament' = 'single'
|
||||
if (newArenaCount === 1) newMode = 'single'
|
||||
else if (newArenaCount === 2) newMode = 'battle'
|
||||
else if (newArenaCount >= 3) newMode = 'tournament'
|
||||
|
||||
setGameMode(newMode)
|
||||
onGameModeChange?.(newMode)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the active player for the drag overlay
|
||||
const activePlayer = activeId
|
||||
? [...availablePlayers, ...arenaPlayers].find(p => p.id === activeId)
|
||||
: null
|
||||
|
||||
// Handle single-click toggle for arena
|
||||
const handleToggleArena = (playerId: number) => {
|
||||
const player = [...availablePlayers, ...arenaPlayers].find(p => p.id === playerId)
|
||||
if (!player) return
|
||||
|
||||
const shouldActivate = !player.isActive
|
||||
updatePlayer(playerId, { isActive: shouldActivate })
|
||||
|
||||
// Update game mode based on new arena count
|
||||
const newArenaCount = shouldActivate
|
||||
? arenaPlayers.length + 1
|
||||
: arenaPlayers.length - 1
|
||||
|
||||
let newMode: 'single' | 'battle' | 'tournament' = 'single'
|
||||
if (newArenaCount === 1) newMode = 'single'
|
||||
else if (newArenaCount === 2) newMode = 'battle'
|
||||
else if (newArenaCount >= 3) newMode = 'tournament'
|
||||
|
||||
setGameMode(newMode)
|
||||
onGameModeChange?.(newMode)
|
||||
}
|
||||
|
||||
// Entry animations are now handled within ChampionCard component
|
||||
// to avoid ref conflicts with dnd-kit
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className={css({
|
||||
background: 'white',
|
||||
rounded: '3xl',
|
||||
p: { base: '1.5', md: '2.5' },
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.200',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.1)',
|
||||
transition: 'all 0.3s ease',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}) + (className ? ` ${className}` : '')}>
|
||||
|
||||
{/* Ultra-Compact Header */}
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
mb: { base: '1', md: '2' },
|
||||
flexShrink: 0
|
||||
})}>
|
||||
{/* Mode Indicator - now the main header */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '1.5', md: '2' },
|
||||
background: arenaPlayers.length === 0
|
||||
? 'linear-gradient(135deg, #f3f4f6, #e5e7eb)'
|
||||
: gameMode === 'single'
|
||||
? 'linear-gradient(135deg, #dbeafe, #bfdbfe)'
|
||||
: gameMode === 'battle'
|
||||
? 'linear-gradient(135deg, #e9d5ff, #ddd6fe)'
|
||||
: 'linear-gradient(135deg, #fef3c7, #fde68a)',
|
||||
px: { base: '2', md: '2.5' },
|
||||
py: { base: '1', md: '1.5' },
|
||||
rounded: 'full',
|
||||
border: { base: '1px solid', md: '2px solid' },
|
||||
borderColor: arenaPlayers.length === 0
|
||||
? 'gray.300'
|
||||
: gameMode === 'single'
|
||||
? 'blue.300'
|
||||
: gameMode === 'battle'
|
||||
? 'purple.300'
|
||||
: 'yellow.300'
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: { base: 'xs', md: 'sm' } })}>
|
||||
{arenaPlayers.length === 0 ? '🎯' : gameMode === 'single' ? '👤' : gameMode === 'battle' ? '⚔️' : '🏆'}
|
||||
</span>
|
||||
<span className={css({
|
||||
fontWeight: 'bold',
|
||||
color: arenaPlayers.length === 0 ? 'gray.700' : gameMode === 'single' ? 'blue.800' : gameMode === 'battle' ? 'purple.800' : 'yellow.800',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: { base: '3xs', md: '2xs' }
|
||||
})}>
|
||||
{arenaPlayers.length === 0 ? 'Select Champions' : gameMode === 'single' ? 'Solo Mode' : gameMode === 'battle' ? 'Battle Mode' : 'Tournament Mode'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className={css({
|
||||
color: 'gray.600',
|
||||
fontSize: { base: '2xs', md: 'xs' },
|
||||
mt: { base: '0.5', md: '1' },
|
||||
display: { base: 'none', md: 'block' }
|
||||
})}>
|
||||
Drag champions between zones • Click to toggle
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Champion Zones - constrained to small fixed space */}
|
||||
<div className={css({
|
||||
height: { base: '140px', md: '160px' },
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', lg: '1fr 1fr' },
|
||||
gap: { base: '1', md: '1.5' },
|
||||
alignItems: 'stretch',
|
||||
flexShrink: 0
|
||||
})}>
|
||||
|
||||
{/* Available Champions Roster */}
|
||||
<div className={css({
|
||||
order: { base: 2, lg: 1 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0
|
||||
})}>
|
||||
<SortableContext items={availablePlayers.map(p => p.id)} strategy={rectSortingStrategy}>
|
||||
<DroppableZone
|
||||
id="roster"
|
||||
title="🎯 Available Champions"
|
||||
subtitle="Drag champions here to remove from arena"
|
||||
isEmpty={availablePlayers.length === 0}
|
||||
>
|
||||
{availablePlayers.map(player => (
|
||||
<ChampionCard
|
||||
key={`roster-${player.id}`}
|
||||
player={player}
|
||||
zone="roster"
|
||||
onConfigure={onConfigurePlayer}
|
||||
onToggleArena={handleToggleArena}
|
||||
crossZoneDrag={dragOverZone === 'roster' && activeId !== player.id}
|
||||
/>
|
||||
))}
|
||||
</DroppableZone>
|
||||
</SortableContext>
|
||||
</div>
|
||||
|
||||
{/* Arena Drop Zone */}
|
||||
<div className={css({
|
||||
order: { base: 1, lg: 2 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0
|
||||
})}>
|
||||
<SortableContext items={arenaPlayers.map(p => p.id)} strategy={rectSortingStrategy}>
|
||||
<DroppableZone
|
||||
id="arena"
|
||||
title="🏟️ Arena"
|
||||
subtitle="1 champion = Solo • 2 = Battle • 3+ = Tournament"
|
||||
isEmpty={arenaPlayers.length === 0}
|
||||
>
|
||||
{arenaPlayers.map(player => (
|
||||
<ChampionCard
|
||||
key={`arena-${player.id}`}
|
||||
player={player}
|
||||
zone="arena"
|
||||
onToggleArena={handleToggleArena}
|
||||
crossZoneDrag={dragOverZone === 'arena' && activeId !== player.id}
|
||||
/>
|
||||
))}
|
||||
</DroppableZone>
|
||||
</SortableContext>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prominent Game Selector - constrained to available space */}
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
mt: { base: '1', md: '2' },
|
||||
pt: { base: '1', md: '2' },
|
||||
borderTop: '2px solid',
|
||||
borderColor: 'gray.200',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
})}>
|
||||
<GameSelector
|
||||
variant="detailed"
|
||||
showHeader={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay>
|
||||
{activePlayer ? (
|
||||
<div className={css({
|
||||
transform: 'rotate(5deg) scale(1.1)',
|
||||
filter: 'drop-shadow(0 10px 20px rgba(0, 0, 0, 0.3))',
|
||||
})}>
|
||||
<ChampionCard
|
||||
player={activePlayer}
|
||||
isOverlay
|
||||
zone={activePlayer.isActive ? "arena" : "roster"}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
@@ -22,8 +22,9 @@ export function GameCard({
|
||||
const router = useRouter()
|
||||
|
||||
// Check if a game is available based on active player count
|
||||
// Games are always visible but only available with valid player count
|
||||
const isGameAvailable = () => {
|
||||
return activePlayerCount <= config.maxPlayers && activePlayerCount > 0
|
||||
return activePlayerCount > 0 && activePlayerCount <= config.maxPlayers
|
||||
}
|
||||
|
||||
const handleGameClick = () => {
|
||||
@@ -182,7 +183,9 @@ export function GameCard({
|
||||
border: '1px solid',
|
||||
borderColor: available ? 'green.200' : 'red.200'
|
||||
})}>
|
||||
{activePlayerCount <= config.maxPlayers
|
||||
{activePlayerCount === 0
|
||||
? `⚠️ Select ${config.maxPlayers} ${config.maxPlayers === 1 ? 'player' : 'players'}`
|
||||
: activePlayerCount <= config.maxPlayers
|
||||
? `✓ ${activePlayerCount}/${config.maxPlayers} ${activePlayerCount === 1 ? 'player' : 'players'}`
|
||||
: `✗ Too many players (max ${config.maxPlayers})`
|
||||
}
|
||||
|
||||
@@ -34,20 +34,20 @@ export const GAMES_CONFIG = {
|
||||
borderColor: 'purple.200',
|
||||
difficulty: 'Intermediate'
|
||||
},
|
||||
'number-hunter': {
|
||||
name: 'Number Hunter',
|
||||
fullName: 'Number Hunter 🎯',
|
||||
maxPlayers: 2,
|
||||
description: 'Hunt down complement pairs in a race against time',
|
||||
longDescription: 'The clock is ticking! Hunt down complement pairs faster than ever. Can you beat the timer and become the ultimate number ninja?',
|
||||
url: '/games/number-hunter',
|
||||
icon: '🎯',
|
||||
chips: ['🚀 Coming Soon', '🔥 Speed Challenge', '⏱️ Time Attack'],
|
||||
color: 'red',
|
||||
gradient: 'linear-gradient(135deg, #fecaca, #fca5a5)',
|
||||
borderColor: 'red.200',
|
||||
difficulty: 'Advanced',
|
||||
available: false
|
||||
'complement-race': {
|
||||
name: 'Speed Complement Race',
|
||||
fullName: 'Speed Complement Race 🏁',
|
||||
maxPlayers: 1,
|
||||
description: 'Race against AI opponents while solving complement problems',
|
||||
longDescription: 'Battle Swift AI and Math Bot in an epic race! Find complement numbers to speed ahead. Choose your mode and difficulty to begin the ultimate math challenge.',
|
||||
url: '/games/complement-race',
|
||||
icon: '🏁',
|
||||
chips: ['🤖 AI Opponents', '🔥 Speed Challenge', '🏆 Three Game Modes'],
|
||||
color: 'blue',
|
||||
gradient: 'linear-gradient(135deg, #dbeafe, #bfdbfe)',
|
||||
borderColor: 'blue.200',
|
||||
difficulty: 'Intermediate',
|
||||
available: true
|
||||
},
|
||||
'master-organizer': {
|
||||
name: 'Master Organizer',
|
||||
@@ -103,19 +103,7 @@ export function GameSelector({
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{activePlayerCount === 0 ? (
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
py: variant === 'compact' ? '4' : '8',
|
||||
color: 'gray.500'
|
||||
})}>
|
||||
<div className={css({ fontSize: variant === 'compact' ? '2xl' : '3xl', mb: '2' })}>🎯</div>
|
||||
<p className={css({ fontSize: variant === 'compact' ? 'sm' : 'base' })}>
|
||||
{emptyStateMessage}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={css({
|
||||
<div className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
gridTemplateRows: { base: 'repeat(4, 1fr)', md: 'repeat(2, 1fr)' },
|
||||
@@ -133,7 +121,6 @@ export function GameSelector({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
apps/web/src/components/PageWithNav.tsx
Normal file
118
apps/web/src/components/PageWithNav.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AppNavBar } from './AppNavBar'
|
||||
import { useGameMode } from '../contexts/GameModeContext'
|
||||
import { useUserProfile } from '../contexts/UserProfileContext'
|
||||
import { GameContextNav } from './nav/GameContextNav'
|
||||
import { PlayerConfigDialog } from './nav/PlayerConfigDialog'
|
||||
|
||||
interface PageWithNavProps {
|
||||
navTitle?: string
|
||||
navEmoji?: string
|
||||
emphasizeGameContext?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function PageWithNav({ navTitle, navEmoji, emphasizeGameContext = false, children }: PageWithNavProps) {
|
||||
const { players, activePlayerCount, updatePlayer } = useGameMode()
|
||||
const { profile } = useUserProfile()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [configurePlayerId, setConfigurePlayerId] = React.useState<1 | 2 | 3 | 4 | null>(null)
|
||||
|
||||
// Delay mounting animation slightly for smooth transition
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setMounted(true), 50)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const handleRemovePlayer = (playerId: number) => {
|
||||
updatePlayer(playerId, { isActive: false })
|
||||
}
|
||||
|
||||
const handleAddPlayer = (playerId: number) => {
|
||||
updatePlayer(playerId, { isActive: true })
|
||||
}
|
||||
|
||||
const handleConfigurePlayer = (playerId: number) => {
|
||||
// Support configuring all players (1-4)
|
||||
if (playerId >= 1 && playerId <= 4) {
|
||||
setConfigurePlayerId(playerId as 1 | 2 | 3 | 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Transform players to use profile emojis and names for all players
|
||||
const getPlayerEmoji = (playerId: number) => {
|
||||
switch (playerId) {
|
||||
case 1: return profile.player1Emoji
|
||||
case 2: return profile.player2Emoji
|
||||
case 3: return profile.player3Emoji
|
||||
case 4: return profile.player4Emoji
|
||||
default: return players.find(p => p.id === playerId)?.emoji || '😀'
|
||||
}
|
||||
}
|
||||
|
||||
const getPlayerName = (playerId: number) => {
|
||||
switch (playerId) {
|
||||
case 1: return profile.player1Name
|
||||
case 2: return profile.player2Name
|
||||
case 3: return profile.player3Name
|
||||
case 4: return profile.player4Name
|
||||
default: return players.find(p => p.id === playerId)?.name || `Player ${playerId}`
|
||||
}
|
||||
}
|
||||
|
||||
const activePlayers = players
|
||||
.filter(p => p.isActive)
|
||||
.map(player => ({
|
||||
...player,
|
||||
emoji: getPlayerEmoji(player.id),
|
||||
name: getPlayerName(player.id)
|
||||
}))
|
||||
|
||||
const inactivePlayers = players
|
||||
.filter(p => !p.isActive)
|
||||
.map(player => ({
|
||||
...player,
|
||||
emoji: getPlayerEmoji(player.id),
|
||||
name: getPlayerName(player.id)
|
||||
}))
|
||||
|
||||
// Compute game mode from active player count
|
||||
const gameMode = activePlayerCount === 0 ? 'none' :
|
||||
activePlayerCount === 1 ? 'single' :
|
||||
activePlayerCount === 2 ? 'battle' :
|
||||
activePlayerCount >= 3 ? 'tournament' : 'none'
|
||||
|
||||
const shouldEmphasize = emphasizeGameContext && mounted
|
||||
const showFullscreenSelection = shouldEmphasize && activePlayerCount === 0
|
||||
|
||||
// Create nav content if title is provided
|
||||
const navContent = navTitle ? (
|
||||
<GameContextNav
|
||||
navTitle={navTitle}
|
||||
navEmoji={navEmoji}
|
||||
gameMode={gameMode}
|
||||
activePlayers={activePlayers}
|
||||
inactivePlayers={inactivePlayers}
|
||||
shouldEmphasize={shouldEmphasize}
|
||||
showFullscreenSelection={showFullscreenSelection}
|
||||
onAddPlayer={handleAddPlayer}
|
||||
onRemovePlayer={handleRemovePlayer}
|
||||
onConfigurePlayer={handleConfigurePlayer}
|
||||
/>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppNavBar navSlot={navContent} />
|
||||
{children}
|
||||
{configurePlayerId && (
|
||||
<PlayerConfigDialog
|
||||
playerId={configurePlayerId}
|
||||
onClose={() => setConfigurePlayerId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ interface StandardGameLayoutProps {
|
||||
* 4. Consistent experience across all games
|
||||
*/
|
||||
export function StandardGameLayout({ children, className }: StandardGameLayoutProps) {
|
||||
|
||||
return (
|
||||
<div className={css({
|
||||
// Exact viewport sizing - no scrolling ever
|
||||
@@ -35,7 +36,10 @@ export function StandardGameLayout({ children, className }: StandardGameLayoutPr
|
||||
|
||||
// Flex container for game content
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
flexDirection: 'column',
|
||||
|
||||
// Transparent background - themes will be applied at nav level
|
||||
background: 'transparent'
|
||||
}, className)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as Switch from '@radix-ui/react-switch'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { stack, hstack, grid } from '../../styled-system/patterns'
|
||||
import { FlashcardFormState } from '@/app/create/page'
|
||||
import { useAbacusDisplay } from '@/contexts/AbacusDisplayContext'
|
||||
import { useAbacusDisplay } from '@soroban/abacus-react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface StyleControlsProps {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { generateSorobanSVG, getWasmStatus, triggerWasmPreload, type SorobanConfig } from '@/lib/typst-soroban'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useAbacusConfig } from '@/contexts/AbacusDisplayContext'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
|
||||
interface TypstSorobanProps {
|
||||
number: number
|
||||
|
||||
120
apps/web/src/components/__tests__/AppNavBar.integration.test.tsx
Normal file
120
apps/web/src/components/__tests__/AppNavBar.integration.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { Suspense } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { AppNavBar } from '../AppNavBar'
|
||||
|
||||
// Mock Next.js hooks
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/games/matching',
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock contexts
|
||||
vi.mock('../../contexts/FullscreenContext', () => ({
|
||||
useFullscreen: () => ({
|
||||
isFullscreen: false,
|
||||
toggleFullscreen: vi.fn(),
|
||||
exitFullscreen: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock AbacusDisplayDropdown
|
||||
vi.mock('../AbacusDisplayDropdown', () => ({
|
||||
AbacusDisplayDropdown: () => <div data-testid="abacus-dropdown">Dropdown</div>,
|
||||
}))
|
||||
|
||||
describe('AppNavBar Nav Slot Integration', () => {
|
||||
it('renders actual nav slot content from lazy component', async () => {
|
||||
// Create a lazy component that simulates the @nav slot behavior
|
||||
const MatchingNavContent = () => (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧩 Memory Pairs
|
||||
</h1>
|
||||
)
|
||||
|
||||
const LazyMatchingNav = React.lazy(() => Promise.resolve({ default: MatchingNavContent }))
|
||||
|
||||
const navSlot = (
|
||||
<Suspense fallback={<div data-testid="nav-loading">Loading...</div>}>
|
||||
<LazyMatchingNav />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
// Initially should show loading fallback
|
||||
expect(screen.getByTestId('nav-loading')).toBeInTheDocument()
|
||||
|
||||
// Wait for lazy component to load and render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🧩 Memory Pairs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Verify loading state is gone
|
||||
expect(screen.queryByTestId('nav-loading')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reproduces the issue: lazy component without Suspense boundary fails to render', async () => {
|
||||
// This test reproduces the actual issue - lazy components need Suspense
|
||||
const MatchingNavContent = () => (
|
||||
<h1>🧩 Memory Pairs</h1>
|
||||
)
|
||||
|
||||
const LazyMatchingNav = React.lazy(() => Promise.resolve({ default: MatchingNavContent }))
|
||||
|
||||
// This is what's happening in the actual app - lazy component without Suspense
|
||||
const navSlot = <LazyMatchingNav />
|
||||
|
||||
// This should throw an error or not render properly
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
// The lazy component should not render without Suspense
|
||||
expect(screen.queryByText('🧩 Memory Pairs')).not.toBeInTheDocument()
|
||||
} catch (error) {
|
||||
// Expected to fail - lazy components need Suspense boundary
|
||||
expect(error.message).toContain('Suspense')
|
||||
}
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('simulates Next.js App Router parallel route slot structure', async () => {
|
||||
// This mimics the actual navSlot structure from Next.js App Router
|
||||
const mockParallelRouteSlot = {
|
||||
$$typeof: Symbol.for('react.element'),
|
||||
type: {
|
||||
$$typeof: Symbol.for('react.lazy'),
|
||||
_payload: Promise.resolve({
|
||||
default: () => <h1>🧩 Memory Pairs</h1>
|
||||
}),
|
||||
_init: (payload: any) => payload.then((module: any) => module.default),
|
||||
},
|
||||
key: null,
|
||||
ref: null,
|
||||
props: {
|
||||
parallelRouterKey: 'nav',
|
||||
segmentPath: ['nav'],
|
||||
template: {},
|
||||
notFoundStyles: []
|
||||
},
|
||||
}
|
||||
|
||||
// This is the structure we're actually receiving from Next.js
|
||||
render(<AppNavBar navSlot={mockParallelRouteSlot as any} />)
|
||||
|
||||
// This should fail to render the content without proper handling
|
||||
expect(screen.queryByText('🧩 Memory Pairs')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { Suspense } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { AppNavBar } from '../AppNavBar'
|
||||
|
||||
// Mock Next.js hooks
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/games/matching',
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock contexts
|
||||
vi.mock('../../contexts/FullscreenContext', () => ({
|
||||
useFullscreen: () => ({
|
||||
isFullscreen: false,
|
||||
toggleFullscreen: vi.fn(),
|
||||
exitFullscreen: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock AbacusDisplayDropdown
|
||||
vi.mock('../AbacusDisplayDropdown', () => ({
|
||||
AbacusDisplayDropdown: () => <div data-testid="abacus-dropdown">Dropdown</div>,
|
||||
}))
|
||||
|
||||
describe('AppNavBar Suspense Fix', () => {
|
||||
it('renders nav slot content with Suspense boundary (FIXED)', async () => {
|
||||
// Simulate the exact structure that Next.js App Router provides
|
||||
const MatchingNavContent = () => (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧩 Memory Pairs
|
||||
</h1>
|
||||
)
|
||||
|
||||
// Create a lazy component like Next.js does
|
||||
const LazyMatchingNav = React.lazy(() => Promise.resolve({ default: MatchingNavContent }))
|
||||
|
||||
// This is what Next.js App Router passes to our component
|
||||
const navSlot = <LazyMatchingNav />
|
||||
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
// Should show loading state briefly
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
|
||||
// Wait for the lazy component to load and render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🧩 Memory Pairs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Loading state should be gone
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
|
||||
|
||||
// Game name should be visible in the nav
|
||||
expect(screen.getByText('🧩 Memory Pairs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('demonstrates the original issue was fixed', async () => {
|
||||
// This test shows that without our Suspense fix, this would have failed
|
||||
const MemoryQuizContent = () => <h1>🧠 Memory Lightning</h1>
|
||||
const LazyMemoryQuizNav = React.lazy(() => Promise.resolve({ default: MemoryQuizContent }))
|
||||
|
||||
const navSlot = <LazyMemoryQuizNav />
|
||||
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
// Without Suspense boundary in AppNavBar, this would fail to render
|
||||
// But now it works because we wrap navSlot in Suspense
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🧠 Memory Lightning')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows that lazy components need Suspense to render', () => {
|
||||
// This test shows what happens without Suspense - it should fail
|
||||
const TestContent = () => <h1>Test Content</h1>
|
||||
const LazyTest = React.lazy(() => Promise.resolve({ default: TestContent }))
|
||||
|
||||
// Trying to render lazy component without Suspense should fail
|
||||
expect(() => render(<LazyTest />)).toThrow()
|
||||
})
|
||||
|
||||
it('handles nav slot gracefully when null or undefined', () => {
|
||||
render(<AppNavBar navSlot={null} />)
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
|
||||
|
||||
render(<AppNavBar navSlot={undefined} />)
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
67
apps/web/src/components/__tests__/AppNavBar.test.tsx
Normal file
67
apps/web/src/components/__tests__/AppNavBar.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { AppNavBar } from '../AppNavBar'
|
||||
|
||||
// Mock Next.js hooks
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/games/matching',
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock contexts
|
||||
vi.mock('../../contexts/FullscreenContext', () => ({
|
||||
useFullscreen: () => ({
|
||||
isFullscreen: false,
|
||||
toggleFullscreen: vi.fn(),
|
||||
exitFullscreen: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock AbacusDisplayDropdown
|
||||
vi.mock('../AbacusDisplayDropdown', () => ({
|
||||
AbacusDisplayDropdown: () => <div data-testid="abacus-dropdown">Dropdown</div>,
|
||||
}))
|
||||
|
||||
describe('AppNavBar', () => {
|
||||
it('renders navSlot when provided', () => {
|
||||
const navSlot = <div data-testid="nav-slot">🧩 Memory Pairs</div>
|
||||
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
expect(screen.getByTestId('nav-slot')).toBeInTheDocument()
|
||||
expect(screen.getByText('🧩 Memory Pairs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render nav branding when navSlot is null', () => {
|
||||
render(<AppNavBar navSlot={null} />)
|
||||
|
||||
expect(screen.queryByTestId('nav-slot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render nav branding when navSlot is undefined', () => {
|
||||
render(<AppNavBar />)
|
||||
|
||||
expect(screen.queryByTestId('nav-slot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders minimal variant for game pages', () => {
|
||||
const navSlot = <div data-testid="nav-slot">Game Name</div>
|
||||
|
||||
render(<AppNavBar variant="full" navSlot={navSlot} />)
|
||||
|
||||
// Should auto-detect minimal variant for /games/matching path
|
||||
expect(screen.getByTestId('nav-slot')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders fullscreen toggle button', () => {
|
||||
const navSlot = <div data-testid="nav-slot">Game Name</div>
|
||||
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
// Check that fullscreen toggle button is present
|
||||
expect(screen.getByTitle('Enter Fullscreen')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
124
apps/web/src/components/nav/ActivePlayersList.tsx
Normal file
124
apps/web/src/components/nav/ActivePlayersList.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from 'react'
|
||||
|
||||
interface Player {
|
||||
id: number
|
||||
name: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
interface ActivePlayersListProps {
|
||||
activePlayers: Player[]
|
||||
shouldEmphasize: boolean
|
||||
onRemovePlayer: (playerId: number) => void
|
||||
onConfigurePlayer: (playerId: number) => void
|
||||
}
|
||||
|
||||
export function ActivePlayersList({ activePlayers, shouldEmphasize, onRemovePlayer, onConfigurePlayer }: ActivePlayersListProps) {
|
||||
const [hoveredPlayerId, setHoveredPlayerId] = React.useState<number | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
{activePlayers.map(player => (
|
||||
<div
|
||||
key={player.id}
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: shouldEmphasize ? '48px' : '20px',
|
||||
lineHeight: 1,
|
||||
transition: 'font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease',
|
||||
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
|
||||
cursor: shouldEmphasize ? 'pointer' : 'default'
|
||||
}}
|
||||
title={player.name}
|
||||
onClick={() => shouldEmphasize && onConfigurePlayer(player.id)}
|
||||
onMouseEnter={() => shouldEmphasize && setHoveredPlayerId(player.id)}
|
||||
onMouseLeave={() => shouldEmphasize && setHoveredPlayerId(null)}
|
||||
>
|
||||
{player.emoji}
|
||||
{shouldEmphasize && hoveredPlayerId === player.id && (
|
||||
<>
|
||||
{/* Configure button - bottom left */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfigurePlayer(player.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-4px',
|
||||
left: '-4px',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#3b82f6'
|
||||
e.currentTarget.style.transform = 'scale(1.15)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#6b7280'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
aria-label={`Configure ${player.name}`}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
|
||||
{/* Remove button - top right */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemovePlayer(player.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#dc2626'
|
||||
e.currentTarget.style.transform = 'scale(1.1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#ef4444'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
aria-label={`Remove ${player.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
155
apps/web/src/components/nav/AddPlayerButton.tsx
Normal file
155
apps/web/src/components/nav/AddPlayerButton.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react'
|
||||
|
||||
interface Player {
|
||||
id: number
|
||||
name: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
interface AddPlayerButtonProps {
|
||||
inactivePlayers: Player[]
|
||||
shouldEmphasize: boolean
|
||||
onAddPlayer: (playerId: number) => void
|
||||
}
|
||||
|
||||
export function AddPlayerButton({ inactivePlayers, shouldEmphasize, onAddPlayer }: AddPlayerButtonProps) {
|
||||
const [showPopover, setShowPopover] = React.useState(false)
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close popover when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setShowPopover(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showPopover) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showPopover])
|
||||
|
||||
const handleAddPlayerClick = (playerId: number) => {
|
||||
onAddPlayer(playerId)
|
||||
setShowPopover(false)
|
||||
}
|
||||
|
||||
if (!shouldEmphasize || inactivePlayers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }} ref={popoverRef}>
|
||||
<button
|
||||
onClick={() => setShowPopover(!showPopover)}
|
||||
style={{
|
||||
fontSize: shouldEmphasize ? '32px' : '16px',
|
||||
width: shouldEmphasize ? '48px' : '24px',
|
||||
height: shouldEmphasize ? '48px' : '24px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid #10b981',
|
||||
background: showPopover ? '#10b981' : 'rgba(16, 185, 129, 0.1)',
|
||||
color: showPopover ? 'white' : '#10b981',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
fontWeight: 'bold',
|
||||
boxShadow: showPopover ? '0 4px 12px rgba(16, 185, 129, 0.4)' : 'none'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!showPopover) {
|
||||
e.currentTarget.style.background = '#10b981'
|
||||
e.currentTarget.style.color = 'white'
|
||||
e.currentTarget.style.transform = 'scale(1.1)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!showPopover) {
|
||||
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.1)'
|
||||
e.currentTarget.style.color = '#10b981'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}
|
||||
}}
|
||||
title="Add player"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
||||
{/* Add Player Popover */}
|
||||
{showPopover && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 12px)',
|
||||
right: 0,
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.2)',
|
||||
border: '2px solid #e5e7eb',
|
||||
padding: '12px',
|
||||
minWidth: '200px',
|
||||
zIndex: 1000,
|
||||
animation: 'fadeIn 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
marginBottom: '8px',
|
||||
paddingBottom: '8px',
|
||||
borderBottom: '1px solid #e5e7eb'
|
||||
}}>
|
||||
Add Player
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{inactivePlayers.map(player => (
|
||||
<button
|
||||
key={player.id}
|
||||
onClick={() => handleAddPlayerClick(player.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f3f4f6'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px', lineHeight: 1 }}>
|
||||
{player.emoji}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#1f2937'
|
||||
}}>
|
||||
{player.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
apps/web/src/components/nav/FullscreenPlayerSelection.tsx
Normal file
172
apps/web/src/components/nav/FullscreenPlayerSelection.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React from 'react'
|
||||
|
||||
interface Player {
|
||||
id: number
|
||||
name: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
interface FullscreenPlayerSelectionProps {
|
||||
inactivePlayers: Player[]
|
||||
onSelectPlayer: (playerId: number) => void
|
||||
onConfigurePlayer: (playerId: number) => void
|
||||
isVisible: boolean
|
||||
}
|
||||
|
||||
export function FullscreenPlayerSelection({ inactivePlayers, onSelectPlayer, onConfigurePlayer, isVisible }: FullscreenPlayerSelectionProps) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '32px',
|
||||
width: isVisible ? '100%' : '0',
|
||||
padding: isVisible ? '40px 20px' : '0',
|
||||
maxHeight: isVisible ? '800px' : '0',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
pointerEvents: isVisible ? 'auto' : 'none'
|
||||
}}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
maxWidth: '600px'
|
||||
}}>
|
||||
<h2 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
marginBottom: '12px',
|
||||
textShadow: '0 2px 10px rgba(0,0,0,0.2)'
|
||||
}}>
|
||||
👥 Select Your Champions
|
||||
</h2>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Choose one or more players to begin
|
||||
</p>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgba(255, 255, 255, 0.7)'
|
||||
}}>
|
||||
💡 Select 2+ players for multiplayer battles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '20px',
|
||||
maxWidth: '800px',
|
||||
width: '100%'
|
||||
}}>
|
||||
{inactivePlayers.map(player => (
|
||||
<div
|
||||
key={player.id}
|
||||
style={{
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => onSelectPlayer(player.id)}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '24px',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.15)',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-6px) scale(1.03)'
|
||||
e.currentTarget.style.boxShadow = '0 15px 50px rgba(0, 0, 0, 0.25)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)'
|
||||
e.currentTarget.style.boxShadow = '0 8px 30px rgba(0, 0, 0, 0.15)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '64px',
|
||||
marginBottom: '12px',
|
||||
lineHeight: 1
|
||||
}}>
|
||||
{player.emoji}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
{player.name}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: '6px 12px',
|
||||
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||
color: 'white',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
+ Select
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Subtle gear icon for configuration */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfigurePlayer(player.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid #e5e7eb',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
color: '#6b7280',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#3b82f6'
|
||||
e.currentTarget.style.color = '#3b82f6'
|
||||
e.currentTarget.style.transform = 'scale(1.1)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.95)'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
e.currentTarget.style.color = '#6b7280'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
title={`Configure ${player.name}`}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
apps/web/src/components/nav/GameContextNav.tsx
Normal file
161
apps/web/src/components/nav/GameContextNav.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react'
|
||||
import { GameModeIndicator } from './GameModeIndicator'
|
||||
import { ActivePlayersList } from './ActivePlayersList'
|
||||
import { AddPlayerButton } from './AddPlayerButton'
|
||||
import { FullscreenPlayerSelection } from './FullscreenPlayerSelection'
|
||||
|
||||
type GameMode = 'none' | 'single' | 'battle' | 'tournament'
|
||||
|
||||
interface Player {
|
||||
id: number
|
||||
name: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
interface GameContextNavProps {
|
||||
navTitle: string
|
||||
navEmoji?: string
|
||||
gameMode: GameMode
|
||||
activePlayers: Player[]
|
||||
inactivePlayers: Player[]
|
||||
shouldEmphasize: boolean
|
||||
showFullscreenSelection: boolean
|
||||
onAddPlayer: (playerId: number) => void
|
||||
onRemovePlayer: (playerId: number) => void
|
||||
onConfigurePlayer: (playerId: number) => void
|
||||
}
|
||||
|
||||
export function GameContextNav({
|
||||
navTitle,
|
||||
navEmoji,
|
||||
gameMode,
|
||||
activePlayers,
|
||||
inactivePlayers,
|
||||
shouldEmphasize,
|
||||
showFullscreenSelection,
|
||||
onAddPlayer,
|
||||
onRemovePlayer,
|
||||
onConfigurePlayer
|
||||
}: GameContextNavProps) {
|
||||
const [isTransitioning, setIsTransitioning] = React.useState(false)
|
||||
const [layoutMode, setLayoutMode] = React.useState<'column' | 'row'>(showFullscreenSelection ? 'column' : 'row')
|
||||
const [containerWidth, setContainerWidth] = React.useState<string>(showFullscreenSelection ? '100%' : 'auto')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showFullscreenSelection) {
|
||||
// Switching to fullscreen - change layout and width immediately
|
||||
setLayoutMode('column')
|
||||
setContainerWidth('100%')
|
||||
} else {
|
||||
// Switching away from fullscreen - delay layout change until transition completes
|
||||
setIsTransitioning(true)
|
||||
setContainerWidth('auto')
|
||||
const timer = setTimeout(() => {
|
||||
setLayoutMode('row')
|
||||
setIsTransitioning(false)
|
||||
}, 400) // Match transition duration
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showFullscreenSelection])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: layoutMode,
|
||||
alignItems: showFullscreenSelection ? 'stretch' : 'center',
|
||||
gap: shouldEmphasize ? '16px' : '12px',
|
||||
width: containerWidth,
|
||||
transition: 'gap 0.4s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}}>
|
||||
{/* Header row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: shouldEmphasize ? '16px' : '12px',
|
||||
justifyContent: showFullscreenSelection ? 'center' : 'flex-start',
|
||||
width: showFullscreenSelection ? '100%' : 'auto'
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: showFullscreenSelection ? '32px' : '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0,
|
||||
transition: 'font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}}>
|
||||
{navEmoji && `${navEmoji} `}{navTitle}
|
||||
</h1>
|
||||
|
||||
<GameModeIndicator
|
||||
gameMode={gameMode}
|
||||
shouldEmphasize={shouldEmphasize}
|
||||
showFullscreenSelection={showFullscreenSelection}
|
||||
/>
|
||||
|
||||
{/* Active Players + Add Button */}
|
||||
{(activePlayers.length > 0 || (shouldEmphasize && inactivePlayers.length > 0)) && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: shouldEmphasize ? '12px' : '2px',
|
||||
padding: shouldEmphasize ? '12px 20px' : '0',
|
||||
background: shouldEmphasize
|
||||
? 'linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.08))'
|
||||
: 'transparent',
|
||||
borderRadius: shouldEmphasize ? '16px' : '0',
|
||||
border: shouldEmphasize ? '3px solid rgba(255, 255, 255, 0.25)' : 'none',
|
||||
boxShadow: shouldEmphasize ? '0 6px 20px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255,255,255,0.3)' : 'none',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: shouldEmphasize ? 'scale(1.05)' : 'scale(1)'
|
||||
}}>
|
||||
<ActivePlayersList
|
||||
activePlayers={activePlayers}
|
||||
shouldEmphasize={shouldEmphasize}
|
||||
onRemovePlayer={onRemovePlayer}
|
||||
onConfigurePlayer={onConfigurePlayer}
|
||||
/>
|
||||
|
||||
<AddPlayerButton
|
||||
inactivePlayers={inactivePlayers}
|
||||
shouldEmphasize={shouldEmphasize}
|
||||
onAddPlayer={onAddPlayer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fullscreen player selection grid */}
|
||||
<FullscreenPlayerSelection
|
||||
inactivePlayers={inactivePlayers}
|
||||
onSelectPlayer={onAddPlayer}
|
||||
onConfigurePlayer={onConfigurePlayer}
|
||||
isVisible={showFullscreenSelection}
|
||||
/>
|
||||
|
||||
{/* Add keyframes for animations */}
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes expandIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
apps/web/src/components/nav/GameModeIndicator.tsx
Normal file
46
apps/web/src/components/nav/GameModeIndicator.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
|
||||
type GameMode = 'none' | 'single' | 'battle' | 'tournament'
|
||||
|
||||
interface GameModeIndicatorProps {
|
||||
gameMode: GameMode
|
||||
shouldEmphasize: boolean
|
||||
showFullscreenSelection: boolean
|
||||
}
|
||||
|
||||
const gameModeConfig = {
|
||||
none: { label: 'Select Players', emoji: '👥', color: '#6b7280' },
|
||||
single: { label: 'Solo', emoji: '🎯', color: '#3b82f6' },
|
||||
battle: { label: 'Battle', emoji: '⚔️', color: '#8b5cf6' },
|
||||
tournament: { label: 'Tournament', emoji: '🏆', color: '#f59e0b' }
|
||||
}
|
||||
|
||||
export function GameModeIndicator({ gameMode, shouldEmphasize, showFullscreenSelection }: GameModeIndicatorProps) {
|
||||
const modeInfo = gameModeConfig[gameMode]
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: shouldEmphasize ? '10px' : '4px',
|
||||
padding: shouldEmphasize ? '12px 24px' : '4px 8px',
|
||||
background: shouldEmphasize
|
||||
? `linear-gradient(135deg, ${modeInfo.color}25, ${modeInfo.color}35)`
|
||||
: `${modeInfo.color}20`,
|
||||
border: `${shouldEmphasize ? '3px' : '2px'} solid ${modeInfo.color}${shouldEmphasize ? '70' : '40'}`,
|
||||
borderRadius: shouldEmphasize ? '16px' : '6px',
|
||||
fontSize: shouldEmphasize ? '20px' : '12px',
|
||||
fontWeight: 'bold',
|
||||
color: modeInfo.color,
|
||||
boxShadow: shouldEmphasize ? `0 6px 20px ${modeInfo.color}40, inset 0 1px 0 rgba(255,255,255,0.3)` : 'none',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: shouldEmphasize ? 'scale(1.05)' : 'scale(1)'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: shouldEmphasize ? '28px' : '12px',
|
||||
transition: 'font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}}>{modeInfo.emoji}</span>
|
||||
<span>{modeInfo.label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
312
apps/web/src/components/nav/PlayerConfigDialog.tsx
Normal file
312
apps/web/src/components/nav/PlayerConfigDialog.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useUserProfile } from '../../contexts/UserProfileContext'
|
||||
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
|
||||
|
||||
interface PlayerConfigDialogProps {
|
||||
playerId: 1 | 2 | 3 | 4
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProps) {
|
||||
const { profile, updatePlayerEmoji, updatePlayerName } = useUserProfile()
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
|
||||
const getCurrentName = () => {
|
||||
switch (playerId) {
|
||||
case 1: return profile.player1Name
|
||||
case 2: return profile.player2Name
|
||||
case 3: return profile.player3Name
|
||||
case 4: return profile.player4Name
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentEmoji = () => {
|
||||
switch (playerId) {
|
||||
case 1: return profile.player1Emoji
|
||||
case 2: return profile.player2Emoji
|
||||
case 3: return profile.player3Emoji
|
||||
case 4: return profile.player4Emoji
|
||||
}
|
||||
}
|
||||
|
||||
const [tempName, setTempName] = useState(getCurrentName())
|
||||
const currentEmoji = getCurrentEmoji()
|
||||
|
||||
const handleSave = () => {
|
||||
updatePlayerName(playerId, tempName)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
updatePlayerEmoji(playerId, emoji)
|
||||
setShowEmojiPicker(false)
|
||||
}
|
||||
|
||||
if (showEmojiPicker) {
|
||||
return (
|
||||
<EmojiPicker
|
||||
currentEmoji={currentEmoji}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
playerNumber={playerId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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: 1000,
|
||||
padding: '20px',
|
||||
animation: 'fadeIn 0.2s ease'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '20px',
|
||||
padding: '32px',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h2 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
background: playerId === 1
|
||||
? 'linear-gradient(135deg, #60a5fa, #3b82f6)'
|
||||
: playerId === 2
|
||||
? 'linear-gradient(135deg, #f472b6, #ec4899)'
|
||||
: playerId === 3
|
||||
? 'linear-gradient(135deg, #a78bfa, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
Configure Player {playerId}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#6b7280',
|
||||
padding: '4px',
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#1f2937'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#6b7280'}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Emoji Selection */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Character
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowEmojiPicker(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #f9fafb, #f3f4f6)',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const borderColor = playerId === 1 ? '#60a5fa' : playerId === 2 ? '#f472b6' : playerId === 3 ? '#a78bfa' : '#fbbf24'
|
||||
e.currentTarget.style.borderColor = borderColor
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '48px',
|
||||
lineHeight: 1
|
||||
}}>
|
||||
{currentEmoji}
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
textAlign: 'left'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
Click to change character
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
Choose from hundreds of emojis
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '20px',
|
||||
color: '#9ca3af'
|
||||
}}>
|
||||
→
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Name Input */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tempName}
|
||||
onChange={(e) => setTempName(e.target.value)}
|
||||
placeholder={`Player ${playerId}`}
|
||||
maxLength={20}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
fontSize: '16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
const focusColor = playerId === 1 ? '#60a5fa' : playerId === 2 ? '#f472b6' : playerId === 3 ? '#a78bfa' : '#fbbf24'
|
||||
const shadowColor = playerId === 1
|
||||
? 'rgba(96, 165, 250, 0.1)'
|
||||
: playerId === 2
|
||||
? 'rgba(244, 114, 182, 0.1)'
|
||||
: playerId === 3
|
||||
? 'rgba(167, 139, 250, 0.1)'
|
||||
: 'rgba(251, 191, 36, 0.1)'
|
||||
e.currentTarget.style.borderColor = focusColor
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${shadowColor}`
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginTop: '4px',
|
||||
textAlign: 'right'
|
||||
}}>
|
||||
{tempName.length}/20 characters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'white',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f9fafb'
|
||||
e.currentTarget.style.borderColor = '#d1d5db'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: playerId === 1
|
||||
? 'linear-gradient(135deg, #60a5fa, #3b82f6)'
|
||||
: playerId === 2
|
||||
? 'linear-gradient(135deg, #f472b6, #ec4899)'
|
||||
: playerId === 3
|
||||
? 'linear-gradient(135deg, #a78bfa, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { TutorialUIProvider } from './TutorialUIContext'
|
||||
import { CoachBar } from './CoachBar/CoachBar'
|
||||
import { PedagogicalDecompositionDisplay } from './PedagogicalDecompositionDisplay'
|
||||
import { DecompositionWithReasons } from './DecompositionWithReasons'
|
||||
import { useAbacusDisplay } from '@/contexts/AbacusDisplayContext'
|
||||
import { useAbacusDisplay } from '@soroban/abacus-react'
|
||||
import './CoachBar/coachbar.css'
|
||||
|
||||
// Helper function to find the topmost bead with arrows
|
||||
@@ -1308,6 +1308,8 @@ function TutorialPlayerContent({
|
||||
colorScheme={abacusConfig.colorScheme}
|
||||
beadShape={abacusConfig.beadShape}
|
||||
hideInactiveBeads={abacusConfig.hideInactiveBeads}
|
||||
soundEnabled={abacusConfig.soundEnabled}
|
||||
soundVolume={abacusConfig.soundVolume}
|
||||
highlightBeads={currentStep.highlightBeads}
|
||||
stepBeadHighlights={currentStepBeads}
|
||||
currentStep={currentMultiStep}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { vi } from 'vitest'
|
||||
import { TutorialProvider, useTutorialContext } from '../TutorialContext'
|
||||
import { TutorialPlayer } from '../TutorialPlayer'
|
||||
import { Tutorial, TutorialStep } from '../../../types/tutorial'
|
||||
import { AbacusDisplayProvider } from '@/contexts/AbacusDisplayContext'
|
||||
import { AbacusDisplayProvider } from '@soroban/abacus-react'
|
||||
|
||||
// Mock tutorial data
|
||||
const mockTutorial: Tutorial = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { vi } from 'vitest'
|
||||
import { TutorialProvider } from '../TutorialContext'
|
||||
import { TutorialPlayer } from '../TutorialPlayer'
|
||||
import { Tutorial } from '../../../types/tutorial'
|
||||
import { AbacusDisplayProvider } from '@/contexts/AbacusDisplayContext'
|
||||
import { AbacusDisplayProvider } from '@soroban/abacus-react'
|
||||
|
||||
// Mock the AbacusReact component to make testing easier
|
||||
vi.mock('@soroban/abacus-react', () => ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user