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>
This commit is contained in:
Thomas Hallock
2025-09-30 10:49:22 -05:00
parent d8b4e425bf
commit 582bce411f
28 changed files with 5936 additions and 15 deletions

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,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,281 @@
'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 style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
padding: '20px 8px',
minHeight: '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,272 @@
'use client'
import { useComplementRace } from '../context/ComplementRaceContext'
import type { GameMode, GameStyle, TimeoutSetting } from '../lib/gameTypes'
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 })
}
const handleTimeoutSelect = (timeout: TimeoutSetting) => {
dispatch({ type: 'SET_TIMEOUT', timeout })
}
const handleStartRace = () => {
dispatch({ type: 'START_COUNTDOWN' })
}
return (
<div style={{
textAlign: 'center',
padding: '40px 20px',
maxWidth: '800px',
margin: '20px auto 0'
}}>
<h2 style={{
fontSize: '36px',
fontWeight: 'bold',
marginBottom: '32px',
color: '#1f2937'
}}>
Race Configuration
</h2>
{/* Number Mode Selection */}
<div style={{
background: 'white',
borderRadius: '16px',
padding: '24px',
marginBottom: '24px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
textAlign: 'left'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#1f2937'
}}>
Number Mode
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px'
}}>
<button
onClick={() => handleModeSelect('friends5')}
style={{
padding: '16px',
borderRadius: '8px',
border: '2px solid',
borderColor: state.mode === 'friends5' ? '#3b82f6' : '#e5e7eb',
background: state.mode === 'friends5' ? '#eff6ff' : 'white',
color: state.mode === 'friends5' ? '#1e40af' : '#6b7280',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
>
<div style={{ fontSize: '24px', marginBottom: '4px' }}>5</div>
<div>Friends of 5</div>
</button>
<button
onClick={() => handleModeSelect('friends10')}
style={{
padding: '16px',
borderRadius: '8px',
border: '2px solid',
borderColor: state.mode === 'friends10' ? '#3b82f6' : '#e5e7eb',
background: state.mode === 'friends10' ? '#eff6ff' : 'white',
color: state.mode === 'friends10' ? '#1e40af' : '#6b7280',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
>
<div style={{ fontSize: '24px', marginBottom: '4px' }}>🔟</div>
<div>Friends of 10</div>
</button>
<button
onClick={() => handleModeSelect('mixed')}
style={{
padding: '16px',
borderRadius: '8px',
border: '2px solid',
borderColor: state.mode === 'mixed' ? '#3b82f6' : '#e5e7eb',
background: state.mode === 'mixed' ? '#eff6ff' : 'white',
color: state.mode === 'mixed' ? '#1e40af' : '#6b7280',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
>
<div style={{ fontSize: '24px', marginBottom: '4px' }}>🎲</div>
<div>Mixed</div>
</button>
</div>
</div>
{/* Game Style Selection */}
<div style={{
background: 'white',
borderRadius: '16px',
padding: '24px',
marginBottom: '24px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
textAlign: 'left'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#1f2937'
}}>
Race Type
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px'
}}>
<button
onClick={() => handleStyleSelect('practice')}
style={{
padding: '16px',
borderRadius: '8px',
border: '2px solid',
borderColor: state.style === 'practice' ? '#10b981' : '#e5e7eb',
background: state.style === 'practice' ? '#d1fae5' : 'white',
color: state.style === 'practice' ? '#047857' : '#6b7280',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
>
<div style={{ fontSize: '24px', marginBottom: '4px' }}>🤖</div>
<div>Robot Showdown</div>
<div style={{ fontSize: '12px', opacity: 0.8 }}>Race AI on track</div>
</button>
<button
onClick={() => handleStyleSelect('sprint')}
style={{
padding: '16px',
borderRadius: '8px',
border: '2px solid',
borderColor: state.style === 'sprint' ? '#f59e0b' : '#e5e7eb',
background: state.style === 'sprint' ? '#fef3c7' : 'white',
color: state.style === 'sprint' ? '#d97706' : '#6b7280',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
>
<div style={{ fontSize: '24px', marginBottom: '4px' }}>🚂</div>
<div>Steam Sprint</div>
<div style={{ fontSize: '12px', opacity: 0.8 }}>60-second journey</div>
</button>
<button
onClick={() => handleStyleSelect('survival')}
style={{
padding: '16px',
borderRadius: '8px',
border: '2px solid',
borderColor: state.style === 'survival' ? '#ef4444' : '#e5e7eb',
background: state.style === 'survival' ? '#fee2e2' : 'white',
color: state.style === 'survival' ? '#dc2626' : '#6b7280',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
>
<div style={{ fontSize: '24px', marginBottom: '4px' }}>🔄</div>
<div>Endless Circuit</div>
<div style={{ fontSize: '12px', opacity: 0.8 }}>Infinite laps</div>
</button>
</div>
</div>
{/* Timeout Setting Selection */}
<div style={{
background: 'white',
borderRadius: '16px',
padding: '24px',
marginBottom: '32px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
textAlign: 'left'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#1f2937'
}}>
Difficulty Level
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
gap: '8px'
}}>
{(['preschool', 'kindergarten', 'relaxed', 'slow', 'normal', 'fast', 'expert'] as TimeoutSetting[]).map((timeout) => (
<button
key={timeout}
onClick={() => handleTimeoutSelect(timeout)}
style={{
padding: '12px 8px',
borderRadius: '8px',
border: '2px solid',
borderColor: state.timeoutSetting === timeout ? '#8b5cf6' : '#e5e7eb',
background: state.timeoutSetting === timeout ? '#f3e8ff' : 'white',
color: state.timeoutSetting === timeout ? '#6b21a8' : '#6b7280',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '14px'
}}
>
{timeout.charAt(0).toUpperCase() + timeout.slice(1)}
</button>
))}
</div>
</div>
<button
onClick={handleStartRace}
style={{
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
border: 'none',
borderRadius: '12px',
padding: '16px 48px',
fontSize: '20px',
fontWeight: 'bold',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 6px 16px rgba(59, 130, 246, 0.4)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
}}
>
Begin Race!
</button>
</div>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import { useEffect, useState } from 'react'
import { useComplementRace } from '../context/ComplementRaceContext'
import { useGameLoop } from '../hooks/useGameLoop'
export function GameCountdown() {
const { dispatch } = useComplementRace()
const [count, setCount] = useState(3)
const [showGo, setShowGo] = useState(false)
useEffect(() => {
const countdownInterval = setInterval(() => {
setCount(prevCount => {
if (prevCount > 1) {
// TODO: Play countdown sound
return prevCount - 1
} else if (prevCount === 1) {
// Show GO!
setShowGo(true)
// TODO: Play start sound
return 0
}
return prevCount
})
}, 1000)
return () => clearInterval(countdownInterval)
}, [])
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,315 @@
'use client'
import { useEffect } from 'react'
import { useComplementRace } from '../context/ComplementRaceContext'
import { useAIRacers } from '../hooks/useAIRacers'
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
import { useSteamJourney } from '../hooks/useSteamJourney'
import { LinearTrack } from './RaceTrack/LinearTrack'
import { CircularTrack } from './RaceTrack/CircularTrack'
import { SteamTrainJourney } from './RaceTrack/SteamTrainJourney'
import { RouteCelebration } from './RouteCelebration'
import { generatePassengers } from '../lib/passengerGenerator'
export function GameDisplay() {
const { state, dispatch } = useComplementRace()
useAIRacers() // Activate AI racer updates (not used in sprint mode)
const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty()
const { boostMomentum } = useSteamJourney()
// 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') {
// End the game
dispatch({ type: 'END_RACE' })
// Show results after a short delay
setTimeout(() => {
dispatch({ type: 'SHOW_RESULTS' })
}, 1000)
}
}, [state.correctAnswers, state.raceGoal, state.isGameActive, state.style, dispatch])
// 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)
// Boost momentum for sprint mode
if (state.style === 'sprint') {
boostMomentum()
}
// 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)
// 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, dispatch, trackPerformance, getAdaptiveFeedbackMessage, boostMomentum])
// Handle route celebration continue
const handleContinueToNextRoute = () => {
const nextRoute = state.currentRoute + 1
// Hide celebration
dispatch({ type: 'HIDE_ROUTE_CELEBRATION' })
// Generate new track and passengers for next route
setTimeout(() => {
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 })
}, 100)
}
if (!state.currentQuestion) return null
return (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
width: '100%'
}}>
{/* Adaptive Feedback */}
{state.adaptiveFeedback && (
<div 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 */}
<div style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
padding: '0 20px',
marginTop: '10px'
}}>
<div 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 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 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 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 style={{
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
marginLeft: '-50vw',
marginRight: '-50vw',
padding: '0 20px',
display: 'flex',
justifyContent: 'center'
}}>
{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 style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
padding: '0 20px'
}}>
<div style={{
display: 'flex',
gap: '20px',
alignItems: 'center',
justifyContent: 'center',
marginTop: '5px'
}}>
{/* Question */}
<div style={{
background: 'white',
borderRadius: '12px',
padding: '16px 24px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
textAlign: 'center'
}}>
<div style={{
fontSize: '14px',
color: '#6b7280',
marginBottom: '4px'
}}>
? + {state.currentQuestion.number} = {state.currentQuestion.targetSum}
</div>
<div style={{
fontSize: '60px',
fontWeight: 'bold',
color: '#1f2937'
}}>
{state.currentQuestion.number}
</div>
</div>
{/* Input */}
<div style={{
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
borderRadius: '12px',
padding: '16px 36px',
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.3)',
textAlign: 'center',
minWidth: '160px'
}}>
<div style={{
fontSize: '60px',
fontWeight: 'bold',
color: 'white',
minHeight: '70px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textShadow: '0 2px 10px rgba(0, 0, 0, 0.2)'
}}>
{state.currentInput || '_'}
</div>
<div style={{
fontSize: '12px',
color: 'rgba(255, 255, 255, 0.9)',
marginTop: '4px'
}}>
Type your answer
</div>
</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,110 @@
'use client'
import type { Passenger, Station } from '../lib/gameTypes'
interface PassengerCardProps {
passenger: Passenger
destinationStation: Station | undefined
}
export function PassengerCard({ passenger, destinationStation }: PassengerCardProps) {
if (!destinationStation) return null
return (
<div
style={{
background: passenger.isDelivered
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: passenger.isUrgent
? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
color: 'white',
padding: '12px 16px',
borderRadius: '12px',
boxShadow: passenger.isUrgent && !passenger.isDelivered
? '0 0 20px rgba(245, 158, 11, 0.6)'
: '0 2px 8px rgba(0, 0, 0, 0.2)',
minWidth: '180px',
position: 'relative',
opacity: passenger.isDelivered ? 0.6 : 1,
animation: passenger.isUrgent && !passenger.isDelivered ? 'urgentPulse 1.5s ease-in-out infinite' : 'none',
transition: 'all 0.3s ease'
}}
>
{/* Passenger icon and name */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px'
}}>
<div style={{ fontSize: '24px' }}>
{passenger.isDelivered ? '✅' : '👤'}
</div>
<div style={{
fontWeight: 'bold',
fontSize: '16px'
}}>
{passenger.name}
</div>
{passenger.isUrgent && !passenger.isDelivered && (
<div style={{
fontSize: '16px',
animation: 'urgentBlink 0.8s ease-in-out infinite'
}}>
</div>
)}
</div>
{/* Destination */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '14px',
opacity: 0.95
}}>
<span></span>
<span>{destinationStation.icon}</span>
<span style={{ fontWeight: '600' }}>{destinationStation.name}</span>
</div>
{/* Points indicator */}
{!passenger.isDelivered && (
<div style={{
position: 'absolute',
top: '8px',
right: '8px',
background: 'rgba(255, 255, 255, 0.3)',
borderRadius: '8px',
padding: '2px 8px',
fontSize: '12px',
fontWeight: 'bold'
}}>
{passenger.isUrgent ? '+20' : '+10'}
</div>
)}
<style>{`
@keyframes urgentPulse {
0%, 100% {
box-shadow: 0 0 20px rgba(245, 158, 11, 0.6);
}
50% {
box-shadow: 0 0 30px rgba(245, 158, 11, 0.9);
}
}
@keyframes urgentBlink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,137 @@
'use client'
interface PressureGaugeProps {
pressure: number // 0-150 PSI
}
export function PressureGauge({ pressure }: PressureGaugeProps) {
// Calculate needle angle (-90deg at 0 PSI to +90deg at 150 PSI)
const maxPressure = 150
const angle = ((pressure / maxPressure) * 180) - 90
// Get pressure color
const getPressureColor = (): string => {
if (pressure < 50) return '#ef4444' // Red (low)
if (pressure < 100) return '#f59e0b' // Orange (medium)
return '#10b981' // Green (high)
}
const color = getPressureColor()
return (
<div style={{
position: 'relative',
background: 'rgba(255, 255, 255, 0.95)',
padding: '16px',
borderRadius: '12px',
minWidth: '160px',
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="0 0 200 120"
style={{
width: '100%',
height: 'auto',
marginBottom: '8px'
}}
>
{/* Background arc */}
<path
d="M 20 100 A 80 80 0 0 1 180 100"
fill="none"
stroke="#e5e7eb"
strokeWidth="12"
strokeLinecap="round"
/>
{/* Colored arc based on pressure */}
<path
d="M 20 100 A 80 80 0 0 1 180 100"
fill="none"
stroke={color}
strokeWidth="12"
strokeLinecap="round"
strokeDasharray={`${(pressure / maxPressure) * 251} 251`}
style={{
transition: 'stroke 0.3s ease-out, stroke-dasharray 0.2s ease-out',
filter: pressure > 50 ? `drop-shadow(0 0 6px ${color})` : 'none'
}}
/>
{/* Tick marks */}
{[0, 50, 100, 150].map((psi, index) => {
const tickAngle = ((psi / maxPressure) * 180) - 90
const tickRad = (tickAngle * Math.PI) / 180
const x1 = 100 + Math.cos(tickRad) * 70
const y1 = 100 + Math.sin(tickRad) * 70
const x2 = 100 + Math.cos(tickRad) * 80
const y2 = 100 + Math.sin(tickRad) * 80
return (
<g key={`tick-${index}`}>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="#6b7280"
strokeWidth="2"
strokeLinecap="round"
/>
<text
x={100 + Math.cos(tickRad) * 60}
y={100 + Math.sin(tickRad) * 60 + 4}
textAnchor="middle"
fontSize="10"
fill="#6b7280"
fontWeight="600"
>
{psi}
</text>
</g>
)
})}
{/* Center pivot */}
<circle cx="100" cy="100" r="4" fill="#1f2937" />
{/* Needle */}
<line
x1="100"
y1="100"
x2={100 + Math.cos((angle * Math.PI) / 180) * 70}
y2={100 + Math.sin((angle * Math.PI) / 180) * 70}
stroke={color}
strokeWidth="3"
strokeLinecap="round"
style={{
transition: 'all 0.2s ease-out',
filter: `drop-shadow(0 2px 3px ${color})`
}}
/>
</svg>
{/* Digital readout */}
<div style={{
textAlign: 'center',
fontSize: '20px',
fontWeight: 'bold',
color
}}>
{Math.round(pressure)} <span style={{ fontSize: '12px' }}>PSI</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,481 @@
'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'
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 [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' })
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 style={{
position: 'relative',
width: `${dimensions.width}px`,
height: `${dimensions.height}px`,
margin: '0 auto'
}}>
{/* SVG 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,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 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,539 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useSteamJourney } from '../../hooks/useSteamJourney'
import { useComplementRace } from '../../context/ComplementRaceContext'
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
import { PassengerCard } from '../PassengerCard'
import { getRouteTheme } from '../../lib/routeThemes'
import { generateLandmarks, type Landmark } from '../../lib/landmarks'
import { PressureGauge } from '../PressureGauge'
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 svgRef = useRef<SVGSVGElement>(null)
const pathRef = useRef<SVGPathElement>(null)
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
const [trackData, setTrackData] = useState<ReturnType<typeof trackGenerator.generateTrack> | null>(null)
const [tiesAndRails, setTiesAndRails] = useState<{
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
leftRailPoints: string[]
rightRailPoints: string[]
} | null>(null)
const [trainTransform, setTrainTransform] = useState({ x: 50, y: 300, rotation: 0 })
const [stationPositions, setStationPositions] = useState<Array<{ x: number; y: number }>>([])
const [landmarks, setLandmarks] = useState<Landmark[]>([])
const [landmarkPositions, setLandmarkPositions] = useState<Array<{ x: number; y: number }>>([])
// Generate landmarks when route changes
useEffect(() => {
const newLandmarks = generateLandmarks(state.currentRoute)
setLandmarks(newLandmarks)
}, [state.currentRoute])
// 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)
// Generate track on mount and when route changes
useEffect(() => {
const track = trackGenerator.generateTrack(state.currentRoute)
setTrackData(track)
}, [trackGenerator, state.currentRoute])
// Generate ties and rails when path is ready
useEffect(() => {
if (pathRef.current && trackData) {
const result = trackGenerator.generateTiesAndRails(pathRef.current)
setTiesAndRails(result)
}
}, [trackData, trackGenerator])
// Calculate station positions when path is ready
useEffect(() => {
if (pathRef.current) {
const positions = state.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, state.stations])
// 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])
// Update train position and rotation
useEffect(() => {
if (pathRef.current) {
const transform = trackGenerator.getTrainTransform(pathRef.current, trainPosition)
setTrainTransform(transform)
}
}, [trainPosition, trackGenerator])
if (!trackData) return null
return (
<div style={{
position: 'relative',
width: '100%',
minHeight: '250px',
background: `linear-gradient(to bottom, ${skyGradient.top}, ${skyGradient.bottom})`,
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
marginTop: '10px',
marginBottom: '10px',
transition: 'background 2s ease-in-out',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{/* Route and time of day indicator */}
<div 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 {state.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)'
}}>
{periodNames[period]}
</div>
</div>
{/* Time remaining */}
<div 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>
{/* Railroad track SVG */}
<svg
ref={svgRef}
viewBox="0 0 800 600"
style={{
width: '100%',
maxWidth: '1400px',
height: 'auto',
aspectRatio: '800 / 600'
}}
>
{/* Railroad ballast (gravel bed) */}
<path
d={trackData.ballastPath}
fill="none"
stroke="#8B7355"
strokeWidth="40"
strokeLinecap="round"
/>
{/* 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.leftRailPoints.length > 1 && (
<polyline
points={tiesAndRails.leftRailPoints.join(' ')}
fill="none"
stroke="#C0C0C0"
strokeWidth="5"
strokeLinecap="round"
/>
)}
{/* Right rail */}
{tiesAndRails && tiesAndRails.rightRailPoints.length > 1 && (
<polyline
points={tiesAndRails.rightRailPoints.join(' ')}
fill="none"
stroke="#C0C0C0"
strokeWidth="5"
strokeLinecap="round"
/>
)}
{/* Reference path (invisible, used for positioning) */}
<path
ref={pathRef}
d={trackData.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"
fontSize={(landmarks[index]?.size || 24) * 1.5}
style={{
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) => (
<g key={`station-${index}`}>
{/* Station platform */}
<circle
cx={pos.x}
cy={pos.y}
r="12"
fill="#8B4513"
stroke="#654321"
strokeWidth="3"
/>
{/* Station icon */}
<text
x={pos.x}
y={pos.y - 28}
textAnchor="middle"
fontSize="32"
style={{ pointerEvents: 'none' }}
>
{state.stations[index]?.icon}
</text>
{/* Station name */}
<text
x={pos.x}
y={pos.y + 40}
textAnchor="middle"
fontSize="14"
fontWeight="bold"
fill="#1f2937"
style={{ pointerEvents: 'none' }}
>
{state.stations[index]?.name}
</text>
</g>
))}
{/* Train group with flip and rotation */}
<g transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`}>
{/* Train locomotive */}
<text
x={0}
y={0}
textAnchor="middle"
style={{
fontSize: '100px',
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
pointerEvents: 'none'
}}
>
🚂
</text>
{/* Coal shoveler - always visible behind the train */}
<text
x={45}
y={0}
textAnchor="middle"
style={{
fontSize: '70px',
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
pointerEvents: 'none'
}}
>
👷
</text>
</g>
{/* Steam puffs - animated */}
{momentum > 10 && (
<>
{[0, 0.6, 1.2].map((delay, i) => (
<circle
key={`steam-${i}`}
cx={trainTransform.x}
cy={trainTransform.y - 20}
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={trainTransform.x - 25}
cy={trainTransform.y}
r="3"
fill="#2c2c2c"
style={{
animation: 'coalFallingSVG 1.2s ease-out infinite',
animationDelay: `${delay}s`,
pointerEvents: 'none'
}}
/>
))}
</>
)}
</svg>
{/* Pressure gauge */}
<div style={{
position: 'absolute',
bottom: '10px',
left: '10px',
zIndex: 10,
width: '120px'
}}>
<PressureGauge pressure={pressure} />
</div>
{/* Distance traveled */}
<div style={{
position: 'absolute',
bottom: '10px',
right: '10px',
background: 'rgba(255, 255, 255, 0.95)',
padding: '10px 14px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 'bold',
color: '#1f2937',
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
zIndex: 10
}}>
<div style={{ marginBottom: '4px' }}>
🚩 {Math.round(trainPosition)}%
</div>
<div style={{ fontSize: '11px', color: '#6b7280' }}>
Total: {state.cumulativeDistance + Math.round(trainPosition)}%
</div>
</div>
{/* Passenger cards */}
{state.passengers.length > 0 && (
<div style={{
position: 'absolute',
bottom: '130px',
right: '10px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
maxHeight: '300px',
overflowY: 'auto',
zIndex: 10
}}>
{state.passengers.map(passenger => (
<PassengerCard
key={passenger.id}
passenger={passenger}
destinationStation={state.stations.find(s => s.id === passenger.destinationStationId)}
/>
))}
</div>
)}
{/* Question Display - centered at bottom */}
{currentQuestion && (
<div style={{
position: 'absolute',
bottom: '10px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '20px',
alignItems: 'center',
zIndex: 10
}}>
{/* Question */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: '12px',
padding: '12px 20px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
textAlign: 'center',
backdropFilter: 'blur(4px)'
}}>
<div style={{
fontSize: '12px',
color: '#6b7280',
marginBottom: '2px'
}}>
? + {currentQuestion.number} = {currentQuestion.targetSum}
</div>
<div style={{
fontSize: '48px',
fontWeight: 'bold',
color: '#1f2937'
}}>
{currentQuestion.number}
</div>
</div>
{/* Input */}
<div style={{
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
borderRadius: '12px',
padding: '12px 28px',
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.4)',
textAlign: 'center',
minWidth: '120px'
}}>
<div style={{
fontSize: '48px',
fontWeight: 'bold',
color: 'white',
minHeight: '56px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textShadow: '0 2px 10px rgba(0, 0, 0, 0.2)'
}}>
{currentInput || '_'}
</div>
<div style={{
fontSize: '10px',
color: 'rgba(255, 255, 255, 0.9)',
marginTop: '2px'
}}>
Type answer
</div>
</div>
</div>
)}
{/* CSS animations */}
<style>{`
@keyframes steamPuffSVG {
0% {
opacity: 0.8;
transform: scale(0.5) translateY(0);
}
50% {
opacity: 0.4;
transform: scale(1.5) translateY(-30px);
}
100% {
opacity: 0;
transform: scale(2) translateY(-60px);
}
}
@keyframes coalFallingSVG {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
50% {
opacity: 0.7;
transform: translateY(15px) scale(0.8);
}
100% {
opacity: 0;
transform: translateY(30px) scale(0.5);
}
}
`}</style>
</div>
)
}

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,414 @@
'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',
// 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: 'intro',
// 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 '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)
return {
number: newNumber,
targetSum,
correctAnswer: targetSum - newNumber
}
}
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
)
return {
number: newNumber,
targetSum,
correctAnswer: targetSum - newNumber
}
}
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,
gamePhase: 'intro'
}
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 '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: 0,
deliveredPassengers: 0
}
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)
export function ComplementRaceProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(gameReducer, initialState)
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,87 @@
import { useEffect } from 'react'
import { useComplementRace } from '../context/ComplementRaceContext'
import { getAICommentary, type CommentaryContext } from '../components/AISystem/aiCommentary'
export function useAIRacers() {
const { state, dispatch } = useComplementRace()
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 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
})
}
}
})
}, 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,200 @@
import { useEffect, useRef } from 'react'
import { useComplementRace } from '../context/ComplementRaceContext'
import { generatePassengers, findDeliverablePassengers } from '../lib/passengerGenerator'
/**
* Steam Sprint momentum system
*
* Momentum mechanics:
* - Each correct answer adds momentum (builds up steam pressure)
* - Momentum decays over time based on skill level
* - Train position = momentum * 0.4 (updated every 200ms)
* - Game lasts 60 seconds
*
* Skill level decay rates (momentum lost per second):
* - Preschool: 0.5/s (very slow decay)
* - Kindergarten: 1.0/s
* - Relaxed: 1.5/s
* - Slow: 2.0/s
* - Normal: 2.5/s
* - Fast: 3.0/s
* - Expert: 3.5/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 gameStartTimeRef = useRef<number>(0)
const lastUpdateRef = useRef<number>(0)
// 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 })
}
}
}, [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
// Check if 60 seconds elapsed
if (elapsed >= GAME_DURATION) {
dispatch({ type: 'END_RACE' })
setTimeout(() => {
dispatch({ type: 'SHOW_RESULTS' })
}, 1000)
return
}
// 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)
const positionDelta = (speed * deltaTime) / 1000
const trainPosition = Math.min(100, 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 deliverable passengers
const deliverable = findDeliverablePassengers(
state.passengers,
state.stations,
trainPosition
)
// Deliver passengers at stations
deliverable.forEach(({ passenger, points }) => {
dispatch({
type: 'DELIVER_PASSENGER',
passengerId: passenger.id,
points
})
})
// Check for route completion (train reaches 100%)
if (trainPosition >= 100 && !state.showRouteCelebration) {
dispatch({ type: 'COMPLETE_ROUTE' })
}
}, UPDATE_INTERVAL)
return () => clearInterval(interval)
}, [state.isGameActive, state.style, state.momentum, state.timeoutSetting, state.passengers, state.stations, state.showRouteCelebration, dispatch])
// 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 over 60 seconds)
const getTimeOfDayPeriod = (): number => {
if (state.elapsedTime === 0) return 0
const periodDuration = GAME_DURATION / 6
return Math.min(5, Math.floor(state.elapsedTime / periodDuration))
}
// 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,183 @@
/**
* 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 }>
leftRailPoints: string[]
rightRailPoints: 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: []
}
}
/**
* Generate waypoints for track with controlled randomness
* Based on route number for variety across different routes
*/
private generateTrackWaypoints(routeNumber: number): Waypoint[] {
// Base waypoints for scenic railroad journey
const baseWaypoints: Waypoint[] = [
{ x: 50, y: 300 }, // Start at depot
{ x: 150, y: 220 }, // Climb into hills
{ x: 280, y: 180 }, // Mountain pass
{ x: 420, y: 240 }, // Descent to valley
{ x: 550, y: 160 }, // Bridge over canyon
{ x: 680, y: 200 }, // Rolling hills
{ x: 750, y: 280 } // Arrive at destination
]
// Add controlled randomness for variety (but keep start/end fixed)
// Use route number as seed for consistent randomness per route
const seed = routeNumber * 123.456
return baseWaypoints.map((point, index) => {
if (index === 0 || index === baseWaypoints.length - 1) {
return point // Keep start/end points fixed
}
// Use deterministic randomness based on route and index
const randomX = Math.sin(seed + index * 1.1) * 30
const randomY = Math.cos(seed + index * 1.3) * 40
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 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 }>
leftRailPoints: string[]
rightRailPoints: 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 }> = []
const leftRailPoints: string[] = []
const rightRailPoints: string[] = []
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 })
// Collect points for rails
leftRailPoints.push(`${leftX},${leftY}`)
rightRailPoints.push(`${rightX},${rightY}`)
}
return { ties, leftRailPoints, rightRailPoints }
}
/**
* 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,148 @@
export type GameMode = 'friends5' | 'friends10' | 'mixed'
export type GameStyle = 'practice' | 'sprint' | 'survival'
export type TimeoutSetting = 'preschool' | 'kindergarten' | 'relaxed' | 'slow' | 'normal' | 'fast' | 'expert'
export interface ComplementQuestion {
number: number
targetSum: number
correctAnswer: number
}
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
destinationStationId: string
isUrgent: boolean
isDelivered: boolean
}
export interface GameState {
// Game configuration
mode: GameMode
style: GameStyle
timeoutSetting: TimeoutSetting
// 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: '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: '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,77 @@
import type { Passenger, Station } from './gameTypes'
// Passenger name pool (mix of diverse names)
const PASSENGER_NAMES = [
'Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah',
'Ian', 'Julia', 'Kevin', 'Laura', 'Marcus', 'Nina', 'Oliver', 'Petra',
'Quinn', 'Rosa', 'Sam', 'Tessa', 'Uma', 'Victor', 'Wendy', 'Xavier',
'Yuki', 'Zara', 'Ahmed', 'Bella', 'Carlos', 'Devi', 'Elias', 'Fatima'
]
/**
* 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 usedNames = new Set<string>()
for (let i = 0; i < count; i++) {
// Pick a unique name
let name: string
do {
name = PASSENGER_NAMES[Math.floor(Math.random() * PASSENGER_NAMES.length)]
} while (usedNames.has(name) && usedNames.size < PASSENGER_NAMES.length)
usedNames.add(name)
// Pick a random destination (exclude first station - Depot)
const destinationStations = stations.slice(1) // Exclude starting depot
const destination = destinationStations[Math.floor(Math.random() * destinationStations.length)]
// 30% chance of urgent
const isUrgent = Math.random() < 0.3
passengers.push({
id: `passenger-${Date.now()}-${i}`,
name,
destinationStationId: destination.id,
isUrgent,
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 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) {
if (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
}

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

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