Compare commits

..

135 Commits

Author SHA1 Message Date
semantic-release-bot
93f4cb0b11 chore(abacus-react): release v1.7.0 [skip ci]
# [1.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.6.0...abacus-react-v1.7.0) (2025-10-01)

### Bug Fixes

* **abacus-react:** apply global columnPosts styling and fix reckoning bar width ([bb9959f](bb9959f7fb))
* **complement-race:** improve abacus display in equations ([491b299](491b299e28))

### Features

* **complement-race:** add abacus displays to pressure gauge ([c5ebc63](c5ebc635af))
2025-10-01 22:44:24 +00:00
Thomas Hallock
c5ebc635af feat(complement-race): add abacus displays to pressure gauge
- Replace digital PSI readout with 3-column abacus display
- Replace tick mark labels (0, 50, 100, 150) with mini abacuses
- Use invisible column posts and proper sizing for readability
- Expand gauge dimensions to accommodate abacus labels without clipping
- Show inactive beads on dial indicators for place value clarity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:43:54 -05:00
Thomas Hallock
491b299e28 fix(complement-race): improve abacus display in equations
- Add hideInactiveBeads to AbacusTarget for cleaner minimal display
- Improve vertical centering with translateY adjustment in equations
- Apply changes consistently across practice, survival, and sprint modes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:43:54 -05:00
Thomas Hallock
bb9959f7fb fix(abacus-react): apply global columnPosts styling and fix reckoning bar width
- Apply global columnPosts property as fallback when rendering column rods
- Fix reckoning bar to span only across actual columns instead of full SVG width
- Column-specific columnPost styling still takes precedence over global

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:43:54 -05:00
semantic-release-bot
88ac0b9bcb chore(abacus-react): release v1.6.0 [skip ci]
# [1.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.5.1...abacus-react-v1.6.0) (2025-10-01)

### Bug Fixes

* align all bottom UI elements to same 20px baseline ([076c97a](076c97abac))
* align bottom-positioned UI elements ([227cfab](227cfabf11))
* change pressure gauge to fixed positioning to stay above terrain ([1b11031](1b11031598))
* change question display to fixed positioning with higher z-index ([4ac8758](4ac8758957))
* correct emoji category group IDs to match Unicode CLDR ([b2a21b7](b2a21b79ad))
* defer URL update until game starts ([12c54b2](12c54b27b7))
* delay passenger display update until train resets ([e06a750](e06a750454))
* eliminate rail jaggies on sharp curves by increasing sampling density ([46d4af2](46d4af2bda))
* ensure passengers only travel forward on train route ([8ad3144](8ad3144d2d))
* increase question display zIndex to stay above terrain ([8c8b8e0](8c8b8e08b4))
* move fontWeight to style object for station names ([05a3ddb](05a3ddb086))
* only show configuration gear icon for players 1 and 2 ([d0a3bc7](d0a3bc7dc1))
* passengers now board/disembark based on their car position, not locomotive ([96782b0](96782b0e7a))
* position tunnels at absolute viewBox edges ([1a5fa28](1a5fa2873b))
* prevent layout shift when selecting Steam Sprint mode ([73a5974](73a59745a5))
* prevent multiple passengers from boarding same car in single frame ([63b0b55](63b0b552a8))
* prevent premature passenger display during route transitions ([fe9ea67](fe9ea67f56))
* prevent random passenger repopulation during route transitions ([db56ce8](db56ce89ee))
* prevent route celebration from immediately reappearing ([1a80934](1a8093416e))
* reduce landmark size from 4.0x to 2.0x multiplier ([c928e90](c928e90785))
* remove double PageWithNav wrapper on matching page ([b58bcd9](b58bcd92ee))
* remove duplicate CAR_SPACING and MAX_CARS declarations ([e704a28](e704a28524))
* remove duplicate previousPassengersRef declaration ([fad8636](fad8636763))
* remove hard-coded car count from game loop ([6c90a68](6c90a68c49))
* remove unnecessary zIndex from question display ([db52e14](db52e14dfe))
* reset momentum and pressure when starting new route ([3ea88d7](3ea88d7a5a))
* smooth rail curves and deterministic track generation ([4f79c08](4f79c08d73))
* stabilize route completion threshold to prevent stuck trains ([b7233f9](b7233f9e4a))
* use displayPassengers for station rendering in RailroadTrackPath ([a9e0d19](a9e0d19734))
* use style fontSize instead of attribute for landmarks ([ebc6894](ebc6894746))
* wrap animated pressure value in animated.span to prevent React error ([5c5954b](5c5954be74))

### Features

* add category browsing and scrolling to emoji picker ([616a50e](616a50e234))
* add complement display options and unify equation display ([2ed7b2c](2ed7b2cbf8))
* add Complement Race game with three unique game modes ([582bce4](582bce411f))
* add configuration access to active player emojis in prominent nav ([6049a7f](6049a7f6b7))
* add configuration access to fullscreen player selection ([b85968b](b85968bcb6))
* add CSS animations and visual feedback system ([80e33e2](80e33e25b3))
* add direct URL routes for each game mode ([a08f053](a08f0535bf))
* add initialStyle prop to ComplementRaceProvider ([f3bc2f6](f3bc2f6d92))
* add interactive remove buttons for players in mini nav ([fa1cf96](fa1cf96789))
* add magnifying glass preview on emoji hover ([2c88b6b](2c88b6b5f3))
* add mini app nav to arcade page ([a854fe3](a854fe3dc9))
* add passenger boarding system with station-based pickup ([23a9016](23a9016245))
* add prominent game context display to mini nav with smooth transitions ([8792393](8792393956))
* add realistic mountains with peaks and ground terrain ([99cdfa8](99cdfa8a0b))
* add smooth spring animations to pressure gauge ([863a2e1](863a2e1319))
* add train car system with smooth boarding/disembarking animations ([1613912](1613912740))
* add Web Audio API sound effects system with 16 sound types ([90ba866](90ba86640c))
* create mode selection landing page for Complement Race ([1ff9695](1ff9695f69))
* create PlayerConfigDialog component for player customization ([4f2a661](4f2a661494))
* display passengers visually on train and at stations ([1599904](159990489f))
* dynamically calculate train cars based on max concurrent passengers ([9ea1553](9ea15535d1))
* enable prominent nav and fix layout on arcade page ([5c8c18c](5c8c18cbb8))
* enhance emoji picker with super powered magnifying glass and hide empty categories ([d8b4e42](d8b4e425bf))
* enhance passenger card UI with boarding status indicators ([4bbdabc](4bbdabc3b5))
* extend ground terrain to cover entire track area ([ee48417](ee48417abf))
* extend player customization to support all 4 players ([72f8dee](72f8dee183))
* extend railroad track to viewport edges ([eadd7da](eadd7da6db))
* extend track and tunnels to absolute viewport edges ([f7419bc](f7419bc6a0))
* implement smooth train exit with fade-out through right tunnel ([0176694](01766944f0))
* improve game availability logic and messaging ([9a3fa93](9a3fa93e53))
* increase landmark emoji size for better visibility ([0bcd7a3](0bcd7a30d4))
* integrate remaining game sound effects ([600bc35](600bc35bc3))
* integrate sound effects into game flow (countdown, answers, performance) ([8c3a855](8c3a855239))
* make Steam Sprint infinite mode ([32c3a35](32c3a35eab))
* make SVG span full viewport width for sprint mode ([7488bb3](7488bb3803))
* preserve track and passengers during route transitions ([f2e7165](f2e71657dc))
* redesign passenger cards with vintage train station aesthetic ([651bc21](651bc21583))
* skip countdown for train mode (sprint) ([65dafc9](65dafc9215))
* skip intro screen and start directly at game setup ([4b6888a](4b6888af05))
* sync URL with selected game mode ([3920bba](3920bbad33))
* UI polish for Sprint mode (viewport, backgrounds, data attributes) ([90ad789](90ad789ff1))
* use CSS transitions for smooth fullscreen player selection collapse ([3189832](31898328a3))
* wire player configuration through nav component hierarchy ([edfdd81](edfdd81227))

### Performance Improvements

* optimize React rendering with memoization and consolidated effects ([93cb070](93cb070ca5))
2025-10-01 22:14:42 +00:00
Thomas Hallock
2ed7b2cbf8 feat: add complement display options and unify equation display
- Add complement display modes (number/abacus/random) in game setup
- Create AbacusTarget component for inline abacus display in equations
- Unify equation display across practice, survival, and sprint modes
- Fix abacus vertical centering using inline-flex alignment
- Add Storybook examples demonstrating invisible column posts
- Fix passenger boarding bug using proper car occupancy tracking
- Redesign game setup UI with compact pill-based controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:13:54 -05:00
Thomas Hallock
63b0b552a8 fix: prevent multiple passengers from boarding same car in single frame
Problem: When multiple passengers were waiting at the same station and a car
arrived, they could all try to board the same car in the same game loop
iteration. This happened because currentBoardedPassengers was calculated once
at the start of each frame and didn't reflect passengers who boarded during
that same iteration.

Solution: Track which cars are assigned within each frame using a Set. Before
assigning a passenger to a car, check both:
1. currentBoardedPassengers (passengers from previous frames)
2. carsAssignedThisFrame (passengers assigned this frame)

This ensures only one passenger boards per car per frame, preventing the bug
where passengers get left behind at stations because the game thinks they've
already boarded when they haven't.

Tests: Added 6 comprehensive boarding logic tests that successfully reproduce
and verify the fix for various edge cases including:
- Single passenger boarding
- Multiple passengers with enough cars
- Fast-moving train scenarios
- Passenger left behind scenarios
- Single car with multiple passengers
- All passengers boarding before train passes

All tests pass (6/6).
2025-10-01 12:44:45 -05:00
Thomas Hallock
b7233f9e4a fix: stabilize route completion threshold to prevent stuck trains
Problem: Route completion threshold was recalculated every frame based on
current passengers. When passengers were auto-regenerated mid-route (after
all delivered), the maxCars value could change, shifting the threshold and
potentially causing the train to never cross it properly.

Solution:
- Store route exit threshold in a ref when route starts
- Only update threshold at route completion for next route
- Threshold remains stable throughout entire route even when passengers regenerate

This ensures trains always complete routes when reaching the exit threshold,
preventing situations where players get stuck answering problems after train
has disappeared.
2025-10-01 12:36:55 -05:00
Thomas Hallock
db56ce89ee fix: prevent random passenger repopulation during route transitions
- Track displayRouteRef to identify which route's passengers are shown
- Only update passengers when train resets (< 0) OR same route and in middle of track (10-90%)
- Exclude start/end transition zones to prevent premature passenger updates
- Add comprehensive unit tests (11 tests) covering edge cases:
  - New passengers at position 0 with old route
  - Passengers regenerated at 5% position
  - Rapid route increment with position oscillation
  - Multiple rapid gameplay updates during same route
- All 44 hook tests pass
2025-10-01 12:22:57 -05:00
Thomas Hallock
4f79c08d73 fix: smooth rail curves and deterministic track generation
- Use bezier curve paths instead of polylines for rails to eliminate jaggies on sharp turns
- Sample rail waypoints densely (every 2px) with gentle control points (0.33)
- Use longer lookahead distance (8px) for smoother perpendicular angle calculation
- Implement seeded random number generator for consistent tracks per route
- Each route generates a unique but deterministic track layout
2025-10-01 12:22:47 -05:00
Thomas Hallock
46d4af2bda fix: eliminate rail jaggies on sharp curves by increasing sampling density
The rails were rendered as polylines with points sampled at each tie position
(every 12 pixels). On sharp turns, the inside rail (smaller radius) didn't
have enough points to maintain smoothness, creating visible jaggies.

Solution: Separate tie generation from rail point generation. Ties still use
12px spacing (looks good), but rails now sample at 3px intervals (4x denser).
This provides enough points for smooth curves even on the inside rail of
sharp turns, while keeping tie density reasonable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:39:26 -05:00
Thomas Hallock
6c90a68c49 fix: remove hard-coded car count from game loop
The useSteamJourney hook was still using hard-coded MAX_CARS = 5 for:
- Checking which cars should board passengers
- Checking which cars should deliver passengers
- Calculating when entire train exits (route completion)

This caused the game to wait for a 5-car train to exit even when there were
fewer passengers, making players answer problems well after the last visible
car had exited.

Now dynamically calculates maxCars in the game loop using the same
calculateMaxConcurrentPassengers() utility, ensuring route completion
happens as soon as the actual last car exits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:34:44 -05:00
Thomas Hallock
9ea15535d1 feat: dynamically calculate train cars based on max concurrent passengers
Instead of hard-coding 5 train cars, now calculate the number of cars needed
based on the maximum number of passengers that will be on the train at any
given moment during the route.

For example: if 8 total passengers exist, but only 4 board at the start, all
get off halfway, then the remaining 4 board and ride to the end, we only need
4 cars (not 8).

Added calculateMaxConcurrentPassengers() utility that:
- Tracks boarding/delivery events by station position
- Sorts events to handle same-station boarding/delivery correctly
- Returns the peak concurrent passenger count

Updated SteamTrainJourney to calculate maxCars dynamically using this utility.
Updated all hooks and tests to use required maxCars/carSpacing parameters.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:26:59 -05:00
Thomas Hallock
65dafc9215 feat: skip countdown for train mode (sprint)
Train mode now starts immediately when clicking "Start Your Race!" button,
bypassing the 3-2-1-GO countdown. Other game modes (practice, survival) still
show the countdown as expected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:20:31 -05:00
Thomas Hallock
e06a750454 fix: delay passenger display update until train resets
Previously, passengers would update as soon as all cars exited (at 97%+),
causing new passengers to briefly appear on the old track before it changed.

Now passengers only update when:
1. Train resets to start position (< 0) - track has changed, OR
2. Same passengers (same route gameplay updates like boarding/delivering)

This eliminates the ephemeral passengers showing up after the last car
fades out but before the new track is displayed.

Updated test to verify passengers don't change until train resets.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:16:20 -05:00
Thomas Hallock
5d2083903e test: add comprehensive unit tests for refactored hooks and components
Added 56 unit tests covering:
- usePassengerAnimations hook (7 tests) - boarding/disembarking animations
- useTrainTransforms hook (14 tests) - train positioning and opacity
- useTrackManagement hook (12 tests) - track generation and route transitions
- TrainTerrainBackground component (10 tests) - terrain rendering
- GameHUD component (13 tests) - HUD overlay elements

Also configured vitest to properly inject React for JSX transforms, eliminating
the need for explicit React imports in test files and components.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:13:32 -05:00
Thomas Hallock
a9e0d19734 fix: use displayPassengers for station rendering in RailroadTrackPath
Fix the actual bug: passengers at stations and in passenger list were
switching to the new route once the locomotive exited, but before the
last train car exited.

Root cause: RailroadTrackPath was using state.passengers directly instead
of displayPassengers, which has transition logic to delay the switch until
all cars have exited.

The previous commit added the transition logic to useTrackManagement, but
RailroadTrackPath wasn't using the displayPassengers output - it was still
reading state.passengers directly.

Changes:
- SteamTrainJourney: Pass displayPassengers to RailroadTrackPath instead
  of state.passengers

Note: usePassengerAnimations correctly continues to use state.passengers
for immediate animation triggering. boardedPassengers and
nonDeliveredPassengers already use displayPassengers correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 10:49:07 -05:00
Thomas Hallock
fe9ea67f56 fix: prevent premature passenger display during route transitions
Fix bug where new route passengers would appear on train cars before
all cars from the previous route had exited through the tunnel.

The issue occurred because passengers were switched when trainPosition < 0
(locomotive exits), but train cars trail behind by up to 35% (5 cars × 7%
spacing). This caused new passengers to briefly appear on the old cars that
were still fading out (positions 65-97%).

Solution: Calculate the last car's position and only switch passengers when:
1. Train has reset to start position (trainPosition < 0), OR
2. All cars have fully faded out (lastCarPosition >= 97%)

Changes:
- useTrackManagement: Added maxCars and carSpacing parameters
- Updated passenger display logic to check lastCarPosition >= fadeOutEnd
- SteamTrainJourney: Pass maxCars and carSpacing from useTrainTransforms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 10:15:43 -05:00
Thomas Hallock
78d5234a79 refactor: extract GameHUD component from SteamTrainJourney
Extract game HUD elements (route info, time, pressure gauge, passenger list,
question display) into a separate GameHUD component for better maintainability.

- Created GameHUD.tsx (190 lines)
- Reduced SteamTrainJourney.tsx from 412 to 283 lines (-129 lines, 31.3%)
- Preserved all HUD elements and functionality exactly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:59:39 -05:00
Thomas Hallock
5ae22e4645 refactor: extract TrainAndCars component from SteamTrainJourney
Extract train rendering (locomotive, cars, passenger animations) into
a separate TrainAndCars component for better maintainability.

- Created TrainAndCars.tsx (217 lines)
- Reduced SteamTrainJourney.tsx from 546 to 412 lines (-134 lines, 24.5%)
- Preserved all train elements and animations exactly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:57:32 -05:00
Thomas Hallock
d9acc0efea refactor: extract RailroadTrackPath component from SteamTrainJourney
Extract track rendering (ties, rails, path, landmarks, stations) into
a separate RailroadTrackPath component for better maintainability.

- Created RailroadTrackPath.tsx (192 lines)
- Reduced SteamTrainJourney.tsx from 687 to 546 lines (-141 lines, 20.5%)
- Preserved all track elements and passenger rendering exactly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:55:46 -05:00
Thomas Hallock
05bb035db5 refactor: extract TrainTerrainBackground component from SteamTrainJourney
Extract terrain background rendering (ground, mountains, tunnels) into
a separate TrainTerrainBackground component for better maintainability.

- Created TrainTerrainBackground.tsx (203 lines)
- Reduced SteamTrainJourney.tsx from 857 to 687 lines (-170 lines, 19.8%)
- Preserved all visual elements and functionality exactly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:53:34 -05:00
Thomas Hallock
a1f2b9736a refactor: extract useTrackManagement hook from SteamTrainJourney
- Create dedicated hook for track generation and positioning logic
- Move 115+ lines of track/landmark/passenger display management to useTrackManagement.ts
- Consolidates track generation, ties/rails, station positions, landmarks, and passenger display transitions
- Reduce SteamTrainJourney.tsx from 959 to 857 lines (102 lines removed)
- Preserve all functionality exactly - no behavioral changes

Benefits:
- Centralizes all track-related state management
- Handles route transition logic in one place
- Makes track generation logic easier to test
- Improves overall code organization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:43:07 -05:00
Thomas Hallock
a2512d5738 refactor: extract useTrainTransforms hook from SteamTrainJourney
- Create dedicated hook for train position and car transform calculations
- Move 71 lines of transform logic to useTrainTransforms.ts
- Extract train position useEffect, trainCars useMemo, and locomotiveOpacity useMemo
- Reduce SteamTrainJourney.tsx from 1023 to 959 lines (64 lines removed)
- Preserve all functionality exactly - no behavioral changes

Benefits:
- Separates transform calculations from presentation logic
- Makes train physics calculations easier to test
- Improves code organization and readability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:34:37 -05:00
Thomas Hallock
32abde107c refactor: extract usePassengerAnimations hook from SteamTrainJourney
- Create dedicated hook for passenger boarding/disembarking animations
- Move 112 lines of animation logic to usePassengerAnimations.ts
- Export BoardingAnimation and DisembarkingAnimation types
- Reduce SteamTrainJourney.tsx from 1147 to 1023 lines (124 lines removed)
- Preserve all functionality exactly - no behavioral changes

Benefits:
- Better separation of concerns
- Easier to test animation logic independently
- Improved maintainability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:30:58 -05:00
Thomas Hallock
fad8636763 fix: remove duplicate previousPassengersRef declaration
- Remove duplicate ref declaration that was causing compilation error
- Use existing previousPassengersRef initialized with state.passengers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:21:52 -05:00
Thomas Hallock
f2e71657dc feat: preserve track and passengers during route transitions
- Store pending track data and only apply when train resets to beginning
- Preserve passenger list display until entire train exits
- Prevent visual jumps by keeping old route data visible during fade-out
- Track transitions now seamless: old track/passengers persist until trainPosition < 0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:19:58 -05:00
Thomas Hallock
e704a28524 fix: remove duplicate CAR_SPACING and MAX_CARS declarations 2025-10-01 09:09:34 -05:00
Thomas Hallock
96782b0e7a fix: passengers now board/disembark based on their car position, not locomotive
Previously, passengers would board/disembark when the locomotive reached their
station, which was incorrect. Now:

**Boarding:**
- Passengers board when an EMPTY train car reaches their origin station
- Each passenger is assigned to the first available empty car (0-4)
- Car position calculated as: trainPosition - (carIndex + 1) * 7%

**Disembarking:**
- Passengers disembark when THEIR specific car reaches their destination
- Uses the passenger's position in the boardedPassengers array to find car index
- Each passenger's car position is independently checked against their destination

This creates more realistic train behavior where passengers interact with
individual train cars rather than just the locomotive.

Removed unused imports: findBoardablePassengers, findDeliverablePassengers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:07:31 -05:00
Thomas Hallock
01766944f0 feat: implement smooth train exit with fade-out through right tunnel
- Allow train position to exceed 100% so entire train can exit before route change
- Change route completion threshold from 100% to 135% (locomotive + 5 cars at 7% spacing)
- Add fade-out effect for locomotive and train cars between 92-97% position
- Symmetric with fade-in effect (3-8%) used for left tunnel entrance
- Prevents jarring snap-back when route changes mid-train

The train now smoothly fades out as it enters the right tunnel, with the route
only changing after the entire train (including all passenger cars) has fully
exited through the tunnel.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:02:41 -05:00
Thomas Hallock
93cb070ca5 perf: optimize React rendering with memoization and consolidated effects
- Memoize BoardingPassengerAnimation and DisembarkingPassengerAnimation with React.memo
- Memoize expensive calculations: train cars, filtered passengers, ground texture
- Consolidate boarding/disembarking useEffect hooks to reduce passenger array processing
- Wrap PassengerCard in React.memo to prevent unnecessary re-renders
- Add useMemo for boardedPassengers and nonDeliveredPassengers lists

These optimizations reduce unnecessary re-renders and recalculations during gameplay,
improving performance especially when multiple passengers are in transit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:00:19 -05:00
Thomas Hallock
1613912740 feat: add train car system with smooth boarding/disembarking animations
Implement a complete passenger train car system with animated boarding
and disembarking using react-spring.

Train Car System:
- Each passenger gets their own train car (🚃) following the track
- Cars follow curved track with proper rotation and spacing (7%)
- Cars fade in as they emerge from tunnel to avoid visual pile-up
- Compact car sizing (65px) and passenger sizing (42px)
- Maximum 5 cars per train for performance

Boarding Animations:
- Smooth spring-animated transitions from station to train car
- Passengers fly from station platform to assigned car (800ms)
- Passengers hidden from station and car during animation
- Animated passenger tracked separately to avoid flickering

Disembarking Animations:
- Reverse animation from train car to destination station
- Green glow effect for delivered passengers
- Celebration animation plays after landing at station
- Smooth transition prevents "ghost" effect

Station Passenger Display:
- Positioned directly above station circle (30px offset)
- Compact 55px size for better visual balance
- Tight horizontal spacing (28px) when multiple waiting
- Passengers properly excluded during boarding/disembarking

Visual Improvements:
- Removed decorative rock/bush elements from mountains
- Cleaner mountain tunnel appearance
- Better layering of animations between stations and train

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:52:44 -05:00
Thomas Hallock
651bc21583 feat: redesign passenger cards with vintage train station aesthetic
Redesign passenger information cards to match classic Grand Central
Terminal style with compact, elegant vintage design.

- Vintage color scheme: sepia, gold accents, dark backgrounds
- Courier New monospace font for mechanical board feel
- Compact layout with FROM/TO route information
- Shows origin station (was missing before)
- Status badges: WAIT, BOARD, DLVRD
- Color-coded by state (waiting/aboard/urgent/delivered)
- Art Deco gold borders and styling
- Much smaller footprint while showing more info

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:52:25 -05:00
Thomas Hallock
8ad3144d2d fix: ensure passengers only travel forward on train route
Update passenger generation to only create passengers whose destination
stations are ahead of their origin stations (higher track position).
This prevents passengers from being stranded waiting for backwards travel.

- Exclude final station as possible origin (no stations ahead)
- Filter destination stations to only those with position > origin
- Maintains 40% depot start probability for variety

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:52:15 -05:00
Thomas Hallock
076c97abac fix: align all bottom UI elements to same 20px baseline 2025-10-01 08:23:44 -05:00
Thomas Hallock
227cfabf11 fix: align bottom-positioned UI elements
Standardize positioning for all bottom-aligned elements:
- Pressure gauge: bottom 10px (fixed, z-index 1000)
- Question display: bottom 40px (fixed, z-index 1000)
- Passenger list: bottom 220px (fixed, z-index 1000)

Changed passenger list from absolute to fixed positioning and adjusted
bottom spacing to sit above the question display without overlapping.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:21:54 -05:00
Thomas Hallock
1b11031598 fix: change pressure gauge to fixed positioning to stay above terrain 2025-10-01 08:20:36 -05:00
Thomas Hallock
4ac8758957 fix: change question display to fixed positioning with higher z-index 2025-10-01 08:19:10 -05:00
Thomas Hallock
db52e14dfe fix: remove unnecessary zIndex from question display 2025-09-30 16:48:00 -05:00
Thomas Hallock
8c8b8e08b4 fix: increase question display zIndex to stay above terrain 2025-09-30 16:46:22 -05:00
Thomas Hallock
ee48417abf feat: extend ground terrain to cover entire track area
Expand ground layer to span full viewport width and extend from
the top of the track (y=120) to the bottom of the viewport (y=650).

Changes:
- Ground now starts at y=120 (above highest track point at ~160)
- Height increased from 200 to 530 pixels
- Added scattered rock/pebble texture across ground surface
- Enhanced surface gradient from 40 to 60 pixels for better depth

The entire railroad track now sits on realistic earth terrain.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:42:35 -05:00
Thomas Hallock
99cdfa8a0b feat: add realistic mountains with peaks and ground terrain
Enhanced tunnel entrances with proper mountain scenery:

Mountains:
- Added triangular mountain peaks rising above tunnel entrances
- Gradient shading for depth and dimension
- Rocky texture with vertical crack details
- Green vegetation/bushes at mountain base

Tunnels:
- Stone brick archways with brown/tan coloring
- Brick texture pattern on arch rim
- Dark interior for depth

Ground:
- Brown earth terrain extending full viewport width
- Gradient shading for ground surface depth
- Positioned below tunnel entrances

Train now emerges from/enters proper mountain tunnels set into
realistic terrain.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:41:41 -05:00
Thomas Hallock
7488bb3803 feat: make SVG span full viewport width for sprint mode
Remove constraints preventing full-width rendering:
- Remove 8px horizontal padding from track container in sprint mode
- Remove maxWidth: 1400px constraint from SVG
- Change justifyContent to 'stretch' for sprint mode
- Remove borderRadius from steam-train-journey container

Now the railroad track SVG truly spans the entire browser viewport
width, with tunnel entrances at the absolute edges.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:39:27 -05:00
Thomas Hallock
1a5fa2873b fix: position tunnels at absolute viewBox edges
Corrected tunnel positioning based on actual viewBox coordinates:
- viewBox is "-50 -50 900 700" (x: -50 to 850, y: -50 to 650)
- Left mountain face now starts at x=-50 (absolute left edge)
- Right mountain face now ends at x=850 (absolute right edge)
- Both mountain faces extend full viewport height
- Track waypoints adjusted to span from left tunnel (x=20) to right tunnel (x=780)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:36:53 -05:00
Thomas Hallock
f7419bc6a0 feat: extend track and tunnels to absolute viewport edges
- Extend track waypoints from x: -120 to x: 920 (beyond viewport)
- Add mountain face rectangles at absolute left and right edges
- Redesign tunnel entrances as proper mountain tunnels with:
  - Gray stone mountain faces extending to viewport edges
  - Dark tunnel interiors
  - Arched stone openings
  - Stone rim detail around arch

Train now truly emerges from and enters into mountain tunnels at
the viewport boundaries.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:34:52 -05:00
Thomas Hallock
eadd7da6db feat: extend railroad track to viewport edges
Modify track waypoints to span from left edge (x: -50) to right edge
(x: 850) of the viewport, creating a more expansive journey across
the full screen width.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:33:05 -05:00
Thomas Hallock
05a3ddb086 fix: move fontWeight to style object for station names 2025-09-30 16:28:51 -05:00
Thomas Hallock
c928e90785 fix: reduce landmark size from 4.0x to 2.0x multiplier 2025-09-30 16:27:28 -05:00
Thomas Hallock
ebc6894746 fix: use style fontSize instead of attribute for landmarks 2025-09-30 16:24:29 -05:00
Thomas Hallock
0bcd7a30d4 feat: increase landmark emoji size for better visibility
Increase landmark size multiplier from 2.5x to 4.0x, making scenery
emojis more prominent and visually similar in scale to the player
avatar (96px default vs 70px player).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:21:15 -05:00
Thomas Hallock
5c5954be74 fix: wrap animated pressure value in animated.span to prevent React error 2025-09-30 15:54:22 -05:00
Thomas Hallock
863a2e1319 feat: add smooth spring animations to pressure gauge
Use React Spring to animate pressure changes with natural physics-based
motion. The gauge needle, digital readout, and color all smoothly
interpolate to new values instead of jumping instantly.

Configuration:
- tension: 120 (responsive spring)
- friction: 14 (smooth damping)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:53:06 -05:00
Thomas Hallock
4b6888af05 feat: skip intro screen and start directly at game setup
Remove "How to Play" screen in favor of self-explanatory UX.
Users now land directly on the game configuration page where they
can immediately see and select their preferred game mode, style,
and difficulty.

The game mechanics are intuitive enough that they can be learned
through play rather than reading instructions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:49:25 -05:00
Thomas Hallock
32c3a35eab feat: make Steam Sprint infinite mode
- Remove 60-second time limit for infinite gameplay
- Auto-advance to next route upon completion instead of showing modal
- Time-of-day cycle now repeats every 60 seconds indefinitely
- Update documentation to reflect infinite mode behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:46:29 -05:00
Thomas Hallock
3ea88d7a5a fix: reset momentum and pressure when starting new route
Added momentum and pressure reset to START_NEW_ROUTE action.
Each new route now starts fresh with zero momentum/pressure,
preventing carryover from the previous route.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:12:13 -05:00
Thomas Hallock
1a8093416e fix: prevent route celebration from immediately reappearing
Fixed race condition where route celebration modal would immediately
reappear after clicking 'Continue Journey'.

Root cause: trainPosition stayed >= 100 while showRouteCelebration
was toggled, causing the interval check to re-trigger COMPLETE_ROUTE.

Solution:
1. START_NEW_ROUTE now resets showRouteCelebration to false
2. Removed setTimeout delay and HIDE_ROUTE_CELEBRATION action
3. Single atomic state update prevents race condition

Now continuing to next route works smoothly without modal flickering.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:09:53 -05:00
Thomas Hallock
12c54b27b7 fix: defer URL update until game starts
Move router.push() from mode selection to game start button.
This prevents the game from rebooting when user is exploring
different race types in the setup screen.

Now the flow is:
1. User selects race type → background preview changes
2. User clicks 'Start Your Race!' → URL updates + game begins

This keeps the setup experience smooth while still providing
shareable URLs once the game starts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:06:56 -05:00
Thomas Hallock
73a59745a5 fix: prevent layout shift when selecting Steam Sprint mode
Remove conditional padding that caused 12px upward shift when
selecting Steam Sprint. Now uses consistent 20px 8px padding
across all game modes for stable layout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:04:06 -05:00
Thomas Hallock
3920bbad33 feat: sync URL with selected game mode
Add Next.js router integration to update URL when user selects
a game mode in GameControls. Now when selecting:
- Practice Mode → URL becomes /games/complement-race/practice
- Steam Sprint → URL becomes /games/complement-race/sprint
- Survival Mode → URL becomes /games/complement-race/survival

This makes URLs shareable at any point in the setup flow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:02:05 -05:00
Thomas Hallock
80e33e25b3 feat: add CSS animations and visual feedback system
Added complete animation system with Panda CSS:

1. Panda CSS Keyframe Animations (panda.config.ts):
   - shake: Horizontal oscillation for errors (-5px to 5px)
   - successPulse: Gentle scale for correct answers (1 to 1.05)
   - errorShake: Stronger shake for errors (-10px to 10px)
   - pulse: Continuous breathing effect (1 to 1.05)
   - bounce: Vertical bounce (0 to -10px)
   - bounceIn: Entry animation with scale and rotate
   - glow: Expanding box shadow effect

   All animations match exact timing and easing from original
   Python implementation (web_generator.py lines 1996-6274)

2. Visual Feedback Integration (GameDisplay.tsx):
   - Trigger successPulse animation on correct answers
   - Trigger errorShake animation on incorrect answers
   - Auto-clear animations after 500ms (matching duration)
   - Applied to answer input element for clear visual feedback
   - Synchronized with sound effects for multi-sensory feedback

Animations enhance user experience by providing immediate
visual confirmation of answer correctness alongside audio cues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:25:10 -05:00
Thomas Hallock
600bc35bc3 feat: integrate remaining game sound effects
Added 5 remaining sound effects across all game modes:

1. ai_turbo (0.12 volume) - Plays when AI enters desperate_catchup
   mode (>10 units behind), matches line 11941

2. lap_celebration (0.6 volume) - Plays when player completes a lap
   in survival mode circular track, matches line 12801

3. celebration (default volume) - Plays when:
   - Player wins practice mode (reaches goal first)
   - Route completes in sprint mode (with train_whistle)
   Matches lines 14182, 13543

4. gameOver (default volume) - Plays when AI wins in practice mode
   (AI reaches goal before player), matches line 14193

5. train_whistle (0.25-0.6 volume) - Plays in sprint mode when:
   - Streak milestone: streak >= 5 and streak % 3 === 0 (0.4 volume)
   - High momentum: momentum >= 90 (30% chance, 0.25 volume)
   - Route completion: train reaches 100% (0.6 volume)
   Matches lines 13222-13235, 13541

All sound integrations preserve exact timing and volume levels from
original Python implementation (web_generator.py).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:22:16 -05:00
Thomas Hallock
920aaa6398 refactor: simplify navigation flow and enhance GameControls UI
Simplified flow:
- /games/complement-race goes directly to setup page (no redundant landing)
- Direct URLs (/practice, /sprint, /survival) pre-select game type
- Users configure all options on single fancy setup page

Enhanced GameControls with:
- Gradient background and hero title
- 3px borders with gradient fills on selected options
- Larger icons (24-28px) and better spacing
- Detailed descriptions for each race type explaining gameplay
- Color-coded sections (blue for number mode, green/amber/purple for race types, pink for difficulty)
- Prominent start button with gradient and emoji
- Professional shadows and hover effects throughout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:18:07 -05:00
Thomas Hallock
1ff9695f69 feat: create mode selection landing page for Complement Race
Transform /games/complement-race into a beautiful mode selection page
with three interactive cards:
- Practice Mode (🏁) - Race to 20 correct answers
- Steam Sprint (🚂) - 60-second momentum challenge
- Survival Mode (🔄) - Endless circular race

Features gradient title, responsive grid layout, and smooth hover
effects (translateY + box-shadow).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:15:32 -05:00
Thomas Hallock
a08f0535bf feat: add direct URL routes for each game mode
Create dedicated routes for each Complement Race game mode:
- /games/complement-race/practice - Practice Mode (race to 20)
- /games/complement-race/sprint - Steam Sprint (60 second momentum)
- /games/complement-race/survival - Survival Mode (endless laps)

Each route uses ComplementRaceProvider with initialStyle prop to set
the game mode, allowing users to share direct links to specific modes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:15:31 -05:00
Thomas Hallock
f3bc2f6d92 feat: add initialStyle prop to ComplementRaceProvider
- Add ComplementRaceProviderProps interface with optional initialStyle
- Accept initialStyle param ('practice' | 'sprint' | 'survival')
- Initialize game state with provided style or default to 'practice'
- Enables URL-based routing for different game modes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:13:37 -05:00
Thomas Hallock
8c3a855239 feat: integrate sound effects into game flow (countdown, answers, performance)
Wire up sound effects to game events:

Countdown & Start:
- Play countdown beep (0.4 volume) for 3-2-1
- Play race_start fanfare (0.6 volume) on GO!

Answer Feedback (matching original logic from web_generator.py):
- Streak sound: every 5th correct answer when streak > 0
- Whoosh sound: responses under 800ms (very fast!)
- Combo sound: responses under 1200ms when streak >= 3
- Correct sound: regular correct answers
- Incorrect sound: wrong answers

Sound triggering follows exact original logic (lines 11530-11542, 11589):
- Check streak % 5 first
- Then check response time < 800ms
- Then check response time < 1200ms AND streak >= 3
- Default to regular correct sound

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:10:47 -05:00
Thomas Hallock
90ba86640c feat: add Web Audio API sound effects system with 16 sound types
Implement useSoundEffects hook with programmatically generated retro 90s arcade sounds:
- correct, incorrect, timeout - answer feedback
- countdown, race_start - game flow
- celebration, lap_celebration, gameOver - major events
- ai_turbo, milestone, streak, combo - performance feedback
- whoosh - fast responses
- train_chuff, train_whistle, coal_spill, steam_hiss - Sprint mode train sounds

Features:
- play90sSound helper for multi-note sequences with waveform control
- Custom oscillators for complex sounds (whoosh, train effects)
- Classic 90s arcade envelope (quick attack, moderate decay)
- Vibrato effects for sawtooth/square waves
- Low-pass filtering for retro sound quality
- Audio context tracking for proper cleanup
- stopAllSounds() for immediate silence when needed

Based on original Python implementation (lines 14218-14490)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:15:07 -05:00
Thomas Hallock
90ad789ff1 feat: UI polish for Sprint mode (viewport, backgrounds, data attributes)
Previous session improvements:
- Add data-component attributes to all major components for debugging
- Fix viewport issues: set overflow:hidden, maxHeight:100vh to prevent scrolling
- Make backgrounds transparent for sprint mode (sky gradient at root level)
- Adjust padding for sprint mode (8px vs 20px)
- Redesign race configuration screen for responsive mobile-first layout
- Rebuild pressure gauge with proper 180° semicircular arc
- Make complement equation more prominent (96px font)
- Enlarge station names and icons for better visibility
- Fix viewport clipping issues with pressure gauge and question display

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:13:48 -05:00
Thomas Hallock
159990489f feat: display passengers visually on train and at stations
- Show boarded passengers riding on the train behind the engineer
  - Position passengers at x=90+ with 35px spacing
  - Size 28px, with urgent passengers having orange glow
- Display waiting passengers at their origin stations
  - Large avatars (fontSize 80) above station platforms
  - Urgent passengers have orange glow effect
  - Positioned at y=-100 with 90px horizontal spacing
- Show delivered passengers celebrating at destination stations
  - Same size as waiting passengers (fontSize 80)
  - Green glow effect with celebrateDelivery animation
  - 2-second celebration before fading away
- Add celebrateDelivery CSS animation with scale and translateY effects
- Update passenger list to show all non-delivered passengers (waiting + aboard)
- Add maxHeight and overflowY to passenger list container

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:06:17 -05:00
Thomas Hallock
4bbdabc3b5 feat: enhance passenger card UI with boarding status indicators
- Add visual status badges showing WAITING () or ABOARD (🚂) state
- Color code cards by passenger state:
  - Gray gradient for waiting passengers
  - Blue/orange gradient for boarded passengers
  - Green gradient for delivered passengers
- Adjust opacity: 0.8 for waiting, 1.0 for aboard, 0.6 for delivered
- Show urgent warning (⚠️) only for boarded urgent passengers
- Update animations to only pulse when passenger is urgent and aboard
- Improve card layout with flex container for status badge positioning

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:06:02 -05:00
Thomas Hallock
23a9016245 feat: add passenger boarding system with station-based pickup
- Add BOARD_PASSENGER action to GameAction types
- Implement BOARD_PASSENGER reducer to mark passengers as boarded
- Add findBoardablePassengers helper to identify passengers ready to board
- Update useSteamJourney hook to automatically board passengers when train reaches their origin station
- Modify findDeliverablePassengers to only check boarded passengers for delivery
- Passengers now transition through complete lifecycle: waiting → boarding → aboard → delivery

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:05:45 -05:00
Thomas Hallock
582bce411f feat: add Complement Race game with three unique game modes
Implemented a new complement arithmetic game with three distinct modes:

Practice Mode (Linear Track):
- Race against AI opponents on a straight track
- Fixed race goal with finish line
- Traditional racing format with visual progress indicators

Endless Circuit (Circular Track):
- Infinite laps around an oval track
- Continuous gameplay with lap tracking
- AI racers with personality-driven commentary system

Steam Sprint (Train Journey):
- 60-second timed challenge with momentum-based gameplay
- SVG railroad track with dynamic curved paths that vary by route
- Passenger delivery system with station stops
- Momentum decay mechanic balanced by skill level
- Animated sky gradient with time-of-day progression (dawn to night)
- Route progression system with 10 themed routes
- Enhanced pressure gauge and visual effects (steam, coal particles)
- Geographic landmarks themed to each route

Core Features:
- Adaptive difficulty system that learns user performance patterns
- Real-time feedback based on speed and accuracy
- Comprehensive state management with React Context
- Multiple track visualization systems (linear, circular, SVG-based)
- AI personality system with dynamic commentary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 10:49:22 -05:00
Thomas Hallock
d8b4e425bf feat: enhance emoji picker with super powered magnifying glass and hide empty categories
Improve emoji selection UX by making hover preview more dramatic and removing empty category tabs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 18:56:40 -05:00
Thomas Hallock
2c88b6b5f3 feat: add magnifying glass preview on emoji hover
Add large preview tooltip when hovering over emojis in picker:
- 72px magnified emoji preview in white card with blue glow
- Appears above hovered emoji with smooth scale-in animation
- Arrow indicator pointing to source emoji
- Fixed positioning follows cursor between emojis
- Helps users make informed decisions with small emoji buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 18:12:07 -05:00
Thomas Hallock
b2a21b79ad fix: correct emoji category group IDs to match Unicode CLDR
Fix category filtering by using correct emojibase group IDs:
- Groups are 0-9, not 0-7
- Skip group 2 (Component - skin tone modifiers)
- Match Unicode CLDR standard group ordering
- Group 0: Smileys & Emotion (not Smileys & People)
- Group 1: People & Body (separate from smileys)
- Groups 3-9: Animals, Food, Travel, Activities, Objects, Symbols, Flags

Categories now correctly filter the right emojis.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 18:10:46 -05:00
Thomas Hallock
616a50e234 feat: add category browsing and scrolling to emoji picker
Enhance emoji picker with better navigation and usability:
- Add 8 emoji category tabs with icons (Smileys, Animals, Food, etc.)
- Enable vertical scrolling in emoji grid with custom scrollbar styling
- Category pills with blue highlight for selected category
- Dynamic header showing category name and emoji count
- Horizontal scrolling for category tabs on smaller screens
- Fix overflow clipping issue by adding scrollable container

Users can now easily browse thousands of emojis by category.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 18:07:57 -05:00
Thomas Hallock
72f8dee183 feat: extend player customization to support all 4 players
Extend profile system and configuration UI to support all players:
- Add player3Emoji, player4Emoji, player3Name, player4Name to UserProfile
- Update PlayerConfigDialog to support players 1-4 with unique gradients
  - Player 1: Blue, Player 2: Pink, Player 3: Purple, Player 4: Yellow
- Update EmojiPicker color schemes for all 4 players
- Revert gear icon restrictions - show for all players
- Update PageWithNav to use profile data for all players

All players now fully customizable with persistent storage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 18:04:09 -05:00
Thomas Hallock
d0a3bc7dc1 fix: only show configuration gear icon for players 1 and 2
Hide gear icons for players 3+ since only players 1 & 2 have customizable profiles in UserProfileContext:
- ActivePlayersList: conditional render gear button, update cursor style
- FullscreenPlayerSelection: conditional render gear button
- Prevents confusing UI where clicking gear had no effect

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:58:45 -05:00
Thomas Hallock
9a3fa93e53 feat: improve game availability logic and messaging
Update game card availability checks and empty state handling:
- Show games as faded when 0 players selected instead of hiding
- Display "⚠️ Select X players" when no players active
- Fix isGameAvailable to require activePlayerCount > 0
- Remove empty state check that hid games with no players
- Always show available games for better discoverability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:41:22 -05:00
Thomas Hallock
982fa45c08 refactor: remove drag-and-drop UI from EnhancedChampionArena
Simplify component by removing all drag-and-drop functionality:
- Remove @dnd-kit and @react-spring dependencies usage
- Remove ChampionCard, DroppableZone, and drag handlers
- Reduce from ~690 lines to ~36 lines
- Now only renders GameSelector component
- Prominent mini nav handles player management instead

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:41:13 -05:00
Thomas Hallock
b58bcd92ee fix: remove double PageWithNav wrapper on matching page
Remove redundant PageWithNav wrapper since MemoryPairsGame component already includes it internally with game phase-aware navigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:41:06 -05:00
Thomas Hallock
5c8c18cbb8 feat: enable prominent nav and fix layout on arcade page
Add emphasizeGameContext to enable player selection UI and adjust layout:
- Set emphasizeGameContext={true} to show fullscreen player selection
- Change height from 100vh to calc(100vh - 80px) for nav clearance
- Add vertical padding for better spacing
- Remove redundant bottom padding from inner container

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:41:00 -05:00
Thomas Hallock
edfdd81227 feat: wire player configuration through nav component hierarchy
Connect configuration dialog to all nav components:
- Add onConfigurePlayer callback prop through GameContextNav
- Manage dialog state in PageWithNav component
- Pass configuration handler to all child nav components
- Support only Players 1 & 2 (customizable players)
- Dialog renders above all content with proper z-index

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:40:45 -05:00
Thomas Hallock
6049a7f6b7 feat: add configuration access to active player emojis in prominent nav
Make player emojis clickable and add hover controls:
- Click emoji to open configuration dialog
- Hover reveals two buttons:
  - Small gear icon (bottom-left) for configuration
  - Red × (top-right) to remove player
- Only active when emphasizeGameContext is true
- Clean during gameplay with no distracting controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:40:38 -05:00
Thomas Hallock
b85968bcb6 feat: add configuration access to fullscreen player selection
Add subtle gear icon to each player card in fullscreen selection view:
- 32px circular button in top-right corner
- Gray by default, blue on hover with glow effect
- Only visible when choosing initial players
- Non-intrusive design that doesn't distract from selection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:40:32 -05:00
Thomas Hallock
4f2a661494 feat: create PlayerConfigDialog component for player customization
Add modal dialog for configuring player name and emoji. Features:
- Beautiful gradient-styled modal (blue for P1, pink for P2)
- Integrates with EmojiPicker component
- Character name input with 20-char limit
- Smooth transitions and hover effects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:40:26 -05:00
Thomas Hallock
31898328a3 feat: use CSS transitions for smooth fullscreen player selection collapse
Replace conditional rendering with always-mounted component that uses CSS transitions for height, width, and opacity. Prevents height from snapping when transitioning between fullscreen and prominent nav modes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:57:36 -05:00
Thomas Hallock
98cfa5645b refactor: simplify PageWithNav by extracting nav components
Reduce PageWithNav from 471 lines to 81 lines by extracting 5 nav components into dedicated files. Fixes Next.js parser errors while maintaining identical functionality.

Total: 608 lines distributed across 6 manageable files, all under parser limits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:35 -05:00
Thomas Hallock
e3f552d8f5 refactor: extract GameContextNav orchestration component
Create orchestration layer that composes GameModeIndicator, ActivePlayersList, AddPlayerButton, and FullscreenPlayerSelection. Handles layout switching between row/column and smooth CSS transitions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:34 -05:00
Thomas Hallock
66f52234e1 refactor: extract FullscreenPlayerSelection component from PageWithNav
Extract fullscreen player grid shown when no players selected. Features responsive grid layout, large emoji cards with hover animations, and multiplayer guidance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:34 -05:00
Thomas Hallock
57a72e34a5 refactor: extract AddPlayerButton component from PageWithNav
Extract green + button with popover dropdown into dedicated component. Includes click-outside detection and scales with emphasis mode (24px→48px).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:33 -05:00
Thomas Hallock
28495767a9 refactor: extract ActivePlayersList component from PageWithNav
Extract active player display with hover-triggered remove buttons into dedicated component. Handles emoji scaling and red × button on hover in emphasized mode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:33 -05:00
Thomas Hallock
d67315f771 refactor: extract GameModeIndicator component from PageWithNav
Extract mode badge display logic into dedicated component to reduce PageWithNav complexity. Includes config for none/single/battle/tournament modes with proper colors and emojis.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:33 -05:00
Thomas Hallock
fa1cf96789 feat: add interactive remove buttons for players in mini nav
- Hover over player emoji to reveal red × button
- Click × to remove player from active game
- Smooth animations on hover (scale 1.1, darker red)
- Button positioned absolutely in top-right corner
- Works on all pages with game context (arcade, matching setup, etc)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
Thomas Hallock
8792393956 feat: add prominent game context display to mini nav with smooth transitions
- Display active player emojis and game mode badge in PageWithNav
- Add emphasizeGameContext prop for conditional prominence
- Player emojis: 20px normal, 48px emphasized with drop-shadow
- Mode badge: gradient backgrounds, 3px borders, scale transforms
- Smooth 0.4s cubic-bezier transitions with mount animation
- Memory Pairs setup phase shows emphasized context, gameplay shows normal
- Use profile emojis for players 1 & 2 for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
Thomas Hallock
386c88a3c0 refactor: make game mode a computed property from active player count
- Remove setGameMode and setGameModeWithPlayers methods from GameModeContext
- Compute game mode: 1 player = single, 2 = battle, 3+ = tournament
- Update localStorage key to 'soroban-game-mode-players'
- Remove setGameMode calls from ChampionArena and EnhancedChampionArena
- Simplify state management by deriving mode instead of storing it

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
Thomas Hallock
a854fe3dc9 feat: add mini app nav to arcade page
- Wrap arcade content with PageWithNav component
- Add "Champion Arena" title with 🏟️ emoji
- Remove redundant embedded header
- Consistent navigation across all pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
Thomas Hallock
402724c80e refactor: remove redundant game titles from game screens
- Remove title/description from Memory Quiz page
- Remove "Game Setup" header from Memory Pairs SetupPhase
- Mini app nav now displays titles inline, making page headers redundant

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
Thomas Hallock
c77e880be3 refactor: extract guide components to fix syntax error in large file
- Create ReadingNumbersGuide.tsx (694 lines)
- Create ArithmeticOperationsGuide.tsx (476 lines)
- Simplify guide/page.tsx from 1290 to 113 lines
- Add tab navigation between guide sections
- Resolve Next.js parser error by breaking up massive file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
semantic-release-bot
6c0bf7b0f7 chore(abacus-react): release v1.5.1 [skip ci]
## [1.5.1](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.5.0...abacus-react-v1.5.1) (2025-09-29)

### Bug Fixes

* resolve JSX parsing error with emoji in guide page ([bf046c9](bf046c999b))
* resolve TypeScript errors in PlayerStatusBar component ([a935e5a](a935e5aed8))
* restore navigation to all pages using PageWithNav ([183706d](183706dade))
* update workflow to support Personal Access Token for GitHub Packages publishing auth ([ae4b71b](ae4b71b986))
2025-09-29 18:23:50 +00:00
Thomas Hallock
ae4b71b986 fix: update workflow to support Personal Access Token for GitHub Packages publishing auth 2025-09-29 13:23:18 -05:00
Thomas Hallock
bf046c999b fix: resolve JSX parsing error with emoji in guide page
Changed guide page emoji from 📚 to 📖 to resolve syntax error that was
preventing the dev server from compiling. The original emoji was causing
JSX parser issues with 'Unexpected token PageWithNav' error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 13:21:45 -05:00
Thomas Hallock
183706dade fix: restore navigation to all pages using PageWithNav
After removing @nav parallel routes, all pages were missing navigation.
Added PageWithNav wrapper to main application pages:

- Homepage: 🧮 Soroban Flashcards
- Games listing: 🕹️ Soroban Arcade
- Interactive guide: 📚 Interactive Guide
- Create flashcards:  Create Flashcards
- Game pages already had navigation from previous commit

Each page now has appropriate nav title and emoji displayed in the
mini navigation bar. All navigation is working correctly again.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 13:21:45 -05:00
Thomas Hallock
54ff20c755 refactor: completely remove @nav parallel routes and simplify navigation
- Remove entire src/app/@nav directory and all parallel route files
- Delete complex AppNav component that handled route-based nav detection
- Update layout.tsx to remove nav slot parameter entirely
- Create simple PageWithNav component that takes title/emoji as props
- Update matching and memory-quiz games to use PageWithNav directly
- Each page now controls its own navigation - dead simple and direct

This eliminates the over-engineered parallel routes approach in favor of
straightforward React prop passing. Much easier to understand and maintain.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 13:21:45 -05:00
Thomas Hallock
7a3e34b4fa refactor: streamline UI and remove duplicate information displays
- Remove redundant "matched pairs" progress from MemoryGrid since it's shown in PlayerStatusBar
- Drastically reduce oversized padding and styling in PlayerStatusBar components
- Simplify single player mode to clean, compact layout without excessive animations
- Remove duplicate game progress info from bottom of multiplayer view
- Scale down emoji sizes and reduce dramatic visual effects
- Keep only essential information in each component to recover screen real estate

The UI now shows game progress information in one place only and has much
more reasonable sizing throughout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 13:21:45 -05:00
Thomas Hallock
a935e5aed8 fix: resolve TypeScript errors in PlayerStatusBar component
- Fix duplicate color properties in single player mode styling
- Fix className prop passing to css() function in both single and multiplayer modes
- Replace undefined 'super-bounce' animation with 'gentle-bounce'

The make-plural pluralization integration is working correctly with proper
display of "1 pair" vs "2 pairs", "1 move" vs "3 moves", etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 13:21:45 -05:00
semantic-release-bot
dd9f688a32 chore(abacus-react): release v1.5.0 [skip ci]
# [1.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.4.0...abacus-react-v1.5.0) (2025-09-29)

### Bug Fixes

* remove frozen lockfile flag from publishing workflow to resolve dependency installation issues ([18af973](18af9730ff))
* resolve mini navigation game name persistence across all routes ([3fa314a](3fa314aaa5))
* update pnpm version to 8.15.6 to resolve ERR_INVALID_THIS error in workflow ([0b9bfed](0b9bfed12d))
* update tutorial tests to use consolidated AbacusDisplayProvider ([899fc69](899fc6975f))

### Features

* **abacus-react:** update description to mention GitHub Packages support ([af77256](af7725622e))
* add comprehensive E2E testing with Playwright ([d58053f](d58053fad3))
* add comprehensive Storybook stories for PlayerStatusBar ([8973241](8973241297))
* add consecutive match tracking system for escalating celebrations ([111c0ce](111c0ced71))
* add PlayerStatusBar with escalating celebration animations ([7f8c90a](7f8c90acea))
* add sound settings support to AbacusReact component ([90b9ffa](90b9ffa0d8))
* implement cozy sound effects for abacus with variable intensity ([c95be1d](c95be1df6d))
* integrate user profiles with PlayerStatusBar and game results ([beff646](beff64652c))
2025-09-29 15:58:53 +00:00
Thomas Hallock
0b9bfed12d fix: update pnpm version to 8.15.6 to resolve ERR_INVALID_THIS error in workflow 2025-09-29 10:58:18 -05:00
Thomas Hallock
af7725622e feat(abacus-react): update description to mention GitHub Packages support 2025-09-29 10:55:49 -05:00
Thomas Hallock
18af9730ff fix: remove frozen lockfile flag from publishing workflow to resolve dependency installation issues 2025-09-29 10:55:34 -05:00
Thomas Hallock
beff64652c feat: integrate user profiles with PlayerStatusBar and game results
Complete PlayerStatusBar integration across the matching game:

- Update ResultsPhase to use proper display names and emojis from profiles
- Add missing UserProfile integration for multiplayer results display
- Ensure consistent player presentation throughout game lifecycle
- Remove old debugging logs from page component

Players now see their custom names and emojis consistently from
arcade selection through game completion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:50:28 -05:00
Thomas Hallock
c4d6691715 refactor: replace bulky MemoryGrid stats with compact progress display
Replace large stats panel above card grid with streamlined inline display:

- Convert from 3-column stats grid to single-line format
- Use color-coded inline text: "3 matched • 12 moves • 75% complete"
- Reduce visual weight and padding significantly
- Remove redundant "Total Pairs" information (static, not dynamic)
- Keep essential dynamic information: matched pairs, moves, completion %
- Maintain responsive behavior for mobile/desktop

Recovers ~80px of vertical space while preserving all essential
game progress information in a more elegant format.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:50:16 -05:00
Thomas Hallock
dcefa74902 refactor: streamline GamePhase header and integrate PlayerStatusBar
Clean up duplicate information and improve screen real estate usage:

- Replace verbose header with minimal game mode indicator
- Remove redundant static information (difficulty, player count details)
- Integrate new PlayerStatusBar for comprehensive player state display
- Reduce header padding and visual weight
- Show helpful tips only at game start and hide after first move
- Maintain essential controls (New Game button) in cleaner layout

Recovers significant screen space while maintaining all essential
functionality and improving visual hierarchy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:50:01 -05:00
Thomas Hallock
8973241297 feat: add comprehensive Storybook stories for PlayerStatusBar
Create detailed Storybook documentation showcasing the celebration
animation system with:

- Individual stories for each celebration level (Normal, Great, Epic, Legendary)
- Complete animation showcase with side-by-side comparison
- Proper CSS animation injection for Storybook environment
- Mock player card components that demonstrate all states
- Comprehensive documentation explaining the celebration system
- Visual examples of consecutive match progression

Stories allow designers and developers to see all animation states
and understand the escalating celebration system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:49:47 -05:00
Thomas Hallock
7f8c90acea feat: add PlayerStatusBar with escalating celebration animations
Create comprehensive PlayerStatusBar component that displays game state
and provides escalating visual celebrations based on consecutive matches:

- Single player mode with epic styling and progress indicators
- Multiplayer mode with competitive grid layout for 1-4 players
- Escalating celebration levels:
  - Great (2+ matches): Green celebration with gentle scaling
  - Epic (3+ matches): Orange celebration with rotation effects
  - Legendary (5+ matches): Purple/gold with dramatic scaling
- Real-time turn indicators with subtle life-like animations
- Streak counters with pulsing effects for active players
- Responsive design with proper mobile/desktop layouts
- Remove overflow clipping to show full glow effects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:49:31 -05:00
Thomas Hallock
111c0ced71 feat: add consecutive match tracking system for escalating celebrations
Add consecutive match tracking to memory pairs game context to enable
escalating celebration effects based on player streaks:

- Add consecutiveMatches state field to track streaks per player
- Increment consecutive matches on successful pairs
- Reset consecutive matches when player turn switches (failed match)
- Initialize consecutive matches for all players on game start

This provides the foundation for visual celebration escalation based
on player performance streaks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:49:15 -05:00
Thomas Hallock
876ace50ec chore: update Claude Code permissions for development workflow
- Add new localhost ports for testing and development
- Include additional bash commands for E2E testing and debugging
- Enable Playwright testing and browser automation permissions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:44:55 -05:00
Thomas Hallock
d58053fad3 feat: add comprehensive E2E testing with Playwright
- Add Playwright configuration for cross-browser testing
- Implement navigation slot persistence tests
- Add sound settings persistence E2E tests
- Ensure robust testing of navigation state management
- Cover edge cases for mini-nav behavior across routes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:44:36 -05:00
Thomas Hallock
899fc6975f fix: update tutorial tests to use consolidated AbacusDisplayProvider
- Update test imports to use centralized abacus-react provider
- Ensure test compatibility with consolidated context management
- Maintain test coverage for tutorial celebration functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:44:17 -05:00
Thomas Hallock
c95be1df6d feat: implement cozy sound effects for abacus with variable intensity
- Add sound intensity based on bead movement distance in games
- Implement gentle sound feedback for abacus interactions
- Update game components to use centralized AbacusDisplayProvider
- Enhance user experience with audio feedback during gameplay

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:43:50 -05:00
Thomas Hallock
a387b030fa refactor: consolidate abacus display context management
- Remove duplicate AbacusDisplayContext in favor of centralized abacus-react provider
- Update all components to use useAbacusDisplay and useAbacusConfig hooks from @soroban/abacus-react
- Create ClientProviders component to centralize provider setup
- Simplify context management across the application

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:43:35 -05:00
Thomas Hallock
90b9ffa0d8 feat: add sound settings support to AbacusReact component
- Add soundEnabled and soundVolume props to AbacusConfig interface
- Allow direct prop overrides for sound settings in component
- Maintain backward compatibility with context-based configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:42:57 -05:00
Thomas Hallock
3fa314aaa5 fix: resolve mini navigation game name persistence across all routes
Add @nav parallel route slots for all major routes to prevent game names
from persisting when navigating between different sections of the app.

- Add @nav/page.tsx (home)
- Add @nav/create/page.tsx (create page)
- Add @nav/guide/page.tsx (guide page)
- Add @nav/games/page.tsx (games listing)
- Keep @nav/games/matching/page.tsx (Memory Pairs game)
- Keep @nav/games/memory-quiz/page.tsx (Memory Lightning game)
- Add comprehensive e2e test covering navigation persistence scenarios

All non-game routes return null (no game name in mini nav).
Only game routes show their specific game names.

Fixes issue where navigating Game → Guide → Games would show stale game name.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:22:33 -05:00
semantic-release-bot
0c46f3a7ba chore(abacus-react): release v1.4.0 [skip ci]
# [1.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.3.0...abacus-react-v1.4.0) (2025-09-29)

### Bug Fixes

* export missing hooks and types from @soroban/abacus-react package ([423ba55](423ba55350))
* migrate viewport from metadata to separate viewport export ([1fe12c4](1fe12c4837))

### Features

* add middleware for pathname header support in [@nav](https://github.com/nav) fallback ([b7e7c4b](b7e7c4beff))
* implement [@nav](https://github.com/nav) parallel routes for game name display in mini navigation ([885fc72](885fc725dc))
2025-09-29 13:54:44 +00:00
Thomas Hallock
1fe12c4837 fix: migrate viewport from metadata to separate viewport export
Move viewport configuration from metadata export to dedicated viewport
export in root layout, following Next.js App Router best practices.

Resolves deprecation warning:
"Unsupported metadata viewport is configured in metadata export"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 08:54:01 -05:00
Thomas Hallock
b7e7c4beff feat: add middleware for pathname header support in @nav fallback
Add Next.js middleware to set x-pathname header on all requests,
enabling Server Components to access pathname for route-based
navigation fallback when @nav slots are not available.

This supports the AppNav component's fallback mechanism for
routes that don't have specific @nav parallel route definitions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 08:54:01 -05:00
Thomas Hallock
423ba55350 fix: export missing hooks and types from @soroban/abacus-react package
- Export useAbacusConfig and useAbacusDisplay hooks from AbacusContext
- Export getDefaultAbacusConfig function and AbacusDisplayProvider component
- Export ColorScheme, BeadShape, ColorPalette, AbacusDisplayConfig, and AbacusDisplayContextType types

Resolves import errors in web components that were trying to import these
hooks but they weren't being exported from the package's main index file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 08:54:01 -05:00
Thomas Hallock
885fc725dc feat: implement @nav parallel routes for game name display in mini navigation
- Add @nav/default.tsx for fallback nav content
- Add @nav/games/matching/page.tsx for Memory Pairs game nav
- Add @nav/games/memory-quiz/page.tsx for Memory Lightning game nav
- Update AppNav to use @nav slot content with header-based fallback
- Remove debug logging from navigation components

The @nav parallel routes pattern allows each game route to declare its own
navigation content server-side, keeping nav content colocated with routes
while avoiding client-side state management or lazy loading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 08:54:01 -05:00
semantic-release-bot
0311b0fe03 chore(abacus-react): release v1.3.0 [skip ci]
# [1.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.2.0...abacus-react-v1.3.0) (2025-09-29)

### Bug Fixes

* ensure game names persist in navigation on page reload ([9191b12](9191b12493))
* implement route-based theme detection for page reload persistence ([3dcff2f](3dcff2ff88))
* improve navigation chrome background color extraction from gradients ([00bfcbc](00bfcbcdee))
* resolve SSR/client hydration mismatch for themed navigation ([301e65d](301e65dfa6))

### Features

* complete themed navigation system with game-specific chrome ([0a4bf17](0a4bf1765c))
* implement cozy sound effects for abacus with variable intensity ([cea5fad](cea5fadbe4))
2025-09-29 11:31:33 +00:00
Thomas Hallock
cea5fadbe4 feat: implement cozy sound effects for abacus with variable intensity
- Add realistic bead click sounds using Web Audio API synthesis
- Support variable intensity based on number of beads moved (1-5)
- Include app-wide sound controls in Style dropdown (enable/disable + volume)
- Settings persist in localStorage with existing style preferences
- SSR-safe implementation with graceful fallback
- Performance optimized with proper audio node cleanup

Sound characteristics:
- Dual-oscillator design (warm thock + sharp click)
- Sub-harmonic richness for multi-bead movements
- Exponential decay envelope for natural sound
- Lower frequencies and longer duration for heavier movements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 06:30:49 -05:00
Thomas Hallock
9191b12493 fix: ensure game names persist in navigation on page reload
Remove hydration dependency for route-based theme detection:
- Game names now display immediately on page load/reload
- Route-based theme backgrounds apply without waiting for hydration
- Maintains SSR compatibility while fixing reload persistence

Game names and themed navigation now work consistently across all scenarios.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:23:37 -05:00
Thomas Hallock
3dcff2ff88 fix: implement route-based theme detection for page reload persistence
Add route-based theme detection as fallback to ensure themed navigation works on direct page loads and reloads:

- Add getRouteBasedTheme() function that detects game themes by pathname
- Use currentTheme that combines context theme with route-based fallback
- Convert navigation chrome to inline styles to bypass Panda CSS caching issues
- Game names and themed backgrounds now persist through page reloads
- Clean up debugging console logs

Navigation theming now works reliably for both navigation events and direct page loads.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:20:57 -05:00
Thomas Hallock
301e65dfa6 fix: resolve SSR/client hydration mismatch for themed navigation
Add hydration state tracking to GameThemeContext to prevent flash of unstyled content:
- Track isHydrated state in GameThemeContext
- Only apply themed backgrounds and game names after client hydration
- Prevents Next.js hydration mismatch where server renders default styles but client overwrites with themed styles
- Eliminates the brief flash where themed navigation appears then reverts to default

Navigation theming now applies consistently without visual flashing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:15:49 -05:00
Thomas Hallock
00bfcbcdee fix: improve navigation chrome background color extraction from gradients
Enhanced getThemedBackground function to properly extract colors from linear gradients:
- Extract hex colors from gradient definitions
- Fall back to RGB value extraction for complex gradients
- Ensure navigation chrome has distinct themed backgrounds instead of inheriting page background
- Maintain proper opacity levels for fullscreen and windowed modes

Now navigation elements display proper themed backgrounds derived from game gradient colors.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:13:34 -05:00
Thomas Hallock
0a4bf1765c feat: complete themed navigation system with game-specific chrome
Implement comprehensive game theming system where games declare their visual identity (name + background) that flows through to navigation chrome:

- Update AppNavBar with GameThemeContext integration and dynamic color calculation
- Enhance StandardGameLayout to accept and apply theme props
- Configure Memory Lightning with green-blue gradient theme
- Configure Memory Pairs with purple gradient theme
- Enable themed navigation backgrounds in fullscreen and non-fullscreen modes
- Display game names in mini navigation instead of generic labels

Games now have cohesive visual branding that extends from background through navigation chrome.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:08:03 -05:00
115 changed files with 16234 additions and 4006 deletions

View File

@@ -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": []

View File

@@ -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

View File

@@ -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.

File diff suppressed because it is too large Load Diff

View 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()
})
})

View 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)
})
})

View 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)
})
})

View File

@@ -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",

View File

@@ -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)' }
}
}
}

View 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,
},
})

View 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()
})
})

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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)]
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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'
}

View File

@@ -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>
)
})

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -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)
})
})

View File

@@ -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))
}
})
})

View File

@@ -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)
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View 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
}
}

View File

@@ -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
}
}

View 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
}
}

View File

@@ -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
}
}

View 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
}
}

View 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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View 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' }

View 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
}

View 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
}

View 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]
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'
}
}

View 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)
}

View File

@@ -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}

View File

@@ -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>
)

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View 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>
)
}

View 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

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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'}

View File

@@ -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)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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})`
}

View File

@@ -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>
)
}

View 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)}
/>
)}
</>
)
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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

View 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()
})
})

View File

@@ -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()
})
})

View 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()
})
})

View 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>
))}
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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}

View File

@@ -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 = {

View File

@@ -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