feat: add complement display options and unify equation display
- Add complement display modes (number/abacus/random) in game setup - Create AbacusTarget component for inline abacus display in equations - Unify equation display across practice, survival, and sprint modes - Fix abacus vertical centering using inline-flex alignment - Add Storybook examples demonstrating invisible column posts - Fix passenger boarding bug using proper car occupancy tracking - Redesign game setup UI with compact pill-based controls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
interface AbacusTargetProps {
|
||||
number: number // The complement number to display
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a small abacus showing a complement number inline in the equation
|
||||
* Used to help learners recognize the abacus representation of complement numbers
|
||||
*/
|
||||
export function AbacusTarget({ number }: AbacusTargetProps) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0
|
||||
}}>
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns={1}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
scaleFactor={0.72}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import type { GameMode, GameStyle, TimeoutSetting } from '../lib/gameTypes'
|
||||
import type { GameMode, GameStyle, TimeoutSetting, ComplementDisplay } from '../lib/gameTypes'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
|
||||
export function GameControls() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const router = useRouter()
|
||||
|
||||
const handleModeSelect = (mode: GameMode) => {
|
||||
dispatch({ type: 'SET_MODE', mode })
|
||||
@@ -14,346 +13,349 @@ export function GameControls() {
|
||||
|
||||
const handleStyleSelect = (style: GameStyle) => {
|
||||
dispatch({ type: 'SET_STYLE', style })
|
||||
}
|
||||
|
||||
const handleTimeoutSelect = (timeout: TimeoutSetting) => {
|
||||
dispatch({ type: 'SET_TIMEOUT', timeout })
|
||||
}
|
||||
|
||||
const handleStartRace = () => {
|
||||
// Update URL to match selected game style when starting
|
||||
router.push(`/games/complement-race/${state.style}`)
|
||||
|
||||
// Train mode (sprint) doesn't need countdown - start immediately
|
||||
if (state.style === 'sprint') {
|
||||
// Start the game immediately - no navigation needed
|
||||
if (style === 'sprint') {
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
} else {
|
||||
dispatch({ type: 'START_COUNTDOWN' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeoutSelect = (timeout: TimeoutSetting) => {
|
||||
dispatch({ type: 'SET_TIMEOUT', timeout })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%)'
|
||||
background: 'linear-gradient(to bottom, #0f172a 0%, #1e293b 50%, #334155 100%)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Animated background pattern */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'radial-gradient(circle at 20% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%)',
|
||||
pointerEvents: 'none'
|
||||
}} />
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '32px 20px',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
width: '100%'
|
||||
padding: '20px',
|
||||
position: 'relative',
|
||||
zIndex: 1
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: '42px',
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
background: 'linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
backgroundClip: 'text',
|
||||
margin: 0,
|
||||
letterSpacing: '-0.5px'
|
||||
}}>
|
||||
Configure Your Race
|
||||
Complement Race
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
Choose your number mode, race type, and difficulty level
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Grid container for all sections on wider screens */}
|
||||
{/* Settings Bar */}
|
||||
<div style={{
|
||||
padding: '0 20px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
position: 'relative',
|
||||
zIndex: 1
|
||||
}}>
|
||||
{/* Number Mode & Display */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: '16px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
{/* Number Mode Selection */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.08)',
|
||||
textAlign: 'left'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
color: '#1f2937'
|
||||
}}>
|
||||
Number Mode
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => handleModeSelect('friends5')}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: '10px',
|
||||
border: '3px solid',
|
||||
borderColor: state.mode === 'friends5' ? '#3b82f6' : '#e5e7eb',
|
||||
background: state.mode === 'friends5'
|
||||
? 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)'
|
||||
: 'white',
|
||||
color: state.mode === 'friends5' ? '#1e40af' : '#6b7280',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
boxShadow: state.mode === 'friends5' ? '0 4px 12px rgba(59, 130, 246, 0.2)' : 'none'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px' }}>5️⃣</span>
|
||||
<span style={{ fontSize: '15px' }}>Friends of 5</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleModeSelect('friends10')}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: '10px',
|
||||
border: '3px solid',
|
||||
borderColor: state.mode === 'friends10' ? '#3b82f6' : '#e5e7eb',
|
||||
background: state.mode === 'friends10'
|
||||
? 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)'
|
||||
: 'white',
|
||||
color: state.mode === 'friends10' ? '#1e40af' : '#6b7280',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
boxShadow: state.mode === 'friends10' ? '0 4px 12px rgba(59, 130, 246, 0.2)' : 'none'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px' }}>🔟</span>
|
||||
<span style={{ fontSize: '15px' }}>Friends of 10</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleModeSelect('mixed')}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: '10px',
|
||||
border: '3px solid',
|
||||
borderColor: state.mode === 'mixed' ? '#3b82f6' : '#e5e7eb',
|
||||
background: state.mode === 'mixed'
|
||||
? 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)'
|
||||
: 'white',
|
||||
color: state.mode === 'mixed' ? '#1e40af' : '#6b7280',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
boxShadow: state.mode === 'mixed' ? '0 4px 12px rgba(59, 130, 246, 0.2)' : 'none'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px' }}>🎲</span>
|
||||
<span style={{ fontSize: '15px' }}>Mixed</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game Style Selection */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.08)',
|
||||
textAlign: 'left'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
color: '#1f2937'
|
||||
}}>
|
||||
Race Type
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => handleStyleSelect('practice')}
|
||||
style={{
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
border: '3px solid',
|
||||
borderColor: state.style === 'practice' ? '#10b981' : '#e5e7eb',
|
||||
background: state.style === 'practice'
|
||||
? 'linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%)'
|
||||
: 'white',
|
||||
color: state.style === 'practice' ? '#047857' : '#6b7280',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'left',
|
||||
boxShadow: state.style === 'practice' ? '0 4px 12px rgba(16, 185, 129, 0.2)' : 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '6px' }}>
|
||||
<span style={{ fontSize: '28px' }}>🏁</span>
|
||||
<span style={{ fontSize: '16px' }}>Practice Mode</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', opacity: 0.85, lineHeight: '1.4' }}>
|
||||
Race AI opponents on a linear track. First to reach 20 correct answers wins! Perfect for building speed and accuracy.
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleStyleSelect('sprint')}
|
||||
style={{
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
border: '3px solid',
|
||||
borderColor: state.style === 'sprint' ? '#f59e0b' : '#e5e7eb',
|
||||
background: state.style === 'sprint'
|
||||
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
|
||||
: 'white',
|
||||
color: state.style === 'sprint' ? '#d97706' : '#6b7280',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'left',
|
||||
boxShadow: state.style === 'sprint' ? '0 4px 12px rgba(245, 158, 11, 0.2)' : 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '6px' }}>
|
||||
<span style={{ fontSize: '28px' }}>🚂</span>
|
||||
<span style={{ fontSize: '16px' }}>Steam Sprint</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', opacity: 0.85, lineHeight: '1.4' }}>
|
||||
Keep your train moving for 60 seconds! Build momentum with correct answers. Pick up passengers and reach stations!
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleStyleSelect('survival')}
|
||||
style={{
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
border: '3px solid',
|
||||
borderColor: state.style === 'survival' ? '#8b5cf6' : '#e5e7eb',
|
||||
background: state.style === 'survival'
|
||||
? 'linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%)'
|
||||
: 'white',
|
||||
color: state.style === 'survival' ? '#6b21a8' : '#6b7280',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'left',
|
||||
boxShadow: state.style === 'survival' ? '0 4px 12px rgba(139, 92, 246, 0.2)' : 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '6px' }}>
|
||||
<span style={{ fontSize: '28px' }}>🔄</span>
|
||||
<span style={{ fontSize: '16px' }}>Survival Mode</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', opacity: 0.85, lineHeight: '1.4' }}>
|
||||
Endless circular race! Complete laps before AI opponents catch you. How long can you survive?
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeout Setting Selection - Full width below */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
background: 'rgba(30, 41, 59, 0.8)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: '16px',
|
||||
padding: '20px',
|
||||
marginBottom: '24px',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.08)',
|
||||
textAlign: 'left'
|
||||
padding: '16px',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
color: '#1f2937'
|
||||
}}>
|
||||
Difficulty Level
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))',
|
||||
gap: '10px'
|
||||
display: 'flex',
|
||||
gap: '20px',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{(['preschool', 'kindergarten', 'relaxed', 'slow', 'normal', 'fast', 'expert'] as TimeoutSetting[]).map((timeout) => (
|
||||
<button
|
||||
key={timeout}
|
||||
onClick={() => handleTimeoutSelect(timeout)}
|
||||
style={{
|
||||
padding: '12px 10px',
|
||||
borderRadius: '10px',
|
||||
border: '3px solid',
|
||||
borderColor: state.timeoutSetting === timeout ? '#ec4899' : '#e5e7eb',
|
||||
background: state.timeoutSetting === timeout
|
||||
? 'linear-gradient(135deg, #fce7f3 0%, #fbcfe8 100%)'
|
||||
: 'white',
|
||||
color: state.timeoutSetting === timeout ? '#be185d' : '#6b7280',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '14px',
|
||||
boxShadow: state.timeoutSetting === timeout ? '0 4px 12px rgba(236, 72, 153, 0.2)' : 'none'
|
||||
}}
|
||||
>
|
||||
{timeout.charAt(0).toUpperCase() + timeout.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Number Mode Pills */}
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flex: 1, minWidth: '200px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#94a3b8', fontWeight: '600', marginRight: '4px' }}>Mode:</span>
|
||||
{[
|
||||
{ mode: 'friends5' as GameMode, label: '5' },
|
||||
{ mode: 'friends10' as GameMode, label: '10' },
|
||||
{ mode: 'mixed' as GameMode, label: 'Mix' }
|
||||
].map(({ mode, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => handleModeSelect(mode)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
background: state.mode === mode
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.mode === mode ? 'white' : '#94a3b8',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleStartRace}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
padding: '18px 56px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 6px 20px rgba(102, 126, 234, 0.4)',
|
||||
transition: 'all 0.2s ease',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
margin: '0 auto',
|
||||
{/* Complement Display Pills */}
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flex: 1, minWidth: '200px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#94a3b8', fontWeight: '600', marginRight: '4px' }}>Show:</span>
|
||||
{(['number', 'abacus', 'random'] as ComplementDisplay[]).map((displayMode) => (
|
||||
<button
|
||||
key={displayMode}
|
||||
onClick={() => dispatch({ type: 'SET_COMPLEMENT_DISPLAY', display: displayMode })}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
background: state.complementDisplay === displayMode
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.complementDisplay === displayMode ? 'white' : '#94a3b8',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
{displayMode === 'number' ? '123' : displayMode === 'abacus' ? '🧮' : '🎲'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Speed Pills */}
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flex: 1, minWidth: '200px', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '13px', color: '#94a3b8', fontWeight: '600', marginRight: '4px' }}>Speed:</span>
|
||||
{(['preschool', 'kindergarten', 'relaxed', 'slow', 'normal', 'fast', 'expert'] as TimeoutSetting[]).map((timeout) => (
|
||||
<button
|
||||
key={timeout}
|
||||
onClick={() => handleTimeoutSelect(timeout)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
background: state.timeoutSetting === timeout
|
||||
? 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.timeoutSetting === timeout ? 'white' : '#94a3b8',
|
||||
fontWeight: state.timeoutSetting === timeout ? 'bold' : 'normal',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
{timeout === 'preschool' ? 'Pre' : timeout === 'kindergarten' ? 'K' : timeout.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview - compact */}
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-3px)'
|
||||
e.currentTarget.style.boxShadow = '0 8px 24px rgba(102, 126, 234, 0.5)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(102, 126, 234, 0.4)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px' }}>🏁</span>
|
||||
<span>Start Your Race!</span>
|
||||
</button>
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', color: '#94a3b8', fontWeight: '600' }}>Preview:</span>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '6px'
|
||||
}}>
|
||||
?
|
||||
</div>
|
||||
<span style={{ fontSize: '16px', color: '#64748b' }}>+</span>
|
||||
{state.complementDisplay === 'number' ? (
|
||||
<span>3</span>
|
||||
) : state.complementDisplay === 'abacus' ? (
|
||||
<div style={{ transform: 'scale(0.8)' }}>
|
||||
<AbacusTarget number={3} />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '14px' }}>🎲</span>
|
||||
)}
|
||||
<span style={{ fontSize: '16px', color: '#64748b' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>{state.mode === 'friends5' ? '5' : state.mode === 'friends10' ? '10' : '?'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HERO SECTION - Race Cards */}
|
||||
<div data-component="race-cards-container" style={{
|
||||
flex: 1,
|
||||
padding: '0 20px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{[
|
||||
{
|
||||
style: 'practice' as GameStyle,
|
||||
emoji: '🏁',
|
||||
title: 'Practice Race',
|
||||
desc: 'Race against AI to 20 correct answers',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
shadowColor: 'rgba(16, 185, 129, 0.5)',
|
||||
accentColor: '#34d399'
|
||||
},
|
||||
{
|
||||
style: 'sprint' as GameStyle,
|
||||
emoji: '🚂',
|
||||
title: 'Steam Sprint',
|
||||
desc: 'High-speed 60-second train journey',
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
shadowColor: 'rgba(245, 158, 11, 0.5)',
|
||||
accentColor: '#fbbf24'
|
||||
},
|
||||
{
|
||||
style: 'survival' as GameStyle,
|
||||
emoji: '🔄',
|
||||
title: 'Survival Circuit',
|
||||
desc: 'Endless laps - beat your best time',
|
||||
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
|
||||
shadowColor: 'rgba(139, 92, 246, 0.5)',
|
||||
accentColor: '#a78bfa'
|
||||
}
|
||||
].map(({ style, emoji, title, desc, gradient, shadowColor, accentColor }) => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => handleStyleSelect(style)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
borderRadius: '24px',
|
||||
background: gradient,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`,
|
||||
transform: 'translateY(0)',
|
||||
flex: 1,
|
||||
minHeight: '140px',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-8px) scale(1.02)'
|
||||
e.currentTarget.style.boxShadow = `0 20px 60px ${shadowColor}, 0 0 0 2px ${accentColor}`
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)'
|
||||
e.currentTarget.style.boxShadow = `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`
|
||||
}}
|
||||
>
|
||||
{/* Shine effect overlay */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 50%)',
|
||||
pointerEvents: 'none'
|
||||
}} />
|
||||
|
||||
<div style={{
|
||||
padding: '28px 32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
zIndex: 1
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
flex: 1
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '64px',
|
||||
lineHeight: 1,
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))'
|
||||
}}>
|
||||
{emoji}
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
marginBottom: '6px',
|
||||
textShadow: '0 2px 8px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '15px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
textShadow: '0 1px 4px rgba(0,0,0,0.2)'
|
||||
}}>
|
||||
{desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PLAY NOW button */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
color: gradient.includes('10b981') ? '#047857' : gradient.includes('f59e0b') ? '#d97706' : '#6b21a8',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '18px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
<span>PLAY</span>
|
||||
<span style={{ fontSize: '24px' }}>▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { LinearTrack } from './RaceTrack/LinearTrack'
|
||||
import { CircularTrack } from './RaceTrack/CircularTrack'
|
||||
import { SteamTrainJourney } from './RaceTrack/SteamTrainJourney'
|
||||
import { RouteCelebration } from './RouteCelebration'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
import { generatePassengers } from '../lib/passengerGenerator'
|
||||
|
||||
type FeedbackAnimation = 'correct' | 'incorrect' | null
|
||||
@@ -293,72 +294,57 @@ export function GameDisplay() {
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
padding: '0 20px'
|
||||
padding: '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: '20px'
|
||||
}}>
|
||||
<div data-component="question-display" style={{
|
||||
display: 'flex',
|
||||
gap: '20px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: '5px'
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
borderRadius: '24px',
|
||||
padding: '28px 50px',
|
||||
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.3), 0 0 0 5px rgba(59, 130, 246, 0.4)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '4px solid rgba(255, 255, 255, 0.95)'
|
||||
}}>
|
||||
{/* Question */}
|
||||
<div data-component="question-card" style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 24px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
textAlign: 'center'
|
||||
{/* Complement equation as main focus */}
|
||||
<div data-element="question-equation" style={{
|
||||
fontSize: '96px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
lineHeight: '1.1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div data-element="question-equation" style={{
|
||||
fontSize: '14px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
? + {state.currentQuestion.number} = {state.currentQuestion.targetSum}
|
||||
</div>
|
||||
<div data-element="question-number" style={{
|
||||
fontSize: '60px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937'
|
||||
}}>
|
||||
{state.currentQuestion.number}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div data-component="answer-input" 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',
|
||||
animation: feedbackAnimation === 'correct'
|
||||
? 'successPulse 0.5s ease'
|
||||
: feedbackAnimation === 'incorrect'
|
||||
? 'errorShake 0.5s ease'
|
||||
: undefined
|
||||
}}>
|
||||
<div data-element="input-value" style={{
|
||||
fontSize: '60px',
|
||||
fontWeight: 'bold',
|
||||
<span style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
minHeight: '70px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textShadow: '0 2px 10px rgba(0, 0, 0, 0.2)'
|
||||
padding: '12px 32px',
|
||||
borderRadius: '16px',
|
||||
minWidth: '140px',
|
||||
display: 'inline-block',
|
||||
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
{state.currentInput || '_'}
|
||||
</div>
|
||||
<div data-element="input-hint" style={{
|
||||
fontSize: '12px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
Type your answer
|
||||
</div>
|
||||
{state.currentInput || '?'}
|
||||
</span>
|
||||
<span style={{ color: '#6b7280' }}>+</span>
|
||||
{state.currentQuestion.showAsAbacus ? (
|
||||
<div style={{
|
||||
transform: 'scale(2.4)',
|
||||
transformOrigin: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<AbacusTarget number={state.currentQuestion.number} />
|
||||
</div>
|
||||
) : (
|
||||
<span>{state.currentQuestion.number}</span>
|
||||
)}
|
||||
<span style={{ color: '#6b7280' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>{state.currentQuestion.targetSum}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Station, Passenger } from '../../lib/gameTypes'
|
||||
import type { Station, Passenger, ComplementQuestion } from '../../lib/gameTypes'
|
||||
import { PassengerCard } from '../PassengerCard'
|
||||
import { PressureGauge } from '../PressureGauge'
|
||||
import { AbacusTarget } from '../AbacusTarget'
|
||||
|
||||
interface RouteTheme {
|
||||
emoji: string
|
||||
@@ -18,11 +19,7 @@ interface GameHUDProps {
|
||||
pressure: number
|
||||
nonDeliveredPassengers: Passenger[]
|
||||
stations: Station[]
|
||||
currentQuestion: {
|
||||
number: number
|
||||
targetSum: number
|
||||
correctAnswer: number
|
||||
} | null
|
||||
currentQuestion: ComplementQuestion | null
|
||||
currentInput: string
|
||||
}
|
||||
|
||||
@@ -173,7 +170,19 @@ export const GameHUD = memo(({
|
||||
{currentInput || '?'}
|
||||
</span>
|
||||
<span style={{ color: '#6b7280' }}>+</span>
|
||||
<span>{currentQuestion.number}</span>
|
||||
{currentQuestion.showAsAbacus ? (
|
||||
<div style={{
|
||||
transform: 'scale(2.4)',
|
||||
transformOrigin: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<AbacusTarget number={currentQuestion.number} />
|
||||
</div>
|
||||
) : (
|
||||
<span>{currentQuestion.number}</span>
|
||||
)}
|
||||
<span style={{ color: '#6b7280' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>{currentQuestion.targetSum}</span>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@ const initialState: GameState = {
|
||||
mode: 'friends5',
|
||||
style: 'practice',
|
||||
timeoutSetting: 'normal',
|
||||
complementDisplay: 'abacus', // Default to showing abacus
|
||||
|
||||
// Current question
|
||||
currentQuestion: null,
|
||||
@@ -121,6 +122,9 @@ function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
case 'SET_TIMEOUT':
|
||||
return { ...state, timeoutSetting: action.timeout }
|
||||
|
||||
case 'SET_COMPLEMENT_DISPLAY':
|
||||
return { ...state, complementDisplay: action.display }
|
||||
|
||||
case 'SHOW_CONTROLS':
|
||||
return { ...state, gamePhase: 'controls' }
|
||||
|
||||
@@ -143,10 +147,15 @@ function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
? Math.floor(Math.random() * 5)
|
||||
: Math.floor(Math.random() * 10)
|
||||
|
||||
// Decide once whether to show as abacus
|
||||
const showAsAbacus = state.complementDisplay === 'abacus' ||
|
||||
(state.complementDisplay === 'random' && Math.random() < 0.5)
|
||||
|
||||
return {
|
||||
number: newNumber,
|
||||
targetSum,
|
||||
correctAnswer: targetSum - newNumber
|
||||
correctAnswer: targetSum - newNumber,
|
||||
showAsAbacus
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,10 +197,15 @@ function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
attempts < 10
|
||||
)
|
||||
|
||||
// Decide once whether to show as abacus
|
||||
const showAsAbacus = state.complementDisplay === 'abacus' ||
|
||||
(state.complementDisplay === 'random' && Math.random() < 0.5)
|
||||
|
||||
return {
|
||||
number: newNumber,
|
||||
targetSum,
|
||||
correctAnswer: targetSum - newNumber
|
||||
correctAnswer: targetSum - newNumber,
|
||||
showAsAbacus
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +306,7 @@ function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
mode: state.mode,
|
||||
style: state.style,
|
||||
timeoutSetting: state.timeoutSetting,
|
||||
complementDisplay: state.complementDisplay,
|
||||
gamePhase: 'controls'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Unit tests for passenger boarding/delivery logic in useSteamJourney
|
||||
*
|
||||
* These tests ensure that:
|
||||
* 1. Passengers always board when an empty car reaches their origin station
|
||||
* 2. Passengers are never left behind
|
||||
* 3. Multiple passengers can board at the same station on different cars
|
||||
* 4. Passengers are delivered to the correct destination
|
||||
*/
|
||||
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { ReactNode } from 'react'
|
||||
import { ComplementRaceProvider } from '../../context/ComplementRaceContext'
|
||||
import { useSteamJourney } from '../useSteamJourney'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
|
||||
// Mock sound effects
|
||||
jest.mock('../useSoundEffects', () => ({
|
||||
useSoundEffects: () => ({
|
||||
playSound: jest.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
// Wrapper component
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ComplementRaceProvider initialStyle="sprint">
|
||||
{children}
|
||||
</ComplementRaceProvider>
|
||||
)
|
||||
|
||||
// Helper to create test passengers
|
||||
const createPassenger = (
|
||||
id: string,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
isBoarded = false,
|
||||
isDelivered = false
|
||||
): Passenger => ({
|
||||
id,
|
||||
name: `Passenger ${id}`,
|
||||
avatar: '👤',
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
isUrgent: false,
|
||||
isBoarded,
|
||||
isDelivered
|
||||
})
|
||||
|
||||
// Test stations
|
||||
const testStations: Station[] = [
|
||||
{ id: 'station-0', name: 'Start', position: 0, icon: '🏁' },
|
||||
{ id: 'station-1', name: 'Middle', position: 50, icon: '🏢' },
|
||||
{ id: 'station-2', name: 'End', position: 100, icon: '🏁' }
|
||||
]
|
||||
|
||||
describe('useSteamJourney - Passenger Boarding', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test('passenger boards when train reaches their origin station', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
}, { wrapper })
|
||||
|
||||
// Setup: Add passenger waiting at station-1 (position 50)
|
||||
const passenger = createPassenger('p1', 'station-1', 'station-2')
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers: [passenger]
|
||||
})
|
||||
// Set train position just before station-1
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 40, // First car will be at ~33 (40 - 7)
|
||||
pressure: 75,
|
||||
elapsedTime: 1000
|
||||
})
|
||||
})
|
||||
|
||||
// Verify passenger is waiting
|
||||
expect(result.current.race.state.passengers[0].isBoarded).toBe(false)
|
||||
|
||||
// Move train to station-1 position
|
||||
act(() => {
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 57, // First car at position 50 (57 - 7)
|
||||
pressure: 75,
|
||||
elapsedTime: 2000
|
||||
})
|
||||
})
|
||||
|
||||
// Advance timers to trigger the interval
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Verify passenger boarded
|
||||
const boardedPassenger = result.current.race.state.passengers.find(p => p.id === 'p1')
|
||||
expect(boardedPassenger?.isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('multiple passengers can board at the same station on different cars', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
}, { wrapper })
|
||||
|
||||
// Setup: Three passengers waiting at station-1
|
||||
const passengers = [
|
||||
createPassenger('p1', 'station-1', 'station-2'),
|
||||
createPassenger('p2', 'station-1', 'station-2'),
|
||||
createPassenger('p3', 'station-1', 'station-2')
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers
|
||||
})
|
||||
// Set train with 3 empty cars approaching station-1 (position 50)
|
||||
// Cars at: 50 (57-7), 43 (57-14), 36 (57-21)
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 60,
|
||||
trainPosition: 57,
|
||||
pressure: 90,
|
||||
elapsedTime: 1000
|
||||
})
|
||||
})
|
||||
|
||||
// Advance timers
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// All three passengers should board (one per car)
|
||||
const boardedCount = result.current.race.state.passengers.filter(p => p.isBoarded).length
|
||||
expect(boardedCount).toBe(3)
|
||||
})
|
||||
|
||||
test('passenger is not left behind when train passes quickly', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
}, { wrapper })
|
||||
|
||||
const passenger = createPassenger('p1', 'station-1', 'station-2')
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers: [passenger]
|
||||
})
|
||||
})
|
||||
|
||||
// Simulate train passing through station quickly
|
||||
const positions = [40, 45, 50, 52, 54, 56, 58, 60, 65, 70]
|
||||
|
||||
for (const pos of positions) {
|
||||
act(() => {
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 80,
|
||||
trainPosition: pos,
|
||||
pressure: 120,
|
||||
elapsedTime: 1000 + pos * 50
|
||||
})
|
||||
jest.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
// Check if passenger boarded
|
||||
const boardedPassenger = result.current.race.state.passengers.find(p => p.id === 'p1')
|
||||
if (boardedPassenger?.isBoarded) {
|
||||
// Success! Passenger boarded during the pass
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, passenger was left behind
|
||||
const boardedPassenger = result.current.race.state.passengers.find(p => p.id === 'p1')
|
||||
expect(boardedPassenger?.isBoarded).toBe(true)
|
||||
})
|
||||
|
||||
test('passenger boards on correct car based on availability', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
}, { wrapper })
|
||||
|
||||
// Setup: One passenger already on car 0, another waiting
|
||||
const passengers = [
|
||||
createPassenger('p1', 'station-0', 'station-2', true, false), // Already boarded on car 0
|
||||
createPassenger('p2', 'station-1', 'station-2') // Waiting at station-1
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers
|
||||
})
|
||||
// Train at station-1, car 0 occupied, car 1 empty
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 57, // Car 0 at 50, Car 1 at 43
|
||||
pressure: 75,
|
||||
elapsedTime: 2000
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// p2 should board (on car 1 since car 0 is occupied)
|
||||
const p2 = result.current.race.state.passengers.find(p => p.id === 'p2')
|
||||
expect(p2?.isBoarded).toBe(true)
|
||||
|
||||
// p1 should still be boarded
|
||||
const p1 = result.current.race.state.passengers.find(p => p.id === 'p1')
|
||||
expect(p1?.isBoarded).toBe(true)
|
||||
expect(p1?.isDelivered).toBe(false)
|
||||
})
|
||||
|
||||
test('passenger is delivered when their car reaches destination', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const journey = useSteamJourney()
|
||||
const race = useComplementRace()
|
||||
return { journey, race }
|
||||
}, { wrapper })
|
||||
|
||||
// Setup: Passenger already boarded, heading to station-2 (position 100)
|
||||
const passenger = createPassenger('p1', 'station-0', 'station-2', true, false)
|
||||
|
||||
act(() => {
|
||||
result.current.race.dispatch({ type: 'BEGIN_GAME' })
|
||||
result.current.race.dispatch({
|
||||
type: 'GENERATE_PASSENGERS',
|
||||
passengers: [passenger]
|
||||
})
|
||||
// Move train so car 0 reaches station-2
|
||||
result.current.race.dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: 50,
|
||||
trainPosition: 107, // Car 0 at position 100 (107 - 7)
|
||||
pressure: 75,
|
||||
elapsedTime: 5000
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Passenger should be delivered
|
||||
const deliveredPassenger = result.current.race.state.passengers.find(p => p.id === 'p1')
|
||||
expect(deliveredPassenger?.isDelivered).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -114,6 +114,12 @@ export function useSteamJourney() {
|
||||
const maxCars = Math.max(1, maxPassengers)
|
||||
const currentBoardedPassengers = state.passengers.filter(p => p.isBoarded && !p.isDelivered)
|
||||
|
||||
// Build a map of which cars are occupied (car index -> passenger)
|
||||
const occupiedCars = new Map<number, typeof currentBoardedPassengers[0]>()
|
||||
currentBoardedPassengers.forEach((passenger, arrayIndex) => {
|
||||
occupiedCars.set(arrayIndex, passenger)
|
||||
})
|
||||
|
||||
// Track which cars are assigned in THIS frame to prevent double-boarding
|
||||
const carsAssignedThisFrame = new Set<number>()
|
||||
|
||||
@@ -128,13 +134,14 @@ export function useSteamJourney() {
|
||||
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (currentBoardedPassengers[carIndex] || carsAssignedThisFrame.has(carIndex)) continue
|
||||
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
|
||||
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If car is at station (within 3% tolerance), board this passenger
|
||||
if (distance < 3) {
|
||||
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
|
||||
// Increased tolerance to ensure fast-moving trains don't miss passengers
|
||||
if (distance < 5) {
|
||||
dispatch({
|
||||
type: 'BOARD_PASSENGER',
|
||||
passengerId: passenger.id
|
||||
@@ -158,8 +165,8 @@ export function useSteamJourney() {
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If this car is at the destination station (within 3% tolerance), deliver
|
||||
if (distance < 3) {
|
||||
// If this car is at the destination station (within 5% tolerance), deliver
|
||||
if (distance < 5) {
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
dispatch({
|
||||
type: 'DELIVER_PASSENGER',
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export type GameMode = 'friends5' | 'friends10' | 'mixed'
|
||||
export type GameStyle = 'practice' | 'sprint' | 'survival'
|
||||
export type TimeoutSetting = 'preschool' | 'kindergarten' | 'relaxed' | 'slow' | 'normal' | 'fast' | 'expert'
|
||||
export type ComplementDisplay = 'number' | 'abacus' | 'random' // How to display the complement number
|
||||
|
||||
export interface ComplementQuestion {
|
||||
number: number
|
||||
targetSum: number
|
||||
correctAnswer: number
|
||||
showAsAbacus: boolean // For random mode, this is decided once per question
|
||||
}
|
||||
|
||||
export interface AIRacer {
|
||||
@@ -61,6 +63,7 @@ export interface GameState {
|
||||
mode: GameMode
|
||||
style: GameStyle
|
||||
timeoutSetting: TimeoutSetting
|
||||
complementDisplay: ComplementDisplay // How to display the complement number
|
||||
|
||||
// Current question
|
||||
currentQuestion: ComplementQuestion | null
|
||||
@@ -122,6 +125,7 @@ export type GameAction =
|
||||
| { type: 'SET_MODE'; mode: GameMode }
|
||||
| { type: 'SET_STYLE'; style: GameStyle }
|
||||
| { type: 'SET_TIMEOUT'; timeout: TimeoutSetting }
|
||||
| { type: 'SET_COMPLEMENT_DISPLAY'; display: ComplementDisplay }
|
||||
| { type: 'SHOW_CONTROLS' }
|
||||
| { type: 'START_COUNTDOWN' }
|
||||
| { type: 'BEGIN_GAME' }
|
||||
|
||||
@@ -1203,4 +1203,256 @@ The system allows users to see both the overall group context and the specific t
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Invisible Column Posts
|
||||
export const InvisibleColumnPosts: Story = {
|
||||
render: () => (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: '20px', color: '#2c3e50' }}>
|
||||
Invisible Column Posts
|
||||
</h3>
|
||||
<p style={{ marginBottom: '20px', color: '#666', maxWidth: '500px', margin: '0 auto 20px' }}>
|
||||
Column posts can be made completely invisible using <code>opacity: 0</code>, creating a floating bead effect ideal for inline displays.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '40px', justifyContent: 'center', alignItems: 'center' }}>
|
||||
{/* Default visible posts */}
|
||||
<div>
|
||||
<div style={{ marginBottom: '10px', fontSize: '14px', fontWeight: 'bold', color: '#6b7280' }}>
|
||||
Default (Visible Posts)
|
||||
</div>
|
||||
<AbacusReact
|
||||
value={7}
|
||||
columns={1}
|
||||
scaleFactor={1.5}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Invisible posts */}
|
||||
<div>
|
||||
<div style={{ marginBottom: '10px', fontSize: '14px', fontWeight: 'bold', color: '#6b7280' }}>
|
||||
Invisible Posts
|
||||
</div>
|
||||
<AbacusReact
|
||||
value={7}
|
||||
columns={1}
|
||||
scaleFactor={1.5}
|
||||
showNumbers={true}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px', fontSize: '14px', color: '#666' }}>
|
||||
Notice how the beads appear to float when the column post is invisible
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
Makes column posts completely invisible using the \`columnPosts\` customStyles property. This creates a clean, minimal display where only the beads are visible.
|
||||
|
||||
**Use Cases:**
|
||||
- Inline equation displays (like complement race game)
|
||||
- Minimalist UI designs
|
||||
- Focus on bead patterns without structural elements
|
||||
- Embedded abacus representations
|
||||
|
||||
\`\`\`typescript
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 }
|
||||
}}
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Single Column Inline
|
||||
export const SingleColumnInline: Story = {
|
||||
render: () => (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: '20px', color: '#2c3e50' }}>
|
||||
Single Column for Inline Equations
|
||||
</h3>
|
||||
<p style={{ marginBottom: '20px', color: '#666', maxWidth: '600px', margin: '0 auto 20px' }}>
|
||||
Demonstrates single-column abacus with invisible posts and no numbers, perfect for embedding in complement equations.
|
||||
</p>
|
||||
|
||||
{/* Example equation like in complement race */}
|
||||
<div style={{
|
||||
fontSize: '72px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '30px'
|
||||
}}>
|
||||
<span style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '8px 24px',
|
||||
borderRadius: '12px',
|
||||
minWidth: '100px',
|
||||
display: 'inline-block',
|
||||
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
?
|
||||
</span>
|
||||
<span style={{ color: '#6b7280' }}>+</span>
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0
|
||||
}}>
|
||||
<AbacusReact
|
||||
value={3}
|
||||
columns={1}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
scaleFactor={0.72}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ color: '#6b7280' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>10</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px', fontSize: '14px', color: '#666' }}>
|
||||
The abacus integrates seamlessly into the equation with no visible structure
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
This pattern is used in the complement race game to display abacus representations inline with equations. Key features:
|
||||
|
||||
- **Single column** for individual digit display
|
||||
- **Invisible posts** (\`opacity: 0\`)
|
||||
- **No numbers** (\`showNumbers: false\`)
|
||||
- **Small scale** (\`scaleFactor: 0.72\`)
|
||||
- **Inline-flex container** for proper vertical alignment
|
||||
|
||||
\`\`\`typescript
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0
|
||||
}}>
|
||||
<AbacusReact
|
||||
value={3}
|
||||
columns={1}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
scaleFactor={0.72}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Multi-Column Invisible
|
||||
export const MultiColumnInvisible: Story = {
|
||||
render: () => (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: '20px', color: '#2c3e50' }}>
|
||||
Multi-Column Invisible Posts
|
||||
</h3>
|
||||
<p style={{ marginBottom: '20px', color: '#666', maxWidth: '600px', margin: '0 auto 20px' }}>
|
||||
Multi-column abacus with invisible posts creates a floating bead pattern that emphasizes the bead positions.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '60px', justifyContent: 'center', alignItems: 'center', marginBottom: '30px' }}>
|
||||
{/* With visible posts */}
|
||||
<div>
|
||||
<div style={{ marginBottom: '15px', fontSize: '14px', fontWeight: 'bold', color: '#6b7280' }}>
|
||||
Standard Display
|
||||
</div>
|
||||
<AbacusReact
|
||||
value={1234}
|
||||
columns={4}
|
||||
scaleFactor={1.3}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* With invisible posts */}
|
||||
<div>
|
||||
<div style={{ marginBottom: '15px', fontSize: '14px', fontWeight: 'bold', color: '#6b7280' }}>
|
||||
Invisible Posts
|
||||
</div>
|
||||
<AbacusReact
|
||||
value={1234}
|
||||
columns={4}
|
||||
scaleFactor={1.3}
|
||||
showNumbers={true}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px', fontSize: '14px', color: '#666', maxWidth: '600px', margin: '0 auto' }}>
|
||||
The invisible posts create a unique visual effect where beads appear to float in organized columns
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
Multi-column abacus with invisible posts demonstrates the floating bead effect across multiple place values.
|
||||
|
||||
**Visual Effects:**
|
||||
- Beads appear to float in precise vertical alignment
|
||||
- Column structure is implied by bead positioning
|
||||
- Creates a more abstract, pattern-focused display
|
||||
- Useful for minimalist or artistic presentations
|
||||
|
||||
**Global Column Post Styling:**
|
||||
|
||||
The \`columnPosts\` property in \`customStyles\` applies to all columns globally:
|
||||
|
||||
\`\`\`typescript
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 } // Applies to ALL columns
|
||||
}}
|
||||
\`\`\`
|
||||
|
||||
To hide specific columns only, use the column-specific approach:
|
||||
|
||||
\`\`\`typescript
|
||||
customStyles={{
|
||||
columns: {
|
||||
0: { columnPost: { opacity: 0 } }, // Hide first column only
|
||||
2: { columnPost: { opacity: 0 } } // Hide third column only
|
||||
}
|
||||
}}
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user