feat(matching): use nav avatars as turn indicators

Replace the in-game PlayerStatusBar with turn indicators integrated
directly into the nav bar avatars. This creates a cleaner, more
unified UI with less visual clutter while making turn state always
visible at the top of the screen.

Changes:
- Pass game state (currentPlayer, scores, streaks) through PageWithNav
  to GameContextNav to ActivePlayersList
- Add turn indicator styling to avatars:
  - Current player: larger (70px vs 56px), colored border ring with glow
  - Other players: dimmed opacity (0.65)
  - Floating animation on current player
- Add score badge (bottom-right) showing pairs matched
- Add streak badge (top-right) with fire emoji, color-coded by level:
  - Green: 2+ streak (great)
  - Orange: 3+ streak (epic)
  - Purple: 5+ streak (legendary)
- Remove PlayerStatusBar from GamePhase since nav now shows all info
- Add CSS animations: avatarFloat, borderPulse, streakPulse

Benefits:
- Less visual clutter in game area
- Turn state always visible in persistent nav
- More space for the actual game
- Consistent with nav-as-control-center design

🤖 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-11 08:56:57 -05:00
parent cecf07e572
commit 7263828ed4
5 changed files with 177 additions and 29 deletions

View File

@@ -5,7 +5,6 @@ import { useGameMode } from '../../../../contexts/GameModeContext'
import { pluralizeWord } from '../../../../utils/pluralization'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { MemoryGrid } from './MemoryGrid'
import { PlayerStatusBar } from './PlayerStatusBar'
export function GamePhase() {
const { state, resetGame, activePlayers } = useMemoryPairs()
@@ -119,8 +118,7 @@ export function GamePhase() {
</div>
</div>
{/* Player Status Bar */}
<PlayerStatusBar />
{/* Player Status Bar - Removed, now using nav avatars as turn indicator */}
{/* Memory Grid - The main game area */}
<div

View File

@@ -28,6 +28,9 @@ export function MemoryPairsGame() {
navTitle="Memory Pairs"
navEmoji="🧩"
emphasizeGameContext={state.gamePhase === 'setup'}
currentPlayerId={state.currentPlayer}
playerScores={state.scores}
playerStreaks={state.consecutiveMatches}
>
<StandardGameLayout>
<div

View File

@@ -18,6 +18,10 @@ interface PageWithNavProps {
onNewGame?: () => void
canModifyPlayers?: boolean
children: React.ReactNode
// Game state for turn indicator
currentPlayerId?: string
playerScores?: Record<string, number>
playerStreaks?: Record<string, number>
}
export function PageWithNav({
@@ -29,6 +33,9 @@ export function PageWithNav({
onNewGame,
canModifyPlayers = true,
children,
currentPlayerId,
playerScores,
playerStreaks,
}: PageWithNavProps) {
const { players, activePlayers, setActive, activePlayerCount } = useGameMode()
const { hasActiveSession, activeSession } = useArcadeGuard({
@@ -142,6 +149,9 @@ export function PageWithNav({
canModifyPlayers={canModifyPlayers}
roomInfo={roomInfo}
networkPlayers={networkPlayers}
currentPlayerId={currentPlayerId}
playerScores={playerScores}
playerStreaks={playerStreaks}
/>
) : null

View File

@@ -15,6 +15,10 @@ interface ActivePlayersListProps {
shouldEmphasize: boolean
onRemovePlayer: (playerId: string) => void
onConfigurePlayer: (playerId: string) => void
// Game state for turn indicator
currentPlayerId?: string
playerScores?: Record<string, number>
playerStreaks?: Record<string, number>
}
export function ActivePlayersList({
@@ -22,37 +26,122 @@ export function ActivePlayersList({
shouldEmphasize,
onRemovePlayer,
onConfigurePlayer,
currentPlayerId,
playerScores = {},
playerStreaks = {},
}: ActivePlayersListProps) {
const [hoveredPlayerId, setHoveredPlayerId] = React.useState<string | null>(null)
// Helper to get celebration level based on consecutive matches
const getCelebrationLevel = (consecutiveMatches: number) => {
if (consecutiveMatches >= 5) return 'legendary'
if (consecutiveMatches >= 3) return 'epic'
if (consecutiveMatches >= 2) return 'great'
return 'normal'
}
return (
<>
{activePlayers.map((player) => (
<PlayerTooltip
key={player.id}
playerName={player.name}
playerColor={player.color}
isLocal={player.isLocal !== false}
createdAt={player.createdAt}
>
<div
style={{
position: 'relative',
fontSize: '56px',
lineHeight: 1,
transition: 'filter 0.4s ease',
filter: 'drop-shadow(0 6px 12px rgba(0,0,0,0.3))',
cursor: shouldEmphasize ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => shouldEmphasize && onConfigurePlayer(player.id)}
onMouseEnter={() => shouldEmphasize && setHoveredPlayerId(player.id)}
onMouseLeave={() => shouldEmphasize && setHoveredPlayerId(null)}
{activePlayers.map((player) => {
const isCurrentPlayer = player.id === currentPlayerId
const score = playerScores[player.id] || 0
const streak = playerStreaks[player.id] || 0
const celebrationLevel = getCelebrationLevel(streak)
return (
<PlayerTooltip
key={player.id}
playerName={player.name}
playerColor={player.color}
isLocal={player.isLocal !== false}
createdAt={player.createdAt}
>
{player.emoji}
{shouldEmphasize && hoveredPlayerId === player.id && (
<div
style={{
position: 'relative',
fontSize: isCurrentPlayer ? '70px' : '56px',
lineHeight: 1,
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
filter: 'drop-shadow(0 6px 12px rgba(0,0,0,0.3))',
cursor: shouldEmphasize ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: isCurrentPlayer ? 1 : 0.65,
transform: isCurrentPlayer ? 'scale(1.1)' : 'scale(1)',
animation: isCurrentPlayer ? 'avatarFloat 3s ease-in-out infinite' : 'none',
}}
onClick={() => shouldEmphasize && onConfigurePlayer(player.id)}
onMouseEnter={() => shouldEmphasize && setHoveredPlayerId(player.id)}
onMouseLeave={() => shouldEmphasize && setHoveredPlayerId(null)}
>
{/* Border ring for current player */}
{isCurrentPlayer && (
<div
style={{
position: 'absolute',
inset: '-8px',
borderRadius: '50%',
border: `4px solid ${player.color || '#3b82f6'}`,
boxShadow: `0 0 0 2px white, 0 0 20px ${player.color || '#3b82f6'}80`,
animation: 'borderPulse 2s ease-in-out infinite',
zIndex: -1,
}}
/>
)}
{player.emoji}
{/* Score badge - bottom right */}
{score > 0 && (
<div
style={{
position: 'absolute',
bottom: '-6px',
right: '-6px',
width: '24px',
height: '24px',
borderRadius: '50%',
border: '3px solid white',
background: player.color || '#3b82f6',
color: 'white',
fontSize: '11px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 10px rgba(0,0,0,0.4)',
zIndex: 2,
lineHeight: 1,
}}
>
{score}
</div>
)}
{/* Streak badge - top right */}
{streak >= 2 && (
<div
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
fontSize: '20px',
filter:
celebrationLevel === 'legendary'
? 'drop-shadow(0 0 8px #a855f7)'
: celebrationLevel === 'epic'
? 'drop-shadow(0 0 8px #f97316)'
: 'drop-shadow(0 0 8px #22c55e)',
animation: isCurrentPlayer ? 'streakPulse 1s ease-in-out infinite' : 'none',
zIndex: 2,
}}
>
🔥
</div>
)}
{shouldEmphasize && hoveredPlayerId === player.id && (
<>
{/* Configure button - bottom left */}
<button
@@ -142,7 +231,45 @@ export function ActivePlayersList({
)}
</div>
</PlayerTooltip>
))}
)})}
{/* Animation styles */}
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes avatarFloat {
0%, 100% {
transform: scale(1.1) translateY(0px);
}
50% {
transform: scale(1.1) translateY(-6px);
}
}
@keyframes borderPulse {
0%, 100% {
opacity: 1;
box-shadow: 0 0 0 2px white, 0 0 20px currentColor;
}
50% {
opacity: 0.8;
box-shadow: 0 0 0 2px white, 0 0 30px currentColor;
}
}
@keyframes streakPulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.8;
}
}
`,
}}
/>
</>
)
}

View File

@@ -47,6 +47,10 @@ interface GameContextNavProps {
// Arcade session info
networkPlayers?: NetworkPlayer[]
roomInfo?: ArcadeRoomInfo
// Game state for turn indicator
currentPlayerId?: string
playerScores?: Record<string, number>
playerStreaks?: Record<string, number>
}
export function GameContextNav({
@@ -66,6 +70,9 @@ export function GameContextNav({
canModifyPlayers = true,
networkPlayers = [],
roomInfo,
currentPlayerId,
playerScores,
playerStreaks,
}: GameContextNavProps) {
// 2x2 grid layout for normal mode, column for fullscreen
if (showFullscreenSelection) {
@@ -259,6 +266,9 @@ export function GameContextNav({
shouldEmphasize={shouldEmphasize}
onRemovePlayer={onRemovePlayer}
onConfigurePlayer={onConfigurePlayer}
currentPlayerId={currentPlayerId}
playerScores={playerScores}
playerStreaks={playerStreaks}
/>
{canModifyPlayers && (