Compare commits

...

24 Commits

Author SHA1 Message Date
semantic-release-bot
fa827ac792 chore(release): 2.16.2 [skip ci]
## [2.16.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.1...v2.16.2) (2025-10-09)

### Bug Fixes

* use only local user's players in LocalMemoryPairsProvider ([c26138f](c26138ffb5))
2025-10-09 23:05:31 +00:00
Thomas Hallock
c26138ffb5 fix: use only local user's players in LocalMemoryPairsProvider
Replace useGameMode() with direct useUserPlayers() call to prevent
room members' players from appearing in local-only games at /arcade/matching.

Before: LocalMemoryPairsProvider used GameModeContext which includes ALL
players from room members when user is in a room, showing remote players
in turn indicator for local games.

After: Direct useUserPlayers() call ensures only the current user's players
appear in local games, regardless of room membership.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 18:04:43 -05:00
semantic-release-bot
168b98b888 chore(release): 2.16.1 [skip ci]
## [2.16.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.0...v2.16.1) (2025-10-09)

### Bug Fixes

* convert LocalMemoryPairsProvider to pure client-side with useReducer ([b128db1](b128db1783))
2025-10-09 22:55:15 +00:00
Thomas Hallock
b128db1783 fix: convert LocalMemoryPairsProvider to pure client-side with useReducer
Replace arcade session-based provider with pure client-side reducer for
local-only games at /arcade/matching. This eliminates "No active session"
errors when starting non-networked games.

Changes:
- Replace useArcadeSession with useReducer for local state management
- Implement complete game logic client-side (match validation, turn switching)
- Remove dependency on socket/server for local games
- Add MATCH_FOUND, MATCH_FAILED, SWITCH_PLAYER actions to reducer
- Auto-detect matches and switch players using useEffect hooks

Local games now run entirely in the browser without requiring an arcade
session or network connection.

Fixes: Starting game at /arcade/matching no longer shows session errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 17:54:19 -05:00
semantic-release-bot
df50239079 chore(release): 2.16.0 [skip ci]
## [2.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.15.0...v2.16.0) (2025-10-09)

### Features

* fade out hover avatar when player stops hovering ([820eeb4](820eeb4fb0))
2025-10-09 22:43:21 +00:00
Thomas Hallock
820eeb4fb0 feat: fade out hover avatar when player stops hovering
Avatar now fades out smoothly when:
- Player moves mouse off all cards
- Player's turn ends
- Player hasn't hovered any card yet

Opacity requires all three conditions: position exists, it's player's turn,
AND player is actively hovering a card (cardElement !== null).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 17:42:28 -05:00
semantic-release-bot
90be7c053c chore(release): 2.15.0 [skip ci]
## [2.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.3...v2.15.0) (2025-10-09)

### Features

* implement smooth hover avatar animations with react-spring ([442c6b4](442c6b4529))
2025-10-09 21:48:46 +00:00
Thomas Hallock
442c6b4529 feat: implement smooth hover avatar animations with react-spring
Add networked hover presence feature showing player avatars over cards
they're considering. Avatars smoothly glide between cards using react-spring
physics-based animations.

Key features:
- Fixed positioning with getBoundingClientRect for accurate placement
- Component keyed by playerId to persist across card changes
- Spring animations for smooth position transitions
- Only shows remote players' avatars (filters out local player)
- Only sends hover events when it's the player's turn

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 16:47:50 -05:00
semantic-release-bot
75b193e1d2 chore(release): 2.14.3 [skip ci]
## [2.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.2...v2.14.3) (2025-10-09)

### Bug Fixes

* enable smooth spring animations between card hovers ([8d53b58](8d53b589aa))
2025-10-09 21:24:22 +00:00
Thomas Hallock
8d53b589aa fix: enable smooth spring animations between card hovers
Fixed spring animation not working when moving between cards:

ISSUE:
- Avatar would jump instantly to new position instead of smoothly gliding
- The 'from' config was preventing spring from animating position changes

SOLUTION:
- Remove 'from' entirely from spring config
- Use simple spring with immediate flag for first render only
- Track isFirstRender with useRef to skip initial animation
- After first render, all position changes animate smoothly

HOW IT WORKS:
1. First hover: immediate:true - Avatar appears instantly at position
2. isFirstRender flag cleared after position is set
3. Subsequent hovers: immediate:false - Spring animates smoothly
4. Position updates trigger spring to animate x,y to new values
5. Config (tension:280, friction:60) provides smooth glide

RESULT:
- First hover: Avatar appears instantly (no fly-in)
- Moving between cards: Avatar smoothly glides with spring physics
- Feels like watching remote player's mouse cursor move

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 16:23:26 -05:00
semantic-release-bot
af85b3e481 chore(release): 2.14.2 [skip ci]
## [2.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.1...v2.14.2) (2025-10-09)

### Bug Fixes

* correct avatar positioning to prevent fly-in animation ([573d0df](573d0df20d))
2025-10-09 21:19:43 +00:00
Thomas Hallock
573d0df20d fix: correct avatar positioning to prevent fly-in animation
Fixed positioning model to work properly with react-spring:

ISSUE:
- Avatar was still flying in from top-right
- Using translate(-50%, -50%) with animated left/top caused positioning issues
- Spring was animating from wrong initial position

SOLUTION:
- Remove transform: translate(-50%, -50%)
- Use negative margins instead: marginLeft: -24px, marginTop: -24px
- Calculate exact center position from getBoundingClientRect
- Set 'from' position in spring to match 'to' on first render
- Use x.to() and y.to() to convert spring values to px strings

POSITIONING MODEL:
1. Get card's bounding rect
2. Calculate avatar center point: (rect.right - 12, rect.top - 12)
3. Apply via left/top with negative margins for centering
4. Spring animates between positions smoothly
5. On first hover: from/to are same (no animation)
6. On subsequent hovers: smooth glide between cards

RESULT:
Avatar appears exactly at the card position with no fly-in animation,
then smoothly glides to new positions as user moves between cards.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 16:18:51 -05:00
semantic-release-bot
d312969747 chore(release): 2.14.1 [skip ci]
## [2.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.0...v2.14.1) (2025-10-09)

### Bug Fixes

* prevent avatar fly-in and hide local player's own hover ([7f65a67](7f65a67cef))
2025-10-09 21:10:10 +00:00
Thomas Hallock
7f65a67cef fix: prevent avatar fly-in and hide local player's own hover
Two critical UX fixes for hover avatars:

1. PREVENT FLY-IN FROM TOP-LEFT:
   - Initialize position state as null instead of {x:0, y:0}
   - Don't render avatar until position is calculated from card element
   - Set immediate:true on first render to prevent animation
   - Add opacity spring (0→1) for smooth fade-in
   - Result: Avatar appears exactly where it should, no fly-in animation

2. HIDE YOUR OWN HOVER AVATAR:
   - Filter out local player (isLocal === true) from hover avatar rendering
   - Only show remote players' hover avatars
   - You see opponents hovering, but not your own cursor
   - Result: Cleaner UX, less visual noise on your own session

BEFORE:
- Avatar would fly in from (0,0) top-left corner when first hovering
- You'd see your own avatar following your mouse (distracting)

AFTER:
- Avatar appears smoothly at the correct card position
- Only remote players' avatars are visible
- Feels like watching someone else's cursor, not your own

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 16:09:19 -05:00
semantic-release-bot
4d7f6f469f chore(release): 2.14.0 [skip ci]
## [2.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.13.0...v2.14.0) (2025-10-09)

### Features

* improve hover avatars with smooth animation and 3D elevation ([71b11f4](71b11f4ef0))
2025-10-09 21:07:42 +00:00
Thomas Hallock
71b11f4ef0 feat: improve hover avatars with smooth animation and 3D elevation
Major improvements to networked hover presence feature:

FIXES:
- Only send hover events when it's YOUR TURN (not all sessions)
  * Check isLocal property of current player before sending hover
  * Prevents all players from broadcasting hover simultaneously

VISUAL ENHANCEMENTS:
- 3D elevation effect with layered shadows for floating appearance
  * Large shadow: 0 8px 20px rgba(0,0,0,0.4)
  * Medium shadow: 0 4px 8px rgba(0,0,0,0.3)
  * Glow effect: 0 0 30px with player color
  * Drop-shadow filter for extra depth
  * 48px avatar size (up from 40px)

SMOOTH ANIMATIONS:
- React-spring for butter-smooth position transitions
  * Tension: 280, Friction: 60, Mass: 1
  * Avatars glide smoothly between cards as mouse moves
  * No jank, feels like remote mouse cursor

- Floating animation with vertical bob
  * 6px up/down motion over 2s
  * Creates illusion of hovering above surface
  * Combined with horizontal spring animation

ARCHITECTURE:
- HoverAvatar component uses fixed positioning
- Card refs stored in Map for position tracking
- Avatar positions calculated from card getBoundingClientRect()
- Rendered outside card wrapper to avoid clipping
- Position updates trigger spring animations automatically

RESULT:
Feels like you're literally watching someone's mouse move over the cards
from across the internet. Smooth, elevated, and visually distinct.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 16:06:50 -05:00
semantic-release-bot
e0d08a1aa2 chore(release): 2.13.0 [skip ci]
## [2.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.3...v2.13.0) (2025-10-09)

### Features

* implement networked hover presence for multiplayer gameplay ([62f3730](62f3730542))

### Code Refactoring

* move canModifyPlayers logic into provider layer ([db9f909](db9f9096b4))
* properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider ([98822ec](98822ecda5))
2025-10-09 20:50:21 +00:00
Thomas Hallock
62f3730542 feat: implement networked hover presence for multiplayer gameplay
Add real-time hover state visualization to show which players are
considering which cards in room-based multiplayer games.

FEATURES:
- Mouse hover detection on cards sends HOVER_CARD moves over network
- Remote player avatars appear as floating badges over hovered cards
- Smooth pulsing animation with glow effect for hover avatars
- Player emoji + color + name shown on hover
- Only active (non-matched) cards trigger hover events

IMPLEMENTATION:
- Added onMouseEnter/onMouseLeave handlers to card wrapper in MemoryGrid
- Calls hoverCard(cardId) on enter, hoverCard(null) on leave
- Filters playerHovers state to find players hovering each card
- Displays floating avatar badges with player info from playerMetadata
- Added hoverPulse animation for smooth visual feedback

UX BENEFITS:
- Network players can see what opponents are thinking about
- Provides rich multiplayer feedback without voice/video
- Makes remote play feel more in-person and engaging
- Smooth transitions as players move between cards

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:49:00 -05:00
Thomas Hallock
98822ecda5 refactor: properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider
Clean separation of concerns between arcade sessions and room-based play:

ARCHITECTURE:
- LocalMemoryPairsProvider: Arcade sessions (roomId: undefined)
  * Uses useArcadeRedirect for canModifyPlayers
  * No room sync, local-only play
  * Used by /arcade/matching

- RoomMemoryPairsProvider: Room-based multiplayer (roomId: roomData.id)
  * Hard-coded canModifyPlayers: false (always show buttons)
  * Network sync across all room members
  * Used by /arcade/room

CHANGES:
- Added canModifyPlayers to LocalMemoryPairsProvider (uses useArcadeRedirect)
- Added hoverCard action to LocalMemoryPairsProvider
- Added HOVER_CARD case to LocalMemoryPairsProvider optimistic updates
- Added playerHovers to LocalMemoryPairsProvider initial state
- Removed isInRoom conditional from RoomMemoryPairsProvider (cleaner)
- Updated /arcade/matching to use LocalMemoryPairsProvider (was incorrectly using Room)
- Added clear doc comments explaining each provider's purpose

FIXES:
- /arcade/matching no longer behaves like a room
- Each provider has single, clear responsibility
- No more cross-cutting concerns or conditional logic based on room state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:44:41 -05:00
Thomas Hallock
db9f9096b4 refactor: move canModifyPlayers logic into provider layer
Architecture improvement to separate concerns between game components
and session management:

BEFORE:
- MemoryPairsGame component had to know about rooms vs arcade sessions
- Component imported useRoomData and useArcadeRedirect
- Logic for button visibility leaked into presentation layer

AFTER:
- canModifyPlayers added to MemoryPairsContextValue interface
- RoomMemoryPairsProvider handles the logic internally:
  * If in room (roomData?.id exists): canModifyPlayers = false (show buttons)
  * If arcade session (no room): uses useArcadeRedirect.canModifyPlayers
- MemoryPairsGame just reads canModifyPlayers from context
- Clean separation: provider knows session type, component doesn't care

FIXES:
- Room-based games now always show Setup/New Game/Quit buttons
- Arcade sessions correctly hide buttons during setup phase
- /arcade/matching was incorrectly behaving like a room (now fixed)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:42:20 -05:00
semantic-release-bot
1fe507bc12 chore(release): 2.12.3 [skip ci]
## [2.12.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.2...v2.12.3) (2025-10-09)

### Bug Fixes

* always show game control buttons in room-based sessions ([14ba422](14ba422919))
2025-10-09 20:38:16 +00:00
Thomas Hallock
14ba422919 fix: always show game control buttons in room-based sessions
In room-based multiplayer games, force canModifyPlayers=false to ensure
Setup, New Game, and Quit buttons are always visible to all room members.

The arcade session locking mechanism (hasActiveSession) doesn't apply
to room-based games, so we detect rooms via isInRoom and override the
canModifyPlayers logic.

This fixes the issue where some room members saw buttons while others
didn't, depending on their arcade session state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:37:19 -05:00
semantic-release-bot
3541466630 chore(release): 2.12.2 [skip ci]
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)

### Bug Fixes

* use RoomMemoryPairsProvider in room page ([c279731](c27973191f))
2025-10-09 19:51:13 +00:00
Thomas Hallock
c27973191f fix: use RoomMemoryPairsProvider in room page
Updates /arcade/room/page.tsx to use RoomMemoryPairsProvider instead
of ArcadeMemoryPairsProvider to match the updated component hooks.

The components now use useMemoryPairs() from MemoryPairsContext, which
is provided by RoomMemoryPairsProvider. This fixes the runtime error:
"useMemoryPairs must be used within a MemoryPairsProvider"

Changes:
- Import RoomMemoryPairsProvider instead of ArcadeMemoryPairsProvider
- Wrap matching game with RoomMemoryPairsProvider

Note: ArcadeMemoryPairsProvider can be deprecated in future cleanup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:50:18 -05:00
11 changed files with 556 additions and 233 deletions

View File

@@ -1,3 +1,86 @@
## [2.16.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.1...v2.16.2) (2025-10-09)
### Bug Fixes
* use only local user's players in LocalMemoryPairsProvider ([c26138f](https://github.com/antialias/soroban-abacus-flashcards/commit/c26138ffb55a237a99cb6ff399c8a2ac54a22b51))
## [2.16.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.0...v2.16.1) (2025-10-09)
### Bug Fixes
* convert LocalMemoryPairsProvider to pure client-side with useReducer ([b128db1](https://github.com/antialias/soroban-abacus-flashcards/commit/b128db1783a8dcffe7879745c3342add2f9ffe29))
## [2.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.15.0...v2.16.0) (2025-10-09)
### Features
* fade out hover avatar when player stops hovering ([820eeb4](https://github.com/antialias/soroban-abacus-flashcards/commit/820eeb4fb03ad8be6a86dd0a26e089052224f427))
## [2.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.3...v2.15.0) (2025-10-09)
### Features
* implement smooth hover avatar animations with react-spring ([442c6b4](https://github.com/antialias/soroban-abacus-flashcards/commit/442c6b4529ba5c820b1fe8a64805a3d85489a8ea))
## [2.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.2...v2.14.3) (2025-10-09)
### Bug Fixes
* enable smooth spring animations between card hovers ([8d53b58](https://github.com/antialias/soroban-abacus-flashcards/commit/8d53b589aa17ebc6d0a9251b3006fd8a90f90a61))
## [2.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.1...v2.14.2) (2025-10-09)
### Bug Fixes
* correct avatar positioning to prevent fly-in animation ([573d0df](https://github.com/antialias/soroban-abacus-flashcards/commit/573d0df20dcdac41021c46feb423dbf3782728f6))
## [2.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.0...v2.14.1) (2025-10-09)
### Bug Fixes
* prevent avatar fly-in and hide local player's own hover ([7f65a67](https://github.com/antialias/soroban-abacus-flashcards/commit/7f65a67cef3d7f0ebce1bd7417972a6138acfc46))
## [2.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.13.0...v2.14.0) (2025-10-09)
### Features
* improve hover avatars with smooth animation and 3D elevation ([71b11f4](https://github.com/antialias/soroban-abacus-flashcards/commit/71b11f4ef08a5f9c3f1c1aaabca21ef023d5c0ce))
## [2.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.3...v2.13.0) (2025-10-09)
### Features
* implement networked hover presence for multiplayer gameplay ([62f3730](https://github.com/antialias/soroban-abacus-flashcards/commit/62f3730542334a0580f5dad1c73adc333614ee58))
### Code Refactoring
* move canModifyPlayers logic into provider layer ([db9f909](https://github.com/antialias/soroban-abacus-flashcards/commit/db9f9096b446b078e1b4dfe970723bef54a6f4ae))
* properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider ([98822ec](https://github.com/antialias/soroban-abacus-flashcards/commit/98822ecda52bf004d9950e3f4c92c834fd820e49))
## [2.12.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.2...v2.12.3) (2025-10-09)
### Bug Fixes
* always show game control buttons in room-based sessions ([14ba422](https://github.com/antialias/soroban-abacus-flashcards/commit/14ba422919abd648e2a134ce167a5e6fd9f84e73))
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)
### Bug Fixes
* use RoomMemoryPairsProvider in room page ([c279731](https://github.com/antialias/soroban-abacus-flashcards/commit/c27973191f0144604e17a8a14adf0a88df476e27))
## [2.12.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.0...v2.12.1) (2025-10-09)

View File

@@ -9,7 +9,9 @@
"Bash(git push:*)",
"Bash(git pull:*)",
"Bash(git stash:*)",
"Bash(npm run format:*)"
"Bash(npm run format:*)",
"Bash(npm run pre-commit:*)",
"Bash(npm run type-check:*)"
],
"deny": [],
"ask": []

View File

@@ -1,7 +1,9 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useSpring, animated } from '@react-spring/web'
import { useEffect, useMemo, useRef, useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { getGridConfiguration } from '../utils/cardGeneration'
import { GameCard } from './GameCard'
@@ -80,8 +82,109 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
return gridDimensions
}
// Animated hover avatar component
function HoverAvatar({
playerId,
playerInfo,
cardElement,
isPlayersTurn,
}: {
playerId: string
playerInfo: { emoji: string; name: string; color?: string }
cardElement: HTMLElement | null
isPlayersTurn: boolean
}) {
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
const isFirstRender = useRef(true)
// Update position when card element changes
useEffect(() => {
if (cardElement) {
const rect = cardElement.getBoundingClientRect()
// Calculate the center of the card for avatar positioning
const avatarCenterX = rect.left + rect.width / 2
const avatarCenterY = rect.top + rect.height / 2
setPosition({
x: avatarCenterX,
y: avatarCenterY,
})
}
}, [cardElement])
// Smooth spring animation for position changes
const springProps = useSpring({
x: position?.x ?? 0,
y: position?.y ?? 0,
opacity: position && isPlayersTurn && cardElement ? 1 : 0,
config: {
tension: 280,
friction: 60,
mass: 1,
},
immediate: isFirstRender.current, // Skip animation on first render only
})
// Clear first render flag after initial render
useEffect(() => {
if (position && isFirstRender.current) {
isFirstRender.current = false
}
}, [position])
// Don't render until we have a position
if (!position) return null
return (
<animated.div
style={{
position: 'fixed',
// Don't use translate, just position directly at the calculated point
left: springProps.x.to((x) => `${x}px`),
top: springProps.y.to((y) => `${y}px`),
opacity: springProps.opacity,
width: '80px',
height: '80px',
marginLeft: '-40px', // Center horizontally (half of width)
marginTop: '-40px', // Center vertically (half of height)
borderRadius: '50%',
background: playerInfo.color || 'linear-gradient(135deg, #667eea, #764ba2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '48px',
// 3D elevation effect
boxShadow:
'0 12px 30px rgba(0,0,0,0.5), 0 6px 12px rgba(0,0,0,0.4), 0 0 40px rgba(102, 126, 234, 0.8)',
border: '4px solid white',
zIndex: 1000,
pointerEvents: 'none',
filter: 'drop-shadow(0 0 12px rgba(102, 126, 234, 0.9))',
}}
className={css({
animation: 'hoverFloat 2s ease-in-out infinite',
})}
title={`${playerInfo.name} is considering this card`}
>
{playerInfo.emoji}
</animated.div>
)
}
export function MemoryGrid() {
const { state, flipCard } = useMemoryPairs()
const { state, flipCard, hoverCard, gameMode } = useMemoryPairs()
const { players: playerMap } = useGameMode()
// Track card element refs for positioning hover avatars
const cardRefs = useRef<Map<string, HTMLElement>>(new Map())
// Check if it's the local player's turn
const isMyTurn = useMemo(() => {
if (gameMode === 'single') return true // Always your turn in single player
const currentPlayerData = playerMap.get(state.currentPlayer)
return currentPlayerData?.isLocal === true
}, [state.currentPlayer, playerMap, gameMode])
// Hooks must be called before early return
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
@@ -95,6 +198,36 @@ export function MemoryGrid() {
flipCard(cardId)
}
// Get player metadata for hover avatars
const getPlayerHoverInfo = (playerId: string) => {
// Check playerMetadata first (from room members)
if (state.playerMetadata && state.playerMetadata[playerId]) {
return {
emoji: state.playerMetadata[playerId].emoji,
name: state.playerMetadata[playerId].name,
color: state.playerMetadata[playerId].color,
}
}
// Fall back to local player map
const player = playerMap.get(playerId)
return player
? {
emoji: player.emoji,
name: player.name,
color: player.color,
}
: null
}
// Set card ref callback
const setCardRef = (cardId: string) => (element: HTMLDivElement | null) => {
if (element) {
cardRefs.current.set(cardId, element)
} else {
cardRefs.current.delete(cardId)
}
}
return (
<div
className={css({
@@ -162,6 +295,7 @@ export function MemoryGrid() {
return (
<div
key={card.id}
ref={setCardRef(card.id)}
className={css({
aspectRatio: '3/4',
// Fully responsive card sizing - no fixed pixel sizes
@@ -172,7 +306,20 @@ export function MemoryGrid() {
opacity: isDimmed ? 0.3 : 1,
transition: 'opacity 0.3s ease',
filter: isDimmed ? 'grayscale(0.7)' : 'none',
position: 'relative',
})}
onMouseEnter={() => {
// Only send hover if it's your turn and card is not matched
if (hoverCard && !isMatched && isMyTurn) {
hoverCard(card.id)
}
}}
onMouseLeave={() => {
// Clear hover state when mouse leaves card
if (hoverCard && !isMatched && isMyTurn) {
hoverCard(null)
}
}}
>
<GameCard
card={card}
@@ -233,23 +380,62 @@ export function MemoryGrid() {
})}
/>
)}
{/* Animated Hover Avatars - Rendered as fixed positioned elements that smoothly transition */}
{/* Render one avatar per remote player - key by playerId to keep component alive */}
{state.playerHovers &&
Object.entries(state.playerHovers)
.filter(([playerId]) => {
// Don't show your own hover avatar (only show remote players)
const player = playerMap.get(playerId)
return player?.isLocal !== true
})
.map(([playerId, cardId]) => {
const playerInfo = getPlayerHoverInfo(playerId)
// Get card element if player is hovering (cardId might be null)
const cardElement = cardId ? cardRefs.current.get(cardId) : null
// Check if it's this player's turn
const isPlayersTurn = state.currentPlayer === playerId
if (!playerInfo) return null
// Render avatar even if no cardElement (it will handle hiding itself)
return (
<HoverAvatar
key={playerId} // Key by playerId keeps component alive across card changes!
playerId={playerId}
playerInfo={playerInfo}
cardElement={cardElement}
isPlayersTurn={isPlayersTurn}
/>
)
})}
</div>
)
}
// Add shake animation for mismatch feedback
const shakeAnimation = `
// Add animations for mismatch feedback and hover avatars
const gridAnimations = `
@keyframes shake {
0%, 100% { transform: translate(-50%, -50%) translateX(0); }
25% { transform: translate(-50%, -50%) translateX(-5px); }
75% { transform: translate(-50%, -50%) translateX(5px); }
}
@keyframes hoverFloat {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-6px);
}
}
`
// 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
style.textContent = gridAnimations
document.head.appendChild(style)
}

View File

@@ -3,7 +3,6 @@
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { css } from '../../../../../styled-system/css'
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
import { useFullscreen } from '../../../../contexts/FullscreenContext'
@@ -14,9 +13,8 @@ import { SetupPhase } from './SetupPhase'
export function MemoryPairsGame() {
const router = useRouter()
const { state, exitSession, resetGame } = useMemoryPairs()
const { state, exitSession, resetGame, goToSetup, canModifyPlayers } = useMemoryPairs()
const { setFullscreenElement } = useFullscreen()
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
const gameRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -37,11 +35,14 @@ export function MemoryPairsGame() {
exitSession()
router.push('/arcade')
}}
onSetup={() => {
// Exit current session and return to arcade (which will redirect to setup)
exitSession()
router.push('/arcade/matching')
}}
onSetup={
goToSetup
? () => {
// Transition to setup phase (will pause game if active)
goToSetup()
}
: undefined
}
onNewGame={() => {
resetGame()
}}

View File

@@ -1,15 +1,16 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { type ReactNode, useCallback, useEffect, useMemo, useReducer } from 'react'
import { useRouter } from 'next/navigation'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useUserPlayers } from '@/hooks/useUserPlayers'
import { generateGameCards } from '../utils/cardGeneration'
import { validateMatch } from '../utils/matchValidation'
import { MemoryPairsContext } from './MemoryPairsContext'
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
// Initial state
// Initial state for local-only games
const initialState: MemoryPairsState = {
cards: [],
gameCards: [],
@@ -18,13 +19,13 @@ const initialState: MemoryPairsState = {
difficulty: 6,
turnTimer: 30,
gamePhase: 'setup',
currentPlayer: '', // Will be set to first player ID on START_GAME
currentPlayer: '',
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {}, // Player metadata for cross-user visibility
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
@@ -34,36 +35,43 @@ const initialState: MemoryPairsState = {
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Initialize paused game fields
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
playerHovers: {},
}
/**
* Optimistic move application (client-side prediction)
* The server will validate and send back the authoritative state
*/
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
switch (move.type) {
// Action types for local reducer
type LocalAction =
| { type: 'START_GAME'; cards: any[]; activePlayers: string[]; playerMetadata: any }
| { type: 'FLIP_CARD'; cardId: string }
| { type: 'MATCH_FOUND'; cardIds: [string, string]; playerId: string }
| { type: 'MATCH_FAILED'; cardIds: [string, string] }
| { type: 'CLEAR_MISMATCH' }
| { type: 'SWITCH_PLAYER' }
| { type: 'GO_TO_SETUP' }
| { type: 'SET_CONFIG'; field: string; value: any }
| { type: 'RESUME_GAME' }
| { type: 'HOVER_CARD'; playerId: string; cardId: string | null }
| { type: 'END_GAME' }
// Pure client-side reducer with complete game logic
function localMemoryPairsReducer(state: MemoryPairsState, action: LocalAction): MemoryPairsState {
switch (action.type) {
case 'START_GAME':
// Generate cards and initialize game
return {
...state,
gamePhase: 'playing',
gameCards: move.data.cards,
cards: move.data.cards,
gameCards: action.cards,
cards: action.cards,
flippedCards: [],
matchedPairs: 0,
moves: 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,
playerMetadata: move.data.playerMetadata || {}, // Include player metadata
currentPlayer: move.data.activePlayers[0] || '',
scores: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
activePlayers: action.activePlayers,
playerMetadata: action.playerMetadata,
currentPlayer: action.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
@@ -71,7 +79,6 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Save original config and clear paused state
originalConfig: {
gameType: state.gameType,
difficulty: state.difficulty,
@@ -82,8 +89,7 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
case 'FLIP_CARD': {
// Optimistically flip the card
const card = state.gameCards.find((c) => c.id === move.data.cardId)
const card = state.gameCards.find((c) => c.id === action.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card]
@@ -91,15 +97,69 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime:
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
currentMoveStartTime: state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2,
showMismatchFeedback: false,
}
}
case 'MATCH_FOUND': {
const [id1, id2] = action.cardIds
const updatedCards = state.gameCards.map((card) =>
card.id === id1 || card.id === id2
? { ...card, matched: true, matchedBy: action.playerId }
: card
)
const newMatchedPairs = state.matchedPairs + 1
const newScores = {
...state.scores,
[action.playerId]: (state.scores[action.playerId] || 0) + 1,
}
const newConsecutiveMatches = {
...state.consecutiveMatches,
[action.playerId]: (state.consecutiveMatches[action.playerId] || 0) + 1,
}
// Check if game is complete
const gameComplete = newMatchedPairs >= state.totalPairs
return {
...state,
gameCards: updatedCards,
cards: updatedCards,
flippedCards: [],
matchedPairs: newMatchedPairs,
moves: state.moves + 1,
scores: newScores,
consecutiveMatches: newConsecutiveMatches,
lastMatchedPair: action.cardIds,
isProcessingMove: false,
showMismatchFeedback: false,
gamePhase: gameComplete ? 'results' : state.gamePhase,
gameEndTime: gameComplete ? Date.now() : null,
// Player keeps their turn on match
}
}
case 'MATCH_FAILED': {
// Reset consecutive matches for current player
const newConsecutiveMatches = {
...state.consecutiveMatches,
[state.currentPlayer]: 0,
}
return {
...state,
moves: state.moves + 1,
showMismatchFeedback: true,
isProcessingMove: true,
consecutiveMatches: newConsecutiveMatches,
// Don't clear flipped cards yet - CLEAR_MISMATCH will do that
}
}
case 'CLEAR_MISMATCH': {
// Clear mismatched cards and feedback
return {
...state,
flippedCards: [],
@@ -108,14 +168,24 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
}
case 'SWITCH_PLAYER': {
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
const nextIndex = (currentIndex + 1) % state.activePlayers.length
const nextPlayer = state.activePlayers[nextIndex]
return {
...state,
currentPlayer: nextPlayer,
currentMoveStartTime: Date.now(),
}
}
case 'GO_TO_SETUP': {
// Return to setup phase - pause game if coming from playing/results
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
return {
...state,
gamePhase: 'setup',
// PAUSE: Save game state if pausing from active game
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
pausedGameState: isPausingGame
? {
@@ -125,12 +195,11 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
moves: state.moves,
scores: state.scores,
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
playerMetadata: state.playerMetadata || {},
consecutiveMatches: state.consecutiveMatches,
gameStartTime: state.gameStartTime,
}
: undefined,
// Reset visible game state
gameCards: [],
cards: [],
flippedCards: [],
@@ -152,16 +221,12 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
case 'SET_CONFIG': {
// Update configuration field optimistically
const { field, value } = move.data as { field: string; value: any }
const clearPausedGame = !!state.pausedGamePhase
return {
...state,
[field]: value,
// Update totalPairs if difficulty changes
...(field === 'difficulty' ? { totalPairs: value } : {}),
// Clear paused game if config changed
[action.field]: action.value,
...(action.field === 'difficulty' ? { totalPairs: action.value } : {}),
...(clearPausedGame
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
: {}),
@@ -169,9 +234,8 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
case 'RESUME_GAME': {
// Resume paused game
if (!state.pausedGamePhase || !state.pausedGameState) {
return state // No paused game, no-op
return state
}
return {
@@ -187,126 +251,142 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
playerMetadata: state.pausedGameState.playerMetadata,
consecutiveMatches: state.pausedGameState.consecutiveMatches,
gameStartTime: state.pausedGameState.gameStartTime,
// Clear paused state
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
case 'HOVER_CARD': {
return {
...state,
playerHovers: {
...state.playerHovers,
[action.playerId]: action.cardId,
},
}
}
case 'END_GAME': {
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
}
}
default:
return state
}
}
// Provider component for LOCAL play (no network sync)
// Provider component for LOCAL-ONLY play (no network, no arcade session)
export function LocalMemoryPairsProvider({ children }: { children: ReactNode }) {
const router = useRouter()
const { data: viewerId } = useViewerId()
// NOTE: We deliberately do NOT call useRoomData() for local play
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
// LOCAL-ONLY: Get only the current user's players (no room members)
const { data: userPlayers = [] } = useUserPlayers()
// Use arcade redirect to determine button visibility
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
// Build players map from current user's players only
const players = useMemo(() => {
const map = new Map()
userPlayers.forEach((player) => {
map.set(player.id, {
id: player.id,
name: player.name,
emoji: player.emoji,
color: player.color,
isLocal: true,
})
})
return map
}, [userPlayers])
// Get active player IDs from current user's players only
const activePlayers = useMemo(() => {
return userPlayers.filter((p) => p.isActive).map((p) => p.id)
}, [userPlayers])
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
const gameMode = activePlayers.length > 1 ? 'multiplayer' : 'single'
// NO LOCAL STATE - Configuration lives in session state
// Changes are sent as moves and synchronized (even in local mode for consistency)
// Pure client-side state with useReducer
const [state, dispatch] = useReducer(localMemoryPairsReducer, initialState)
// Arcade session integration WITHOUT room sync
const {
state,
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: undefined, // CRITICAL: No roomId means no network sync
initialState,
applyMove: applyMoveOptimistically,
})
// Handle mismatch feedback timeout
// Handle mismatch feedback timeout and player switching
useEffect(() => {
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
// After 1.5 seconds, send CLEAR_MISMATCH
// Server will validate that cards are still in mismatch state before clearing
const timeout = setTimeout(() => {
sendMove({
type: 'CLEAR_MISMATCH',
playerId: state.currentPlayer,
data: {},
})
dispatch({ type: 'CLEAR_MISMATCH' })
// Switch to next player after mismatch
dispatch({ type: 'SWITCH_PLAYER' })
}, 1500)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
}, [state.showMismatchFeedback, state.flippedCards.length])
// Handle automatic match checking when 2 cards flipped
useEffect(() => {
if (state.flippedCards.length === 2 && !state.showMismatchFeedback) {
const [card1, card2] = state.flippedCards
const isMatch = validateMatch(card1, card2)
const timeout = setTimeout(() => {
if (isMatch.isValid) {
dispatch({
type: 'MATCH_FOUND',
cardIds: [card1.id, card2.id],
playerId: state.currentPlayer,
})
// Player keeps turn on match - no SWITCH_PLAYER
} else {
dispatch({
type: 'MATCH_FAILED',
cardIds: [card1.id, card2.id],
})
// SWITCH_PLAYER will happen after CLEAR_MISMATCH timeout
}
}, 600) // Small delay to show both cards
return () => clearTimeout(timeout)
}
}, [state.flippedCards, state.showMismatchFeedback, state.currentPlayer])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = useCallback(
(cardId: string): boolean => {
console.log('[LocalProvider][canFlipCard] Checking card:', {
cardId,
isGameActive,
isProcessingMove: state.isProcessingMove,
currentPlayer: state.currentPlayer,
flippedCardsCount: state.flippedCards.length,
})
if (!isGameActive || state.isProcessingMove) {
console.log('[LocalProvider][canFlipCard] Blocked: game not active or processing')
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
console.log('[LocalProvider][canFlipCard] Blocked: card not found or already matched')
return false
}
// Can't flip if already flipped
if (state.flippedCards.some((c) => c.id === cardId)) {
console.log('[LocalProvider][canFlipCard] Blocked: card already flipped')
return false
}
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) {
console.log('[LocalProvider][canFlipCard] Blocked: 2 cards already flipped')
return false
}
// In local play, we allow the current player to flip
// Authorization is simpler - just check if it's this player's turn
// In local play, all local players can flip during their turn
const currentPlayerData = players.get(state.currentPlayer)
console.log('[LocalProvider][canFlipCard] Authorization check:', {
currentPlayerId: state.currentPlayer,
currentPlayerFound: !!currentPlayerData,
currentPlayerIsLocal: currentPlayerData?.isLocal,
})
// Block if current player is explicitly marked as remote (shouldn't happen in local play)
if (currentPlayerData && currentPlayerData.isLocal === false) {
console.log(
'[LocalProvider][canFlipCard] BLOCKED: Current player is remote (unexpected in local play)'
)
return false
}
console.log('[LocalProvider][canFlipCard] ALLOWED: All checks passed')
return true
},
[
isGameActive,
state.isProcessingMove,
state.gameCards,
state.flippedCards,
state.currentPlayer,
players,
]
[isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards, state.currentPlayer, players]
)
const currentGameStatistics: GameStatistics = useMemo(
@@ -324,7 +404,6 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
)
// PAUSE/RESUME: Computed values for pause/resume functionality
const hasConfigChanged = useMemo(() => {
if (!state.originalConfig) return false
return (
@@ -338,16 +417,13 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Action creators - send moves to arcade session
// Action creators
const startGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[LocalMemoryPairs] Cannot start game without active players')
return
}
// Capture player metadata from local players map
// This ensures all room members can display player info even if they don't own the players
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
@@ -362,56 +438,31 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
}
}
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0]
sendMove({
dispatch({
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
playerMetadata,
},
cards,
activePlayers,
playerMetadata,
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
const flipCard = useCallback(
(cardId: string) => {
console.log('[LocalProvider] flipCard called:', {
cardId,
viewerId,
currentPlayer: state.currentPlayer,
activePlayers: state.activePlayers,
gamePhase: state.gamePhase,
canFlip: canFlipCard(cardId),
})
if (!canFlipCard(cardId)) {
console.log('[LocalProvider] Cannot flip card - canFlipCard returned false')
return
}
const move = {
type: 'FLIP_CARD' as const,
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
data: { cardId },
}
console.log('[LocalProvider] Sending FLIP_CARD move via sendMove:', move)
sendMove(move)
dispatch({ type: 'FLIP_CARD', cardId })
},
[canFlipCard, sendMove, viewerId, state.currentPlayer, state.activePlayers, state.gamePhase]
[canFlipCard]
)
const resetGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[LocalMemoryPairs] Cannot reset game without active players')
return
}
// Capture player metadata from local players map
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
@@ -426,97 +477,71 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
}
}
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0]
sendMove({
dispatch({
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
playerMetadata,
},
cards,
activePlayers,
playerMetadata,
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
const setGameType = useCallback(
(gameType: typeof state.gameType) => {
// Use first active player as playerId, or empty string if none
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'gameType', value: gameType },
})
},
[activePlayers, sendMove]
)
const setGameType = useCallback((gameType: typeof state.gameType) => {
dispatch({ type: 'SET_CONFIG', field: 'gameType', value: gameType })
}, [])
const setDifficulty = useCallback(
(difficulty: typeof state.difficulty) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'difficulty', value: difficulty },
})
},
[activePlayers, sendMove]
)
const setDifficulty = useCallback((difficulty: typeof state.difficulty) => {
dispatch({ type: 'SET_CONFIG', field: 'difficulty', value: difficulty })
}, [])
const setTurnTimer = useCallback(
(turnTimer: typeof state.turnTimer) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'turnTimer', value: turnTimer },
})
},
[activePlayers, sendMove]
)
const setTurnTimer = useCallback((turnTimer: typeof state.turnTimer) => {
dispatch({ type: 'SET_CONFIG', field: 'turnTimer', value: turnTimer })
}, [])
const resumeGame = useCallback(() => {
// PAUSE/RESUME: Resume paused game if config unchanged
if (!canResumeGame) {
console.warn('[LocalMemoryPairs] Cannot resume - no paused game or config changed')
return
}
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'RESUME_GAME',
playerId,
data: {},
})
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
dispatch({ type: 'RESUME_GAME' })
}, [canResumeGame])
const goToSetup = useCallback(() => {
// Send GO_TO_SETUP move - synchronized across all room members
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'GO_TO_SETUP',
playerId,
data: {},
})
}, [activePlayers, state.currentPlayer, sendMove])
dispatch({ type: 'GO_TO_SETUP' })
}, [])
const hoverCard = useCallback(
(cardId: string | null) => {
const playerId = state.currentPlayer || activePlayers[0] || ''
if (!playerId) return
dispatch({
type: 'HOVER_CARD',
playerId,
cardId,
})
},
[state.currentPlayer, activePlayers]
)
const exitSession = useCallback(() => {
router.push('/arcade')
}, [router])
// NO MORE effectiveState merging! Just use session state directly with gameMode added
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
const contextValue: MemoryPairsContextValue = {
state: effectiveState,
dispatch: () => {
// No-op - replaced with sendMove
console.warn('dispatch() is deprecated in arcade mode, use action creators instead')
// No-op - local provider uses action creators instead
console.warn('dispatch() is not available in local mode, use action creators instead')
},
isGameActive,
canFlipCard,
currentGameStatistics,
hasConfigChanged,
canResumeGame,
canModifyPlayers,
startGame,
resumeGame,
flipCard,
@@ -525,6 +550,7 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
setGameType,
setDifficulty,
setTurnTimer,
hoverCard,
exitSession,
gameMode,
activePlayers,

View File

@@ -1,6 +1,7 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
@@ -213,6 +214,8 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
// Provider component for ROOM-BASED play (with network sync)
// NOTE: This provider should ONLY be used for room-based multiplayer games.
// For arcade sessions without rooms, use LocalMemoryPairsProvider instead.
export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // Fetch room data for room-based play
@@ -557,6 +560,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
currentGameStatistics,
hasConfigChanged,
canResumeGame,
canModifyPlayers: false, // Room-based games: always show buttons (false = show buttons)
startGame,
resumeGame,
flipCard,

View File

@@ -0,0 +1,20 @@
/**
* Central export point for arcade matching game context
* Re-exports the hook from the appropriate provider
*/
// Export the hook (works with both local and room providers)
export { useMemoryPairs } from './MemoryPairsContext'
// Export the room provider (networked multiplayer)
export { RoomMemoryPairsProvider } from './RoomMemoryPairsProvider'
// Export types
export type {
GameCard,
GameMode,
GamePhase,
GameType,
MemoryPairsState,
MemoryPairsContextValue,
} from './types'

View File

@@ -120,6 +120,7 @@ export interface MemoryPairsContextValue {
currentGameStatistics: GameStatistics
gameMode: GameMode // Derived from global context
activePlayers: Player[] // Active player IDs from arena
canModifyPlayers: boolean // Whether players can be added/removed (controls button visibility)
// PAUSE/RESUME: Computed pause/resume values
hasConfigChanged?: boolean

View File

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

View File

@@ -2,7 +2,7 @@
import { useRoomData } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairsContext'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
/**
* /arcade/room - Renders the game for the user's current room
@@ -68,9 +68,9 @@ export default function RoomPage() {
switch (roomData.gameName) {
case 'matching':
return (
<ArcadeMemoryPairsProvider>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</ArcadeMemoryPairsProvider>
</RoomMemoryPairsProvider>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "2.12.1",
"version": "2.16.2",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [