feat: add arcade matching game components and utilities

- Add game components (GameCard, GamePhase, SetupPhase, MemoryGrid)
- Add player status bar with multiplayer support
- Add emoji picker for player customization
- Add card generation and validation utilities
- Add game scoring system with combo multipliers
- Add page route for arcade matching game

🤖 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-06 11:04:41 -05:00
parent 9d4cba05be
commit ff16303a7c
13 changed files with 4219 additions and 0 deletions

View File

@@ -0,0 +1,727 @@
'use client'
import { useState, useMemo } from 'react'
import { PLAYER_EMOJIS } from '../../../../constants/playerEmojis'
import { css } from '../../../../../styled-system/css'
import emojiData from 'emojibase-data/en/data.json'
// Proper TypeScript interface for emojibase-data structure
interface EmojibaseEmoji {
label: string
hexcode: string
tags?: string[]
emoji: string
text: string
type: number
order: number
group: number
subgroup: number
version: number
emoticon?: string | string[] // Can be string, array, or undefined
}
interface EmojiPickerProps {
currentEmoji: string
onEmojiSelect: (emoji: string) => void
onClose: () => void
playerNumber: number
}
// Emoji group categories from emojibase (matching Unicode CLDR group IDs)
const EMOJI_GROUPS = {
0: { name: 'Smileys & Emotion', icon: '😀' },
1: { name: 'People & Body', icon: '👤' },
3: { name: 'Animals & Nature', icon: '🐶' },
4: { name: 'Food & Drink', icon: '🍎' },
5: { name: 'Travel & Places', icon: '🚗' },
6: { name: 'Activities', icon: '⚽' },
7: { name: 'Objects', icon: '💡' },
8: { name: 'Symbols', icon: '❤️' },
9: { name: 'Flags', icon: '🏁' }
} as const
// Create a map of emoji to their searchable data and group
const emojiMap = new Map<string, { keywords: string[], group: number }>()
;(emojiData as EmojibaseEmoji[]).forEach((emoji) => {
if (emoji.emoji) {
// Handle emoticon field which can be string, array, or undefined
const emoticons: string[] = []
if (emoji.emoticon) {
if (Array.isArray(emoji.emoticon)) {
emoticons.push(...emoji.emoticon.map((e) => e.toLowerCase()))
} else {
emoticons.push(emoji.emoticon.toLowerCase())
}
}
emojiMap.set(emoji.emoji, {
keywords: [
emoji.label?.toLowerCase(),
...(emoji.tags || []).map((tag: string) => tag.toLowerCase()),
...emoticons
].filter(Boolean),
group: emoji.group
})
}
})
// Enhanced search function using emojibase-data
function getEmojiKeywords(emoji: string): string[] {
const data = emojiMap.get(emoji)
if (data) {
return data.keywords
}
// Fallback categories for emojis not in emojibase-data
if (/[\u{1F600}-\u{1F64F}]/u.test(emoji)) return ['face', 'emotion', 'person', 'expression']
if (/[\u{1F400}-\u{1F43F}]/u.test(emoji)) return ['animal', 'nature', 'cute', 'pet']
if (/[\u{1F440}-\u{1F4FF}]/u.test(emoji)) return ['object', 'symbol', 'tool']
if (/[\u{1F300}-\u{1F3FF}]/u.test(emoji)) return ['nature', 'travel', 'activity', 'place']
if (/[\u{1F680}-\u{1F6FF}]/u.test(emoji)) return ['transport', 'travel', 'vehicle']
if (/[\u{2600}-\u{26FF}]/u.test(emoji)) return ['symbol', 'misc', 'sign']
return ['misc', 'other']
}
export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber }: EmojiPickerProps) {
const [searchFilter, setSearchFilter] = useState('')
const [selectedCategory, setSelectedCategory] = useState<number | null>(null)
const [hoveredEmoji, setHoveredEmoji] = useState<string | null>(null)
const [hoverPosition, setHoverPosition] = useState({ x: 0, y: 0 })
// Enhanced search functionality - clear separation between default and search
const isSearching = searchFilter.trim().length > 0
const isCategoryFiltered = selectedCategory !== null && !isSearching
// Calculate which categories have emojis
const availableCategories = useMemo(() => {
const categoryCounts: Record<number, number> = {}
PLAYER_EMOJIS.forEach(emoji => {
const data = emojiMap.get(emoji)
if (data && data.group !== undefined) {
categoryCounts[data.group] = (categoryCounts[data.group] || 0) + 1
}
})
return Object.keys(EMOJI_GROUPS)
.map(Number)
.filter(groupId => categoryCounts[groupId] > 0)
}, [])
const displayEmojis = useMemo(() => {
// Start with all emojis
let emojis = PLAYER_EMOJIS
// Apply category filter first (unless searching)
if (isCategoryFiltered) {
emojis = emojis.filter(emoji => {
const data = emojiMap.get(emoji)
return data && data.group === selectedCategory
})
}
// Then apply search filter
if (!isSearching) {
return emojis
}
const searchTerm = searchFilter.toLowerCase().trim()
const results = PLAYER_EMOJIS.filter(emoji => {
const keywords = getEmojiKeywords(emoji)
return keywords.some(keyword =>
keyword && keyword.includes(searchTerm)
)
})
// Sort results by relevance
const sortedResults = results.sort((a, b) => {
const aKeywords = getEmojiKeywords(a)
const bKeywords = getEmojiKeywords(b)
// Exact match priority
const aExact = aKeywords.some(k => k === searchTerm)
const bExact = bKeywords.some(k => k === searchTerm)
if (aExact && !bExact) return -1
if (!aExact && bExact) return 1
// Word boundary matches (start of word)
const aStartsWithTerm = aKeywords.some(k => k && k.startsWith(searchTerm))
const bStartsWithTerm = bKeywords.some(k => k && k.startsWith(searchTerm))
if (aStartsWithTerm && !bStartsWithTerm) return -1
if (!aStartsWithTerm && bStartsWithTerm) return 1
// Score by number of matching keywords
const aScore = aKeywords.filter(k => k && k.includes(searchTerm)).length
const bScore = bKeywords.filter(k => k && k.includes(searchTerm)).length
return bScore - aScore
})
return sortedResults
}, [searchFilter, isSearching, selectedCategory, isCategoryFiltered])
return (
<div className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
animation: 'fadeIn 0.2s ease',
padding: '20px'
})}>
<div className={css({
background: 'white',
borderRadius: '20px',
padding: '24px',
width: '90vw',
height: '90vh',
maxWidth: '1200px',
maxHeight: '800px',
boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
})}>
{/* Header */}
<div className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
borderBottom: '2px solid',
borderColor: 'gray.100',
paddingBottom: '12px',
flexShrink: 0
})}>
<h3 className={css({
fontSize: '18px',
fontWeight: 'bold',
color: 'gray.800',
margin: 0
})}>
Choose Character for Player {playerNumber}
</h3>
<button
className={css({
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: 'gray.500',
_hover: { color: 'gray.700' },
padding: '4px'
})}
onClick={onClose}
>
</button>
</div>
{/* Current Selection & Search */}
<div className={css({
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '16px',
flexShrink: 0
})}>
<div className={css({
padding: '8px 12px',
background: playerNumber === 1
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
: playerNumber === 2
? 'linear-gradient(135deg, #fd79a8, #e84393)'
: playerNumber === 3
? 'linear-gradient(135deg, #a78bfa, #8b5cf6)'
: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
borderRadius: '12px',
color: 'white',
display: 'flex',
alignItems: 'center',
gap: '8px',
flexShrink: 0
})}>
<div className={css({ fontSize: '24px' })}>
{currentEmoji}
</div>
<div className={css({ fontSize: '12px', fontWeight: 'bold' })}>
Current
</div>
</div>
<input
type="text"
placeholder="Search: face, smart, heart, animal, food..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className={css({
flex: 1,
padding: '8px 12px',
border: '2px solid',
borderColor: 'gray.200',
borderRadius: '12px',
fontSize: '14px',
_focus: {
outline: 'none',
borderColor: 'blue.400',
boxShadow: '0 0 0 3px rgba(66, 153, 225, 0.1)'
}
})}
/>
{isSearching && (
<div className={css({
fontSize: '12px',
color: 'gray.600',
flexShrink: 0,
padding: '4px 8px',
background: displayEmojis.length > 0 ? 'green.100' : 'red.100',
borderRadius: '8px',
border: '1px solid',
borderColor: displayEmojis.length > 0 ? 'green.300' : 'red.300'
})}>
{displayEmojis.length > 0 ? `${displayEmojis.length} found` : '✗ No matches'}
</div>
)}
</div>
{/* Category Tabs */}
{!isSearching && (
<div className={css({
display: 'flex',
gap: '8px',
overflowX: 'auto',
paddingBottom: '8px',
marginBottom: '12px',
flexShrink: 0,
'&::-webkit-scrollbar': {
height: '6px'
},
'&::-webkit-scrollbar-thumb': {
background: '#cbd5e1',
borderRadius: '3px'
}
})}>
<button
onClick={() => setSelectedCategory(null)}
className={css({
padding: '8px 16px',
borderRadius: '20px',
border: selectedCategory === null ? '2px solid #3b82f6' : '2px solid #e5e7eb',
background: selectedCategory === null ? '#eff6ff' : 'white',
color: selectedCategory === null ? '#1e40af' : '#6b7280',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'all 0.2s ease',
_hover: {
background: selectedCategory === null ? '#dbeafe' : '#f9fafb',
transform: 'translateY(-1px)'
}
})}
>
All
</button>
{availableCategories.map((groupId) => {
const group = EMOJI_GROUPS[groupId as keyof typeof EMOJI_GROUPS]
return (
<button
key={groupId}
onClick={() => setSelectedCategory(Number(groupId))}
className={css({
padding: '8px 16px',
borderRadius: '20px',
border: selectedCategory === Number(groupId) ? '2px solid #3b82f6' : '2px solid #e5e7eb',
background: selectedCategory === Number(groupId) ? '#eff6ff' : 'white',
color: selectedCategory === Number(groupId) ? '#1e40af' : '#6b7280',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'all 0.2s ease',
_hover: {
background: selectedCategory === Number(groupId) ? '#dbeafe' : '#f9fafb',
transform: 'translateY(-1px)'
}
})}
>
{group.icon} {group.name}
</button>
)
})}
</div>
)}
{/* Search Mode Header */}
{isSearching && displayEmojis.length > 0 && (
<div className={css({
padding: '8px 12px',
background: 'blue.50',
border: '1px solid',
borderColor: 'blue.200',
borderRadius: '8px',
marginBottom: '12px',
flexShrink: 0
})}>
<div className={css({
fontSize: '14px',
fontWeight: 'bold',
color: 'blue.700',
marginBottom: '4px'
})}>
🔍 Search Results for "{searchFilter}"
</div>
<div className={css({
fontSize: '12px',
color: 'blue.600'
})}>
Showing {displayEmojis.length} of {PLAYER_EMOJIS.length} emojis Clear search to see all
</div>
</div>
)}
{/* Default Mode Header */}
{!isSearching && (
<div className={css({
padding: '8px 12px',
background: 'gray.50',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '8px',
marginBottom: '12px',
flexShrink: 0
})}>
<div className={css({
fontSize: '14px',
fontWeight: 'bold',
color: 'gray.700',
marginBottom: '4px'
})}>
{selectedCategory !== null
? `${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].icon} ${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].name}`
: '📝 All Available Characters'}
</div>
<div className={css({
fontSize: '12px',
color: 'gray.600'
})}>
{displayEmojis.length} emojis {selectedCategory !== null ? 'in category' : 'available'} Use search to find specific emojis
</div>
</div>
)}
{/* Emoji Grid - Only show when there are emojis to display */}
{displayEmojis.length > 0 && (
<div className={css({
flex: 1,
overflowY: 'auto',
minHeight: 0,
'&::-webkit-scrollbar': {
width: '10px'
},
'&::-webkit-scrollbar-track': {
background: '#f1f5f9',
borderRadius: '5px'
},
'&::-webkit-scrollbar-thumb': {
background: '#cbd5e1',
borderRadius: '5px',
'&:hover': {
background: '#94a3b8'
}
}
})}>
<div className={css({
display: 'grid',
gridTemplateColumns: 'repeat(16, 1fr)',
gap: '4px',
padding: '4px',
'@media (max-width: 1200px)': {
gridTemplateColumns: 'repeat(14, 1fr)'
},
'@media (max-width: 1000px)': {
gridTemplateColumns: 'repeat(12, 1fr)'
},
'@media (max-width: 800px)': {
gridTemplateColumns: 'repeat(10, 1fr)'
},
'@media (max-width: 600px)': {
gridTemplateColumns: 'repeat(8, 1fr)'
}
})}>
{displayEmojis.map(emoji => {
const isSelected = emoji === currentEmoji
const getSelectedBg = () => {
if (!isSelected) return 'transparent'
if (playerNumber === 1) return 'blue.100'
if (playerNumber === 2) return 'pink.100'
if (playerNumber === 3) return 'purple.100'
return 'yellow.100'
}
const getSelectedBorder = () => {
if (!isSelected) return 'transparent'
if (playerNumber === 1) return 'blue.400'
if (playerNumber === 2) return 'pink.400'
if (playerNumber === 3) return 'purple.400'
return 'yellow.400'
}
const getHoverBg = () => {
if (!isSelected) return 'gray.100'
if (playerNumber === 1) return 'blue.200'
if (playerNumber === 2) return 'pink.200'
if (playerNumber === 3) return 'purple.200'
return 'yellow.200'
}
return (
<button
key={emoji}
className={css({
aspectRatio: '1',
background: getSelectedBg(),
border: '2px solid',
borderColor: getSelectedBorder(),
borderRadius: '6px',
fontSize: '20px',
cursor: 'pointer',
transition: 'all 0.1s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
_hover: {
background: getHoverBg(),
transform: 'scale(1.15)',
zIndex: 1,
fontSize: '24px'
}
})}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
setHoveredEmoji(emoji)
setHoverPosition({
x: rect.left + rect.width / 2,
y: rect.top
})
}}
onMouseLeave={() => setHoveredEmoji(null)}
onClick={() => {
onEmojiSelect(emoji)
}}
>
{emoji}
</button>
)
})}
</div>
</div>
)}
{/* No results message */}
{isSearching && displayEmojis.length === 0 && (
<div className={css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
color: 'gray.500'
})}>
<div className={css({ fontSize: '48px', marginBottom: '16px' })}>🔍</div>
<div className={css({ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' })}>
No emojis found for "{searchFilter}"
</div>
<div className={css({ fontSize: '14px', marginBottom: '12px' })}>
Try searching for "face", "smart", "heart", "animal", "food", etc.
</div>
<button
className={css({
background: 'blue.500',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '8px 16px',
fontSize: '12px',
cursor: 'pointer',
_hover: { background: 'blue.600' }
})}
onClick={() => setSearchFilter('')}
>
Clear search to see all {PLAYER_EMOJIS.length} emojis
</button>
</div>
)}
{/* Quick selection hint */}
<div className={css({
marginTop: '8px',
padding: '6px 12px',
background: 'gray.50',
borderRadius: '8px',
fontSize: '11px',
color: 'gray.600',
textAlign: 'center',
flexShrink: 0
})}>
💡 Powered by emojibase-data Try: "face", "smart", "heart", "animal", "food" Click to select
</div>
</div>
{/* Magnifying Glass Preview - SUPER POWERED! */}
{hoveredEmoji && (
<div
style={{
position: 'fixed',
left: `${hoverPosition.x}px`,
top: `${hoverPosition.y - 120}px`,
transform: 'translateX(-50%)',
pointerEvents: 'none',
zIndex: 10000,
animation: 'magnifyIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)'
}}
>
{/* Outer glow ring */}
<div
style={{
position: 'absolute',
inset: '-20px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.3) 0%, transparent 70%)',
animation: 'pulseGlow 2s ease-in-out infinite'
}}
/>
{/* Main preview card */}
<div
style={{
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
borderRadius: '24px',
padding: '20px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.4), 0 0 0 4px rgba(59, 130, 246, 0.6), inset 0 2px 4px rgba(255,255,255,0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '120px',
lineHeight: 1,
minWidth: '160px',
minHeight: '160px',
position: 'relative',
animation: 'emojiFloat 3s ease-in-out infinite'
}}
>
{/* Sparkle effects */}
<div style={{
position: 'absolute',
top: '10px',
right: '10px',
fontSize: '20px',
animation: 'sparkle 1.5s ease-in-out infinite',
animationDelay: '0s'
}}></div>
<div style={{
position: 'absolute',
bottom: '15px',
left: '15px',
fontSize: '16px',
animation: 'sparkle 1.5s ease-in-out infinite',
animationDelay: '0.5s'
}}></div>
<div style={{
position: 'absolute',
top: '20px',
left: '20px',
fontSize: '12px',
animation: 'sparkle 1.5s ease-in-out infinite',
animationDelay: '1s'
}}></div>
{hoveredEmoji}
</div>
{/* Arrow pointing down with glow */}
<div
style={{
position: 'absolute',
bottom: '-12px',
left: '50%',
transform: 'translateX(-50%)',
width: 0,
height: 0,
borderLeft: '14px solid transparent',
borderRight: '14px solid transparent',
borderTop: '14px solid white',
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))'
}}
/>
</div>
)}
{/* Add magnifying animations */}
<style dangerouslySetInnerHTML={{ __html: `
@keyframes magnifyIn {
from {
opacity: 0;
transform: translateX(-50%) scale(0.5);
}
to {
opacity: 1;
transform: translateX(-50%) scale(1);
}
}
@keyframes pulseGlow {
0%, 100% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
@keyframes emojiFloat {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-5px);
}
}
@keyframes sparkle {
0%, 100% {
opacity: 0;
transform: scale(0.5) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(180deg);
}
}
` }} />
</div>
)
}
// Add fade in animation
const fadeInAnimation = `
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
`
// Inject animation styles
if (typeof document !== 'undefined' && !document.getElementById('emoji-picker-animations')) {
const style = document.createElement('style')
style.id = 'emoji-picker-animations'
style.textContent = fadeInAnimation
document.head.appendChild(style)
}

View File

@@ -0,0 +1,516 @@
'use client'
import { AbacusReact } from '@soroban/abacus-react'
import { useAbacusConfig } from '@soroban/abacus-react'
import { useGameMode } from '../../../../contexts/GameModeContext'
import type { GameCardProps } from '../context/types'
import { css } from '../../../../../styled-system/css'
export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false }: GameCardProps) {
const appConfig = useAbacusConfig()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// Get active players array for mapping numeric IDs to actual players
const activePlayers = Array.from(activePlayerIds)
.map(id => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
const cardBackStyles = css({
position: 'absolute',
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '28px',
fontWeight: 'bold',
textShadow: '1px 1px 2px rgba(0,0,0,0.3)',
cursor: disabled ? 'default' : 'pointer',
userSelect: 'none',
transition: 'all 0.2s ease'
})
const cardFrontStyles = css({
position: 'absolute',
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
borderRadius: '12px',
background: 'white',
border: '3px solid',
transform: 'rotateY(180deg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '8px',
overflow: 'hidden',
transition: 'all 0.2s ease'
})
// Dynamic styling based on card type and state
const getCardBackGradient = () => {
if (isMatched) {
// Player-specific colors for matched cards
if (card.matchedBy === 1) {
return 'linear-gradient(135deg, #74b9ff, #0984e3)' // Blue for player 1
} else if (card.matchedBy === 2) {
return 'linear-gradient(135deg, #fd79a8, #e84393)' // Pink for player 2
}
return 'linear-gradient(135deg, #48bb78, #38a169)' // Default green for single player
}
switch (card.type) {
case 'abacus':
return 'linear-gradient(135deg, #7b4397, #dc2430)'
case 'number':
return 'linear-gradient(135deg, #2E86AB, #A23B72)'
case 'complement':
return 'linear-gradient(135deg, #F18F01, #6A994E)'
default:
return 'linear-gradient(135deg, #667eea, #764ba2)'
}
}
const getCardBackIcon = () => {
if (isMatched) {
// Show player emoji for matched cards in multiplayer mode
if (card.matchedBy && card.matchedBy <= activePlayers.length) {
return activePlayers[card.matchedBy - 1]?.emoji || '✓'
}
return '✓' // Default checkmark for single player
}
switch (card.type) {
case 'abacus':
return '🧮'
case 'number':
return '🔢'
case 'complement':
return '🤝'
default:
return '❓'
}
}
const getBorderColor = () => {
if (isMatched) {
// Player-specific border colors for matched cards
if (card.matchedBy === 1) {
return '#74b9ff' // Blue for player 1
} else if (card.matchedBy === 2) {
return '#fd79a8' // Pink for player 2
}
return '#48bb78' // Default green for single player
}
if (isFlipped) return '#667eea'
return '#e2e8f0'
}
return (
<div
className={css({
perspective: '1000px',
width: '100%',
height: '100%',
cursor: disabled || isMatched ? 'default' : 'pointer',
transition: 'transform 0.2s ease',
_hover: disabled || isMatched ? {} : {
transform: 'translateY(-2px)'
}
})}
onClick={disabled || isMatched ? undefined : onClick}
>
<div
className={css({
position: 'relative',
width: '100%',
height: '100%',
textAlign: 'center',
transition: 'transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1)',
transformStyle: 'preserve-3d',
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)'
})}
>
{/* Card Back (hidden/face-down state) */}
<div
className={cardBackStyles}
style={{
background: getCardBackGradient()
}}
>
<div className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px'
})}>
<div className={css({ fontSize: '32px' })}>
{getCardBackIcon()}
</div>
{isMatched && (
<div className={css({ fontSize: '14px', opacity: 0.9 })}>
{card.matchedBy ? 'Claimed!' : 'Matched!'}
</div>
)}
</div>
</div>
{/* Card Front (revealed/face-up state) */}
<div
className={cardFrontStyles}
style={{
borderColor: getBorderColor(),
boxShadow: isMatched
? card.matchedBy === 1
? '0 0 20px rgba(116, 185, 255, 0.4)' // Blue glow for player 1
: card.matchedBy === 2
? '0 0 20px rgba(253, 121, 168, 0.4)' // Pink glow for player 2
: '0 0 20px rgba(72, 187, 120, 0.4)' // Default green glow
: isFlipped
? '0 0 15px rgba(102, 126, 234, 0.3)'
: 'none'
}}
>
{/* Player Badge for matched cards */}
{isMatched && card.matchedBy && (
<>
{/* Explosion Ring */}
<div className={css({
position: 'absolute',
top: '6px',
right: '6px',
width: '32px',
height: '32px',
borderRadius: '50%',
border: '3px solid',
borderColor: card.matchedBy === 1 ? '#74b9ff' : '#fd79a8',
animation: 'explosionRing 0.6s ease-out',
zIndex: 9
})} />
{/* Main Badge */}
<div className={css({
position: 'absolute',
top: '6px',
right: '6px',
width: '32px',
height: '32px',
borderRadius: '50%',
background: card.matchedBy === 1
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
: 'linear-gradient(135deg, #fd79a8, #e84393)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
boxShadow: card.matchedBy === 1
? '0 0 20px rgba(116, 185, 255, 0.6), 0 0 40px rgba(116, 185, 255, 0.4)'
: '0 0 20px rgba(253, 121, 168, 0.6), 0 0 40px rgba(253, 121, 168, 0.4)',
animation: 'epicClaim 1.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
zIndex: 10,
'&::before': {
content: '""',
position: 'absolute',
top: '-2px',
left: '-2px',
right: '-2px',
bottom: '-2px',
borderRadius: '50%',
background: card.matchedBy === 1
? 'linear-gradient(45deg, #74b9ff, #a29bfe, #6c5ce7, #74b9ff)'
: 'linear-gradient(45deg, #fd79a8, #fdcb6e, #e17055, #fd79a8)',
animation: 'spinningHalo 2s linear infinite',
zIndex: -1
}
})}>
<span className={css({
animation: 'emojiBlast 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55) 0.4s both',
filter: 'drop-shadow(0 0 8px rgba(255,255,255,0.8))'
})}>
{card.matchedBy && card.matchedBy <= activePlayers.length
? activePlayers[card.matchedBy - 1]?.emoji || '✓'
: '✓'}
</span>
</div>
{/* Sparkle Effects */}
{[...Array(6)].map((_, i) => (
<div
key={i}
className={css({
position: 'absolute',
top: '22px',
right: '22px',
width: '4px',
height: '4px',
background: '#ffeaa7',
borderRadius: '50%',
animation: `sparkle${i + 1} 1.5s ease-out`,
zIndex: 8
})}
/>
))}
</>
)}
{card.type === 'abacus' ? (
<div className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
'& svg': {
maxWidth: '100%',
maxHeight: '100%'
}
})}>
<AbacusReact
value={card.number}
columns="auto"
beadShape={appConfig.beadShape}
colorScheme={appConfig.colorScheme}
hideInactiveBeads={appConfig.hideInactiveBeads}
scaleFactor={0.8} // Smaller for card display
interactive={false}
showNumbers={false}
animated={false}
/>
</div>
) : card.type === 'number' ? (
<div className={css({
fontSize: '32px',
fontWeight: 'bold',
color: 'gray.800',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
})}>
{card.number}
</div>
) : card.type === 'complement' ? (
<div className={css({
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '4px'
})}>
<div className={css({
fontSize: '28px',
fontWeight: 'bold',
color: 'gray.800'
})}>
{card.number}
</div>
<div className={css({
fontSize: '16px',
color: 'gray.600',
display: 'flex',
alignItems: 'center',
gap: '4px'
})}>
<span>{card.targetSum === 5 ? '✋' : '🔟'}</span>
<span>Friends</span>
</div>
{card.complement !== undefined && (
<div className={css({
fontSize: '12px',
color: 'gray.500'
})}>
+ {card.complement} = {card.targetSum}
</div>
)}
</div>
) : (
<div className={css({
fontSize: '24px',
color: 'gray.500'
})}>
?
</div>
)}
</div>
</div>
{/* Match animation overlay */}
{isMatched && (
<div className={css({
position: 'absolute',
top: '-5px',
left: '-5px',
right: '-5px',
bottom: '-5px',
borderRadius: '16px',
background: 'linear-gradient(45deg, transparent, rgba(72, 187, 120, 0.3), transparent)',
animation: 'pulse 2s infinite',
pointerEvents: 'none',
zIndex: 1
})} />
)}
</div>
)
}
// Add global animation styles
const globalCardAnimations = `
@keyframes pulse {
0%, 100% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.02);
}
}
@keyframes explosionRing {
0% {
transform: scale(0);
opacity: 1;
}
50% {
opacity: 0.8;
}
100% {
transform: scale(4);
opacity: 0;
}
}
@keyframes epicClaim {
0% {
opacity: 0;
transform: scale(0) rotate(-360deg);
}
30% {
opacity: 1;
transform: scale(1.4) rotate(-180deg);
}
60% {
transform: scale(0.8) rotate(-90deg);
}
80% {
transform: scale(1.1) rotate(-30deg);
}
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
}
@keyframes emojiBlast {
0% {
transform: scale(0) rotate(180deg);
opacity: 0;
}
70% {
transform: scale(1.5) rotate(-10deg);
opacity: 1;
}
85% {
transform: scale(0.9) rotate(5deg);
}
100% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
@keyframes spinningHalo {
0% {
transform: rotate(0deg);
opacity: 0.8;
}
50% {
opacity: 1;
}
100% {
transform: rotate(360deg);
opacity: 0.8;
}
}
@keyframes sparkle1 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(-20px, -15px) scale(1); opacity: 0; }
}
@keyframes sparkle2 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(15px, -20px) scale(1); opacity: 0; }
}
@keyframes sparkle3 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(-25px, 10px) scale(1); opacity: 0; }
}
@keyframes sparkle4 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(20px, 15px) scale(1); opacity: 0; }
}
@keyframes sparkle5 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(-10px, -25px) scale(1); opacity: 0; }
}
@keyframes sparkle6 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(25px, -5px) scale(1); opacity: 0; }
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
opacity: 1;
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes cardFlip {
0% { transform: rotateY(0deg); }
100% { transform: rotateY(180deg); }
}
@keyframes matchSuccess {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
@keyframes invalidMove {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-3px); }
75% { transform: translateX(3px); }
}
`
// Inject global styles
if (typeof document !== 'undefined' && !document.getElementById('memory-card-animations')) {
const style = document.createElement('style')
style.id = 'memory-card-animations'
style.textContent = globalCardAnimations
document.head.appendChild(style)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { MemoryGrid } from './MemoryGrid'
import { PlayerStatusBar } from './PlayerStatusBar'
import { css } from '../../../../../styled-system/css'
import { pluralizeWord } from '../../../../utils/pluralization'
export function GamePhase() {
const { state, resetGame, activePlayers } = useArcadeMemoryPairs()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// Convert Map to array and create mapping from numeric index to player
const playersArray = Array.from(playerMap.values())
const activePlayersArray = Array.from(activePlayerIds)
.map(id => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
// Map numeric player ID (1, 2, 3...) to actual player data
const currentPlayerData = activePlayersArray[state.currentPlayer - 1]
const activePlayerData = activePlayersArray
return (
<div className={css({
width: '100%',
height: '100%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
})}>
{/* Minimal Game Header */}
<div className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: { base: '8px 12px', sm: '10px 16px', md: '12px 20px' },
background: 'linear-gradient(135deg, rgba(102, 126, 234, 0.08), rgba(118, 75, 162, 0.08))',
borderRadius: '12px',
marginBottom: { base: '12px', sm: '16px', md: '20px' },
border: '1px solid rgba(102, 126, 234, 0.15)',
flexShrink: 0
})}>
{/* Game Mode Indicator - Compact */}
<div className={css({
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: { base: '14px', sm: '15px' },
fontWeight: 'bold',
color: 'gray.600'
})}>
<span className={css({ fontSize: { base: '16px', sm: '18px' } })}>
{state.gameType === 'abacus-numeral' ? '🧮' : '🤝'}
</span>
<span className={css({ display: { base: 'none', sm: 'inline' } })}>
{state.gameType === 'abacus-numeral' ? 'Abacus Match' : 'Complement Pairs'}
</span>
{state.gameMode === 'multiplayer' && (
<>
<span className={css({ color: 'gray.400' })}></span>
<span> {activePlayers.length}{pluralizeWord(activePlayers.length, 'P')}</span>
</>
)}
</div>
{/* Game Controls */}
<div className={css({
display: 'flex',
alignItems: 'center',
gap: '12px'
})}>
{/* New Game Button */}
<button
className={css({
background: 'linear-gradient(135deg, #ffeaa7, #fab1a0)',
color: '#2d3436',
border: 'none',
borderRadius: '10px',
padding: { base: '8px 12px', sm: '10px 16px' },
fontSize: { base: '13px', sm: '14px' },
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 6px rgba(255, 234, 167, 0.3)',
_hover: {
transform: 'translateY(-1px)',
boxShadow: '0 3px 8px rgba(255, 234, 167, 0.5)',
background: 'linear-gradient(135deg, #fdcb6e, #e17055)'
}
})}
onClick={resetGame}
>
<div className={css({
display: 'flex',
alignItems: 'center',
gap: '6px'
})}>
<span>🔄</span>
<span>New Game</span>
</div>
</button>
</div>
</div>
{/* Player Status Bar */}
<PlayerStatusBar />
{/* Memory Grid - The main game area */}
<div className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden'
})}>
<MemoryGrid />
</div>
{/* Quick Tip - Only show when game is starting and on larger screens */}
{state.moves === 0 && (
<div className={css({
textAlign: 'center',
marginTop: '12px',
padding: '8px 16px',
background: 'rgba(248, 250, 252, 0.7)',
borderRadius: '8px',
border: '1px solid rgba(226, 232, 240, 0.6)',
display: { base: 'none', lg: 'block' },
flexShrink: 0
})}>
<p className={css({
fontSize: '13px',
color: 'gray.600',
margin: 0,
fontWeight: 'medium'
})}>
💡 {state.gameType === 'abacus-numeral'
? 'Match abacus beads with numbers'
: 'Find pairs that add to 5 or 10'
}
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,239 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { GameCard } from './GameCard'
import { getGridConfiguration } from '../utils/cardGeneration'
import { css } from '../../../../../styled-system/css'
import { gamePlurals } from '../../../../utils/pluralization'
// Helper function to calculate optimal grid dimensions
function calculateOptimalGrid(cards: number, aspectRatio: number, config: any) {
// For consistent grid layout, we need to ensure r×c = totalCards
// Choose columns based on viewport, then calculate exact rows needed
let targetColumns
const width = typeof window !== 'undefined' ? window.innerWidth : 1024
// Choose column count based on viewport
if (aspectRatio >= 1.6 && width >= 1200) {
// Ultra-wide: prefer wider grids
targetColumns = config.landscapeColumns || config.desktopColumns || 6
} else if (aspectRatio >= 1.33 && width >= 768) {
// Desktop/landscape: use desktop columns
targetColumns = config.desktopColumns || config.landscapeColumns || 6
} else if (aspectRatio >= 1.0 && width >= 600) {
// Tablet: use tablet columns
targetColumns = config.tabletColumns || config.desktopColumns || 4
} else {
// Mobile: use mobile columns
targetColumns = config.mobileColumns || 3
}
// Calculate exact rows needed for this column count
const rows = Math.ceil(cards / targetColumns)
// If we have leftover cards that would create an uneven bottom row,
// try to redistribute for a more balanced grid
const leftoverCards = cards % targetColumns
if (leftoverCards > 0 && leftoverCards < targetColumns / 2 && targetColumns > 3) {
// Try one less column for a more balanced grid
const altColumns = targetColumns - 1
const altRows = Math.ceil(cards / altColumns)
const altLeftover = cards % altColumns
// Use alternative if it creates a more balanced grid
if (altLeftover === 0 || altLeftover > leftoverCards) {
return { columns: altColumns, rows: altRows }
}
}
return { columns: targetColumns, rows }
}
// Custom hook to calculate proper grid dimensions for consistent r×c layout
function useGridDimensions(gridConfig: any, totalCards: number) {
const [gridDimensions, setGridDimensions] = useState(() => {
// Calculate optimal rows and columns based on total cards and viewport
if (typeof window !== 'undefined') {
const aspectRatio = window.innerWidth / window.innerHeight
return calculateOptimalGrid(totalCards, aspectRatio, gridConfig)
}
return { columns: gridConfig.mobileColumns || 3, rows: Math.ceil(totalCards / (gridConfig.mobileColumns || 3)) }
})
useEffect(() => {
const updateGrid = () => {
if (typeof window === 'undefined') return
const aspectRatio = window.innerWidth / window.innerHeight
setGridDimensions(calculateOptimalGrid(totalCards, aspectRatio, gridConfig))
}
updateGrid()
window.addEventListener('resize', updateGrid)
return () => window.removeEventListener('resize', updateGrid)
}, [gridConfig, totalCards])
return gridDimensions
}
export function MemoryGrid() {
const { state, flipCard } = useArcadeMemoryPairs()
if (!state.gameCards.length) {
return null
}
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
const gridDimensions = useGridDimensions(gridConfig, state.gameCards.length)
const handleCardClick = (cardId: string) => {
flipCard(cardId)
}
return (
<div className={css({
padding: { base: '12px', sm: '16px', md: '20px' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: { base: '12px', sm: '16px', md: '20px' }
})}>
{/* Cards Grid - Consistent r×c Layout */}
<div
style={{
display: 'grid',
gap: '6px',
justifyContent: 'center',
maxWidth: '100%',
margin: '0 auto',
padding: '0 8px',
// Consistent grid ensuring all cards fit in r×c layout
gridTemplateColumns: `repeat(${gridDimensions.columns}, 1fr)`,
gridTemplateRows: `repeat(${gridDimensions.rows}, 1fr)`
}}
>
{state.gameCards.map(card => {
const isFlipped = state.flippedCards.some(c => c.id === card.id) || card.matched
const isMatched = card.matched
// Smart card filtering for abacus-numeral mode
let isValidForSelection = true
let isDimmed = false
if (state.gameType === 'abacus-numeral' && state.flippedCards.length === 1 && !isFlipped && !isMatched) {
const firstFlippedCard = state.flippedCards[0]
// If first card is abacus, only numeral cards should be clickable
if (firstFlippedCard.type === 'abacus' && card.type !== 'number') {
isValidForSelection = false
isDimmed = true
}
// If first card is numeral, only abacus cards should be clickable
else if (firstFlippedCard.type === 'number' && card.type !== 'abacus') {
isValidForSelection = false
isDimmed = true
}
// Also check if it's a potential match by number
else if (
(firstFlippedCard.type === 'abacus' && card.type === 'number' && card.number !== firstFlippedCard.number) ||
(firstFlippedCard.type === 'number' && card.type === 'abacus' && card.number !== firstFlippedCard.number)
) {
// Don't completely disable, but could add subtle visual hint for non-matching numbers
// For now, keep all valid type combinations clickable
}
}
return (
<div
key={card.id}
className={css({
aspectRatio: '3/4',
// Fully responsive card sizing - no fixed pixel sizes
width: '100%',
minWidth: '100px',
maxWidth: '200px',
// Dimming effect for invalid cards
opacity: isDimmed ? 0.3 : 1,
transition: 'opacity 0.3s ease',
filter: isDimmed ? 'grayscale(0.7)' : 'none'
})}>
<GameCard
card={card}
isFlipped={isFlipped}
isMatched={isMatched}
onClick={() => isValidForSelection ? handleCardClick(card.id) : undefined}
disabled={state.isProcessingMove || !isValidForSelection}
/>
</div>
)
})}
</div>
{/* Mismatch Feedback */}
{state.showMismatchFeedback && (
<div className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
color: 'white',
padding: '16px 24px',
borderRadius: '16px',
fontSize: '18px',
fontWeight: 'bold',
boxShadow: '0 8px 25px rgba(255, 107, 107, 0.4)',
zIndex: 1000,
animation: 'shake 0.5s ease-in-out'
})}>
<div className={css({
display: 'flex',
alignItems: 'center',
gap: '8px'
})}>
<span></span>
<span>Not a match! Try again.</span>
</div>
</div>
)}
{/* Processing Overlay */}
{state.isProcessingMove && (
<div className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.1)',
zIndex: 999,
pointerEvents: 'none'
})} />
)}
</div>
)
}
// Add shake animation for mismatch feedback
const shakeAnimation = `
@keyframes shake {
0%, 100% { transform: translate(-50%, -50%) translateX(0); }
25% { transform: translate(-50%, -50%) translateX(-5px); }
75% { transform: translate(-50%, -50%) translateX(5px); }
}
`
// Inject animation styles
if (typeof document !== 'undefined' && !document.getElementById('memory-grid-animations')) {
const style = document.createElement('style')
style.id = 'memory-grid-animations'
style.textContent = shakeAnimation
document.head.appendChild(style)
}

View File

@@ -0,0 +1,455 @@
import type { Meta, StoryObj } from '@storybook/react'
import React, { useEffect } from 'react'
import { css } from '../../../../../styled-system/css'
import { gamePlurals } from '../../../../utils/pluralization'
// Inject the celebration animations for Storybook
const celebrationAnimations = `
@keyframes gentle-pulse {
0%, 100% {
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.3), 0 12px 32px rgba(0,0,0,0.1);
}
50% {
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.5), 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes gentle-bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes gentle-sway {
0%, 100% { transform: rotate(-2deg) scale(1); }
50% { transform: rotate(2deg) scale(1.05); }
}
@keyframes breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-6px); }
}
@keyframes turn-entrance {
0% {
transform: scale(0.8) rotate(-10deg);
opacity: 0.6;
}
50% {
transform: scale(1.1) rotate(5deg);
opacity: 1;
}
100% {
transform: scale(1.08) rotate(0deg);
opacity: 1;
}
}
@keyframes streak-pulse {
0%, 100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
}
@keyframes great-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
}
50% {
transform: scale(1.12) translateY(-6px);
box-shadow: 0 0 0 2px white, 0 0 0 8px #22c55e60, 0 15px 35px rgba(34,197,94,0.3);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes epic-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
}
25% {
transform: scale(1.15) translateY(-8px) rotate(2deg);
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
}
75% {
transform: scale(1.15) translateY(-8px) rotate(-2deg);
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes legendary-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
}
20% {
transform: scale(1.2) translateY(-12px) rotate(5deg);
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
}
40% {
transform: scale(1.18) translateY(-10px) rotate(-3deg);
box-shadow: 0 0 0 3px gold, 0 0 0 10px #a855f7, 0 20px 45px rgba(168,85,247,0.4);
}
60% {
transform: scale(1.22) translateY(-14px) rotate(3deg);
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
}
80% {
transform: scale(1.15) translateY(-8px) rotate(-1deg);
box-shadow: 0 0 0 3px gold, 0 0 0 8px #a855f7, 0 18px 40px rgba(168,85,247,0.3);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
}
}
`
// Component to inject animations
const AnimationProvider = ({ children }: { children: React.ReactNode }) => {
useEffect(() => {
if (typeof document !== 'undefined' && !document.getElementById('celebration-animations')) {
const style = document.createElement('style')
style.id = 'celebration-animations'
style.textContent = celebrationAnimations
document.head.appendChild(style)
}
}, [])
return <>{children}</>
}
const meta: Meta = {
title: 'Games/Matching/PlayerStatusBar',
parameters: {
layout: 'centered',
docs: {
description: {
component: `
The PlayerStatusBar component displays the current state of players in the matching game.
It shows different layouts for single player vs multiplayer modes and includes escalating
celebration effects for consecutive matching pairs.
## Features
- Single player mode with epic styling
- Multiplayer mode with competitive grid layout
- Escalating celebration animations based on consecutive matches:
- 2+ matches: Great celebration (green)
- 3+ matches: Epic celebration (orange)
- 5+ matches: Legendary celebration (purple with gold accents)
- Real-time turn indicators
- Score tracking and progress display
- Responsive design for mobile and desktop
## Animation Preview
The animations demonstrate different celebration levels that activate when players get consecutive matches.
`
}
}
},
decorators: [
(Story) => (
<AnimationProvider>
<div className={css({
width: '800px',
maxWidth: '90vw',
padding: '20px',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
minHeight: '400px'
})}>
<Story />
</div>
</AnimationProvider>
)
]
}
export default meta
type Story = StoryObj<typeof meta>
// Create a mock player card component that showcases the animations
const MockPlayerCard = ({
emoji,
name,
score,
consecutiveMatches,
isCurrentPlayer = true,
celebrationLevel
}: {
emoji: string
name: string
score: number
consecutiveMatches: number
isCurrentPlayer?: boolean
celebrationLevel: 'normal' | 'great' | 'epic' | 'legendary'
}) => {
const playerColor = celebrationLevel === 'legendary' ? '#a855f7' :
celebrationLevel === 'epic' ? '#f97316' :
celebrationLevel === 'great' ? '#22c55e' : '#3b82f6'
return (
<div className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '3', md: '4' },
p: isCurrentPlayer ? { base: '4', md: '6' } : { base: '2', md: '3' },
rounded: isCurrentPlayer ? '2xl' : 'lg',
background: isCurrentPlayer
? `linear-gradient(135deg, ${playerColor}15, ${playerColor}25, ${playerColor}15)`
: 'white',
border: isCurrentPlayer ? '4px solid' : '2px solid',
borderColor: isCurrentPlayer ? playerColor : 'gray.200',
boxShadow: isCurrentPlayer
? `0 0 0 2px white, 0 0 0 6px ${playerColor}40, 0 12px 32px rgba(0,0,0,0.2)`
: '0 2px 4px rgba(0,0,0,0.1)',
transition: 'all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
position: 'relative',
transform: isCurrentPlayer ? 'scale(1.08) translateY(-4px)' : 'scale(1)',
zIndex: isCurrentPlayer ? 10 : 1,
animation: isCurrentPlayer
? (celebrationLevel === 'legendary' ? 'legendary-celebration 0.8s ease-out, turn-entrance 0.6s ease-out'
: celebrationLevel === 'epic' ? 'epic-celebration 0.7s ease-out, turn-entrance 0.6s ease-out'
: celebrationLevel === 'great' ? 'great-celebration 0.6s ease-out, turn-entrance 0.6s ease-out'
: 'turn-entrance 0.6s ease-out')
: 'none'
})}>
{/* Player emoji */}
<div className={css({
fontSize: isCurrentPlayer ? { base: '3xl', md: '5xl' } : { base: 'lg', md: 'xl' },
flexShrink: 0,
animation: isCurrentPlayer
? 'float 3s ease-in-out infinite'
: 'breathe 5s ease-in-out infinite',
transform: isCurrentPlayer ? 'scale(1.2)' : 'scale(1)',
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
textShadow: isCurrentPlayer ? '0 0 20px currentColor' : 'none'
})}>
{emoji}
</div>
{/* Player info */}
<div className={css({
flex: 1,
minWidth: 0
})}>
<div className={css({
fontSize: isCurrentPlayer ? { base: 'md', md: 'lg' } : { base: 'xs', md: 'sm' },
fontWeight: 'black',
color: isCurrentPlayer ? 'gray.900' : 'gray.700',
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
})}>
{name}
</div>
<div className={css({
fontSize: isCurrentPlayer ? { base: 'sm', md: 'md' } : { base: '2xs', md: 'xs' },
color: isCurrentPlayer ? playerColor : 'gray.500',
fontWeight: isCurrentPlayer ? 'black' : 'semibold'
})}>
{gamePlurals.pair(score)}
{isCurrentPlayer && (
<span className={css({
color: 'red.600',
fontWeight: 'black',
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
textShadow: '0 0 15px currentColor'
})}>
{' • Your turn'}
</span>
)}
{consecutiveMatches > 1 && (
<div className={css({
fontSize: { base: '2xs', md: 'xs' },
color: celebrationLevel === 'legendary' ? 'purple.600' :
celebrationLevel === 'epic' ? 'orange.600' :
celebrationLevel === 'great' ? 'green.600' : 'gray.500',
fontWeight: 'black',
animation: isCurrentPlayer ? 'streak-pulse 1s ease-in-out infinite' : 'none',
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
})}>
🔥 {consecutiveMatches} streak!
</div>
)}
</div>
</div>
{/* Epic score display */}
{isCurrentPlayer && (
<div className={css({
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
color: 'white',
px: { base: '3', md: '4' },
py: { base: '2', md: '3' },
rounded: 'xl',
fontSize: { base: 'lg', md: 'xl' },
fontWeight: 'black',
boxShadow: '0 4px 15px rgba(238, 90, 36, 0.4)',
animation: 'gentle-bounce 1.5s ease-in-out infinite',
textShadow: '0 0 10px rgba(255,255,255,0.8)'
})}>
{score}
</div>
)}
</div>
)
}
// Normal celebration level
export const NormalPlayer: Story = {
render: () => (
<MockPlayerCard
emoji="🚀"
name="Solo Champion"
score={3}
consecutiveMatches={0}
celebrationLevel="normal"
/>
)
}
// Great celebration level
export const GreatStreak: Story = {
render: () => (
<MockPlayerCard
emoji="🎯"
name="Streak Master"
score={5}
consecutiveMatches={2}
celebrationLevel="great"
/>
)
}
// Epic celebration level
export const EpicStreak: Story = {
render: () => (
<MockPlayerCard
emoji="🔥"
name="Epic Matcher"
score={7}
consecutiveMatches={4}
celebrationLevel="epic"
/>
)
}
// Legendary celebration level
export const LegendaryStreak: Story = {
render: () => (
<MockPlayerCard
emoji="👑"
name="Legend"
score={8}
consecutiveMatches={6}
celebrationLevel="legendary"
/>
)
}
// All levels showcase
export const AllCelebrationLevels: Story = {
render: () => (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '20px' })}>
<h3 className={css({ textAlign: 'center', fontSize: '24px', fontWeight: 'bold', marginBottom: '20px' })}>
Consecutive Match Celebration Levels
</h3>
<div className={css({ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))', gap: '20px' })}>
{/* Normal */}
<div>
<h4 className={css({ textAlign: 'center', marginBottom: '10px', fontSize: '16px', fontWeight: 'bold' })}>
Normal (0-1 matches)
</h4>
<MockPlayerCard
emoji="🚀"
name="Solo Champion"
score={3}
consecutiveMatches={0}
celebrationLevel="normal"
/>
</div>
{/* Great */}
<div>
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'green.600', fontSize: '16px', fontWeight: 'bold' })}>
Great (2+ matches)
</h4>
<MockPlayerCard
emoji="🎯"
name="Streak Master"
score={5}
consecutiveMatches={2}
celebrationLevel="great"
/>
</div>
{/* Epic */}
<div>
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'orange.600', fontSize: '16px', fontWeight: 'bold' })}>
Epic (3+ matches)
</h4>
<MockPlayerCard
emoji="🔥"
name="Epic Matcher"
score={7}
consecutiveMatches={4}
celebrationLevel="epic"
/>
</div>
{/* Legendary */}
<div>
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'purple.600', fontSize: '16px', fontWeight: 'bold' })}>
Legendary (5+ matches)
</h4>
<MockPlayerCard
emoji="👑"
name="Legend"
score={8}
consecutiveMatches={6}
celebrationLevel="legendary"
/>
</div>
</div>
<div className={css({
textAlign: 'center',
marginTop: '20px',
padding: '16px',
background: 'rgba(255,255,255,0.8)',
borderRadius: '12px',
border: '1px solid rgba(0,0,0,0.1)'
})}>
<p className={css({ fontSize: '14px', color: 'gray.700', margin: 0 })}>
These animations trigger when a player gets consecutive matching pairs in the memory matching game.
The celebrations get more intense as the streak grows, providing visual feedback and excitement!
</p>
</div>
</div>
),
parameters: {
layout: 'fullscreen'
}
}

View File

@@ -0,0 +1,455 @@
'use client'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useUserProfile } from '../../../../contexts/UserProfileContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { gamePlurals } from '../../../../utils/pluralization'
interface PlayerStatusBarProps {
className?: string
}
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
const { state } = useArcadeMemoryPairs()
// Get active players array
const activePlayersData = Array.from(activePlayerIds)
.map(id => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
// Map active players to display data with scores
// State uses numeric player IDs (1, 2, 3...), so we map by index
const activePlayers = activePlayersData.map((player, index) => ({
...player,
displayName: player.name,
displayEmoji: player.emoji,
score: state.scores[index + 1] || 0,
consecutiveMatches: state.consecutiveMatches?.[index + 1] || 0
}))
// Get celebration level based on consecutive matches
const getCelebrationLevel = (consecutiveMatches: number) => {
if (consecutiveMatches >= 5) return 'legendary'
if (consecutiveMatches >= 3) return 'epic'
if (consecutiveMatches >= 2) return 'great'
return 'normal'
}
if (activePlayers.length <= 1) {
// Simple single player indicator
return (
<div className={`${css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: 'white',
rounded: 'lg',
p: { base: '2', md: '3' },
border: '2px solid',
borderColor: 'blue.200',
mb: { base: '2', md: '3' },
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
})} ${className || ''}`}>
<div className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '2', md: '3' }
})}>
<div className={css({
fontSize: { base: 'xl', md: '2xl' }
})}>
{activePlayers[0]?.displayEmoji || '🚀'}
</div>
<div className={css({
fontSize: { base: 'sm', md: 'md' },
fontWeight: 'bold',
color: 'gray.700'
})}>
{activePlayers[0]?.displayName || 'Player 1'}
</div>
<div className={css({
fontSize: { base: 'xs', md: 'sm' },
color: 'blue.600',
fontWeight: 'medium'
})}>
{gamePlurals.pair(state.matchedPairs)} of {state.totalPairs} {gamePlurals.move(state.moves)}
</div>
</div>
</div>
)
}
// For multiplayer, show competitive status bar
return (
<div className={`${css({
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
rounded: 'xl',
p: { base: '2', md: '3' },
border: '2px solid',
borderColor: 'gray.200',
mb: { base: '3', md: '4' }
})} ${className || ''}`}>
<div className={css({
display: 'grid',
gridTemplateColumns: activePlayers.length <= 2
? 'repeat(2, 1fr)'
: activePlayers.length === 3
? 'repeat(3, 1fr)'
: 'repeat(2, 1fr) repeat(2, 1fr)',
gap: { base: '2', md: '3' },
alignItems: 'center'
})}>
{activePlayers.map((player, index) => {
const isCurrentPlayer = (index + 1) === state.currentPlayer
const isLeading = player.score === Math.max(...activePlayers.map(p => p.score)) && player.score > 0
const celebrationLevel = getCelebrationLevel(player.consecutiveMatches)
return (
<div
key={player.id}
className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '2', md: '3' },
p: isCurrentPlayer ? { base: '3', md: '4' } : { base: '2', md: '2' },
rounded: isCurrentPlayer ? '2xl' : 'lg',
background: isCurrentPlayer
? `linear-gradient(135deg, ${player.color || '#3b82f6'}15, ${player.color || '#3b82f6'}25, ${player.color || '#3b82f6'}15)`
: 'white',
border: isCurrentPlayer ? '4px solid' : '2px solid',
borderColor: isCurrentPlayer
? (player.color || '#3b82f6')
: 'gray.200',
boxShadow: isCurrentPlayer
? '0 0 0 2px white, 0 0 0 6px ' + (player.color || '#3b82f6') + '40, 0 12px 32px rgba(0,0,0,0.2)'
: '0 2px 4px rgba(0,0,0,0.1)',
transition: 'all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
position: 'relative',
transform: isCurrentPlayer ? 'scale(1.08) translateY(-4px)' : 'scale(1)',
zIndex: isCurrentPlayer ? 10 : 1,
animation: isCurrentPlayer
? (celebrationLevel === 'legendary' ? 'legendary-celebration 0.8s ease-out, turn-entrance 0.6s ease-out'
: celebrationLevel === 'epic' ? 'epic-celebration 0.7s ease-out, turn-entrance 0.6s ease-out'
: celebrationLevel === 'great' ? 'great-celebration 0.6s ease-out, turn-entrance 0.6s ease-out'
: 'turn-entrance 0.6s ease-out')
: 'none'
})}
>
{/* Leading crown with sparkle */}
{isLeading && (
<div className={css({
position: 'absolute',
top: isCurrentPlayer ? '-3' : '-1',
right: isCurrentPlayer ? '-3' : '-1',
background: 'linear-gradient(135deg, #ffd700, #ffaa00)',
rounded: 'full',
w: isCurrentPlayer ? '10' : '6',
h: isCurrentPlayer ? '10' : '6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: isCurrentPlayer ? 'lg' : 'xs',
zIndex: 10,
animation: 'none',
boxShadow: '0 0 20px rgba(255, 215, 0, 0.6)'
})}>
👑
</div>
)}
{/* Subtle turn indicator */}
{isCurrentPlayer && (
<div className={css({
position: 'absolute',
top: '-2',
left: '-2',
background: player.color || '#3b82f6',
rounded: 'full',
w: '4',
h: '4',
animation: 'gentle-sway 2s ease-in-out infinite',
zIndex: 5
})} />
)}
{/* Living, breathing player emoji */}
<div className={css({
fontSize: isCurrentPlayer ? { base: '2xl', md: '3xl' } : { base: 'lg', md: 'xl' },
flexShrink: 0,
animation: isCurrentPlayer
? 'float 3s ease-in-out infinite'
: 'breathe 5s ease-in-out infinite',
transform: isCurrentPlayer ? 'scale(1.2)' : 'scale(1)',
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
textShadow: isCurrentPlayer ? '0 0 20px currentColor' : 'none',
cursor: 'pointer',
'&:hover': {
transform: isCurrentPlayer ? 'scale(1.3)' : 'scale(1.1)',
animation: 'gentle-sway 1s ease-in-out infinite'
}
})}>
{player.displayEmoji}
</div>
{/* Enhanced player info */}
<div className={css({
flex: 1,
minWidth: 0
})}>
<div className={css({
fontSize: isCurrentPlayer ? { base: 'md', md: 'lg' } : { base: 'xs', md: 'sm' },
fontWeight: 'black',
color: isCurrentPlayer ? 'gray.900' : 'gray.700',
animation: 'none',
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
})}>
{player.displayName}
</div>
<div className={css({
fontSize: isCurrentPlayer ? { base: 'sm', md: 'md' } : { base: '2xs', md: 'xs' },
color: isCurrentPlayer ? (player.color || '#3b82f6') : 'gray.500',
fontWeight: isCurrentPlayer ? 'black' : 'semibold',
animation: 'none'
})}>
{gamePlurals.pair(player.score)}
{isCurrentPlayer && (
<span className={css({
color: 'red.600',
fontWeight: 'black',
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
animation: 'none',
textShadow: '0 0 15px currentColor'
})}>
{' • Your turn'}
</span>
)}
{player.consecutiveMatches > 1 && (
<div className={css({
fontSize: { base: '2xs', md: 'xs' },
color: celebrationLevel === 'legendary' ? 'purple.600' :
celebrationLevel === 'epic' ? 'orange.600' :
celebrationLevel === 'great' ? 'green.600' : 'gray.500',
fontWeight: 'black',
animation: isCurrentPlayer ? 'streak-pulse 1s ease-in-out infinite' : 'none',
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
})}>
🔥 {player.consecutiveMatches} streak!
</div>
)}
</div>
</div>
{/* Simple score display for current player */}
{isCurrentPlayer && (
<div className={css({
background: 'blue.500',
color: 'white',
px: { base: '2', md: '3' },
py: { base: '1', md: '2' },
rounded: 'md',
fontSize: { base: 'sm', md: 'md' },
fontWeight: 'bold'
})}>
{player.score}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
// Epic animations for extreme emphasis
const epicAnimations = `
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.1);
}
}
@keyframes gentle-pulse {
0%, 100% {
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.3), 0 12px 32px rgba(0,0,0,0.1);
}
50% {
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.5), 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes gentle-bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes gentle-sway {
0%, 100% { transform: rotate(-2deg) scale(1); }
50% { transform: rotate(2deg) scale(1.05); }
}
@keyframes breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-6px); }
}
@keyframes turn-entrance {
0% {
transform: scale(0.8) rotate(-10deg);
opacity: 0.6;
}
50% {
transform: scale(1.1) rotate(5deg);
opacity: 1;
}
100% {
transform: scale(1.08) rotate(0deg);
opacity: 1;
}
}
@keyframes turn-exit {
0% {
transform: scale(1.08);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0.8;
}
}
@keyframes spotlight {
0%, 100% {
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.3) 50%, transparent 70%);
transform: translateX(-100%);
}
50% {
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.6) 50%, transparent 70%);
transform: translateX(100%);
}
}
@keyframes neon-flicker {
0%, 100% {
text-shadow: 0 0 5px currentColor, 0 0 10px currentColor, 0 0 15px currentColor;
opacity: 1;
}
50% {
text-shadow: 0 0 2px currentColor, 0 0 5px currentColor, 0 0 8px currentColor;
opacity: 0.8;
}
}
@keyframes crown-sparkle {
0%, 100% {
transform: rotate(0deg) scale(1);
filter: brightness(1);
}
25% {
transform: rotate(-5deg) scale(1.1);
filter: brightness(1.5);
}
75% {
transform: rotate(5deg) scale(1.1);
filter: brightness(1.5);
}
}
@keyframes streak-pulse {
0%, 100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
}
@keyframes great-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
}
50% {
transform: scale(1.12) translateY(-6px);
box-shadow: 0 0 0 2px white, 0 0 0 8px #22c55e60, 0 15px 35px rgba(34,197,94,0.3);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes epic-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
}
25% {
transform: scale(1.15) translateY(-8px) rotate(2deg);
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
}
75% {
transform: scale(1.15) translateY(-8px) rotate(-2deg);
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes legendary-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
}
20% {
transform: scale(1.2) translateY(-12px) rotate(5deg);
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
}
40% {
transform: scale(1.18) translateY(-10px) rotate(-3deg);
box-shadow: 0 0 0 3px gold, 0 0 0 10px #a855f7, 0 20px 45px rgba(168,85,247,0.4);
}
60% {
transform: scale(1.22) translateY(-14px) rotate(3deg);
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
}
80% {
transform: scale(1.15) translateY(-8px) rotate(-1deg);
box-shadow: 0 0 0 3px gold, 0 0 0 8px #a855f7, 0 18px 40px rgba(168,85,247,0.3);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
}
}
`
// Inject animation styles
if (typeof document !== 'undefined' && !document.getElementById('player-status-animations')) {
const style = document.createElement('style')
style.id = 'player-status-animations'
style.textContent = epicAnimations
document.head.appendChild(style)
}

View File

@@ -0,0 +1,415 @@
'use client'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { generateGameCards } from '../utils/cardGeneration'
import { css } from '../../../../../styled-system/css'
// Add bounce animation for the start button
const bounceAnimation = `
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
`
// Inject animation styles
if (typeof document !== 'undefined' && !document.getElementById('setup-animations')) {
const style = document.createElement('style')
style.id = 'setup-animations'
style.textContent = bounceAnimation
document.head.appendChild(style)
}
export function SetupPhase() {
const {
state,
setGameType,
setDifficulty,
startGame,
activePlayers
} = useArcadeMemoryPairs()
const { activePlayerCount, gameMode: globalGameMode } = useGameMode()
const handleStartGame = () => {
startGame()
}
const getButtonStyles = (isSelected: boolean, variant: 'primary' | 'secondary' | 'difficulty' = 'primary') => {
const baseStyles = {
border: 'none',
borderRadius: { base: '12px', md: '16px' },
padding: { base: '12px 16px', sm: '14px 20px', md: '16px 24px' },
fontSize: { base: '14px', sm: '15px', md: '16px' },
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
minWidth: { base: '120px', sm: '140px', md: '160px' },
textAlign: 'center' as const,
position: 'relative' as const,
overflow: 'hidden' as const,
textShadow: isSelected ? '0 1px 2px rgba(0,0,0,0.2)' : 'none',
transform: 'translateZ(0)', // Enable GPU acceleration
}
if (variant === 'difficulty') {
return css({
...baseStyles,
background: isSelected
? 'linear-gradient(135deg, #ff6b6b, #ee5a24)'
: 'linear-gradient(135deg, #f8f9fa, #e9ecef)',
color: isSelected ? 'white' : '#495057',
boxShadow: isSelected
? '0 8px 25px rgba(255, 107, 107, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 2px 8px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8)',
_hover: {
transform: 'translateY(-3px) scale(1.02)',
boxShadow: isSelected
? '0 12px 35px rgba(255, 107, 107, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)',
},
_active: {
transform: 'translateY(-1px) scale(1.01)',
}
})
}
if (variant === 'secondary') {
return css({
...baseStyles,
background: isSelected
? 'linear-gradient(135deg, #a78bfa, #8b5cf6)'
: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
color: isSelected ? 'white' : '#475569',
boxShadow: isSelected
? '0 8px 25px rgba(167, 139, 250, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 2px 8px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8)',
_hover: {
transform: 'translateY(-3px) scale(1.02)',
boxShadow: isSelected
? '0 12px 35px rgba(167, 139, 250, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)',
},
_active: {
transform: 'translateY(-1px) scale(1.01)',
}
})
}
// Primary variant
return css({
...baseStyles,
background: isSelected
? 'linear-gradient(135deg, #667eea, #764ba2)'
: 'linear-gradient(135deg, #ffffff, #f1f5f9)',
color: isSelected ? 'white' : '#334155',
boxShadow: isSelected
? '0 8px 25px rgba(102, 126, 234, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 2px 8px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8)',
_hover: {
transform: 'translateY(-3px) scale(1.02)',
boxShadow: isSelected
? '0 12px 35px rgba(102, 126, 234, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)',
},
_active: {
transform: 'translateY(-1px) scale(1.01)',
}
})
}
return (
<div className={css({
textAlign: 'center',
padding: { base: '12px 16px', sm: '16px 20px', md: '20px' },
maxWidth: '800px',
margin: '0 auto',
display: 'flex',
flexDirection: 'column',
minHeight: 0, // Allow shrinking
overflow: 'auto' // Enable scrolling if needed
})}>
<div className={css({
display: 'grid',
gap: { base: '8px', sm: '12px', md: '16px' },
margin: '0 auto',
flex: 1,
minHeight: 0 // Allow shrinking
})}>
{/* Warning if no players */}
{activePlayerCount === 0 && (
<div className={css({
p: '4',
background: 'rgba(239, 68, 68, 0.1)',
border: '2px solid',
borderColor: 'red.300',
rounded: 'xl',
textAlign: 'center'
})}>
<p className={css({ color: 'red.700', fontSize: { base: '14px', md: '16px' }, fontWeight: 'bold' })}>
Go back to the arcade to select players before starting the game
</p>
</div>
)}
{/* Game Type Selection */}
<div>
<label className={css({
display: 'block',
fontSize: { base: '16px', sm: '18px', md: '20px' },
fontWeight: 'bold',
marginBottom: { base: '12px', md: '16px' },
color: 'gray.700'
})}>
Game Type
</label>
<div className={css({
display: 'grid',
gridTemplateColumns: {
base: '1fr',
sm: 'repeat(2, 1fr)'
},
gap: { base: '8px', sm: '10px', md: '12px' },
justifyItems: 'stretch'
})}>
<button
className={getButtonStyles(state.gameType === 'abacus-numeral', 'secondary')}
onClick={() => setGameType('abacus-numeral')}
>
<div className={css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: { base: '4px', md: '6px' } })}>
<div className={css({ fontSize: { base: '20px', sm: '24px', md: '28px' }, display: 'flex', alignItems: 'center', gap: { base: '4px', md: '8px' } })}>
<span>🧮</span>
<span className={css({ fontSize: { base: '16px', md: '20px' } })}></span>
<span>🔢</span>
</div>
<div className={css({ fontWeight: 'bold', fontSize: { base: '12px', sm: '13px', md: '14px' } })}>Abacus-Numeral</div>
<div className={css({ fontSize: { base: '10px', sm: '11px', md: '12px' }, opacity: 0.8, textAlign: 'center', display: { base: 'none', sm: 'block' } })}>
Match visual patterns<br/>with numbers
</div>
</div>
</button>
<button
className={getButtonStyles(state.gameType === 'complement-pairs', 'secondary')}
onClick={() => setGameType('complement-pairs')}
>
<div className={css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: { base: '4px', md: '6px' } })}>
<div className={css({ fontSize: { base: '20px', sm: '24px', md: '28px' }, display: 'flex', alignItems: 'center', gap: { base: '4px', md: '8px' } })}>
<span>🤝</span>
<span className={css({ fontSize: { base: '16px', md: '20px' } })}></span>
<span>🔟</span>
</div>
<div className={css({ fontWeight: 'bold', fontSize: { base: '12px', sm: '13px', md: '14px' } })}>Complement Pairs</div>
<div className={css({ fontSize: { base: '10px', sm: '11px', md: '12px' }, opacity: 0.8, textAlign: 'center', display: { base: 'none', sm: 'block' } })}>
Find number friends<br/>that add to 5 or 10
</div>
</div>
</button>
</div>
<p className={css({
fontSize: { base: '12px', md: '14px' },
color: 'gray.500',
marginTop: { base: '6px', md: '8px' },
textAlign: 'center',
display: { base: 'none', sm: 'block' }
})}>
{state.gameType === 'abacus-numeral'
? 'Match abacus representations with their numerical values'
: 'Find pairs of numbers that add up to 5 or 10'
}
</p>
</div>
{/* Difficulty Selection */}
<div>
<label className={css({
display: 'block',
fontSize: { base: '16px', sm: '18px', md: '20px' },
fontWeight: 'bold',
marginBottom: { base: '12px', md: '16px' },
color: 'gray.700'
})}>
Difficulty ({state.difficulty} pairs)
</label>
<div className={css({
display: 'grid',
gridTemplateColumns: {
base: 'repeat(2, 1fr)',
sm: 'repeat(4, 1fr)'
},
gap: { base: '8px', sm: '10px', md: '12px' },
justifyItems: 'stretch'
})}>
{([6, 8, 12, 15] as const).map(difficulty => {
const difficultyInfo = {
6: { icon: '🌱', label: 'Beginner', description: 'Perfect to start!' },
8: { icon: '⚡', label: 'Medium', description: 'Getting spicy!' },
12: { icon: '🔥', label: 'Hard', description: 'Serious challenge!' },
15: { icon: '💀', label: 'Expert', description: 'Memory master!' }
}
return (
<button
key={difficulty}
className={getButtonStyles(state.difficulty === difficulty, 'difficulty')}
onClick={() => setDifficulty(difficulty)}
>
<div className={css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px' })}>
<div className={css({ fontSize: '32px' })}>
{difficultyInfo[difficulty].icon}
</div>
<div className={css({ fontSize: '18px', fontWeight: 'bold' })}>{difficulty} pairs</div>
<div className={css({ fontSize: '14px', fontWeight: 'bold' })}>
{difficultyInfo[difficulty].label}
</div>
<div className={css({ fontSize: '11px', opacity: 0.9, textAlign: 'center' })}>
{difficultyInfo[difficulty].description}
</div>
</div>
</button>
)
})}
</div>
<p className={css({
fontSize: '14px',
color: 'gray.500',
marginTop: '8px'
})}>
{state.difficulty} pairs = {state.difficulty * 2} cards total
</p>
</div>
{/* Multi-Player Timer Setting */}
{activePlayerCount > 1 && (
<div>
<label className={css({
display: 'block',
fontSize: '20px',
fontWeight: 'bold',
marginBottom: '16px',
color: 'gray.700'
})}>
Turn Timer
</label>
<div className={css({
display: 'flex',
gap: '12px',
justifyContent: 'center',
flexWrap: 'wrap'
})}>
{([15, 30, 45, 60] as const).map(timer => {
const timerInfo: Record<15 | 30 | 45 | 60, { icon: string; label: string }> = {
15: { icon: '💨', label: 'Lightning' },
30: { icon: '⚡', label: 'Quick' },
45: { icon: '🏃', label: 'Standard' },
60: { icon: '🧘', label: 'Relaxed' }
}
return (
<button
key={timer}
className={getButtonStyles(state.turnTimer === timer, 'secondary')}
onClick={() => dispatch({ type: 'SET_TURN_TIMER', timer })}
>
<div className={css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px' })}>
<span className={css({ fontSize: '24px' })}>{timerInfo[timer].icon}</span>
<span className={css({ fontSize: '18px', fontWeight: 'bold' })}>{timer}s</span>
<span className={css({ fontSize: '12px', opacity: 0.8 })}>{timerInfo[timer].label}</span>
</div>
</button>
)
})}
</div>
<p className={css({
fontSize: '14px',
color: 'gray.500',
marginTop: '8px'
})}>
Time limit for each player's turn
</p>
</div>
)}
{/* Start Game Button - Sticky at bottom */}
<div className={css({
marginTop: 'auto', // Push to bottom
paddingTop: { base: '12px', md: '16px' },
position: 'sticky',
bottom: 0,
background: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(10px)',
borderTop: '1px solid rgba(0,0,0,0.1)',
margin: '0 -16px -12px -16px', // Extend to edges
padding: { base: '12px 16px', md: '16px' }
})}>
<button
className={css({
background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
color: 'white',
border: 'none',
borderRadius: { base: '16px', sm: '20px', md: '24px' },
padding: { base: '14px 28px', sm: '16px 32px', md: '18px 36px' },
fontSize: { base: '16px', sm: '18px', md: '20px' },
fontWeight: 'black',
cursor: 'pointer',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
position: 'relative',
overflow: 'hidden',
width: '100%',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: '-100%',
width: '100%',
height: '100%',
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)',
transition: 'left 0.6s ease',
},
_hover: {
transform: { base: 'translateY(-2px)', md: 'translateY(-3px) scale(1.02)' },
boxShadow: '0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
background: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
_before: {
left: '100%'
}
},
_active: {
transform: 'translateY(-1px) scale(1.01)',
}
})}
onClick={handleStartGame}
>
<div className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '6px', md: '8px' },
justifyContent: 'center'
})}>
<span className={css({
fontSize: { base: '18px', sm: '20px', md: '24px' },
animation: 'bounce 2s infinite'
})}>🚀</span>
<span>START GAME</span>
<span className={css({
fontSize: { base: '18px', sm: '20px', md: '24px' },
animation: 'bounce 2s infinite',
animationDelay: '0.5s'
})}>🎮</span>
</div>
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,148 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, test, expect, beforeEach, vi } from 'vitest'
import { EmojiPicker } from '../EmojiPicker'
import { PLAYER_EMOJIS } from '../../../../../constants/playerEmojis'
// Mock the emoji keywords function for testing
vi.mock('emojibase-data/en/data.json', () => ({
default: [
{
emoji: '🐱',
label: 'cat face',
tags: ['cat', 'animal', 'pet', 'cute'],
emoticon: ':)'
},
{
emoji: '🐯',
label: 'tiger face',
tags: ['tiger', 'animal', 'big cat', 'wild'],
emoticon: null
},
{
emoji: '🤩',
label: 'star-struck',
tags: ['face', 'happy', 'excited', 'star'],
emoticon: null
},
{
emoji: '🎭',
label: 'performing arts',
tags: ['theater', 'performance', 'drama', 'arts'],
emoticon: null
}
]
}))
describe('EmojiPicker Search Functionality', () => {
const mockProps = {
currentEmoji: '😀',
onEmojiSelect: vi.fn(),
onClose: vi.fn(),
playerNumber: 1 as const
}
beforeEach(() => {
vi.clearAllMocks()
})
test('shows all emojis by default (no search)', () => {
render(<EmojiPicker {...mockProps} />)
// Should show default header
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
// Should show emoji count
expect(screen.getByText(new RegExp(`${PLAYER_EMOJIS.length} characters available`))).toBeInTheDocument()
// Should show emoji grid
const emojiButtons = screen.getAllByRole('button').filter(button =>
button.textContent && /[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(button.textContent)
)
expect(emojiButtons.length).toBe(PLAYER_EMOJIS.length)
})
test('shows search results when searching for "cat"', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
fireEvent.change(searchInput, { target: { value: 'cat' } })
// Should show search header
expect(screen.getByText(/🔍 Search Results for "cat"/)).toBeInTheDocument()
// Should show results count
expect(screen.getByText(/✓ \d+ found/)).toBeInTheDocument()
// Should only show cat-related emojis (🐱, 🐯)
const emojiButtons = screen.getAllByRole('button').filter(button =>
button.textContent && /[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(button.textContent)
)
// Verify only cat emojis are shown
const displayedEmojis = emojiButtons.map(btn => btn.textContent)
expect(displayedEmojis).toContain('🐱')
expect(displayedEmojis).toContain('🐯')
expect(displayedEmojis).not.toContain('🤩')
expect(displayedEmojis).not.toContain('🎭')
})
test('shows no results message when search has zero matches', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
fireEvent.change(searchInput, { target: { value: 'nonexistentterm' } })
// Should show no results indicator
expect(screen.getByText('✗ No matches')).toBeInTheDocument()
// Should show no results message
expect(screen.getByText(/No emojis found for "nonexistentterm"/)).toBeInTheDocument()
// Should NOT show any emoji buttons
const emojiButtons = screen.queryAllByRole('button').filter(button =>
button.textContent && /[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(button.textContent)
)
expect(emojiButtons).toHaveLength(0)
})
test('returns to default view when clearing search', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
// Search for something
fireEvent.change(searchInput, { target: { value: 'cat' } })
expect(screen.getByText(/🔍 Search Results for "cat"/)).toBeInTheDocument()
// Clear search
fireEvent.change(searchInput, { target: { value: '' } })
// Should return to default view
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
expect(screen.getByText(new RegExp(`${PLAYER_EMOJIS.length} characters available`))).toBeInTheDocument()
// Should show all emojis again
const emojiButtons = screen.getAllByRole('button').filter(button =>
button.textContent && /[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(button.textContent)
)
expect(emojiButtons.length).toBe(PLAYER_EMOJIS.length)
})
test('clear search button works from no results state', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
// Search for something with no results
fireEvent.change(searchInput, { target: { value: 'nonexistentterm' } })
expect(screen.getByText(/No emojis found/)).toBeInTheDocument()
// Click clear search button
const clearButton = screen.getByText(/Clear search to see all/)
fireEvent.click(clearButton)
// Should return to default view
expect(searchInput).toHaveValue('')
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,379 @@
'use client'
import { createContext, useContext, useReducer, useEffect, type ReactNode } from 'react'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { validateMatch } from '../utils/matchValidation'
import { generateGameCards } from '../utils/cardGeneration'
import type {
MemoryPairsState,
MemoryPairsAction,
MemoryPairsContextValue,
GameCard,
GameStatistics,
CelebrationAnimation,
PlayerScore
} from './types'
// Initial state (gameMode removed - now derived from global context)
const initialState: MemoryPairsState = {
// Core game data
cards: [],
gameCards: [],
flippedCards: [],
// Game configuration (gameMode removed)
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
// Game progression
gamePhase: 'setup',
currentPlayer: 1,
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
consecutiveMatches: {},
// Timing
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
// UI state
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null
}
// Reducer function
function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction): MemoryPairsState {
switch (action.type) {
// SET_GAME_MODE removed - game mode now derived from global context
case 'SET_GAME_TYPE':
return {
...state,
gameType: action.gameType
}
case 'SET_DIFFICULTY':
return {
...state,
difficulty: action.difficulty,
totalPairs: action.difficulty
}
case 'SET_TURN_TIMER':
return {
...state,
turnTimer: action.timer
}
case 'START_GAME':
// Initialize scores and consecutive matches for all active players
const scores: PlayerScore = {}
const consecutiveMatches: { [playerId: number]: number } = {}
action.activePlayers.forEach(playerId => {
scores[playerId] = 0
consecutiveMatches[playerId] = 0
})
return {
...state,
gamePhase: 'playing',
gameCards: action.cards,
cards: action.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores,
consecutiveMatches,
activePlayers: action.activePlayers,
currentPlayer: action.activePlayers[0] || 1,
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null
}
case 'FLIP_CARD': {
const cardToFlip = state.gameCards.find(card => card.id === action.cardId)
if (!cardToFlip || cardToFlip.matched || state.flippedCards.length >= 2 || state.isProcessingMove) {
return state
}
const newFlippedCards = [...state.flippedCards, cardToFlip]
const newMoveStartTime = state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime: newMoveStartTime,
showMismatchFeedback: false
}
}
case 'MATCH_FOUND': {
const [card1Id, card2Id] = action.cardIds
const updatedCards = state.gameCards.map(card => {
if (card.id === card1Id || card.id === card2Id) {
return {
...card,
matched: true,
matchedBy: state.currentPlayer
}
}
return card
})
const newMatchedPairs = state.matchedPairs + 1
const newScores = {
...state.scores,
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1
}
const newConsecutiveMatches = {
...state.consecutiveMatches,
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1
}
// Check if game is complete
const isGameComplete = newMatchedPairs === state.totalPairs
return {
...state,
gameCards: updatedCards,
matchedPairs: newMatchedPairs,
scores: newScores,
consecutiveMatches: newConsecutiveMatches,
flippedCards: [],
moves: state.moves + 1,
lastMatchedPair: action.cardIds,
gamePhase: isGameComplete ? 'results' : 'playing',
gameEndTime: isGameComplete ? Date.now() : null,
isProcessingMove: false
// Note: Player keeps turn after successful match in multiplayer mode
}
}
case 'MATCH_FAILED': {
// Player switching is now handled by passing activePlayerCount
return {
...state,
flippedCards: [],
moves: state.moves + 1,
showMismatchFeedback: true,
isProcessingMove: false,
// currentPlayer will be updated by SWITCH_PLAYER action when needed
}
}
case 'SWITCH_PLAYER': {
// Cycle through all active players
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
const nextIndex = (currentIndex + 1) % state.activePlayers.length
// Reset consecutive matches for the player who failed
const newConsecutiveMatches = {
...state.consecutiveMatches,
[state.currentPlayer]: 0
}
return {
...state,
currentPlayer: state.activePlayers[nextIndex] || state.activePlayers[0],
consecutiveMatches: newConsecutiveMatches
}
}
case 'ADD_CELEBRATION':
return {
...state,
celebrationAnimations: [...state.celebrationAnimations, action.animation]
}
case 'REMOVE_CELEBRATION':
return {
...state,
celebrationAnimations: state.celebrationAnimations.filter(
anim => anim.id !== action.animationId
)
}
case 'SET_PROCESSING':
return {
...state,
isProcessingMove: action.processing
}
case 'SET_MISMATCH_FEEDBACK':
return {
...state,
showMismatchFeedback: action.show
}
case 'SHOW_RESULTS':
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
flippedCards: []
}
case 'RESET_GAME':
return {
...initialState,
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
totalPairs: state.difficulty
}
case 'UPDATE_TIMER':
// This can be used for any timer-related updates
return state
default:
return state
}
}
// Create context
const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
// Provider component
export function MemoryPairsProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(memoryPairsReducer, initialState)
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
// Get active player IDs as numbers (convert from string IDs for now to maintain compatibility)
const activePlayers = Array.from(activePlayerIds).map((id, index) => index + 1)
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
// Handle card matching logic when two cards are flipped
useEffect(() => {
if (state.flippedCards.length === 2 && !state.isProcessingMove) {
dispatch({ type: 'SET_PROCESSING', processing: true })
const [card1, card2] = state.flippedCards
const matchResult = validateMatch(card1, card2)
// Delay to allow card flip animation
setTimeout(() => {
if (matchResult.isValid) {
dispatch({ type: 'MATCH_FOUND', cardIds: [card1.id, card2.id] })
} else {
dispatch({ type: 'MATCH_FAILED', cardIds: [card1.id, card2.id] })
// Switch player only in multiplayer mode
if (gameMode === 'multiplayer') {
dispatch({ type: 'SWITCH_PLAYER' })
}
}
}, 1000) // Give time to see both cards
}
}, [state.flippedCards, state.isProcessingMove, gameMode])
// Auto-hide mismatch feedback
useEffect(() => {
if (state.showMismatchFeedback) {
const timeout = setTimeout(() => {
dispatch({ type: 'SET_MISMATCH_FEEDBACK', show: false })
}, 2000)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = (cardId: string): boolean => {
if (!isGameActive || state.isProcessingMove) return false
const card = state.gameCards.find(c => c.id === cardId)
if (!card || card.matched) return false
// Can't flip if already flipped
if (state.flippedCards.some(c => c.id === cardId)) return false
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) return false
return true
}
const currentGameStatistics: GameStatistics = {
totalMoves: state.moves,
matchedPairs: state.matchedPairs,
totalPairs: state.totalPairs,
gameTime: state.gameStartTime ?
(state.gameEndTime || Date.now()) - state.gameStartTime : 0,
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
averageTimePerMove: state.moves > 0 && state.gameStartTime ?
((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves : 0
}
// Action creators
const startGame = () => {
const cards = generateGameCards(state.gameType, state.difficulty)
dispatch({ type: 'START_GAME', cards, activePlayers })
}
const flipCard = (cardId: string) => {
if (!canFlipCard(cardId)) return
dispatch({ type: 'FLIP_CARD', cardId })
}
const resetGame = () => {
dispatch({ type: 'RESET_GAME' })
}
// setGameMode removed - game mode is now derived from global context
const setGameType = (gameType: typeof state.gameType) => {
dispatch({ type: 'SET_GAME_TYPE', gameType })
}
const setDifficulty = (difficulty: typeof state.difficulty) => {
dispatch({ type: 'SET_DIFFICULTY', difficulty })
}
const contextValue: MemoryPairsContextValue = {
state: { ...state, gameMode }, // Add derived gameMode to state
dispatch,
isGameActive,
canFlipCard,
currentGameStatistics,
startGame,
flipCard,
resetGame,
setGameType,
setDifficulty,
gameMode, // Expose derived gameMode
activePlayers // Expose active players
}
return (
<MemoryPairsContext.Provider value={contextValue}>
{children}
</MemoryPairsContext.Provider>
)
}
// Hook to use the context
export function useMemoryPairs(): MemoryPairsContextValue {
const context = useContext(MemoryPairsContext)
if (!context) {
throw new Error('useMemoryPairs must be used within a MemoryPairsProvider')
}
return context
}

View File

@@ -0,0 +1,13 @@
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
import { ArcadeMemoryPairsProvider } from './context/ArcadeMemoryPairsContext'
import { MemoryPairsGame } from './components/MemoryPairsGame'
export default function MatchingPage() {
return (
<ArcadeGuardedPage>
<ArcadeMemoryPairsProvider>
<MemoryPairsGame />
</ArcadeMemoryPairsProvider>
</ArcadeGuardedPage>
)
}

View File

@@ -0,0 +1,191 @@
import type { GameCard, GameType, Difficulty } from '../context/types'
// Utility function to generate unique random numbers
function generateUniqueNumbers(count: number, options: { min: number; max: number }): number[] {
const numbers = new Set<number>()
const { min, max } = options
while (numbers.size < count) {
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min
numbers.add(randomNum)
}
return Array.from(numbers)
}
// Utility function to shuffle an array
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}
// Generate cards for abacus-numeral game mode
export function generateAbacusNumeralCards(pairs: Difficulty): GameCard[] {
// Generate unique numbers based on difficulty
// For easier games, use smaller numbers; for harder games, use larger ranges
const numberRanges: Record<Difficulty, { min: number; max: number }> = {
6: { min: 1, max: 50 }, // 6 pairs: 1-50
8: { min: 1, max: 100 }, // 8 pairs: 1-100
12: { min: 1, max: 200 }, // 12 pairs: 1-200
15: { min: 1, max: 300 } // 15 pairs: 1-300
}
const range = numberRanges[pairs]
const numbers = generateUniqueNumbers(pairs, range)
const cards: GameCard[] = []
numbers.forEach(number => {
// Abacus representation card
cards.push({
id: `abacus_${number}`,
type: 'abacus',
number,
matched: false
})
// Numerical representation card
cards.push({
id: `number_${number}`,
type: 'number',
number,
matched: false
})
})
return shuffleArray(cards)
}
// Generate cards for complement pairs game mode
export function generateComplementCards(pairs: Difficulty): GameCard[] {
// Define complement pairs for friends of 5 and friends of 10
const complementPairs = [
// Friends of 5
{ pair: [0, 5], targetSum: 5 as const },
{ pair: [1, 4], targetSum: 5 as const },
{ pair: [2, 3], targetSum: 5 as const },
// Friends of 10
{ pair: [0, 10], targetSum: 10 as const },
{ pair: [1, 9], targetSum: 10 as const },
{ pair: [2, 8], targetSum: 10 as const },
{ pair: [3, 7], targetSum: 10 as const },
{ pair: [4, 6], targetSum: 10 as const },
{ pair: [5, 5], targetSum: 10 as const },
// Additional pairs for higher difficulties
{ pair: [6, 4], targetSum: 10 as const },
{ pair: [7, 3], targetSum: 10 as const },
{ pair: [8, 2], targetSum: 10 as const },
{ pair: [9, 1], targetSum: 10 as const },
{ pair: [10, 0], targetSum: 10 as const },
// More challenging pairs (can be used for expert mode)
{ pair: [11, 9], targetSum: 20 as const },
{ pair: [12, 8], targetSum: 20 as const }
]
// Select the required number of complement pairs
const selectedPairs = complementPairs.slice(0, pairs)
const cards: GameCard[] = []
selectedPairs.forEach(({ pair: [num1, num2], targetSum }, index) => {
// First number in the pair
cards.push({
id: `comp1_${index}_${num1}`,
type: 'complement',
number: num1,
complement: num2,
targetSum,
matched: false
})
// Second number in the pair
cards.push({
id: `comp2_${index}_${num2}`,
type: 'complement',
number: num2,
complement: num1,
targetSum,
matched: false
})
})
return shuffleArray(cards)
}
// Main card generation function
export function generateGameCards(gameType: GameType, difficulty: Difficulty): GameCard[] {
switch (gameType) {
case 'abacus-numeral':
return generateAbacusNumeralCards(difficulty)
case 'complement-pairs':
return generateComplementCards(difficulty)
default:
throw new Error(`Unknown game type: ${gameType}`)
}
}
// Utility function to get responsive grid configuration based on difficulty and screen size
export function getGridConfiguration(difficulty: Difficulty) {
const configs: Record<Difficulty, {
totalCards: number;
// Orientation-optimized responsive columns
mobileColumns: number; // Portrait mobile
tabletColumns: number; // Tablet
desktopColumns: number; // Desktop/landscape
landscapeColumns: number; // Landscape mobile/tablet
cardSize: { width: string; height: string };
gridTemplate: string;
}> = {
6: {
totalCards: 12,
mobileColumns: 3, // 3x4 grid in portrait
tabletColumns: 4, // 4x3 grid on tablet
desktopColumns: 4, // 4x3 grid on desktop
landscapeColumns: 6, // 6x2 grid in landscape
cardSize: { width: '140px', height: '180px' },
gridTemplate: 'repeat(3, 1fr)'
},
8: {
totalCards: 16,
mobileColumns: 3, // 3x6 grid in portrait (some spillover)
tabletColumns: 4, // 4x4 grid on tablet
desktopColumns: 4, // 4x4 grid on desktop
landscapeColumns: 6, // 6x3 grid in landscape (some spillover)
cardSize: { width: '120px', height: '160px' },
gridTemplate: 'repeat(3, 1fr)'
},
12: {
totalCards: 24,
mobileColumns: 3, // 3x8 grid in portrait
tabletColumns: 4, // 4x6 grid on tablet
desktopColumns: 6, // 6x4 grid on desktop
landscapeColumns: 6, // 6x4 grid in landscape (changed from 8x3)
cardSize: { width: '100px', height: '140px' },
gridTemplate: 'repeat(3, 1fr)'
},
15: {
totalCards: 30,
mobileColumns: 3, // 3x10 grid in portrait
tabletColumns: 5, // 5x6 grid on tablet
desktopColumns: 6, // 6x5 grid on desktop
landscapeColumns: 10, // 10x3 grid in landscape
cardSize: { width: '90px', height: '120px' },
gridTemplate: 'repeat(3, 1fr)'
}
}
return configs[difficulty]
}
// Generate a unique ID for cards
export function generateCardId(type: string, identifier: string | number): string {
return `${type}_${identifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}

View File

@@ -0,0 +1,307 @@
import type { MemoryPairsState, GameStatistics, Player } from '../context/types'
// Calculate final game score based on multiple factors
export function calculateFinalScore(
matchedPairs: number,
totalPairs: number,
moves: number,
gameTime: number,
difficulty: number,
gameMode: 'single' | 'two-player'
): number {
// Base score for completing pairs
const baseScore = matchedPairs * 100
// Efficiency bonus (fewer moves = higher bonus)
const idealMoves = totalPairs * 2 // Perfect game would be 2 moves per pair
const efficiency = idealMoves / Math.max(moves, idealMoves)
const efficiencyBonus = Math.round(baseScore * efficiency * 0.5)
// Time bonus (faster completion = higher bonus)
const timeInMinutes = gameTime / (1000 * 60)
const timeBonus = Math.max(0, Math.round((1000 * difficulty) / timeInMinutes))
// Difficulty multiplier
const difficultyMultiplier = 1 + (difficulty - 6) * 0.1
// Two-player mode bonus
const modeMultiplier = gameMode === 'two-player' ? 1.2 : 1.0
const finalScore = Math.round(
(baseScore + efficiencyBonus + timeBonus) * difficultyMultiplier * modeMultiplier
)
return Math.max(0, finalScore)
}
// Calculate star rating (1-5 stars) based on performance
export function calculateStarRating(
accuracy: number,
efficiency: number,
gameTime: number,
difficulty: number
): number {
// Normalize time score (assuming reasonable time ranges)
const expectedTime = difficulty * 30000 // 30 seconds per pair as baseline
const timeScore = Math.max(0, Math.min(100, (expectedTime / gameTime) * 100))
// Weighted average of different factors
const overallScore = (accuracy * 0.4) + (efficiency * 0.4) + (timeScore * 0.2)
// Convert to stars
if (overallScore >= 90) return 5
if (overallScore >= 80) return 4
if (overallScore >= 70) return 3
if (overallScore >= 60) return 2
return 1
}
// Get achievement badges based on performance
export interface Achievement {
id: string
name: string
description: string
icon: string
earned: boolean
}
export function getAchievements(state: MemoryPairsState, gameMode: 'single' | 'multiplayer'): Achievement[] {
const { matchedPairs, totalPairs, moves, scores, gameStartTime, gameEndTime } = state
const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0
const gameTime = gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0
const gameTimeInSeconds = gameTime / 1000
const achievements: Achievement[] = [
{
id: 'perfect_game',
name: 'Perfect Memory',
description: 'Complete a game with 100% accuracy',
icon: '🧠',
earned: matchedPairs === totalPairs && moves === totalPairs * 2
},
{
id: 'speed_demon',
name: 'Speed Demon',
description: 'Complete a game in under 2 minutes',
icon: '⚡',
earned: gameTimeInSeconds > 0 && gameTimeInSeconds < 120 && matchedPairs === totalPairs
},
{
id: 'accuracy_ace',
name: 'Accuracy Ace',
description: 'Achieve 90% accuracy or higher',
icon: '🎯',
earned: accuracy >= 90 && matchedPairs === totalPairs
},
{
id: 'marathon_master',
name: 'Marathon Master',
description: 'Complete the hardest difficulty (15 pairs)',
icon: '🏃',
earned: totalPairs === 15 && matchedPairs === totalPairs
},
{
id: 'complement_champion',
name: 'Complement Champion',
description: 'Master complement pairs mode',
icon: '🤝',
earned: state.gameType === 'complement-pairs' && matchedPairs === totalPairs && accuracy >= 85
},
{
id: 'two_player_triumph',
name: 'Two-Player Triumph',
description: 'Win a two-player game',
icon: '👥',
earned: gameMode === 'multiplayer' && matchedPairs === totalPairs &&
Object.keys(scores).length > 1 && Math.max(...Object.values(scores)) > 0
},
{
id: 'shutout_victory',
name: 'Shutout Victory',
description: 'Win a two-player game without opponent scoring',
icon: '🛡️',
earned: gameMode === 'multiplayer' && matchedPairs === totalPairs &&
Object.values(scores).some(score => score === totalPairs) &&
Object.values(scores).some(score => score === 0)
},
{
id: 'comeback_kid',
name: 'Comeback Kid',
description: 'Win after being behind by 3+ points',
icon: '🔄',
earned: false // This would need more complex tracking during the game
},
{
id: 'first_timer',
name: 'First Timer',
description: 'Complete your first game',
icon: '🌟',
earned: matchedPairs === totalPairs
},
{
id: 'consistency_king',
name: 'Consistency King',
description: 'Achieve 80%+ accuracy in 5 consecutive games',
icon: '👑',
earned: false // This would need persistent game history
}
]
return achievements
}
// Get performance metrics and analysis
export function getPerformanceAnalysis(state: MemoryPairsState): {
statistics: GameStatistics
grade: 'A+' | 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D' | 'F'
strengths: string[]
improvements: string[]
starRating: number
} {
const { matchedPairs, totalPairs, moves, difficulty, gameStartTime, gameEndTime } = state
const gameTime = gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0
// Calculate statistics
const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0
const averageTimePerMove = moves > 0 ? gameTime / moves : 0
const statistics: GameStatistics = {
totalMoves: moves,
matchedPairs,
totalPairs,
gameTime,
accuracy,
averageTimePerMove
}
// Calculate efficiency (ideal vs actual moves)
const idealMoves = totalPairs * 2
const efficiency = (idealMoves / Math.max(moves, idealMoves)) * 100
// Determine grade
let grade: 'A+' | 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D' | 'F' = 'F'
if (accuracy >= 95 && efficiency >= 90) grade = 'A+'
else if (accuracy >= 90 && efficiency >= 85) grade = 'A'
else if (accuracy >= 85 && efficiency >= 80) grade = 'B+'
else if (accuracy >= 80 && efficiency >= 75) grade = 'B'
else if (accuracy >= 75 && efficiency >= 70) grade = 'C+'
else if (accuracy >= 70 && efficiency >= 65) grade = 'C'
else if (accuracy >= 60 && efficiency >= 50) grade = 'D'
// Calculate star rating
const starRating = calculateStarRating(accuracy, efficiency, gameTime, difficulty)
// Analyze strengths and areas for improvement
const strengths: string[] = []
const improvements: string[] = []
if (accuracy >= 90) {
strengths.push('Excellent memory and pattern recognition')
} else if (accuracy < 70) {
improvements.push('Focus on remembering card positions more carefully')
}
if (efficiency >= 85) {
strengths.push('Very efficient with minimal unnecessary moves')
} else if (efficiency < 60) {
improvements.push('Try to reduce random guessing and use memory strategies')
}
const avgTimePerMoveSeconds = averageTimePerMove / 1000
if (avgTimePerMoveSeconds < 3) {
strengths.push('Quick decision making')
} else if (avgTimePerMoveSeconds > 8) {
improvements.push('Practice to improve decision speed')
}
if (difficulty >= 12) {
strengths.push('Tackled challenging difficulty levels')
}
if (state.gameType === 'complement-pairs' && accuracy >= 80) {
strengths.push('Strong mathematical complement skills')
}
// Fallback messages
if (strengths.length === 0) {
strengths.push('Keep practicing to improve your skills!')
}
if (improvements.length === 0) {
improvements.push('Great job! Continue challenging yourself with harder difficulties.')
}
return {
statistics,
grade,
strengths,
improvements,
starRating
}
}
// Format time duration for display
export function formatGameTime(milliseconds: number): string {
const seconds = Math.floor(milliseconds / 1000)
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (minutes > 0) {
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
return `${remainingSeconds}s`
}
// Get two-player game winner
export function getTwoPlayerWinner(state: MemoryPairsState): {
winner: Player | 'tie'
winnerScore: number
loserScore: number
margin: number
} {
const { scores } = state
if (scores[1] > scores[2]) {
return {
winner: 1,
winnerScore: scores[1],
loserScore: scores[2],
margin: scores[1] - scores[2]
}
} else if (scores[2] > scores[1]) {
return {
winner: 2,
winnerScore: scores[2],
loserScore: scores[1],
margin: scores[2] - scores[1]
}
} else {
return {
winner: 'tie',
winnerScore: scores[1],
loserScore: scores[2],
margin: 0
}
}
}
// Get multiplayer game winner (supports N players)
export function getMultiplayerWinner(state: MemoryPairsState, activePlayers: Player[]): {
winners: Player[]
winnerScore: number
scores: { [playerId: number]: number }
isTie: boolean
} {
const { scores } = state
// Find the highest score
const maxScore = Math.max(...activePlayers.map(playerId => scores[playerId] || 0))
// Find all players with the highest score
const winners = activePlayers.filter(playerId => (scores[playerId] || 0) === maxScore)
return {
winners,
winnerScore: maxScore,
scores,
isTie: winners.length > 1
}
}

View File

@@ -0,0 +1,225 @@
import type { GameCard, MatchValidationResult } from '../context/types'
// Validate abacus-numeral match (abacus card matches with number card of same value)
export function validateAbacusNumeralMatch(
card1: GameCard,
card2: GameCard
): MatchValidationResult {
// Both cards must have the same number
if (card1.number !== card2.number) {
return {
isValid: false,
reason: 'Numbers do not match',
type: 'invalid'
}
}
// Cards must be different types (one abacus, one number)
if (card1.type === card2.type) {
return {
isValid: false,
reason: 'Both cards are the same type',
type: 'invalid'
}
}
// One must be abacus, one must be number
const hasAbacus = card1.type === 'abacus' || card2.type === 'abacus'
const hasNumber = card1.type === 'number' || card2.type === 'number'
if (!hasAbacus || !hasNumber) {
return {
isValid: false,
reason: 'Must match abacus with number representation',
type: 'invalid'
}
}
// Neither should be complement type for this game mode
if (card1.type === 'complement' || card2.type === 'complement') {
return {
isValid: false,
reason: 'Complement cards not valid in abacus-numeral mode',
type: 'invalid'
}
}
return {
isValid: true,
type: 'abacus-numeral'
}
}
// Validate complement match (two numbers that add up to target sum)
export function validateComplementMatch(
card1: GameCard,
card2: GameCard
): MatchValidationResult {
// Both cards must be complement type
if (card1.type !== 'complement' || card2.type !== 'complement') {
return {
isValid: false,
reason: 'Both cards must be complement type',
type: 'invalid'
}
}
// Both cards must have the same target sum
if (card1.targetSum !== card2.targetSum) {
return {
isValid: false,
reason: 'Cards have different target sums',
type: 'invalid'
}
}
// Check if the numbers are actually complements
if (!card1.complement || !card2.complement) {
return {
isValid: false,
reason: 'Complement information missing',
type: 'invalid'
}
}
// Verify the complement relationship
if (card1.number !== card2.complement || card2.number !== card1.complement) {
return {
isValid: false,
reason: 'Numbers are not complements of each other',
type: 'invalid'
}
}
// Verify the sum equals the target
const sum = card1.number + card2.number
if (sum !== card1.targetSum) {
return {
isValid: false,
reason: `Sum ${sum} does not equal target ${card1.targetSum}`,
type: 'invalid'
}
}
return {
isValid: true,
type: 'complement'
}
}
// Main validation function that determines which validation to use
export function validateMatch(card1: GameCard, card2: GameCard): MatchValidationResult {
// Cannot match the same card with itself
if (card1.id === card2.id) {
return {
isValid: false,
reason: 'Cannot match card with itself',
type: 'invalid'
}
}
// Cannot match already matched cards
if (card1.matched || card2.matched) {
return {
isValid: false,
reason: 'Cannot match already matched cards',
type: 'invalid'
}
}
// Determine which type of match to validate based on card types
const hasComplement = card1.type === 'complement' || card2.type === 'complement'
if (hasComplement) {
// If either card is complement type, use complement validation
return validateComplementMatch(card1, card2)
} else {
// Otherwise, use abacus-numeral validation
return validateAbacusNumeralMatch(card1, card2)
}
}
// Helper function to check if a card can be flipped
export function canFlipCard(
card: GameCard,
flippedCards: GameCard[],
isProcessingMove: boolean
): boolean {
// Cannot flip if processing a move
if (isProcessingMove) return false
// Cannot flip already matched cards
if (card.matched) return false
// Cannot flip if already flipped
if (flippedCards.some(c => c.id === card.id)) return false
// Cannot flip if two cards are already flipped
if (flippedCards.length >= 2) return false
return true
}
// Get hint for what kind of match the player should look for
export function getMatchHint(card: GameCard): string {
switch (card.type) {
case 'abacus':
return `Find the number ${card.number}`
case 'number':
return `Find the abacus showing ${card.number}`
case 'complement':
if (card.complement !== undefined && card.targetSum !== undefined) {
return `Find ${card.complement} to make ${card.targetSum}`
}
return 'Find the matching complement'
default:
return 'Find the matching card'
}
}
// Calculate match score based on difficulty and time
export function calculateMatchScore(
difficulty: number,
timeForMatch: number,
isComplementMatch: boolean
): number {
const baseScore = isComplementMatch ? 15 : 10 // Complement matches worth more
const difficultyMultiplier = difficulty / 6 // Scale with difficulty
const timeBonus = Math.max(0, (10000 - timeForMatch) / 1000) // Bonus for speed
return Math.round(baseScore * difficultyMultiplier + timeBonus)
}
// Analyze game performance
export function analyzeGamePerformance(
totalMoves: number,
matchedPairs: number,
totalPairs: number,
gameTime: number
): {
accuracy: number
efficiency: number
averageTimePerMove: number
grade: 'A' | 'B' | 'C' | 'D' | 'F'
} {
const accuracy = totalMoves > 0 ? (matchedPairs / totalMoves) * 100 : 0
const efficiency = totalPairs > 0 ? (matchedPairs / (totalPairs * 2)) * 100 : 0 // Ideal is 100% (each pair found in 2 moves)
const averageTimePerMove = totalMoves > 0 ? gameTime / totalMoves : 0
// Calculate grade based on accuracy and efficiency
let grade: 'A' | 'B' | 'C' | 'D' | 'F' = 'F'
if (accuracy >= 90 && efficiency >= 80) grade = 'A'
else if (accuracy >= 80 && efficiency >= 70) grade = 'B'
else if (accuracy >= 70 && efficiency >= 60) grade = 'C'
else if (accuracy >= 60 && efficiency >= 50) grade = 'D'
return {
accuracy,
efficiency,
averageTimePerMove,
grade
}
}