Compare commits

...

6 Commits

Author SHA1 Message Date
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
8 changed files with 162 additions and 17 deletions

View File

@@ -1,3 +1,23 @@
## [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)

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, 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'
@@ -81,7 +82,8 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
}
export function MemoryGrid() {
const { state, flipCard } = useMemoryPairs()
const { state, flipCard, hoverCard } = useMemoryPairs()
const { players: playerMap } = useGameMode()
// Hooks must be called before early return
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
@@ -95,6 +97,27 @@ 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
}
return (
<div
className={css({
@@ -172,7 +195,20 @@ export function MemoryGrid() {
opacity: isDimmed ? 0.3 : 1,
transition: 'opacity 0.3s ease',
filter: isDimmed ? 'grayscale(0.7)' : 'none',
position: 'relative', // For avatar positioning
})}
onMouseEnter={() => {
// Send hover state when mouse enters card (if not matched)
if (hoverCard && !isMatched) {
hoverCard(card.id)
}
}}
onMouseLeave={() => {
// Clear hover state when mouse leaves card
if (hoverCard && !isMatched) {
hoverCard(null)
}
}}
>
<GameCard
card={card}
@@ -181,6 +217,43 @@ export function MemoryGrid() {
onClick={() => (isValidForSelection ? handleCardClick(card.id) : undefined)}
disabled={state.isProcessingMove || !isValidForSelection}
/>
{/* Hover Avatars - Show which players are hovering over this card */}
{state.playerHovers &&
Object.entries(state.playerHovers)
.filter(([playerId, hoveredCardId]) => hoveredCardId === card.id)
.map(([playerId]) => {
const playerInfo = getPlayerHoverInfo(playerId)
if (!playerInfo) return null
return (
<div
key={playerId}
className={css({
position: 'absolute',
top: '-12px',
right: '-12px',
width: '40px',
height: '40px',
borderRadius: '50%',
background: playerInfo.color || 'linear-gradient(135deg, #667eea, #764ba2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3), 0 0 20px rgba(102, 126, 234, 0.6)',
border: '3px solid white',
zIndex: 100,
animation: 'hoverPulse 1.5s ease-in-out infinite',
transition: 'all 0.3s cubic-bezier(0.4, 0.0, 0.2, 1)',
pointerEvents: 'none',
})}
title={`${playerInfo.name} is considering this card`}
>
{playerInfo.emoji}
</div>
)
})}
</div>
)
})}
@@ -237,19 +310,30 @@ export function MemoryGrid() {
)
}
// 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 hoverPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3), 0 0 20px rgba(102, 126, 234, 0.6);
}
50% {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(0,0,0,0.4), 0 0 30px rgba(102, 126, 234, 0.9);
}
}
`
// 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,6 +1,7 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
@@ -38,6 +39,8 @@ const initialState: MemoryPairsState = {
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
// HOVER: Initialize hover state
playerHovers: {},
}
/**
@@ -193,6 +196,17 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
}
case 'HOVER_CARD': {
// Update player hover state for networked presence
return {
...state,
playerHovers: {
...state.playerHovers,
[move.playerId]: move.data.cardId,
},
}
}
default:
return state
}
@@ -204,6 +218,9 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
// NOTE: We deliberately do NOT call useRoomData() for local play
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
// Use arcade redirect to determine button visibility for arcade sessions
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
@@ -494,7 +511,7 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
const goToSetup = useCallback(() => {
// Send GO_TO_SETUP move - synchronized across all room members
// Send GO_TO_SETUP move
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'GO_TO_SETUP',
@@ -503,6 +520,22 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
})
}, [activePlayers, state.currentPlayer, sendMove])
const hoverCard = useCallback(
(cardId: string | null) => {
// HOVER: Send hover state for networked presence
// Use current player as the one hovering
const playerId = state.currentPlayer || activePlayers[0] || ''
if (!playerId) return // No active player to send hover for
sendMove({
type: 'HOVER_CARD',
playerId,
data: { cardId },
})
},
[state.currentPlayer, activePlayers, sendMove]
)
// NO MORE effectiveState merging! Just use session state directly with gameMode added
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
@@ -517,6 +550,7 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
currentGameStatistics,
hasConfigChanged,
canResumeGame,
canModifyPlayers, // Arcade sessions: use arcade redirect logic
startGame,
resumeGame,
flipCard,
@@ -525,6 +559,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

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

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