fix: use player IDs instead of array indices in matching game
Changed Player type from number to string (UUID) throughout the matching game to properly identify players by their unique IDs rather than array positions. This fixes the "Not your turn" validation errors that were occurring because server-side validation was comparing UUIDs (move.playerId) with numeric indices (state.currentPlayer). Changes: - Updated Player type from number to string in both arcade and games matching context types - Changed all player tracking to use UUID strings instead of numeric indices (1, 2, 3) - Updated turn validation in MatchingGameValidator to compare string IDs correctly - Fixed all UI components (GameCard, PlayerStatusBar, etc.) to use player.findIndex() for array positions when needed - Updated MatchingStartGameMove type to expect string[] for activePlayers - Re-enabled turn validation (previously disabled as workaround) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,12 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
.map(id => playerMap.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
|
||||
// Helper to get player index from ID (0-based)
|
||||
const getPlayerIndex = (playerId: string | undefined): number => {
|
||||
if (!playerId) return -1
|
||||
return activePlayers.findIndex(p => p.id === playerId)
|
||||
}
|
||||
|
||||
const cardBackStyles = css({
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
@@ -53,13 +59,14 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
// 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
|
||||
// Player-specific colors for matched cards - find player index by ID
|
||||
const playerIndex = getPlayerIndex(card.matchedBy)
|
||||
if (playerIndex === 0) {
|
||||
return 'linear-gradient(135deg, #74b9ff, #0984e3)' // Blue for first player
|
||||
} else if (playerIndex === 1) {
|
||||
return 'linear-gradient(135deg, #fd79a8, #e84393)' // Pink for second player
|
||||
}
|
||||
return 'linear-gradient(135deg, #48bb78, #38a169)' // Default green for single player
|
||||
return 'linear-gradient(135deg, #48bb78, #38a169)' // Default green for single player or 3+
|
||||
}
|
||||
|
||||
switch (card.type) {
|
||||
@@ -77,8 +84,9 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
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 || '✓'
|
||||
if (card.matchedBy) {
|
||||
const matchedPlayer = activePlayers.find(p => p.id === card.matchedBy)
|
||||
return matchedPlayer?.emoji || '✓'
|
||||
}
|
||||
return '✓' // Default checkmark for single player
|
||||
}
|
||||
@@ -98,12 +106,13 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
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
|
||||
const playerIndex = getPlayerIndex(card.matchedBy)
|
||||
if (playerIndex === 0) {
|
||||
return '#74b9ff' // Blue for first player
|
||||
} else if (playerIndex === 1) {
|
||||
return '#fd79a8' // Pink for second player
|
||||
}
|
||||
return '#48bb78' // Default green for single player
|
||||
return '#48bb78' // Default green for single player or 3+
|
||||
}
|
||||
if (isFlipped) return '#667eea'
|
||||
return '#e2e8f0'
|
||||
@@ -164,9 +173,9 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
style={{
|
||||
borderColor: getBorderColor(),
|
||||
boxShadow: isMatched
|
||||
? card.matchedBy === 1
|
||||
? getPlayerIndex(card.matchedBy) === 0
|
||||
? '0 0 20px rgba(116, 185, 255, 0.4)' // Blue glow for player 1
|
||||
: card.matchedBy === 2
|
||||
: getPlayerIndex(card.matchedBy) === 1
|
||||
? '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
|
||||
@@ -186,7 +195,7 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid',
|
||||
borderColor: card.matchedBy === 1 ? '#74b9ff' : '#fd79a8',
|
||||
borderColor: getPlayerIndex(card.matchedBy) === 0 ? '#74b9ff' : '#fd79a8',
|
||||
animation: 'explosionRing 0.6s ease-out',
|
||||
zIndex: 9
|
||||
})} />
|
||||
@@ -199,14 +208,14 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: card.matchedBy === 1
|
||||
background: getPlayerIndex(card.matchedBy) === 0
|
||||
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
|
||||
: 'linear-gradient(135deg, #fd79a8, #e84393)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
boxShadow: card.matchedBy === 1
|
||||
boxShadow: getPlayerIndex(card.matchedBy) === 0
|
||||
? '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)',
|
||||
@@ -219,7 +228,7 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
right: '-2px',
|
||||
bottom: '-2px',
|
||||
borderRadius: '50%',
|
||||
background: card.matchedBy === 1
|
||||
background: getPlayerIndex(card.matchedBy) === 0
|
||||
? 'linear-gradient(45deg, #74b9ff, #a29bfe, #6c5ce7, #74b9ff)'
|
||||
: 'linear-gradient(45deg, #fd79a8, #fdcb6e, #e17055, #fd79a8)',
|
||||
animation: 'spinningHalo 2s linear infinite',
|
||||
@@ -230,8 +239,8 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
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 || '✓'
|
||||
{card.matchedBy
|
||||
? activePlayers.find(p => p.id === card.matchedBy)?.emoji || '✓'
|
||||
: '✓'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,9 @@ export function GamePhase() {
|
||||
.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]
|
||||
// Map player ID (UUID string) to actual player data using array index
|
||||
const currentPlayerIndex = activePlayers.findIndex(id => id === state.currentPlayer)
|
||||
const currentPlayerData = currentPlayerIndex >= 0 ? activePlayersArray[currentPlayerIndex] : undefined
|
||||
const activePlayerData = activePlayersArray
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useFullscreen } from '../../../../contexts/FullscreenContext'
|
||||
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
import { GamePhase } from './GamePhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
@@ -11,8 +13,10 @@ import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
export function MemoryPairsGame() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession } = useArcadeMemoryPairs()
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
|
||||
const gameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,9 +32,10 @@ export function MemoryPairsGame() {
|
||||
navTitle="Memory Pairs"
|
||||
navEmoji="🧩"
|
||||
emphasizeGameContext={state.gamePhase === 'setup'}
|
||||
canModifyPlayers={canModifyPlayers}
|
||||
onExitSession={() => {
|
||||
exitSession()
|
||||
window.location.reload()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
>
|
||||
<StandardGameLayout>
|
||||
|
||||
@@ -20,13 +20,13 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
.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) => ({
|
||||
// State now uses player IDs (UUIDs) as keys
|
||||
const activePlayers = activePlayersData.map((player) => ({
|
||||
...player,
|
||||
displayName: player.name,
|
||||
displayEmoji: player.emoji,
|
||||
score: state.scores[index + 1] || 0,
|
||||
consecutiveMatches: state.consecutiveMatches?.[index + 1] || 0
|
||||
score: state.scores[player.id] || 0,
|
||||
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0
|
||||
}))
|
||||
|
||||
// Get celebration level based on consecutive matches
|
||||
@@ -101,8 +101,8 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
gap: { base: '2', md: '3' },
|
||||
alignItems: 'center'
|
||||
})}>
|
||||
{activePlayers.map((player, index) => {
|
||||
const isCurrentPlayer = (index + 1) === state.currentPlayer
|
||||
{activePlayers.map((player) => {
|
||||
const isCurrentPlayer = player.id === state.currentPlayer
|
||||
const isLeading = player.score === Math.max(...activePlayers.map(p => p.score)) && player.score > 0
|
||||
const celebrationLevel = getCelebrationLevel(player.consecutiveMatches)
|
||||
|
||||
|
||||
@@ -16,11 +16,10 @@ export function ResultsPhase() {
|
||||
const activePlayerData = Array.from(activePlayerIds)
|
||||
.map(id => playerMap.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
.map((player, index) => ({
|
||||
.map((player) => ({
|
||||
...player,
|
||||
displayName: player.name,
|
||||
displayEmoji: player.emoji,
|
||||
numericId: index + 1 // For compatibility with state.scores
|
||||
displayEmoji: player.emoji
|
||||
}))
|
||||
|
||||
const gameTime = state.gameEndTime && state.gameStartTime
|
||||
@@ -78,7 +77,7 @@ export function ResultsPhase() {
|
||||
color: 'blue.600',
|
||||
fontWeight: 'bold'
|
||||
})}>
|
||||
🏆 {activePlayerData.find(p => p.numericId === multiplayerResult.winners[0])?.displayName || `Player ${multiplayerResult.winners[0]}`} Wins!
|
||||
🏆 {activePlayerData.find(p => p.id === multiplayerResult.winners[0])?.displayName || `Player ${multiplayerResult.winners[0]}`} Wins!
|
||||
</p>
|
||||
) : (
|
||||
<p className={css({
|
||||
@@ -191,8 +190,8 @@ export function ResultsPhase() {
|
||||
flexWrap: 'wrap'
|
||||
})}>
|
||||
{activePlayerData.map((player) => {
|
||||
const score = multiplayerResult.scores[player.numericId] || 0
|
||||
const isWinner = multiplayerResult.winners.includes(player.numericId)
|
||||
const score = multiplayerResult.scores[player.id] || 0
|
||||
const isWinner = multiplayerResult.winners.includes(player.id)
|
||||
|
||||
return (
|
||||
<div key={player.id} className={css({
|
||||
|
||||
@@ -21,7 +21,7 @@ const initialState: MemoryPairsState = {
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: 1,
|
||||
currentPlayer: '', // Will be set to first player ID on START_GAME
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
@@ -54,10 +54,10 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: move.data.activePlayers.reduce((acc: any, p: number) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: move.data.activePlayers.reduce((acc: any, p: number) => ({ ...acc, [p]: 0 }), {}),
|
||||
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
activePlayers: move.data.activePlayers,
|
||||
currentPlayer: move.data.activePlayers[0] || 1,
|
||||
currentPlayer: move.data.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
@@ -106,8 +106,8 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active player IDs as numbers
|
||||
const activePlayers = Array.from(activePlayerIds).map((id, index) => index + 1)
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
@@ -28,7 +28,7 @@ const initialState: MemoryPairsState = {
|
||||
|
||||
// Game progression
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: 1,
|
||||
currentPlayer: '', // Will be set to first player ID on START_GAME
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
@@ -76,7 +76,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
case 'START_GAME':
|
||||
// Initialize scores and consecutive matches for all active players
|
||||
const scores: PlayerScore = {}
|
||||
const consecutiveMatches: { [playerId: number]: number } = {}
|
||||
const consecutiveMatches: { [playerId: string]: number } = {}
|
||||
action.activePlayers.forEach(playerId => {
|
||||
scores[playerId] = 0
|
||||
consecutiveMatches[playerId] = 0
|
||||
@@ -93,7 +93,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
scores,
|
||||
consecutiveMatches,
|
||||
activePlayers: action.activePlayers,
|
||||
currentPlayer: action.activePlayers[0] || 1,
|
||||
currentPlayer: action.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
@@ -253,8 +253,8 @@ 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)
|
||||
// Get active player IDs directly from GameModeContext
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
@@ -358,6 +358,7 @@ export function MemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
resetGame,
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
exitSession: () => {}, // No-op for non-arcade mode
|
||||
gameMode, // Expose derived gameMode
|
||||
activePlayers // Expose active players
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export type GameType = 'abacus-numeral' | 'complement-pairs'
|
||||
export type GamePhase = 'setup' | 'playing' | 'results'
|
||||
export type CardType = 'abacus' | 'number' | 'complement'
|
||||
export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs
|
||||
export type Player = number // Now supports any player ID
|
||||
export type Player = string // Player ID (UUID)
|
||||
export type TargetSum = 5 | 10 | 20
|
||||
|
||||
export interface GameCard {
|
||||
@@ -20,7 +20,7 @@ export interface GameCard {
|
||||
}
|
||||
|
||||
export interface PlayerScore {
|
||||
[playerId: number]: number
|
||||
[playerId: string]: number
|
||||
}
|
||||
|
||||
export interface CelebrationAnimation {
|
||||
@@ -59,7 +59,7 @@ export interface MemoryPairsState {
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
consecutiveMatches: { [playerId: number]: number } // Track consecutive matches per player
|
||||
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
gameStartTime: number | null
|
||||
|
||||
@@ -251,33 +251,42 @@ export function formatGameTime(milliseconds: number): string {
|
||||
}
|
||||
|
||||
// Get two-player game winner
|
||||
export function getTwoPlayerWinner(state: MemoryPairsState): {
|
||||
// @deprecated Use getMultiplayerWinner instead which supports N players
|
||||
export function getTwoPlayerWinner(state: MemoryPairsState, activePlayers: Player[]): {
|
||||
winner: Player | 'tie'
|
||||
winnerScore: number
|
||||
loserScore: number
|
||||
margin: number
|
||||
} {
|
||||
const { scores } = state
|
||||
const [player1, player2] = activePlayers
|
||||
|
||||
if (scores[1] > scores[2]) {
|
||||
if (!player1 || !player2) {
|
||||
throw new Error('getTwoPlayerWinner requires at least 2 active players')
|
||||
}
|
||||
|
||||
const score1 = scores[player1] || 0
|
||||
const score2 = scores[player2] || 0
|
||||
|
||||
if (score1 > score2) {
|
||||
return {
|
||||
winner: 1,
|
||||
winnerScore: scores[1],
|
||||
loserScore: scores[2],
|
||||
margin: scores[1] - scores[2]
|
||||
winner: player1,
|
||||
winnerScore: score1,
|
||||
loserScore: score2,
|
||||
margin: score1 - score2
|
||||
}
|
||||
} else if (scores[2] > scores[1]) {
|
||||
} else if (score2 > score1) {
|
||||
return {
|
||||
winner: 2,
|
||||
winnerScore: scores[2],
|
||||
loserScore: scores[1],
|
||||
margin: scores[2] - scores[1]
|
||||
winner: player2,
|
||||
winnerScore: score2,
|
||||
loserScore: score1,
|
||||
margin: score2 - score1
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
winner: 'tie',
|
||||
winnerScore: scores[1],
|
||||
loserScore: scores[2],
|
||||
winnerScore: score1,
|
||||
loserScore: score2,
|
||||
margin: 0
|
||||
}
|
||||
}
|
||||
@@ -287,7 +296,7 @@ export function getTwoPlayerWinner(state: MemoryPairsState): {
|
||||
export function getMultiplayerWinner(state: MemoryPairsState, activePlayers: Player[]): {
|
||||
winners: Player[]
|
||||
winnerScore: number
|
||||
scores: { [playerId: number]: number }
|
||||
scores: { [playerId: string]: number }
|
||||
isTie: boolean
|
||||
} {
|
||||
const { scores } = state
|
||||
|
||||
@@ -53,11 +53,12 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
// 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
|
||||
// Player-specific colors for matched cards - use array index lookup
|
||||
const playerIndex = card.matchedBy ? activePlayers.findIndex(p => p.id === card.matchedBy) : -1
|
||||
if (playerIndex === 0) {
|
||||
return 'linear-gradient(135deg, #74b9ff, #0984e3)' // Blue for first player
|
||||
} else if (playerIndex === 1) {
|
||||
return 'linear-gradient(135deg, #fd79a8, #e84393)' // Pink for second player
|
||||
}
|
||||
return 'linear-gradient(135deg, #48bb78, #38a169)' // Default green for single player
|
||||
}
|
||||
@@ -77,8 +78,9 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
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 || '✓'
|
||||
if (card.matchedBy) {
|
||||
const player = activePlayers.find(p => p.id === card.matchedBy)
|
||||
return player?.emoji || '✓'
|
||||
}
|
||||
return '✓' // Default checkmark for single player
|
||||
}
|
||||
@@ -97,11 +99,12 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
|
||||
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
|
||||
// Player-specific border colors for matched cards - use array index lookup
|
||||
const playerIndex = card.matchedBy ? activePlayers.findIndex(p => p.id === card.matchedBy) : -1
|
||||
if (playerIndex === 0) {
|
||||
return '#74b9ff' // Blue for first player
|
||||
} else if (playerIndex === 1) {
|
||||
return '#fd79a8' // Pink for second player
|
||||
}
|
||||
return '#48bb78' // Default green for single player
|
||||
}
|
||||
@@ -164,11 +167,15 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
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
|
||||
? (() => {
|
||||
const playerIndex = card.matchedBy ? activePlayers.findIndex(p => p.id === card.matchedBy) : -1
|
||||
if (playerIndex === 0) {
|
||||
return '0 0 20px rgba(116, 185, 255, 0.4)' // Blue glow for first player
|
||||
} else if (playerIndex === 1) {
|
||||
return '0 0 20px rgba(253, 121, 168, 0.4)' // Pink glow for second player
|
||||
}
|
||||
return '0 0 20px rgba(72, 187, 120, 0.4)' // Default green glow
|
||||
})()
|
||||
: isFlipped
|
||||
? '0 0 15px rgba(102, 126, 234, 0.3)'
|
||||
: 'none'
|
||||
@@ -186,7 +193,10 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid',
|
||||
borderColor: card.matchedBy === 1 ? '#74b9ff' : '#fd79a8',
|
||||
borderColor: (() => {
|
||||
const playerIndex = activePlayers.findIndex(p => p.id === card.matchedBy)
|
||||
return playerIndex === 0 ? '#74b9ff' : '#fd79a8'
|
||||
})(),
|
||||
animation: 'explosionRing 0.6s ease-out',
|
||||
zIndex: 9
|
||||
})} />
|
||||
@@ -199,16 +209,22 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: card.matchedBy === 1
|
||||
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
|
||||
: 'linear-gradient(135deg, #fd79a8, #e84393)',
|
||||
background: (() => {
|
||||
const playerIndex = activePlayers.findIndex(p => p.id === card.matchedBy)
|
||||
return playerIndex === 0
|
||||
? '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)',
|
||||
boxShadow: (() => {
|
||||
const playerIndex = activePlayers.findIndex(p => p.id === card.matchedBy)
|
||||
return playerIndex === 0
|
||||
? '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': {
|
||||
@@ -219,9 +235,12 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
right: '-2px',
|
||||
bottom: '-2px',
|
||||
borderRadius: '50%',
|
||||
background: card.matchedBy === 1
|
||||
? 'linear-gradient(45deg, #74b9ff, #a29bfe, #6c5ce7, #74b9ff)'
|
||||
: 'linear-gradient(45deg, #fd79a8, #fdcb6e, #e17055, #fd79a8)',
|
||||
background: (() => {
|
||||
const playerIndex = activePlayers.findIndex(p => p.id === card.matchedBy)
|
||||
return playerIndex === 0
|
||||
? 'linear-gradient(45deg, #74b9ff, #a29bfe, #6c5ce7, #74b9ff)'
|
||||
: 'linear-gradient(45deg, #fd79a8, #fdcb6e, #e17055, #fd79a8)'
|
||||
})(),
|
||||
animation: 'spinningHalo 2s linear infinite',
|
||||
zIndex: -1
|
||||
}
|
||||
@@ -230,9 +249,7 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
|
||||
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 || '✓'
|
||||
: '✓'}
|
||||
{activePlayers.find(p => p.id === card.matchedBy)?.emoji || '✓'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ export function GamePhase() {
|
||||
.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]
|
||||
// Map player ID (UUID string) to actual player data using array index
|
||||
const currentPlayerIndex = activePlayers.findIndex(id => id === state.currentPlayer)
|
||||
const currentPlayerData = currentPlayerIndex >= 0 ? activePlayersArray[currentPlayerIndex] : undefined
|
||||
const activePlayerData = activePlayersArray
|
||||
|
||||
return (
|
||||
|
||||
@@ -102,7 +102,7 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
alignItems: 'center'
|
||||
})}>
|
||||
{activePlayers.map((player, index) => {
|
||||
const isCurrentPlayer = (index + 1) === state.currentPlayer
|
||||
const isCurrentPlayer = player.id === state.currentPlayer
|
||||
const isLeading = player.score === Math.max(...activePlayers.map(p => p.score)) && player.score > 0
|
||||
const celebrationLevel = getCelebrationLevel(player.consecutiveMatches)
|
||||
|
||||
|
||||
@@ -16,11 +16,10 @@ export function ResultsPhase() {
|
||||
const activePlayerData = Array.from(activePlayerIds)
|
||||
.map(id => playerMap.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
.map((player, index) => ({
|
||||
.map((player) => ({
|
||||
...player,
|
||||
displayName: player.name,
|
||||
displayEmoji: player.emoji,
|
||||
numericId: index + 1 // For compatibility with state.scores
|
||||
displayEmoji: player.emoji
|
||||
}))
|
||||
|
||||
const gameTime = state.gameEndTime && state.gameStartTime
|
||||
@@ -73,7 +72,7 @@ export function ResultsPhase() {
|
||||
color: 'blue.600',
|
||||
fontWeight: 'bold'
|
||||
})}>
|
||||
🏆 {activePlayerData.find(p => p.numericId === multiplayerResult.winners[0])?.displayName || `Player ${multiplayerResult.winners[0]}`} Wins!
|
||||
🏆 {activePlayerData.find(p => p.id === multiplayerResult.winners[0])?.displayName || `Player ${multiplayerResult.winners[0]}`} Wins!
|
||||
</p>
|
||||
) : (
|
||||
<p className={css({
|
||||
@@ -186,8 +185,8 @@ export function ResultsPhase() {
|
||||
flexWrap: 'wrap'
|
||||
})}>
|
||||
{activePlayerData.map((player) => {
|
||||
const score = multiplayerResult.scores[player.numericId] || 0
|
||||
const isWinner = multiplayerResult.winners.includes(player.numericId)
|
||||
const score = multiplayerResult.scores[player.id] || 0
|
||||
const isWinner = multiplayerResult.winners.includes(player.id)
|
||||
|
||||
return (
|
||||
<div key={player.id} className={css({
|
||||
|
||||
@@ -28,7 +28,7 @@ const initialState: MemoryPairsState = {
|
||||
|
||||
// Game progression
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: 1,
|
||||
currentPlayer: '', // Will be set to first player ID on START_GAME
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
@@ -76,7 +76,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
case 'START_GAME':
|
||||
// Initialize scores and consecutive matches for all active players
|
||||
const scores: PlayerScore = {}
|
||||
const consecutiveMatches: { [playerId: number]: number } = {}
|
||||
const consecutiveMatches: { [playerId: string]: number } = {}
|
||||
action.activePlayers.forEach(playerId => {
|
||||
scores[playerId] = 0
|
||||
consecutiveMatches[playerId] = 0
|
||||
@@ -93,7 +93,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
scores,
|
||||
consecutiveMatches,
|
||||
activePlayers: action.activePlayers,
|
||||
currentPlayer: action.activePlayers[0] || 1,
|
||||
currentPlayer: action.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
@@ -253,8 +253,8 @@ 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)
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
@@ -358,6 +358,7 @@ export function MemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
resetGame,
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
exitSession: () => {}, // No-op for non-arcade mode
|
||||
gameMode, // Expose derived gameMode
|
||||
activePlayers // Expose active players
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export type GameType = 'abacus-numeral' | 'complement-pairs'
|
||||
export type GamePhase = 'setup' | 'playing' | 'results'
|
||||
export type CardType = 'abacus' | 'number' | 'complement'
|
||||
export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs
|
||||
export type Player = number // Now supports any player ID
|
||||
export type Player = string // Player ID (UUID)
|
||||
export type TargetSum = 5 | 10 | 20
|
||||
|
||||
export interface GameCard {
|
||||
@@ -20,7 +20,7 @@ export interface GameCard {
|
||||
}
|
||||
|
||||
export interface PlayerScore {
|
||||
[playerId: number]: number
|
||||
[playerId: string]: number
|
||||
}
|
||||
|
||||
export interface CelebrationAnimation {
|
||||
@@ -59,7 +59,7 @@ export interface MemoryPairsState {
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
consecutiveMatches: { [playerId: number]: number } // Track consecutive matches per player
|
||||
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
gameStartTime: number | null
|
||||
@@ -108,6 +108,7 @@ export interface MemoryPairsContextValue {
|
||||
resetGame: () => void
|
||||
setGameType: (type: GameType) => void
|
||||
setDifficulty: (difficulty: Difficulty) => void
|
||||
exitSession: () => void // Exit arcade session (no-op for non-arcade mode)
|
||||
}
|
||||
|
||||
// Utility types for component props
|
||||
|
||||
@@ -251,33 +251,42 @@ export function formatGameTime(milliseconds: number): string {
|
||||
}
|
||||
|
||||
// Get two-player game winner
|
||||
export function getTwoPlayerWinner(state: MemoryPairsState): {
|
||||
// @deprecated Use getMultiplayerWinner instead which supports N players
|
||||
export function getTwoPlayerWinner(state: MemoryPairsState, activePlayers: Player[]): {
|
||||
winner: Player | 'tie'
|
||||
winnerScore: number
|
||||
loserScore: number
|
||||
margin: number
|
||||
} {
|
||||
const { scores } = state
|
||||
const [player1, player2] = activePlayers
|
||||
|
||||
if (scores[1] > scores[2]) {
|
||||
if (!player1 || !player2) {
|
||||
throw new Error('getTwoPlayerWinner requires at least 2 active players')
|
||||
}
|
||||
|
||||
const score1 = scores[player1] || 0
|
||||
const score2 = scores[player2] || 0
|
||||
|
||||
if (score1 > score2) {
|
||||
return {
|
||||
winner: 1,
|
||||
winnerScore: scores[1],
|
||||
loserScore: scores[2],
|
||||
margin: scores[1] - scores[2]
|
||||
winner: player1,
|
||||
winnerScore: score1,
|
||||
loserScore: score2,
|
||||
margin: score1 - score2
|
||||
}
|
||||
} else if (scores[2] > scores[1]) {
|
||||
} else if (score2 > score1) {
|
||||
return {
|
||||
winner: 2,
|
||||
winnerScore: scores[2],
|
||||
loserScore: scores[1],
|
||||
margin: scores[2] - scores[1]
|
||||
winner: player2,
|
||||
winnerScore: score2,
|
||||
loserScore: score1,
|
||||
margin: score2 - score1
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
winner: 'tie',
|
||||
winnerScore: scores[1],
|
||||
loserScore: scores[2],
|
||||
winnerScore: score1,
|
||||
loserScore: score2,
|
||||
margin: 0
|
||||
}
|
||||
}
|
||||
@@ -287,7 +296,7 @@ export function getTwoPlayerWinner(state: MemoryPairsState): {
|
||||
export function getMultiplayerWinner(state: MemoryPairsState, activePlayers: Player[]): {
|
||||
winners: Player[]
|
||||
winnerScore: number
|
||||
scores: { [playerId: number]: number }
|
||||
scores: { [playerId: string]: number }
|
||||
isTie: boolean
|
||||
} {
|
||||
const { scores } = state
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
ValidationResult,
|
||||
MatchingGameMove,
|
||||
} from './types'
|
||||
import type { MemoryPairsState, GameCard, Difficulty, GameType } from '@/app/games/matching/context/types'
|
||||
import type { MemoryPairsState, GameCard, Difficulty, GameType, Player } from '@/app/games/matching/context/types'
|
||||
import { validateMatch, canFlipCard } from '@/app/games/matching/utils/matchValidation'
|
||||
import { generateGameCards } from '@/app/games/matching/utils/cardGeneration'
|
||||
|
||||
@@ -45,11 +45,13 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
}
|
||||
|
||||
// Skip turn validation in arcade mode where a single user controls multiple players
|
||||
// In this case, playerId is the viewer ID (UUID), not a player number
|
||||
// Turn validation only applies to true multiplayer with different users
|
||||
// Since we can't distinguish here, we disable turn validation for arcade sessions
|
||||
// (A better solution would be to pass both viewerId and playerNumber in moves)
|
||||
// Check if it's the player's turn (in multiplayer)
|
||||
if (state.activePlayers.length > 1 && state.currentPlayer !== playerId) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Not your turn',
|
||||
}
|
||||
}
|
||||
|
||||
// Find the card
|
||||
const card = state.gameCards.find(c => c.id === cardId)
|
||||
@@ -147,7 +149,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
|
||||
private validateStartGame(
|
||||
state: MemoryPairsState,
|
||||
activePlayers: number[],
|
||||
activePlayers: Player[],
|
||||
cards?: GameCard[]
|
||||
): ValidationResult {
|
||||
// Allow starting a new game from any phase (for "New Game" button)
|
||||
@@ -214,7 +216,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
difficulty: config.difficulty,
|
||||
turnTimer: config.turnTimer,
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: 1,
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
totalPairs: config.difficulty,
|
||||
moves: 0,
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface MatchingFlipCardMove extends GameMove {
|
||||
export interface MatchingStartGameMove extends GameMove {
|
||||
type: 'START_GAME'
|
||||
data: {
|
||||
activePlayers: number[]
|
||||
activePlayers: string[] // Player IDs (UUIDs)
|
||||
cards?: any[] // GameCard type from context
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user