refactor(card-sorting): send complete card sequence instead of individual moves

Simplified architecture: instead of sending individual INSERT_CARD moves
sequentially (which caused race conditions), the client now owns the card
arrangement entirely and sends the complete final sequence in CHECK_SOLUTION.

Changes:
- PlayingPhaseDrag: Send full sequence when clicking "Done"
- Provider: Update checkSolution to accept optional finalSequence parameter
- Validator: Accept and use finalSequence in CHECK_SOLUTION validation
- Validator: Save finalSequence to placedCards for results display
- Remove sequential INSERT_CARD flow complexity

This eliminates:
- Sequential move processing
- Race conditions between INSERT_CARD moves
- Need for complex optimistic locking in card placement
- Version conflicts during rapid card insertion

The client-side spatial arrangement is the source of truth, sent once
to the server for validation and scoring.

🤖 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-23 13:19:07 -05:00
parent fbcde2505f
commit e4df8432b9
6 changed files with 1325 additions and 444 deletions

View File

@@ -1,6 +1,14 @@
'use client'
import { type ReactNode, useCallback, useMemo, createContext, useContext, useState } from 'react'
import {
type ReactNode,
useCallback,
useMemo,
createContext,
useContext,
useState,
useEffect,
} from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
@@ -18,7 +26,7 @@ interface CardSortingContextValue {
placeCard: (cardId: string, position: number) => void
insertCard: (cardId: string, insertPosition: number) => void
removeCard: (position: number) => void
checkSolution: () => void
checkSolution: (finalSequence?: SortingCard[]) => void
revealNumbers: () => void
goToSetup: () => void
resumeGame: () => void
@@ -126,7 +134,9 @@ function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardS
case 'INSERT_CARD': {
const { cardId, insertPosition } = typedMove.data
const card = state.availableCards.find((c) => c.id === cardId)
if (!card) return state
if (!card) {
return state
}
// Insert with shift and compact (no gaps)
const newPlaced = new Array(state.cardCount).fill(null)
@@ -201,12 +211,8 @@ function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardS
}
case 'CHECK_SOLUTION': {
// Server will calculate score - just transition to results optimistically
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
}
// Don't apply optimistic update - wait for server to calculate and return score
return state
}
case 'GO_TO_SETUP': {
@@ -376,7 +382,6 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
// Action creators
const startGame = useCallback(() => {
if (!localPlayerId) {
console.error('[CardSortingProvider] No local player available')
return
}
@@ -392,7 +397,7 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
selectedCards,
},
})
}, [localPlayerId, state.cardCount, buildPlayerMetadata, sendMove, viewerId])
}, [localPlayerId, state.cardCount, buildPlayerMetadata, sendMove, viewerId, state.gamePhase])
const placeCard = useCallback(
(cardId: string, position: number) => {
@@ -442,20 +447,26 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
[localPlayerId, sendMove, viewerId]
)
const checkSolution = useCallback(() => {
if (!localPlayerId) return
if (!canCheckSolution) {
console.warn('[CardSortingProvider] Cannot check - not all cards placed')
return
}
const checkSolution = useCallback(
(finalSequence?: SortingCard[]) => {
if (!localPlayerId) return
sendMove({
type: 'CHECK_SOLUTION',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, canCheckSolution, sendMove, viewerId])
// If finalSequence provided, use it. Otherwise check current placedCards
if (!finalSequence && !canCheckSolution) {
return
}
sendMove({
type: 'CHECK_SOLUTION',
playerId: localPlayerId,
userId: viewerId || '',
data: {
finalSequence,
},
})
},
[localPlayerId, canCheckSolution, sendMove, viewerId, state.cardCount, state.gamePhase]
)
const revealNumbers = useCallback(() => {
if (!localPlayerId) return

View File

@@ -25,7 +25,7 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
case 'REVEAL_NUMBERS':
return this.validateRevealNumbers(state)
case 'CHECK_SOLUTION':
return this.validateCheckSolution(state)
return this.validateCheckSolution(state, move.data.finalSequence)
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
@@ -45,13 +45,7 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
data: { playerMetadata: unknown; selectedCards: unknown },
playerId: string
): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Can only start game from setup phase',
}
}
// Allow starting a new game from any phase (for "Play Again" button)
// Validate selectedCards
if (!Array.isArray(data.selectedCards)) {
@@ -82,11 +76,13 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
playerId,
playerMetadata: data.playerMetadata,
gameStartTime: Date.now(),
gameEndTime: null,
selectedCards: selectedCards as typeof state.selectedCards,
correctOrder: correctOrder as typeof state.correctOrder,
availableCards: selectedCards as typeof state.availableCards,
placedCards: new Array(state.cardCount).fill(null),
numbersRevealed: false,
scoreBreakdown: null,
},
}
}
@@ -263,7 +259,10 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
}
}
private validateCheckSolution(state: CardSortingState): ValidationResult {
private validateCheckSolution(
state: CardSortingState,
finalSequence?: typeof state.selectedCards
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
@@ -272,13 +271,18 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
}
}
// All slots must be filled
if (state.placedCards.some((c) => c === null)) {
// Use finalSequence if provided, otherwise use placedCards
const userCards =
finalSequence ||
state.placedCards.filter((c): c is (typeof state.selectedCards)[0] => c !== null)
// Must have all cards
if (userCards.length !== state.cardCount) {
return { valid: false, error: 'Must place all cards before checking' }
}
// Calculate score using scoring algorithms
const userSequence = state.placedCards.map((c) => c!.number)
const userSequence = userCards.map((c) => c.number)
const correctSequence = state.correctOrder.map((c) => c.number)
const scoreBreakdown = calculateScore(
@@ -288,6 +292,11 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
state.numbersRevealed
)
// If finalSequence was provided, update placedCards with it
const newPlacedCards = finalSequence
? [...userCards, ...new Array(state.cardCount - userCards.length).fill(null)]
: state.placedCards
return {
valid: true,
newState: {
@@ -295,6 +304,8 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
gamePhase: 'results',
gameEndTime: Date.now(),
scoreBreakdown,
placedCards: newPlacedCards,
availableCards: [], // All cards are now placed
},
}
}

View File

@@ -8,7 +8,7 @@ import { StandardGameLayout } from '@/components/StandardGameLayout'
import { useFullscreen } from '@/contexts/FullscreenContext'
import { useCardSorting } from '../Provider'
import { SetupPhase } from './SetupPhase'
import { PlayingPhase } from './PlayingPhase'
import { PlayingPhaseDrag } from './PlayingPhaseDrag'
import { ResultsPhase } from './ResultsPhase'
export function GameComponent() {
@@ -49,16 +49,16 @@ export function GameComponent() {
ref={gameRef}
className={css({
flex: 1,
padding: { base: '12px', sm: '16px', md: '20px' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
overflow: 'auto',
overflow: 'hidden',
// Remove all padding/margins for playing phase
padding: state.gamePhase === 'playing' ? '0' : { base: '12px', sm: '16px', md: '20px' },
})}
>
{/* Spectator Mode Banner */}
{isSpectating && state.gamePhase !== 'setup' && (
{/* Spectator Mode Banner - only show in setup/results */}
{isSpectating && state.gamePhase !== 'setup' && state.gamePhase !== 'playing' && (
<div
className={css({
width: '100%',
@@ -76,6 +76,7 @@ export function GameComponent() {
fontWeight: '600',
color: '#92400e',
textAlign: 'center',
alignSelf: 'center',
})}
>
<span role="img" aria-label="watching">
@@ -85,24 +86,29 @@ export function GameComponent() {
</div>
)}
<main
className={css({
width: '100%',
maxWidth: '1200px',
background: 'rgba(255,255,255,0.95)',
borderRadius: { base: '12px', md: '20px' },
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</main>
{/* For playing phase, render full viewport. For setup/results, use container */}
{state.gamePhase === 'playing' ? (
<PlayingPhaseDrag />
) : (
<main
className={css({
width: '100%',
maxWidth: '1200px',
background: 'rgba(255,255,255,0.95)',
borderRadius: { base: '12px', md: '20px' },
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
alignSelf: 'center',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</main>
)}
</div>
</StandardGameLayout>
</PageWithNav>

View File

@@ -0,0 +1,670 @@
'use client'
import { css } from '../../../../styled-system/css'
import { useCardSorting } from '../Provider'
import { useState, useEffect, useRef } from 'react'
import type { SortingCard } from '../types'
// Add celebration animations
if (typeof document !== 'undefined') {
const style = document.createElement('style')
style.textContent = `
@keyframes celebrate {
0%, 100% {
transform: scale(1) rotate(0deg);
background-position: 0% 50%;
}
25% {
transform: scale(1.2) rotate(-10deg);
background-position: 100% 50%;
}
50% {
transform: scale(1.3) rotate(0deg);
background-position: 0% 50%;
}
75% {
transform: scale(1.2) rotate(10deg);
background-position: 100% 50%;
}
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.1); }
}
@keyframes correctArrowGlow {
0%, 100% {
filter: brightness(1) drop-shadow(0 0 8px rgba(34, 197, 94, 0.6));
opacity: 0.9;
}
50% {
filter: brightness(1.3) drop-shadow(0 0 15px rgba(34, 197, 94, 0.9));
opacity: 1;
}
}
@keyframes correctBadgePulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-50%, -50%) scale(1.15);
}
}
`
document.head.appendChild(style)
}
interface CardState {
x: number
y: number
rotation: number
zIndex: number
}
/**
* Infers the sequence order of cards based on their spatial positions.
* Uses a horizontal left-to-right ordering with some vertical tolerance.
*
* Algorithm:
* 1. Group cards into horizontal "lanes" (vertical tolerance of ~60px)
* 2. Within each lane, sort left-to-right by x position
* 3. Sort lanes top-to-bottom
* 4. Flatten to get final sequence
*/
function inferSequenceFromPositions(
cardStates: Map<string, CardState>,
allCards: SortingCard[]
): SortingCard[] {
const VERTICAL_TOLERANCE = 60 // Cards within 60px vertically are in the same "lane"
// Get all positioned cards
const positionedCards = allCards
.map((card) => {
const state = cardStates.get(card.id)
if (!state) return null
return { card, ...state }
})
.filter(
(
item
): item is { card: SortingCard; x: number; y: number; rotation: number; zIndex: number } =>
item !== null
)
if (positionedCards.length === 0) return []
// Sort by x position first
const sortedByX = [...positionedCards].sort((a, b) => a.x - b.x)
// Group into lanes
const lanes: (typeof positionedCards)[] = []
for (const item of sortedByX) {
// Find a lane this card fits into (similar y position)
const matchingLane = lanes.find((lane) => {
// Check if card's y is within tolerance of lane's average y
const laneAvgY = lane.reduce((sum, c) => sum + c.y, 0) / lane.length
return Math.abs(item.y - laneAvgY) < VERTICAL_TOLERANCE
})
if (matchingLane) {
matchingLane.push(item)
} else {
lanes.push([item])
}
}
// Sort lanes top-to-bottom
lanes.sort((laneA, laneB) => {
const avgYA = laneA.reduce((sum, c) => sum + c.y, 0) / laneA.length
const avgYB = laneB.reduce((sum, c) => sum + c.y, 0) / laneB.length
return avgYA - avgYB
})
// Within each lane, sort left-to-right
for (const lane of lanes) {
lane.sort((a, b) => a.x - b.x)
}
// Flatten to get final sequence
return lanes.flat().map((item) => item.card)
}
export function PlayingPhaseDrag() {
const {
state,
insertCard,
checkSolution,
revealNumbers,
goToSetup,
canCheckSolution,
elapsedTime,
isSpectating,
} = useCardSorting()
const containerRef = useRef<HTMLDivElement>(null)
const dragStateRef = useRef<{
cardId: string
offsetX: number
offsetY: number
startX: number
startY: number
} | null>(null)
// Track card positions and visual states (UI only - not game state)
const [cardStates, setCardStates] = useState<Map<string, CardState>>(new Map())
const [draggingCardId, setDraggingCardId] = useState<string | null>(null)
const [nextZIndex, setNextZIndex] = useState(1)
// Track when we're waiting to check solution
const [waitingToCheck, setWaitingToCheck] = useState(false)
const cardsToInsertRef = useRef<SortingCard[]>([])
const currentInsertIndexRef = useRef(0)
// Initialize card positions when game starts or restarts
useEffect(() => {
// Reset when entering playing phase or when cards change
const allCards = [
...state.availableCards,
...state.placedCards.filter((c): c is SortingCard => c !== null),
]
// Only initialize if we have cards and either:
// 1. No card states exist yet, OR
// 2. The number of cards has changed (new game started)
const shouldInitialize =
allCards.length > 0 && (cardStates.size === 0 || cardStates.size !== allCards.length)
if (!shouldInitialize) return
// Use full viewport dimensions
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const newStates = new Map<string, CardState>()
allCards.forEach((card, index) => {
// Scatter cards randomly across the entire viewport
// Leave margin for card size (140x180) and UI elements
newStates.set(card.id, {
x: Math.random() * (viewportWidth - 180) + 20,
y: Math.random() * (viewportHeight - 250) + 80, // Extra margin for top UI
rotation: Math.random() * 30 - 15, // -15 to 15 degrees
zIndex: index,
})
})
setCardStates(newStates)
setNextZIndex(allCards.length)
}, [state.availableCards.length, state.placedCards.length, state.gameStartTime, cardStates.size])
// Infer sequence from current positions
const inferredSequence = inferSequenceFromPositions(cardStates, [
...state.availableCards,
...state.placedCards.filter((c): c is SortingCard => c !== null),
])
// Format time display
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
// Handle pointer down (start drag)
const handlePointerDown = (e: React.PointerEvent, cardId: string) => {
if (isSpectating) return
const target = e.currentTarget as HTMLElement
target.setPointerCapture(e.pointerId)
const rect = target.getBoundingClientRect()
const offsetX = e.clientX - rect.left
const offsetY = e.clientY - rect.top
dragStateRef.current = {
cardId,
offsetX,
offsetY,
startX: e.clientX,
startY: e.clientY,
}
setDraggingCardId(cardId)
// Bring card to front
setCardStates((prev) => {
const newStates = new Map(prev)
const cardState = newStates.get(cardId)
if (cardState) {
newStates.set(cardId, { ...cardState, zIndex: nextZIndex })
}
return newStates
})
setNextZIndex((prev) => prev + 1)
}
// Handle pointer move (dragging)
const handlePointerMove = (e: React.PointerEvent, cardId: string) => {
if (!dragStateRef.current || dragStateRef.current.cardId !== cardId) return
const { offsetX, offsetY } = dragStateRef.current
// Calculate new position in viewport coordinates
const newX = e.clientX - offsetX
const newY = e.clientY - offsetY
// Calculate rotation based on drag velocity
const dragDeltaX = e.clientX - dragStateRef.current.startX
const rotation = Math.max(-15, Math.min(15, dragDeltaX * 0.05))
setCardStates((prev) => {
const newStates = new Map(prev)
const cardState = newStates.get(cardId)
if (cardState) {
newStates.set(cardId, {
...cardState,
x: newX,
y: newY,
rotation,
})
}
return newStates
})
}
// Handle pointer up (end drag)
const handlePointerUp = (e: React.PointerEvent, cardId: string) => {
if (!dragStateRef.current || dragStateRef.current.cardId !== cardId) return
const target = e.currentTarget as HTMLElement
target.releasePointerCapture(e.pointerId)
// Reset rotation to slight random tilt
setCardStates((prev) => {
const newStates = new Map(prev)
const cardState = newStates.get(cardId)
if (cardState) {
newStates.set(cardId, {
...cardState,
rotation: Math.random() * 10 - 5,
})
}
return newStates
})
dragStateRef.current = null
setDraggingCardId(null)
}
// For drag mode, check solution is available when we have a valid inferred sequence
const canCheckSolutionDrag = inferredSequence.length === state.cardCount
// Real-time check: is the current sequence correct?
const isSequenceCorrect =
canCheckSolutionDrag &&
inferredSequence.every((card, index) => {
const correctCard = state.correctOrder[index]
return correctCard && card.id === correctCard.id
})
// Watch for server confirmations and insert next card or check solution
useEffect(() => {
if (!waitingToCheck) return
const cardsToInsert = cardsToInsertRef.current
const currentIndex = currentInsertIndexRef.current
console.log('[PlayingPhaseDrag] useEffect check:', {
waitingToCheck,
currentIndex,
totalCards: cardsToInsert.length,
canCheckSolution,
})
// If all cards have been sent, wait for server to confirm all are placed
if (currentIndex >= cardsToInsert.length) {
if (canCheckSolution) {
console.log('[PlayingPhaseDrag] ✅ Server confirmed all cards placed, checking solution')
setWaitingToCheck(false)
cardsToInsertRef.current = []
currentInsertIndexRef.current = 0
checkSolution()
}
return
}
// Send next card
const card = cardsToInsert[currentIndex]
const position = inferredSequence.findIndex((c) => c.id === card.id)
console.log(
`[PlayingPhaseDrag] 📥 Inserting card ${currentIndex + 1}/${cardsToInsert.length}: ${card.id} at position ${position}`
)
insertCard(card.id, position)
currentInsertIndexRef.current++
}, [waitingToCheck, canCheckSolution, checkSolution, insertCard, inferredSequence])
// Custom check solution that uses the inferred sequence
const handleCheckSolution = () => {
if (isSpectating) return
if (!canCheckSolutionDrag) return
// Send the complete inferred sequence to the server
checkSolution(inferredSequence)
}
return (
<div
className={css({
width: '100%',
height: '100%',
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
})}
>
{/* Floating action buttons */}
{!isSpectating && (
<div
className={css({
position: 'absolute',
top: '16px',
right: '16px',
display: 'flex',
gap: '12px',
zIndex: 10,
})}
>
{/* Reveal Numbers Button */}
{!state.showNumbers && (
<button
type="button"
onClick={revealNumbers}
title="Reveal Numbers"
className={css({
width: '56px',
height: '56px',
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
border: '3px solid #f59e0b',
borderRadius: '50%',
fontSize: '24px',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
_hover: {
transform: 'scale(1.1)',
boxShadow: '0 6px 20px rgba(245, 158, 11, 0.4)',
},
})}
>
👁
</button>
)}
{/* Check Solution Button with Label */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
})}
>
<button
type="button"
onClick={handleCheckSolution}
disabled={!canCheckSolutionDrag}
title="Check Solution"
className={css({
width: '64px',
height: '64px',
background: isSequenceCorrect
? 'linear-gradient(135deg, #fbbf24, #f59e0b, #fbbf24)'
: canCheckSolutionDrag
? 'linear-gradient(135deg, #bbf7d0, #86efac)'
: 'linear-gradient(135deg, #e5e7eb, #d1d5db)',
border: '4px solid',
borderColor: isSequenceCorrect
? '#f59e0b'
: canCheckSolutionDrag
? '#22c55e'
: '#9ca3af',
borderRadius: '50%',
fontSize: '32px',
cursor: canCheckSolutionDrag ? 'pointer' : 'not-allowed',
opacity: canCheckSolutionDrag ? 1 : 0.5,
transition: isSequenceCorrect ? 'none' : 'all 0.2s ease',
boxShadow: isSequenceCorrect
? '0 0 30px rgba(245, 158, 11, 0.8), 0 0 60px rgba(245, 158, 11, 0.6)'
: '0 4px 12px rgba(0, 0, 0, 0.15)',
animation: isSequenceCorrect ? 'celebrate 0.5s ease-in-out infinite' : 'none',
backgroundSize: isSequenceCorrect ? '200% 200%' : '100% 100%',
_hover:
canCheckSolutionDrag && !isSequenceCorrect
? {
transform: 'scale(1.1)',
boxShadow: '0 6px 20px rgba(34, 197, 94, 0.4)',
}
: {},
})}
style={{
animationName: isSequenceCorrect ? 'celebrate' : undefined,
}}
>
</button>
<div
className={css({
fontSize: '13px',
fontWeight: '700',
color: isSequenceCorrect ? '#f59e0b' : canCheckSolutionDrag ? '#22c55e' : '#9ca3af',
textTransform: 'uppercase',
letterSpacing: '0.5px',
textShadow: isSequenceCorrect ? '0 0 10px rgba(245, 158, 11, 0.8)' : 'none',
animation: isSequenceCorrect ? 'pulse 0.5s ease-in-out infinite' : 'none',
})}
>
{isSequenceCorrect ? 'PERFECT!' : 'Done?'}
</div>
</div>
</div>
)}
{/* Timer (minimal, top-left) */}
<div
className={css({
position: 'absolute',
top: '16px',
left: '16px',
padding: '8px 16px',
background: 'rgba(255, 255, 255, 0.9)',
border: '2px solid rgba(59, 130, 246, 0.3)',
borderRadius: '20px',
fontSize: '16px',
fontWeight: '600',
color: '#0c4a6e',
zIndex: 10,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
})}
>
{formatTime(elapsedTime)}
</div>
{/* Play area with freely positioned cards - full viewport */}
<div
ref={containerRef}
className={css({
width: '100vw',
height: '100vh',
position: 'absolute',
top: 0,
left: 0,
background: 'linear-gradient(135deg, #f0f9ff, #e0f2fe)',
overflow: 'hidden',
})}
>
{/* Render arrows between cards in inferred sequence */}
{inferredSequence.length > 1 &&
inferredSequence.slice(0, -1).map((card, index) => {
const currentCard = cardStates.get(card.id)
const nextCard = cardStates.get(inferredSequence[index + 1].id)
if (!currentCard || !nextCard) return null
// Check if this connection is correct
const isCorrectConnection =
state.correctOrder[index]?.id === card.id &&
state.correctOrder[index + 1]?.id === inferredSequence[index + 1].id
// Calculate arrow position (from center of current card to center of next card)
const fromX = currentCard.x + 70 // 70 = half of card width (140px)
const fromY = currentCard.y + 90 // 90 = half of card height (180px)
const toX = nextCard.x + 70
const toY = nextCard.y + 90
// Calculate angle and distance
const dx = toX - fromX
const dy = toY - fromY
const angle = Math.atan2(dy, dx) * (180 / Math.PI)
const distance = Math.sqrt(dx * dx + dy * dy)
// Don't draw arrow if cards are too close (overlapping or very near)
if (distance < 80) return null
return (
<div
key={`arrow-${card.id}-${inferredSequence[index + 1].id}`}
style={{
position: 'absolute',
left: `${fromX}px`,
top: `${fromY}px`,
width: `${distance}px`,
height: isCorrectConnection ? '4px' : '3px',
transformOrigin: '0 50%',
transform: `rotate(${angle}deg)`,
pointerEvents: 'none',
zIndex: 0, // Behind cards
animation: isCorrectConnection
? 'correctArrowGlow 1.5s ease-in-out infinite'
: 'none',
}}
>
{/* Arrow line */}
<div
style={{
width: '100%',
height: '100%',
background: isCorrectConnection
? 'linear-gradient(90deg, rgba(34, 197, 94, 0.7) 0%, rgba(34, 197, 94, 0.9) 100%)'
: 'linear-gradient(90deg, rgba(251, 146, 60, 0.6) 0%, rgba(251, 146, 60, 0.8) 100%)',
position: 'relative',
}}
>
{/* Arrow head */}
<div
style={{
position: 'absolute',
right: '-8px',
top: '50%',
width: '0',
height: '0',
borderLeft: isCorrectConnection
? '10px solid rgba(34, 197, 94, 0.9)'
: '10px solid rgba(251, 146, 60, 0.8)',
borderTop: '6px solid transparent',
borderBottom: '6px solid transparent',
transform: 'translateY(-50%)',
}}
/>
{/* Sequence number badge */}
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
background: isCorrectConnection
? 'rgba(34, 197, 94, 0.95)'
: 'rgba(251, 146, 60, 0.95)',
color: 'white',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
border: '2px solid white',
boxShadow: isCorrectConnection
? '0 0 12px rgba(34, 197, 94, 0.6)'
: '0 2px 4px rgba(0,0,0,0.2)',
animation: isCorrectConnection
? 'correctBadgePulse 1.5s ease-in-out infinite'
: 'none',
}}
>
{index + 1}
</div>
</div>
</div>
)
})}
{/* Render all cards at their positions */}
{[
...state.availableCards,
...state.placedCards.filter((c): c is SortingCard => c !== null),
].map((card) => {
const cardState = cardStates.get(card.id)
if (!cardState) return null
const isDragging = draggingCardId === card.id
return (
<div
key={card.id}
onPointerDown={(e) => handlePointerDown(e, card.id)}
onPointerMove={(e) => handlePointerMove(e, card.id)}
onPointerUp={(e) => handlePointerUp(e, card.id)}
className={css({
position: 'absolute',
width: '140px',
height: '180px',
cursor: isSpectating ? 'default' : 'grab',
touchAction: 'none',
userSelect: 'none',
transition: isDragging ? 'none' : 'transform 0.2s ease, box-shadow 0.2s ease',
})}
style={{
left: `${cardState.x}px`,
top: `${cardState.y}px`,
transform: `rotate(${cardState.rotation}deg)`,
zIndex: cardState.zIndex,
boxShadow: isDragging
? '0 20px 40px rgba(0, 0, 0, 0.3)'
: '0 4px 8px rgba(0, 0, 0, 0.15)',
}}
>
<div
className={css({
width: '100%',
height: '100%',
background: 'white',
borderRadius: '12px',
border: '3px solid #0369a1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px',
boxSizing: 'border-box',
})}
dangerouslySetInnerHTML={{ __html: card.svgContent }}
/>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -2,435 +2,616 @@
import { css } from '../../../../styled-system/css'
import { useCardSorting } from '../Provider'
import { useSpring, animated, config } from '@react-spring/web'
import { useState, useEffect } from 'react'
import type { SortingCard } from '../types'
// Add result animations
if (typeof document !== 'undefined') {
const style = document.createElement('style')
style.textContent = `
@keyframes scoreReveal {
0% {
transform: scale(0.8);
opacity: 0;
}
60% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes perfectCelebrate {
0%, 100% { transform: scale(1) rotate(0deg); }
25% { transform: scale(1.1) rotate(-5deg); }
75% { transform: scale(1.1) rotate(5deg); }
}
`
document.head.appendChild(style)
}
interface CardPosition {
x: number
y: number
rotation: number
}
export function ResultsPhase() {
const { state, startGame, goToSetup, exitSession } = useCardSorting()
const { scoreBreakdown } = state
const [showCorrections, setShowCorrections] = useState(false)
// Get user's sequence from placedCards
const userSequence = state.placedCards.filter((c): c is SortingCard => c !== null)
// Calculate positions for cards in a compact grid layout
const calculateCardPositions = (
cards: SortingCard[],
shouldCorrect: boolean
): Map<string, CardPosition> => {
const positions = new Map<string, CardPosition>()
const gridCols = 3
const cardWidth = 100
const cardHeight = 130
const gap = 20
const startX = 50
const startY = 100
cards.forEach((card, index) => {
const correctIndex = shouldCorrect
? state.correctOrder.findIndex((c) => c.id === card.id)
: index
const col = correctIndex % gridCols
const row = Math.floor(correctIndex / gridCols)
positions.set(card.id, {
x: startX + col * (cardWidth + gap),
y: startY + row * (cardHeight + gap),
rotation: 0,
})
})
return positions
}
const [cardPositions, setCardPositions] = useState(() =>
calculateCardPositions(userSequence, false)
)
// Auto-show corrections after 2 seconds
useEffect(() => {
const timer = setTimeout(() => {
setShowCorrections(true)
setCardPositions(calculateCardPositions(userSequence, true))
}, 2000)
return () => clearTimeout(timer)
}, [])
// Panel slide-in animation
const panelSpring = useSpring({
from: { opacity: 0, transform: 'translateX(50px)' },
to: { opacity: 1, transform: 'translateX(0px)' },
config: config.gentle,
})
if (!scoreBreakdown) {
return (
<div className={css({ textAlign: 'center', padding: '2rem' })}>
<p>No score data available</p>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
fontSize: '18px',
color: '#666',
})}
>
No score data available
</div>
)
}
const isPerfect = scoreBreakdown.finalScore === 100
const isExcellent = scoreBreakdown.finalScore >= 80
const getMessage = (score: number) => {
if (score === 100) return '🎉 Perfect! All cards in correct order!'
if (score >= 80) return '👍 Excellent! Very close to perfect!'
if (score >= 60) return '👍 Good job! You understand the pattern!'
return '💪 Keep practicing! Focus on reading each abacus carefully.'
if (score === 100) return 'Perfect! All cards in correct order!'
if (score >= 80) return 'Excellent! Very close to perfect!'
if (score >= 60) return 'Good job! You understand the pattern!'
return 'Keep practicing! Focus on reading each abacus carefully.'
}
const getEmoji = (score: number) => {
if (score === 100) return '🏆'
if (score >= 80) return '⭐'
if (score >= 60) return '👍'
return '📈'
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
return (
<div
className={css({
width: '100%',
height: '100%',
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(135deg, #f0f9ff, #e0f2fe)',
display: 'flex',
flexDirection: 'column',
gap: '2rem',
padding: '1rem',
overflow: 'auto',
})}
>
{/* Score Display */}
<div className={css({ textAlign: 'center' })}>
<div className={css({ fontSize: '4rem', marginBottom: '0.5rem' })}>
{getEmoji(scoreBreakdown.finalScore)}
</div>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
marginBottom: '0.5rem',
color: 'gray.800',
})}
>
Your Score: {scoreBreakdown.finalScore}%
</h2>
<p className={css({ fontSize: 'lg', color: 'gray.600' })}>
{getMessage(scoreBreakdown.finalScore)}
</p>
</div>
{/* Score Breakdown */}
{/* Left side: Card visualization */}
<div
className={css({
background: 'white',
borderRadius: '0.75rem',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
flex: '0 0 50%',
position: 'relative',
padding: '20px',
overflow: 'hidden',
})}
>
<h3
{/* Title */}
<div
className={css({
fontSize: 'xl',
fontWeight: 'bold',
marginBottom: '1rem',
color: 'gray.800',
position: 'absolute',
top: '20px',
left: '20px',
fontSize: '24px',
fontWeight: '700',
color: '#0c4a6e',
})}
>
Score Breakdown
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1rem' })}>
{/* Exact Position Matches */}
<div>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
marginBottom: '0.25rem',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>
Exact Position Matches (30%)
</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{scoreBreakdown.exactMatches}/{state.cardCount} cards
</span>
</div>
<div
className={css({
width: '100%',
height: '1.5rem',
background: 'gray.200',
borderRadius: '9999px',
overflow: 'hidden',
})}
>
<div
className={css({
height: '100%',
background: 'teal.500',
transition: 'width 0.5s ease',
})}
style={{ width: `${scoreBreakdown.exactPositionScore}%` }}
/>
</div>
</div>
{/* Relative Order */}
<div>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
marginBottom: '0.25rem',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>
Relative Order (50%)
</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{scoreBreakdown.lcsLength}/{state.cardCount} in sequence
</span>
</div>
<div
className={css({
width: '100%',
height: '1.5rem',
background: 'gray.200',
borderRadius: '9999px',
overflow: 'hidden',
})}
>
<div
className={css({
height: '100%',
background: 'teal.500',
transition: 'width 0.5s ease',
})}
style={{ width: `${scoreBreakdown.relativeOrderScore}%` }}
/>
</div>
</div>
{/* Organization */}
<div>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
marginBottom: '0.25rem',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>Organization (20%)</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{scoreBreakdown.inversions} out-of-order pairs
</span>
</div>
<div
className={css({
width: '100%',
height: '1.5rem',
background: 'gray.200',
borderRadius: '9999px',
overflow: 'hidden',
})}
>
<div
className={css({
height: '100%',
background: 'teal.500',
transition: 'width 0.5s ease',
})}
style={{ width: `${scoreBreakdown.inversionScore}%` }}
/>
</div>
</div>
{/* Time Taken */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
paddingTop: '0.5rem',
borderTop: '1px solid',
borderColor: 'gray.200',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>Time Taken</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{Math.floor(scoreBreakdown.elapsedTime / 60)}:
{(scoreBreakdown.elapsedTime % 60).toString().padStart(2, '0')}
</span>
</div>
{scoreBreakdown.numbersRevealed && (
<div
className={css({
padding: '0.75rem',
background: 'orange.50',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'orange.200',
fontSize: 'sm',
color: 'orange.700',
textAlign: 'center',
})}
>
Numbers were revealed during play
</div>
)}
Your Arrangement
</div>
</div>
{/* Comparison */}
<div
className={css({
background: 'white',
borderRadius: '0.75rem',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
})}
>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
marginBottom: '1rem',
color: 'gray.800',
})}
>
Comparison
</h3>
{/* Cards with animated positions */}
{userSequence.map((card, userIndex) => {
const position = cardPositions.get(card.id)
if (!position) return null
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1.5rem' })}>
{/* User's Answer */}
<div>
<h4
className={css({
fontSize: 'md',
fontWeight: '600',
marginBottom: '0.5rem',
color: 'gray.700',
})}
// Check if this card is correct for its position in the user's sequence
// Same logic as during gameplay: does the card at this position match the correct card for this position?
const isCorrect = state.correctOrder[userIndex]?.id === card.id
const correctIndex = state.correctOrder.findIndex((c) => c.id === card.id)
return (
<animated.div
key={card.id}
style={{
position: 'absolute',
left: `${position.x}px`,
top: `${position.y}px`,
width: '100px',
height: '130px',
transition: 'all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)',
zIndex: 5,
}}
>
Your Answer:
</h4>
<div className={css({ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' })}>
{state.placedCards.map((card, i) => {
if (!card) return null
const isCorrect = card.number === state.correctOrder[i]?.number
{/* Card */}
<div
className={css({
width: '100%',
height: '100%',
background: 'white',
borderRadius: '8px',
border: '3px solid',
borderColor: isCorrect ? '#22c55e' : showCorrections ? '#ef4444' : '#0369a1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '8px',
boxSizing: 'border-box',
position: 'relative',
boxShadow: isCorrect
? '0 0 20px rgba(34, 197, 94, 0.4)'
: showCorrections
? '0 0 20px rgba(239, 68, 68, 0.4)'
: '0 4px 8px rgba(0, 0, 0, 0.1)',
})}
dangerouslySetInnerHTML={{ __html: card.svgContent }}
/>
return (
<div
key={i}
className={css({
padding: '0.5rem',
border: '2px solid',
borderColor: isCorrect ? 'green.500' : 'red.500',
borderRadius: '0.375rem',
background: isCorrect ? 'green.50' : 'red.50',
textAlign: 'center',
minWidth: '60px',
})}
>
<div
className={css({
fontSize: 'xs',
color: 'gray.600',
marginBottom: '0.25rem',
})}
>
#{i + 1}
</div>
<div
className={css({
fontSize: 'lg',
fontWeight: 'bold',
color: isCorrect ? 'green.700' : 'red.700',
})}
>
{card.number}
</div>
{isCorrect ? (
<div className={css({ fontSize: 'xs' })}></div>
) : (
<div className={css({ fontSize: 'xs' })}></div>
)}
</div>
)
})}
</div>
</div>
{/* Correct Order */}
<div>
<h4
className={css({
fontSize: 'md',
fontWeight: '600',
marginBottom: '0.5rem',
color: 'gray.700',
})}
>
Correct Order:
</h4>
<div className={css({ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' })}>
{state.correctOrder.map((card, i) => (
{/* Correct/Incorrect indicator */}
{showCorrections && (
<div
key={i}
className={css({
padding: '0.5rem',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '0.375rem',
background: 'gray.50',
textAlign: 'center',
minWidth: '60px',
position: 'absolute',
top: '-12px',
right: '-12px',
width: '32px',
height: '32px',
borderRadius: '50%',
background: isCorrect ? '#22c55e' : '#ef4444',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
color: 'white',
fontWeight: 'bold',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
animation: 'scoreReveal 0.4s ease-out',
})}
>
<div
className={css({
fontSize: 'xs',
color: 'gray.600',
marginBottom: '0.25rem',
})}
>
#{i + 1}
</div>
<div
className={css({
fontSize: 'lg',
fontWeight: 'bold',
color: 'gray.700',
})}
>
{card.number}
</div>
{isCorrect ? '✓' : '✗'}
</div>
))}
)}
{/* Position number */}
<div
className={css({
position: 'absolute',
bottom: '-8px',
left: '50%',
transform: 'translateX(-50%)',
background: isCorrect ? '#22c55e' : showCorrections ? '#ef4444' : '#0369a1',
color: 'white',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 'bold',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
})}
>
#{showCorrections ? correctIndex + 1 : userIndex + 1}
</div>
</animated.div>
)
})}
{/* Correction message */}
{showCorrections && (
<div
className={css({
position: 'absolute',
bottom: '20px',
left: '20px',
right: '20px',
padding: '12px 16px',
background: 'rgba(59, 130, 246, 0.2)',
border: '2px solid rgba(59, 130, 246, 0.4)',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '600',
color: '#1e3a8a',
textAlign: 'center',
animation: 'scoreReveal 0.6s ease-out 0.5s both',
})}
>
{isPerfect
? '🎉 Perfect arrangement!'
: '↗️ Cards have moved to their correct positions'}
</div>
)}
</div>
{/* Right side: Score panel */}
<animated.div
style={panelSpring}
className={css({
flex: '0 0 50%',
background: 'rgba(255, 255, 255, 0.95)',
borderLeft: '3px solid rgba(59, 130, 246, 0.3)',
padding: '40px',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '24px',
})}
>
{/* Score Circle */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '16px',
})}
>
<div
className={css({
width: '160px',
height: '160px',
borderRadius: '50%',
background: isPerfect
? 'linear-gradient(135deg, #fbbf24, #f59e0b)'
: isExcellent
? 'linear-gradient(135deg, #86efac, #22c55e)'
: 'linear-gradient(135deg, #93c5fd, #3b82f6)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxShadow: isPerfect
? '0 0 40px rgba(245, 158, 11, 0.5), 0 10px 30px rgba(0, 0, 0, 0.2)'
: '0 10px 30px rgba(0, 0, 0, 0.15)',
animation: isPerfect
? 'perfectCelebrate 0.6s ease-in-out'
: 'scoreReveal 0.6s ease-out',
})}
style={{
animationName: isPerfect ? 'perfectCelebrate' : 'scoreReveal',
}}
>
<div
className={css({
fontSize: '64px',
fontWeight: 'bold',
color: 'white',
lineHeight: 1,
textShadow: '0 2px 10px rgba(0, 0, 0, 0.2)',
})}
>
{scoreBreakdown.finalScore}
</div>
<div
className={css({
fontSize: '20px',
fontWeight: '600',
color: 'white',
opacity: 0.9,
})}
>
{isPerfect ? '🏆' : isExcellent ? '⭐' : '%'}
</div>
</div>
<div
className={css({
textAlign: 'center',
fontSize: '18px',
fontWeight: '600',
color: '#0c4a6e',
})}
>
{getMessage(scoreBreakdown.finalScore)}
</div>
{/* Time Badge */}
<div
className={css({
padding: '8px 20px',
background: 'rgba(59, 130, 246, 0.1)',
border: '2px solid rgba(59, 130, 246, 0.3)',
borderRadius: '20px',
fontSize: '16px',
fontWeight: '600',
color: '#0c4a6e',
})}
>
{formatTime(scoreBreakdown.elapsedTime)}
</div>
</div>
{/* Score Details - Compact Cards */}
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '12px',
})}
>
{/* Exact Matches */}
<div
className={css({
background: 'white',
borderRadius: '12px',
padding: '12px',
border: '2px solid rgba(59, 130, 246, 0.2)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)',
})}
>
<div
className={css({
fontSize: '11px',
fontWeight: '600',
color: '#64748b',
marginBottom: '4px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
})}
>
Exact
</div>
<div
className={css({
fontSize: '28px',
fontWeight: 'bold',
color: '#0c4a6e',
})}
>
{scoreBreakdown.exactMatches}
<span
className={css({
fontSize: '14px',
color: '#64748b',
fontWeight: '500',
})}
>
/{state.cardCount}
</span>
</div>
</div>
{/* Sequence */}
<div
className={css({
background: 'white',
borderRadius: '12px',
padding: '12px',
border: '2px solid rgba(59, 130, 246, 0.2)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)',
})}
>
<div
className={css({
fontSize: '11px',
fontWeight: '600',
color: '#64748b',
marginBottom: '4px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
})}
>
Sequence
</div>
<div
className={css({
fontSize: '28px',
fontWeight: 'bold',
color: '#0c4a6e',
})}
>
{scoreBreakdown.lcsLength}
<span
className={css({
fontSize: '14px',
color: '#64748b',
fontWeight: '500',
})}
>
/{state.cardCount}
</span>
</div>
</div>
{/* Misplaced */}
<div
className={css({
background: 'white',
borderRadius: '12px',
padding: '12px',
border: '2px solid rgba(59, 130, 246, 0.2)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)',
})}
>
<div
className={css({
fontSize: '11px',
fontWeight: '600',
color: '#64748b',
marginBottom: '4px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
})}
>
Wrong
</div>
<div
className={css({
fontSize: '28px',
fontWeight: 'bold',
color: '#0c4a6e',
})}
>
{scoreBreakdown.inversions}
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
maxWidth: '400px',
margin: '0 auto',
width: '100%',
})}
>
<button
type="button"
onClick={startGame}
{/* Warning if numbers revealed */}
{scoreBreakdown.numbersRevealed && (
<div
className={css({
padding: '12px 16px',
background: 'rgba(251, 146, 60, 0.2)',
border: '2px solid rgba(251, 146, 60, 0.4)',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '600',
color: '#9a3412',
textAlign: 'center',
})}
>
👁 Numbers were revealed during play
</div>
)}
{/* Action Buttons */}
<div
className={css({
padding: '1rem',
borderRadius: '0.5rem',
background: 'teal.600',
color: 'white',
fontWeight: '600',
fontSize: 'lg',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
background: 'teal.700',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
display: 'flex',
flexDirection: 'column',
gap: '10px',
marginTop: 'auto',
})}
>
New Game (Same Settings)
</button>
<button
type="button"
onClick={goToSetup}
className={css({
padding: '1rem',
borderRadius: '0.5rem',
background: 'gray.600',
color: 'white',
fontWeight: '600',
fontSize: 'lg',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
background: 'gray.700',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
})}
>
Change Settings
</button>
<button
type="button"
onClick={exitSession}
className={css({
padding: '1rem',
borderRadius: '0.5rem',
background: 'white',
color: 'gray.700',
fontWeight: '600',
fontSize: 'lg',
border: '2px solid',
borderColor: 'gray.300',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'gray.400',
background: 'gray.50',
},
})}
>
Exit to Room
</button>
</div>
<button
type="button"
onClick={startGame}
className={css({
padding: '14px 24px',
background: 'linear-gradient(135deg, #86efac, #22c55e)',
border: '3px solid #22c55e',
borderRadius: '12px',
fontSize: '16px',
fontWeight: '700',
color: 'white',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 4px 12px rgba(34, 197, 94, 0.3)',
textTransform: 'uppercase',
letterSpacing: '0.5px',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 6px 20px rgba(34, 197, 94, 0.4)',
},
})}
>
🎮 Play Again
</button>
<button
type="button"
onClick={goToSetup}
className={css({
padding: '12px 20px',
background: 'white',
border: '2px solid rgba(59, 130, 246, 0.3)',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '700',
color: '#0c4a6e',
cursor: 'pointer',
transition: 'all 0.2s ease',
textTransform: 'uppercase',
letterSpacing: '0.5px',
_hover: {
borderColor: 'rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.05)',
},
})}
>
Settings
</button>
<button
type="button"
onClick={exitSession}
className={css({
padding: '12px 20px',
background: 'white',
border: '2px solid rgba(239, 68, 68, 0.3)',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '700',
color: '#991b1b',
cursor: 'pointer',
transition: 'all 0.2s ease',
textTransform: 'uppercase',
letterSpacing: '0.5px',
_hover: {
borderColor: 'rgba(239, 68, 68, 0.5)',
background: 'rgba(239, 68, 68, 0.05)',
},
})}
>
🚪 Exit
</button>
</div>
</animated.div>
</div>
)
}

View File

@@ -150,7 +150,9 @@ export type CardSortingMove =
playerId: string
userId: string
timestamp: number
data: Record<string, never>
data: {
finalSequence?: SortingCard[] // Optional - if provided, use this as the final placement
}
}
| {
type: 'GO_TO_SETUP'