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:
Thomas Hallock
2025-10-01 17:13:54 -05:00
parent 63b0b552a8
commit 2ed7b2cbf8
9 changed files with 974 additions and 386 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,280 @@
/**
* Unit tests for passenger boarding/delivery logic in useSteamJourney
*
* These tests ensure that:
* 1. Passengers always board when an empty car reaches their origin station
* 2. Passengers are never left behind
* 3. Multiple passengers can board at the same station on different cars
* 4. Passengers are delivered to the correct destination
*/
import { renderHook, act } from '@testing-library/react'
import { ReactNode } from 'react'
import { ComplementRaceProvider } from '../../context/ComplementRaceContext'
import { useSteamJourney } from '../useSteamJourney'
import { useComplementRace } from '../../context/ComplementRaceContext'
import type { Passenger, Station } from '../../lib/gameTypes'
// Mock sound effects
jest.mock('../useSoundEffects', () => ({
useSoundEffects: () => ({
playSound: jest.fn()
})
}))
// Wrapper component
const wrapper = ({ children }: { children: ReactNode }) => (
<ComplementRaceProvider initialStyle="sprint">
{children}
</ComplementRaceProvider>
)
// Helper to create test passengers
const createPassenger = (
id: string,
originStationId: string,
destinationStationId: string,
isBoarded = false,
isDelivered = false
): Passenger => ({
id,
name: `Passenger ${id}`,
avatar: '👤',
originStationId,
destinationStationId,
isUrgent: false,
isBoarded,
isDelivered
})
// Test stations
const testStations: Station[] = [
{ id: 'station-0', name: 'Start', position: 0, icon: '🏁' },
{ id: 'station-1', name: 'Middle', position: 50, icon: '🏢' },
{ id: 'station-2', name: 'End', position: 100, icon: '🏁' }
]
describe('useSteamJourney - Passenger Boarding', () => {
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.runOnlyPendingTimers()
jest.useRealTimers()
})
test('passenger boards when train reaches their origin station', () => {
const { result } = renderHook(() => {
const journey = useSteamJourney()
const race = useComplementRace()
return { journey, race }
}, { wrapper })
// Setup: Add passenger waiting at station-1 (position 50)
const passenger = createPassenger('p1', 'station-1', 'station-2')
act(() => {
result.current.race.dispatch({ type: 'BEGIN_GAME' })
result.current.race.dispatch({
type: 'GENERATE_PASSENGERS',
passengers: [passenger]
})
// Set train position just before station-1
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 50,
trainPosition: 40, // First car will be at ~33 (40 - 7)
pressure: 75,
elapsedTime: 1000
})
})
// Verify passenger is waiting
expect(result.current.race.state.passengers[0].isBoarded).toBe(false)
// Move train to station-1 position
act(() => {
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 50,
trainPosition: 57, // First car at position 50 (57 - 7)
pressure: 75,
elapsedTime: 2000
})
})
// Advance timers to trigger the interval
act(() => {
jest.advanceTimersByTime(100)
})
// Verify passenger boarded
const boardedPassenger = result.current.race.state.passengers.find(p => p.id === 'p1')
expect(boardedPassenger?.isBoarded).toBe(true)
})
test('multiple passengers can board at the same station on different cars', () => {
const { result } = renderHook(() => {
const journey = useSteamJourney()
const race = useComplementRace()
return { journey, race }
}, { wrapper })
// Setup: Three passengers waiting at station-1
const passengers = [
createPassenger('p1', 'station-1', 'station-2'),
createPassenger('p2', 'station-1', 'station-2'),
createPassenger('p3', 'station-1', 'station-2')
]
act(() => {
result.current.race.dispatch({ type: 'BEGIN_GAME' })
result.current.race.dispatch({
type: 'GENERATE_PASSENGERS',
passengers
})
// Set train with 3 empty cars approaching station-1 (position 50)
// Cars at: 50 (57-7), 43 (57-14), 36 (57-21)
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 60,
trainPosition: 57,
pressure: 90,
elapsedTime: 1000
})
})
// Advance timers
act(() => {
jest.advanceTimersByTime(100)
})
// All three passengers should board (one per car)
const boardedCount = result.current.race.state.passengers.filter(p => p.isBoarded).length
expect(boardedCount).toBe(3)
})
test('passenger is not left behind when train passes quickly', () => {
const { result } = renderHook(() => {
const journey = useSteamJourney()
const race = useComplementRace()
return { journey, race }
}, { wrapper })
const passenger = createPassenger('p1', 'station-1', 'station-2')
act(() => {
result.current.race.dispatch({ type: 'BEGIN_GAME' })
result.current.race.dispatch({
type: 'GENERATE_PASSENGERS',
passengers: [passenger]
})
})
// Simulate train passing through station quickly
const positions = [40, 45, 50, 52, 54, 56, 58, 60, 65, 70]
for (const pos of positions) {
act(() => {
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 80,
trainPosition: pos,
pressure: 120,
elapsedTime: 1000 + pos * 50
})
jest.advanceTimersByTime(50)
})
// Check if passenger boarded
const boardedPassenger = result.current.race.state.passengers.find(p => p.id === 'p1')
if (boardedPassenger?.isBoarded) {
// Success! Passenger boarded during the pass
return
}
}
// If we get here, passenger was left behind
const boardedPassenger = result.current.race.state.passengers.find(p => p.id === 'p1')
expect(boardedPassenger?.isBoarded).toBe(true)
})
test('passenger boards on correct car based on availability', () => {
const { result } = renderHook(() => {
const journey = useSteamJourney()
const race = useComplementRace()
return { journey, race }
}, { wrapper })
// Setup: One passenger already on car 0, another waiting
const passengers = [
createPassenger('p1', 'station-0', 'station-2', true, false), // Already boarded on car 0
createPassenger('p2', 'station-1', 'station-2') // Waiting at station-1
]
act(() => {
result.current.race.dispatch({ type: 'BEGIN_GAME' })
result.current.race.dispatch({
type: 'GENERATE_PASSENGERS',
passengers
})
// Train at station-1, car 0 occupied, car 1 empty
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 50,
trainPosition: 57, // Car 0 at 50, Car 1 at 43
pressure: 75,
elapsedTime: 2000
})
})
act(() => {
jest.advanceTimersByTime(100)
})
// p2 should board (on car 1 since car 0 is occupied)
const p2 = result.current.race.state.passengers.find(p => p.id === 'p2')
expect(p2?.isBoarded).toBe(true)
// p1 should still be boarded
const p1 = result.current.race.state.passengers.find(p => p.id === 'p1')
expect(p1?.isBoarded).toBe(true)
expect(p1?.isDelivered).toBe(false)
})
test('passenger is delivered when their car reaches destination', () => {
const { result } = renderHook(() => {
const journey = useSteamJourney()
const race = useComplementRace()
return { journey, race }
}, { wrapper })
// Setup: Passenger already boarded, heading to station-2 (position 100)
const passenger = createPassenger('p1', 'station-0', 'station-2', true, false)
act(() => {
result.current.race.dispatch({ type: 'BEGIN_GAME' })
result.current.race.dispatch({
type: 'GENERATE_PASSENGERS',
passengers: [passenger]
})
// Move train so car 0 reaches station-2
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 50,
trainPosition: 107, // Car 0 at position 100 (107 - 7)
pressure: 75,
elapsedTime: 5000
})
})
act(() => {
jest.advanceTimersByTime(100)
})
// Passenger should be delivered
const deliveredPassenger = result.current.race.state.passengers.find(p => p.id === 'p1')
expect(deliveredPassenger?.isDelivered).toBe(true)
})
})

View File

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

View File

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

View File

@@ -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
}
}}
\`\`\`
`,
},
},
},
};