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:
14
README.md
14
README.md
@@ -773,4 +773,16 @@ make verify-examples
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
This project uses DejaVu Sans font (included), which is released under a free license.
|
||||
This project uses DejaVu Sans font (included), which is released under a free license.
|
||||
---
|
||||
|
||||
## 🚀 Active Development Projects
|
||||
|
||||
### Speed Complement Race Port (In Progress)
|
||||
**Status**: Planning Complete, Ready to Implement
|
||||
**Plan Document**: [`apps/web/COMPLEMENT_RACE_PORT_PLAN.md`](./apps/web/COMPLEMENT_RACE_PORT_PLAN.md)
|
||||
**Source**: `packages/core/src/web_generator.py` (lines 10956-15113)
|
||||
**Target**: `apps/web/src/app/games/complement-race/`
|
||||
|
||||
A comprehensive port of the sophisticated Speed Complement Race game from standalone HTML to Next.js. Features 3 game modes, 2 AI personalities with 82 unique commentary messages, adaptive difficulty, and multiple visualization systems.
|
||||
|
||||
|
||||
1170
apps/web/COMPLEMENT_RACE_PORT_PLAN.md
Normal file
1170
apps/web/COMPLEMENT_RACE_PORT_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface SpeechBubbleProps {
|
||||
message: string
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
export function SpeechBubble({ message, onHide }: SpeechBubbleProps) {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-hide after 3.5s (line 11749-11752)
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
setTimeout(onHide, 300) // Wait for fade-out animation
|
||||
}, 3500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [onHide])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 10px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'white',
|
||||
borderRadius: '15px',
|
||||
padding: '10px 15px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
fontSize: '14px',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
maxWidth: '250px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{message}
|
||||
{/* Tail pointing down */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '-8px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '8px solid transparent',
|
||||
borderRight: '8px solid transparent',
|
||||
borderTop: '8px solid white',
|
||||
filter: 'drop-shadow(0 2px 2px rgba(0,0,0,0.1))'
|
||||
}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
|
||||
export type CommentaryContext =
|
||||
| 'ahead'
|
||||
| 'behind'
|
||||
| 'adaptive_struggle'
|
||||
| 'adaptive_mastery'
|
||||
| 'player_passed'
|
||||
| 'ai_passed'
|
||||
| 'lapped'
|
||||
| 'desperate_catchup'
|
||||
|
||||
// Swift AI - Competitive personality (lines 11768-11834)
|
||||
export const swiftAICommentary: Record<CommentaryContext, string[]> = {
|
||||
ahead: [
|
||||
"💨 Eat my dust!",
|
||||
"🔥 Too slow for me!",
|
||||
"⚡ You can't catch me!",
|
||||
"🚀 I'm built for speed!",
|
||||
"🏃♂️ This is way too easy!"
|
||||
],
|
||||
behind: [
|
||||
"😤 Not over yet!",
|
||||
"💪 I'm just getting started!",
|
||||
"🔥 Watch me catch up to you!",
|
||||
"⚡ I'm coming for you!",
|
||||
"🏃♂️ This is my comeback!"
|
||||
],
|
||||
adaptive_struggle: [
|
||||
"😏 You struggling much?",
|
||||
"🤖 Math is easy for me!",
|
||||
"⚡ You need to think faster!",
|
||||
"🔥 Need me to slow down?"
|
||||
],
|
||||
adaptive_mastery: [
|
||||
"😮 You're actually impressive!",
|
||||
"🤔 You're getting faster...",
|
||||
"😤 Time for me to step it up!",
|
||||
"⚡ Not bad for a human!"
|
||||
],
|
||||
player_passed: [
|
||||
"😠 No way you just passed me!",
|
||||
"🔥 This isn't over!",
|
||||
"💨 I'm just getting warmed up!",
|
||||
"😤 Your lucky streak won't last!",
|
||||
"⚡ I'll be back in front of you soon!"
|
||||
],
|
||||
ai_passed: [
|
||||
"💨 See ya later, slowpoke!",
|
||||
"😎 Thanks for the warm-up!",
|
||||
"🔥 This is how it's done!",
|
||||
"⚡ I'll see you at the finish line!",
|
||||
"💪 Try to keep up with me!"
|
||||
],
|
||||
lapped: [
|
||||
"😡 You just lapped me?! No way!",
|
||||
"🤬 This is embarrassing for me!",
|
||||
"😤 I'm not going down without a fight!",
|
||||
"💢 How did you get so far ahead?!",
|
||||
"🔥 Time to show you my real speed!",
|
||||
"😠 You won't stay ahead for long!"
|
||||
],
|
||||
desperate_catchup: [
|
||||
"🚨 TURBO MODE ACTIVATED! I'm coming for you!",
|
||||
"💥 You forced me to unleash my true power!",
|
||||
"🔥 NO MORE MR. NICE AI! Time to go all out!",
|
||||
"⚡ I'm switching to MAXIMUM OVERDRIVE!",
|
||||
"😤 You made me angry - now you'll see what I can do!",
|
||||
"🚀 AFTERBURNERS ENGAGED! This isn't over!"
|
||||
]
|
||||
}
|
||||
|
||||
// Math Bot - Analytical personality (lines 11835-11901)
|
||||
export const mathBotCommentary: Record<CommentaryContext, string[]> = {
|
||||
ahead: [
|
||||
"📊 My performance is optimal!",
|
||||
"🤖 My logic beats your speed!",
|
||||
"📈 I have 87% win probability!",
|
||||
"⚙️ I'm perfectly calibrated!",
|
||||
"🔬 Science prevails over you!"
|
||||
],
|
||||
behind: [
|
||||
"🤔 Recalculating my strategy...",
|
||||
"📊 You're exceeding my projections!",
|
||||
"⚙️ Adjusting my parameters!",
|
||||
"🔬 I'm analyzing your technique!",
|
||||
"📈 You're a statistical anomaly!"
|
||||
],
|
||||
adaptive_struggle: [
|
||||
"📊 I detect inefficiencies in you!",
|
||||
"🔬 You should focus on patterns!",
|
||||
"⚙️ Use that extra time wisely!",
|
||||
"📈 You have room for improvement!"
|
||||
],
|
||||
adaptive_mastery: [
|
||||
"🤖 Your optimization is excellent!",
|
||||
"📊 Your metrics are impressive!",
|
||||
"⚙️ I'm updating my models because of you!",
|
||||
"🔬 You have near-AI efficiency!"
|
||||
],
|
||||
player_passed: [
|
||||
"🤖 Your strategy is fascinating!",
|
||||
"📊 You're an unexpected variable!",
|
||||
"⚙️ I'm adjusting my algorithms...",
|
||||
"🔬 Your execution is impressive!",
|
||||
"📈 I'm recalculating the odds!"
|
||||
],
|
||||
ai_passed: [
|
||||
"🤖 My efficiency is optimized!",
|
||||
"📊 Just as I calculated!",
|
||||
"⚙️ All my systems nominal!",
|
||||
"🔬 My logic prevails over you!",
|
||||
"📈 I'm at 96% confidence level!"
|
||||
],
|
||||
lapped: [
|
||||
"🤖 Error: You have exceeded my projections!",
|
||||
"📊 This outcome has 0.3% probability!",
|
||||
"⚙️ I need to recalibrate my systems!",
|
||||
"🔬 Your performance is... statistically improbable!",
|
||||
"📈 My confidence level just dropped to 12%!",
|
||||
"🤔 I must analyze your methodology!"
|
||||
],
|
||||
desperate_catchup: [
|
||||
"🤖 EMERGENCY PROTOCOL ACTIVATED! Initiating maximum speed!",
|
||||
"🚨 CRITICAL GAP DETECTED! Engaging catchup algorithms!",
|
||||
"⚙️ OVERCLOCKING MY PROCESSORS! Prepare for rapid acceleration!",
|
||||
"📊 PROBABILITY OF FAILURE: UNACCEPTABLE! Switching to turbo mode!",
|
||||
"🔬 HYPOTHESIS: You're about to see my true potential!",
|
||||
"📈 CONFIDENCE LEVEL: RISING! My comeback protocol is online!"
|
||||
]
|
||||
}
|
||||
|
||||
// Get AI commentary message (lines 11636-11657)
|
||||
export function getAICommentary(
|
||||
racer: AIRacer,
|
||||
context: CommentaryContext,
|
||||
playerProgress: number,
|
||||
aiProgress: number
|
||||
): string | null {
|
||||
// Check cooldown (line 11759-11761)
|
||||
const now = Date.now()
|
||||
if (now - racer.lastComment < racer.commentCooldown) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Select message set based on personality and context
|
||||
const messages = racer.personality === 'competitive'
|
||||
? swiftAICommentary[context]
|
||||
: mathBotCommentary[context]
|
||||
|
||||
if (!messages || messages.length === 0) return null
|
||||
|
||||
// Return random message
|
||||
return messages[Math.floor(Math.random() * messages.length)]
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
120
apps/web/src/app/games/complement-race/components/GameIntro.tsx
Normal file
120
apps/web/src/app/games/complement-race/components/GameIntro.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
|
||||
export function GameIntro() {
|
||||
const { dispatch } = useComplementRace()
|
||||
|
||||
const handleStartClick = () => {
|
||||
dispatch({ type: 'SHOW_CONTROLS' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
maxWidth: '800px',
|
||||
margin: '20px auto 0'
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: '48px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
}}>
|
||||
Speed Complement Race
|
||||
</h1>
|
||||
|
||||
<p style={{
|
||||
fontSize: '18px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '32px',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
Race against AI opponents while solving complement problems!
|
||||
Find the missing number to complete the equation.
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
marginBottom: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
textAlign: 'left'
|
||||
}}>
|
||||
<h2 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
color: '#1f2937'
|
||||
}}>
|
||||
How to Play
|
||||
</h2>
|
||||
|
||||
<ul style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🎯</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Find the complement number to reach the target sum
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>⚡</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Type your answer quickly to move forward in the race
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🤖</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Compete against Swift AI and Math Bot with unique personalities
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🏆</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Earn points for correct answers and build up your streak
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleStartClick}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 48px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(16, 185, 129, 0.4)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.3)'
|
||||
}}
|
||||
>
|
||||
Start Racing!
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
|
||||
export function GameResults() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
// Determine race outcome
|
||||
const playerWon = state.aiRacers.every(racer => state.correctAnswers > racer.position)
|
||||
const playerPosition = state.aiRacers.filter(racer => racer.position >= state.correctAnswers).length + 1
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '60px 40px 40px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '24px',
|
||||
padding: '48px',
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{/* Result Header */}
|
||||
<div style={{
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
{playerWon ? '🏆' : playerPosition === 2 ? '🥈' : playerPosition === 3 ? '🥉' : '🎯'}
|
||||
</div>
|
||||
|
||||
<h1 style={{
|
||||
fontSize: '36px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
{playerWon ? 'Victory!' : `${playerPosition}${getOrdinalSuffix(playerPosition)} Place`}
|
||||
</h1>
|
||||
|
||||
<p style={{
|
||||
fontSize: '18px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
{playerWon
|
||||
? 'You beat all the AI racers!'
|
||||
: `You finished the race!`}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '16px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>
|
||||
Final Score
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#3b82f6' }}>
|
||||
{state.score}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>
|
||||
Best Streak
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#10b981' }}>
|
||||
{state.bestStreak} 🔥
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>
|
||||
Total Questions
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#f59e0b' }}>
|
||||
{state.totalQuestions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>
|
||||
Accuracy
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#8b5cf6' }}>
|
||||
{state.totalQuestions > 0
|
||||
? Math.round((state.correctAnswers / state.totalQuestions) * 100)
|
||||
: 0}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final Standings */}
|
||||
<div style={{
|
||||
marginBottom: '32px',
|
||||
textAlign: 'left'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
Final Standings
|
||||
</h3>
|
||||
|
||||
{[
|
||||
{ name: 'You', position: state.correctAnswers, icon: '👤' },
|
||||
...state.aiRacers.map(racer => ({
|
||||
name: racer.name,
|
||||
position: racer.position,
|
||||
icon: racer.icon
|
||||
}))
|
||||
]
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map((racer, index) => (
|
||||
<div
|
||||
key={racer.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px',
|
||||
background: racer.name === 'You' ? '#eff6ff' : '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '8px',
|
||||
border: racer.name === 'You' ? '2px solid #3b82f6' : 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#9ca3af', minWidth: '32px' }}>
|
||||
#{index + 1}
|
||||
</div>
|
||||
<div style={{ fontSize: '20px' }}>{racer.icon}</div>
|
||||
<div style={{ fontWeight: racer.name === 'You' ? 'bold' : 'normal' }}>
|
||||
{racer.name}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#6b7280' }}>
|
||||
{Math.floor(racer.position)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'RESET_GAME' })}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.4)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
}}
|
||||
>
|
||||
Race Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getOrdinalSuffix(num: number): string {
|
||||
if (num === 1) return 'st'
|
||||
if (num === 2) return 'nd'
|
||||
if (num === 3) return 'rd'
|
||||
return 'th'
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { getRouteTheme } from '../lib/routeThemes'
|
||||
|
||||
interface RouteCelebrationProps {
|
||||
completedRouteNumber: number
|
||||
nextRouteNumber: number
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
export function RouteCelebration({ completedRouteNumber, nextRouteNumber, onContinue }: RouteCelebrationProps) {
|
||||
const completedTheme = getRouteTheme(completedRouteNumber)
|
||||
const nextTheme = getRouteTheme(nextRouteNumber)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
animation: 'fadeIn 0.3s ease-out'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '24px',
|
||||
padding: '40px',
|
||||
maxWidth: '500px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||
animation: 'scaleIn 0.5s ease-out',
|
||||
color: 'white'
|
||||
}}>
|
||||
{/* Celebration header */}
|
||||
<div style={{
|
||||
fontSize: '64px',
|
||||
marginBottom: '20px',
|
||||
animation: 'bounce 1s ease-in-out infinite'
|
||||
}}>
|
||||
🎉
|
||||
</div>
|
||||
|
||||
<h2 style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
textShadow: '0 2px 10px rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
Route Complete!
|
||||
</h2>
|
||||
|
||||
{/* Completed route info */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '8px' }}>
|
||||
{completedTheme.emoji}
|
||||
</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: '600' }}>
|
||||
{completedTheme.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', opacity: 0.9, marginTop: '4px' }}>
|
||||
Route {completedRouteNumber}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next route preview */}
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
opacity: 0.9,
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Next destination:
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px',
|
||||
marginBottom: '24px',
|
||||
border: '2px dashed rgba(255, 255, 255, 0.3)'
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '4px' }}>
|
||||
{nextTheme.emoji}
|
||||
</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600' }}>
|
||||
{nextTheme.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', opacity: 0.8, marginTop: '4px' }}>
|
||||
Route {nextRouteNumber}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Continue button */}
|
||||
<button
|
||||
onClick={onContinue}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#667eea',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 32px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.2)'
|
||||
}}
|
||||
>
|
||||
Continue Journey 🚂
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
87
apps/web/src/app/games/complement-race/hooks/useAIRacers.ts
Normal file
87
apps/web/src/app/games/complement-race/hooks/useAIRacers.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import type { PairPerformance } from '../lib/gameTypes'
|
||||
|
||||
export function useAdaptiveDifficulty() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
// Track performance after each answer (lines 14495-14553)
|
||||
const trackPerformance = (isCorrect: boolean, responseTime: number) => {
|
||||
if (!state.currentQuestion) return
|
||||
|
||||
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
|
||||
|
||||
// Get or create performance data for this pair
|
||||
let pairData: PairPerformance = state.difficultyTracker.pairPerformance.get(pairKey) || {
|
||||
attempts: 0,
|
||||
correct: 0,
|
||||
avgTime: 0,
|
||||
difficulty: 1
|
||||
}
|
||||
|
||||
// Update performance data
|
||||
pairData.attempts++
|
||||
if (isCorrect) {
|
||||
pairData.correct++
|
||||
}
|
||||
|
||||
// Update average time (rolling average)
|
||||
const totalTime = (pairData.avgTime * (pairData.attempts - 1)) + responseTime
|
||||
pairData.avgTime = totalTime / pairData.attempts
|
||||
|
||||
// Calculate pair-specific difficulty (lines 14555-14576)
|
||||
if (pairData.attempts >= 2) {
|
||||
const accuracyRate = pairData.correct / pairData.attempts
|
||||
const avgTime = pairData.avgTime
|
||||
|
||||
let difficulty = 1
|
||||
if (accuracyRate >= 0.9 && avgTime < 1500) {
|
||||
difficulty = 1 // Very easy
|
||||
} else if (accuracyRate >= 0.8 && avgTime < 2000) {
|
||||
difficulty = 2 // Easy
|
||||
} else if (accuracyRate >= 0.7 || avgTime < 2500) {
|
||||
difficulty = 3 // Medium
|
||||
} else if (accuracyRate >= 0.5 || avgTime < 3500) {
|
||||
difficulty = 4 // Hard
|
||||
} else {
|
||||
difficulty = 5 // Very hard
|
||||
}
|
||||
|
||||
pairData.difficulty = difficulty
|
||||
}
|
||||
|
||||
// Update difficulty tracker in state
|
||||
const newPairPerformance = new Map(state.difficultyTracker.pairPerformance)
|
||||
newPairPerformance.set(pairKey, pairData)
|
||||
|
||||
// Update consecutive counters
|
||||
const newTracker = {
|
||||
...state.difficultyTracker,
|
||||
pairPerformance: newPairPerformance,
|
||||
consecutiveCorrect: isCorrect ? state.difficultyTracker.consecutiveCorrect + 1 : 0,
|
||||
consecutiveIncorrect: !isCorrect ? state.difficultyTracker.consecutiveIncorrect + 1 : 0
|
||||
}
|
||||
|
||||
// Adapt global difficulty (lines 14578-14605)
|
||||
if (newTracker.consecutiveCorrect >= 3) {
|
||||
// Reduce time limit (increase difficulty)
|
||||
newTracker.currentTimeLimit = Math.max(1000,
|
||||
newTracker.currentTimeLimit - (newTracker.currentTimeLimit * newTracker.adaptationRate))
|
||||
} else if (newTracker.consecutiveIncorrect >= 2) {
|
||||
// Increase time limit (decrease difficulty)
|
||||
newTracker.currentTimeLimit = Math.min(5000,
|
||||
newTracker.currentTimeLimit + (newTracker.baseTimeLimit * newTracker.adaptationRate))
|
||||
}
|
||||
|
||||
// Update overall difficulty level
|
||||
const avgDifficulty = Array.from(newTracker.pairPerformance.values())
|
||||
.reduce((sum, data) => sum + data.difficulty, 0) /
|
||||
Math.max(1, newTracker.pairPerformance.size)
|
||||
|
||||
newTracker.difficultyLevel = Math.round(avgDifficulty)
|
||||
|
||||
// Exit learning mode after sufficient data (lines 14548-14552)
|
||||
if (newTracker.pairPerformance.size >= 5 &&
|
||||
Array.from(newTracker.pairPerformance.values())
|
||||
.some(data => data.attempts >= 3)) {
|
||||
newTracker.learningMode = false
|
||||
}
|
||||
|
||||
// Dispatch update
|
||||
dispatch({ type: 'UPDATE_DIFFICULTY_TRACKER', tracker: newTracker })
|
||||
|
||||
// Adapt AI speeds based on player performance
|
||||
adaptAISpeeds(newTracker)
|
||||
}
|
||||
|
||||
// Calculate recent success rate (lines 14685-14693)
|
||||
const calculateRecentSuccessRate = (): number => {
|
||||
const recentQuestions = Math.min(10, state.totalQuestions)
|
||||
if (recentQuestions === 0) return 0.5 // Default for first question
|
||||
|
||||
// Use global tracking for recent performance
|
||||
const recentCorrect = Math.max(0, state.correctAnswers - Math.max(0, state.totalQuestions - recentQuestions))
|
||||
return recentCorrect / recentQuestions
|
||||
}
|
||||
|
||||
// Calculate average response time (lines 14695-14705)
|
||||
const calculateAverageResponseTime = (): number => {
|
||||
const recentPairs = Array.from(state.difficultyTracker.pairPerformance.values())
|
||||
.filter(data => data.attempts >= 1)
|
||||
.slice(-5) // Last 5 different pairs encountered
|
||||
|
||||
if (recentPairs.length === 0) return 3000 // Default for learning mode
|
||||
|
||||
const totalTime = recentPairs.reduce((sum, data) => sum + data.avgTime, 0)
|
||||
return totalTime / recentPairs.length
|
||||
}
|
||||
|
||||
// Adapt AI speeds based on performance (lines 14607-14683)
|
||||
const adaptAISpeeds = (tracker: typeof state.difficultyTracker) => {
|
||||
// Don't adapt during learning mode
|
||||
if (tracker.learningMode) return
|
||||
|
||||
const playerSuccessRate = calculateRecentSuccessRate()
|
||||
const avgResponseTime = calculateAverageResponseTime()
|
||||
|
||||
// Base speed multipliers for each race mode
|
||||
let baseSpeedMultiplier: number
|
||||
switch (state.style) {
|
||||
case 'practice': baseSpeedMultiplier = 0.7; break
|
||||
case 'sprint': baseSpeedMultiplier = 0.9; break
|
||||
case 'survival': baseSpeedMultiplier = state.speedMultiplier * state.survivalMultiplier; break
|
||||
default: baseSpeedMultiplier = 0.7
|
||||
}
|
||||
|
||||
// Calculate adaptive multiplier based on player performance
|
||||
let adaptiveMultiplier = 1.0
|
||||
|
||||
// Success rate factor (0.5x to 1.6x based on success rate)
|
||||
if (playerSuccessRate > 0.85) {
|
||||
adaptiveMultiplier *= 1.6 // Player doing great - speed up AI significantly
|
||||
} else if (playerSuccessRate > 0.75) {
|
||||
adaptiveMultiplier *= 1.3 // Player doing well - speed up AI moderately
|
||||
} else if (playerSuccessRate > 0.60) {
|
||||
adaptiveMultiplier *= 1.0 // Player doing okay - keep AI at base speed
|
||||
} else if (playerSuccessRate > 0.45) {
|
||||
adaptiveMultiplier *= 0.75 // Player struggling - slow down AI
|
||||
} else {
|
||||
adaptiveMultiplier *= 0.5 // Player really struggling - significantly slow AI
|
||||
}
|
||||
|
||||
// Response time factor - faster players get faster AI
|
||||
if (avgResponseTime < 1500) {
|
||||
adaptiveMultiplier *= 1.2 // Very fast player
|
||||
} else if (avgResponseTime < 2500) {
|
||||
adaptiveMultiplier *= 1.1 // Fast player
|
||||
} else if (avgResponseTime > 4000) {
|
||||
adaptiveMultiplier *= 0.9 // Slow player
|
||||
}
|
||||
|
||||
// Streak bonus - players on hot streaks get more challenge
|
||||
if (state.streak >= 8) {
|
||||
adaptiveMultiplier *= 1.3
|
||||
} else if (state.streak >= 5) {
|
||||
adaptiveMultiplier *= 1.15
|
||||
}
|
||||
|
||||
// Apply bounds to prevent extreme values
|
||||
adaptiveMultiplier = Math.max(0.3, Math.min(2.0, adaptiveMultiplier))
|
||||
|
||||
// Update AI speeds with adaptive multiplier
|
||||
const finalSpeedMultiplier = baseSpeedMultiplier * adaptiveMultiplier
|
||||
|
||||
// Update AI racer speeds
|
||||
const updatedRacers = state.aiRacers.map((racer, index) => {
|
||||
if (index === 0) {
|
||||
// Swift AI (more aggressive)
|
||||
return { ...racer, speed: 0.32 * finalSpeedMultiplier }
|
||||
} else {
|
||||
// Math Bot (more consistent)
|
||||
return { ...racer, speed: 0.20 * finalSpeedMultiplier }
|
||||
}
|
||||
})
|
||||
|
||||
dispatch({ type: 'UPDATE_AI_SPEEDS', racers: updatedRacers })
|
||||
|
||||
// Debug logging for AI adaptation (every 5 questions)
|
||||
if (state.totalQuestions % 5 === 0) {
|
||||
console.log('🤖 AI Speed Adaptation:', {
|
||||
playerSuccessRate: Math.round(playerSuccessRate * 100) + '%',
|
||||
avgResponseTime: Math.round(avgResponseTime) + 'ms',
|
||||
streak: state.streak,
|
||||
adaptiveMultiplier: Math.round(adaptiveMultiplier * 100) / 100,
|
||||
swiftAISpeed: updatedRacers[0] ? Math.round(updatedRacers[0].speed * 1000) / 1000 : 0,
|
||||
mathBotSpeed: updatedRacers[1] ? Math.round(updatedRacers[1].speed * 1000) / 1000 : 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get adaptive time limit for current question (lines 14740-14763)
|
||||
const getAdaptiveTimeLimit = (): number => {
|
||||
if (!state.currentQuestion) return 3000
|
||||
|
||||
let adaptiveTime: number
|
||||
|
||||
if (state.difficultyTracker.learningMode) {
|
||||
adaptiveTime = Math.max(2000, state.difficultyTracker.currentTimeLimit)
|
||||
} else {
|
||||
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
|
||||
const pairData = state.difficultyTracker.pairPerformance.get(pairKey)
|
||||
|
||||
if (pairData && pairData.attempts >= 2) {
|
||||
// Use pair-specific difficulty
|
||||
const baseTime = state.difficultyTracker.baseTimeLimit
|
||||
const difficultyMultiplier = (6 - pairData.difficulty) / 5 // Invert: difficulty 1 = more time
|
||||
adaptiveTime = Math.max(1000, baseTime * difficultyMultiplier)
|
||||
} else {
|
||||
// Default for new pairs
|
||||
adaptiveTime = state.difficultyTracker.currentTimeLimit
|
||||
}
|
||||
}
|
||||
|
||||
// Apply user timeout setting override (lines 14765-14785)
|
||||
return applyTimeoutSetting(adaptiveTime)
|
||||
}
|
||||
|
||||
// Apply timeout setting multiplier (lines 14765-14785)
|
||||
const applyTimeoutSetting = (baseTime: number): number => {
|
||||
switch (state.timeoutSetting) {
|
||||
case 'preschool':
|
||||
return Math.max(baseTime * 4, 20000) // At least 20 seconds
|
||||
case 'kindergarten':
|
||||
return Math.max(baseTime * 3, 15000) // At least 15 seconds
|
||||
case 'relaxed':
|
||||
return Math.max(baseTime * 2.4, 12000) // At least 12 seconds
|
||||
case 'slow':
|
||||
return Math.max(baseTime * 1.6, 8000) // At least 8 seconds
|
||||
case 'normal':
|
||||
return Math.max(baseTime, 5000) // At least 5 seconds
|
||||
case 'fast':
|
||||
return Math.max(baseTime * 0.6, 3000) // At least 3 seconds
|
||||
case 'expert':
|
||||
return Math.max(baseTime * 0.4, 2000) // At least 2 seconds
|
||||
default:
|
||||
return baseTime
|
||||
}
|
||||
}
|
||||
|
||||
// Get adaptive feedback message (lines 11655-11721)
|
||||
const getAdaptiveFeedbackMessage = (
|
||||
pairKey: string,
|
||||
isCorrect: boolean,
|
||||
responseTime: number
|
||||
): { message: string; type: 'learning' | 'struggling' | 'mastered' | 'adapted' } | null => {
|
||||
const pairData = state.difficultyTracker.pairPerformance.get(pairKey)
|
||||
const [num1, num2, sum] = pairKey.split('_').map(Number)
|
||||
|
||||
// Learning mode messages
|
||||
if (state.difficultyTracker.learningMode) {
|
||||
const encouragements = [
|
||||
"🧠 I'm learning your style! Keep going!",
|
||||
"📊 Building your skill profile...",
|
||||
"🎯 Every answer helps me understand you better!",
|
||||
"🚀 Analyzing your complement superpowers!"
|
||||
]
|
||||
return {
|
||||
message: encouragements[Math.floor(Math.random() * encouragements.length)],
|
||||
type: 'learning'
|
||||
}
|
||||
}
|
||||
|
||||
// After learning - provide specific feedback
|
||||
if (pairData && pairData.attempts >= 3) {
|
||||
const accuracy = pairData.correct / pairData.attempts
|
||||
const avgTime = pairData.avgTime
|
||||
|
||||
// Struggling pairs (< 60% accuracy)
|
||||
if (accuracy < 0.6) {
|
||||
const strugglingMessages = [
|
||||
`💪 ${num1}+${num2} needs practice - I'm giving you extra time!`,
|
||||
`🎯 Working on ${num1}+${num2} - you've got this!`,
|
||||
`⏰ Taking it slower with ${num1}+${num2} - no rush!`,
|
||||
`🧩 ${num1}+${num2} is getting special attention from me!`
|
||||
]
|
||||
return {
|
||||
message: strugglingMessages[Math.floor(Math.random() * strugglingMessages.length)],
|
||||
type: 'struggling'
|
||||
}
|
||||
}
|
||||
|
||||
// Mastered pairs (> 85% accuracy and fast)
|
||||
if (accuracy > 0.85 && avgTime < 2000) {
|
||||
const masteredMessages = [
|
||||
`⚡ ${num1}+${num2} = MASTERED! Lightning mode activated!`,
|
||||
`🔥 You've conquered ${num1}+${num2} - speeding it up!`,
|
||||
`🏆 ${num1}+${num2} expert detected! Challenge mode ON!`,
|
||||
`⭐ ${num1}+${num2} is your superpower! Going faster!`
|
||||
]
|
||||
return {
|
||||
message: masteredMessages[Math.floor(Math.random() * masteredMessages.length)],
|
||||
type: 'mastered'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show adaptation when difficulty changes
|
||||
if (state.difficultyTracker.consecutiveCorrect >= 3) {
|
||||
return {
|
||||
message: "🚀 You're on fire! Increasing the challenge!",
|
||||
type: 'adapted'
|
||||
}
|
||||
} else if (state.difficultyTracker.consecutiveIncorrect >= 2) {
|
||||
return {
|
||||
message: "🤗 Let's slow down a bit - I'm here to help!",
|
||||
type: 'adapted'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
trackPerformance,
|
||||
getAdaptiveTimeLimit,
|
||||
calculateRecentSuccessRate,
|
||||
calculateAverageResponseTime,
|
||||
getAdaptiveFeedbackMessage
|
||||
}
|
||||
}
|
||||
67
apps/web/src/app/games/complement-race/hooks/useGameLoop.ts
Normal file
67
apps/web/src/app/games/complement-race/hooks/useGameLoop.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
|
||||
export function useGameLoop() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
// Generate first question when game begins
|
||||
useEffect(() => {
|
||||
if (state.gamePhase === 'playing' && !state.currentQuestion) {
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
}
|
||||
}, [state.gamePhase, state.currentQuestion, dispatch])
|
||||
|
||||
const nextQuestion = useCallback(() => {
|
||||
if (!state.isGameActive) return
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
}, [state.isGameActive, dispatch])
|
||||
|
||||
const submitAnswer = useCallback((answer: number) => {
|
||||
if (!state.currentQuestion) return
|
||||
|
||||
const isCorrect = answer === state.currentQuestion.correctAnswer
|
||||
|
||||
if (isCorrect) {
|
||||
// Update score, streak, progress
|
||||
// TODO: Will implement full scoring in next step
|
||||
dispatch({ type: 'SUBMIT_ANSWER', answer })
|
||||
|
||||
// Move to next question
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
} else {
|
||||
// Reset streak
|
||||
// TODO: Will implement incorrect answer handling
|
||||
dispatch({ type: 'SUBMIT_ANSWER', answer })
|
||||
}
|
||||
|
||||
}, [state.currentQuestion, dispatch])
|
||||
|
||||
const startCountdown = useCallback(() => {
|
||||
// Trigger countdown phase
|
||||
dispatch({ type: 'START_COUNTDOWN' })
|
||||
|
||||
// Start 3-2-1-GO countdown (lines 11163-11211)
|
||||
let count = 3
|
||||
const countdownInterval = setInterval(() => {
|
||||
if (count > 0) {
|
||||
// TODO: Play countdown sound
|
||||
count--
|
||||
} else {
|
||||
// GO!
|
||||
// TODO: Play start sound
|
||||
clearInterval(countdownInterval)
|
||||
|
||||
// Start the actual game after GO animation (1 second delay)
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
}, 1000)
|
||||
}
|
||||
}, 1000)
|
||||
}, [dispatch])
|
||||
|
||||
return {
|
||||
nextQuestion,
|
||||
submitAnswer,
|
||||
startCountdown
|
||||
}
|
||||
}
|
||||
200
apps/web/src/app/games/complement-race/hooks/useSteamJourney.ts
Normal file
200
apps/web/src/app/games/complement-race/hooks/useSteamJourney.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
148
apps/web/src/app/games/complement-race/lib/gameTypes.ts
Normal file
148
apps/web/src/app/games/complement-race/lib/gameTypes.ts
Normal 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' }
|
||||
103
apps/web/src/app/games/complement-race/lib/landmarks.ts
Normal file
103
apps/web/src/app/games/complement-race/lib/landmarks.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Geographic landmarks for Steam Train Journey
|
||||
* Landmarks add visual variety to the landscape based on route themes
|
||||
*/
|
||||
|
||||
export interface Landmark {
|
||||
emoji: string
|
||||
position: number // 0-100% along track
|
||||
offset: { x: number; y: number } // Offset from track position
|
||||
size: number // Font size multiplier
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate landmarks for a specific route
|
||||
* Different route themes have different landmark types
|
||||
*/
|
||||
export function generateLandmarks(routeNumber: number): Landmark[] {
|
||||
const seed = routeNumber * 456.789
|
||||
|
||||
// Deterministic randomness for landmark placement
|
||||
const random = (index: number) => {
|
||||
return Math.abs(Math.sin(seed + index * 2.7))
|
||||
}
|
||||
|
||||
const landmarks: Landmark[] = []
|
||||
|
||||
// Route theme determines landmark types
|
||||
const themeIndex = (routeNumber - 1) % 10
|
||||
|
||||
// Generate 4-6 landmarks along the route
|
||||
const landmarkCount = Math.floor(random(0) * 3) + 4
|
||||
|
||||
for (let i = 0; i < landmarkCount; i++) {
|
||||
const position = (i + 1) * (100 / (landmarkCount + 1))
|
||||
const offsetSide = random(i) > 0.5 ? 1 : -1
|
||||
const offsetDistance = 30 + random(i + 10) * 40
|
||||
|
||||
let emoji = '🌳' // Default tree
|
||||
let size = 24
|
||||
|
||||
// Choose emoji based on theme and position
|
||||
switch (themeIndex) {
|
||||
case 0: // Prairie Express
|
||||
emoji = random(i) > 0.6 ? '🌾' : '🌻'
|
||||
size = 20
|
||||
break
|
||||
case 1: // Mountain Climb
|
||||
emoji = random(i) > 0.5 ? '⛰️' : '🗻'
|
||||
size = 32
|
||||
break
|
||||
case 2: // Coastal Run
|
||||
emoji = random(i) > 0.7 ? '🌊' : random(i) > 0.4 ? '🏖️' : '⛵'
|
||||
size = 24
|
||||
break
|
||||
case 3: // Desert Crossing
|
||||
emoji = random(i) > 0.6 ? '🌵' : '🏜️'
|
||||
size = 28
|
||||
break
|
||||
case 4: // Forest Trail
|
||||
emoji = random(i) > 0.7 ? '🌲' : random(i) > 0.4 ? '🌳' : '🦌'
|
||||
size = 26
|
||||
break
|
||||
case 5: // Canyon Route
|
||||
emoji = random(i) > 0.5 ? '🏞️' : '🪨'
|
||||
size = 30
|
||||
break
|
||||
case 6: // River Valley
|
||||
emoji = random(i) > 0.6 ? '🌊' : random(i) > 0.3 ? '🌳' : '🦆'
|
||||
size = 24
|
||||
break
|
||||
case 7: // Highland Pass
|
||||
emoji = random(i) > 0.6 ? '🗻' : '☁️'
|
||||
size = 28
|
||||
break
|
||||
case 8: // Lakeside Journey
|
||||
emoji = random(i) > 0.7 ? '🏞️' : random(i) > 0.4 ? '🌳' : '🦢'
|
||||
size = 26
|
||||
break
|
||||
case 9: // Grand Circuit
|
||||
emoji = random(i) > 0.7 ? '🎪' : random(i) > 0.4 ? '🎡' : '🎠'
|
||||
size = 28
|
||||
break
|
||||
}
|
||||
|
||||
// Add bridges at specific positions (around 40-60%)
|
||||
if (position > 40 && position < 60 && random(i + 20) > 0.7) {
|
||||
emoji = '🌉'
|
||||
size = 36
|
||||
}
|
||||
|
||||
landmarks.push({
|
||||
emoji,
|
||||
position,
|
||||
offset: {
|
||||
x: offsetSide * offsetDistance,
|
||||
y: random(i + 5) * 20 - 10
|
||||
},
|
||||
size
|
||||
})
|
||||
}
|
||||
|
||||
return landmarks
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
26
apps/web/src/app/games/complement-race/lib/routeThemes.ts
Normal file
26
apps/web/src/app/games/complement-race/lib/routeThemes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Route themes for Steam Train Journey
|
||||
* Each route has a unique name and emoji to make the journey feel varied
|
||||
*/
|
||||
|
||||
export const ROUTE_THEMES = [
|
||||
{ name: 'Prairie Express', emoji: '🌾' },
|
||||
{ name: 'Mountain Climb', emoji: '⛰️' },
|
||||
{ name: 'Coastal Run', emoji: '🌊' },
|
||||
{ name: 'Desert Crossing', emoji: '🏜️' },
|
||||
{ name: 'Forest Trail', emoji: '🌲' },
|
||||
{ name: 'Canyon Route', emoji: '🏞️' },
|
||||
{ name: 'River Valley', emoji: '🏞️' },
|
||||
{ name: 'Highland Pass', emoji: '🗻' },
|
||||
{ name: 'Lakeside Journey', emoji: '🏔️' },
|
||||
{ name: 'Grand Circuit', emoji: '🎪' }
|
||||
]
|
||||
|
||||
/**
|
||||
* Get route theme for a given route number
|
||||
* Cycles through themes if route number exceeds available themes
|
||||
*/
|
||||
export function getRouteTheme(routeNumber: number): { name: string; emoji: string } {
|
||||
const index = (routeNumber - 1) % ROUTE_THEMES.length
|
||||
return ROUTE_THEMES[index]
|
||||
}
|
||||
15
apps/web/src/app/games/complement-race/page.tsx
Normal file
15
apps/web/src/app/games/complement-race/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceProvider } from './context/ComplementRaceContext'
|
||||
import { ComplementRaceGame } from './components/ComplementRaceGame'
|
||||
|
||||
export default function ComplementRacePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Speed Complement Race" navEmoji="🏁">
|
||||
<ComplementRaceProvider>
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user