feat(nav): add turn indicators to network players
Network players now display the same turn indicator features as local players: - Size increase and float animation for current player - Border ring with player color - Turn arrow badge (▶) in top-left - Score badge in bottom-right - Streak badge (🔥) in top-right when streak >= 2 - Opacity dimming for non-current players during game This ensures all players (local and remote) have identical turn indicator treatment, making it clear whose turn it is from any player's perspective. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -212,7 +212,14 @@ export function GameContextNav({
|
||||
}}
|
||||
/>
|
||||
{networkPlayers.map((player) => (
|
||||
<NetworkPlayerIndicator key={player.id} player={player} shouldEmphasize={shouldEmphasize} />
|
||||
<NetworkPlayerIndicator
|
||||
key={player.id}
|
||||
player={player}
|
||||
shouldEmphasize={shouldEmphasize}
|
||||
currentPlayerId={currentPlayerId}
|
||||
playerScores={playerScores}
|
||||
playerStreaks={playerStreaks}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -13,18 +13,43 @@ interface NetworkPlayer {
|
||||
interface NetworkPlayerIndicatorProps {
|
||||
player: NetworkPlayer
|
||||
shouldEmphasize: boolean
|
||||
// Game state for turn indicator
|
||||
currentPlayerId?: string
|
||||
playerScores?: Record<string, number>
|
||||
playerStreaks?: Record<string, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a network player with a special "network" frame border
|
||||
* to distinguish them from local players
|
||||
*/
|
||||
export function NetworkPlayerIndicator({ player, shouldEmphasize }: NetworkPlayerIndicatorProps) {
|
||||
export function NetworkPlayerIndicator({
|
||||
player,
|
||||
shouldEmphasize,
|
||||
currentPlayerId,
|
||||
playerScores = {},
|
||||
playerStreaks = {},
|
||||
}: NetworkPlayerIndicatorProps) {
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
const playerName = player.name || `Network Player ${player.id.slice(0, 8)}`
|
||||
const extraInfo = player.memberName ? `Controlled by ${player.memberName}` : undefined
|
||||
const isOnline = player.isOnline !== false // Default to online if not specified
|
||||
|
||||
// Turn indicator logic (same as ActivePlayersList)
|
||||
const isCurrentPlayer = currentPlayerId ? player.id === currentPlayerId : false
|
||||
const hasGameState = currentPlayerId !== undefined
|
||||
const score = playerScores[player.id] || 0
|
||||
const streak = playerStreaks[player.id] || 0
|
||||
|
||||
// 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'
|
||||
}
|
||||
const celebrationLevel = getCelebrationLevel(streak)
|
||||
|
||||
return (
|
||||
<PlayerTooltip
|
||||
playerName={playerName}
|
||||
@@ -35,69 +60,159 @@ export function NetworkPlayerIndicator({ player, shouldEmphasize }: NetworkPlaye
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: '56px',
|
||||
fontSize: isCurrentPlayer && hasGameState ? '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: 'default',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: hasGameState ? (isCurrentPlayer ? 1 : 0.65) : 1,
|
||||
transform: isCurrentPlayer && hasGameState ? 'scale(1.1)' : 'scale(1)',
|
||||
animation: isCurrentPlayer && hasGameState ? 'avatarFloat 3s ease-in-out infinite' : 'none',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Network frame border - larger and more prominent */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-8px',
|
||||
borderRadius: '12px',
|
||||
background: `
|
||||
linear-gradient(135deg,
|
||||
rgba(59, 130, 246, 0.5),
|
||||
rgba(147, 51, 234, 0.5),
|
||||
rgba(236, 72, 153, 0.5))
|
||||
`,
|
||||
opacity: isHovered ? 1 : 0.8,
|
||||
transition: 'opacity 0.2s ease',
|
||||
boxShadow: '0 6px 16px rgba(59, 130, 246, 0.3)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
{/* Network frame border - only show when not current player */}
|
||||
{!isCurrentPlayer && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-8px',
|
||||
borderRadius: '12px',
|
||||
background: `
|
||||
linear-gradient(135deg,
|
||||
rgba(59, 130, 246, 0.5),
|
||||
rgba(147, 51, 234, 0.5),
|
||||
rgba(236, 72, 153, 0.5))
|
||||
`,
|
||||
opacity: isHovered ? 1 : 0.8,
|
||||
transition: 'opacity 0.2s ease',
|
||||
boxShadow: '0 6px 16px rgba(59, 130, 246, 0.3)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Turn indicator border ring - show when current player */}
|
||||
{isCurrentPlayer && hasGameState && (
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Turn indicator arrow badge */}
|
||||
{isCurrentPlayer && hasGameState && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-12px',
|
||||
left: '-12px',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid white',
|
||||
background: `linear-gradient(135deg, ${player.color || '#3b82f6'}, ${player.color || '#3b82f6'}dd)`,
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: `0 4px 12px ${player.color || '#3b82f6'}80`,
|
||||
zIndex: 3,
|
||||
animation: 'turnBadgePulse 1.5s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Player emoji or fallback */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
filter: 'drop-shadow(0 6px 12px rgba(0,0,0,0.3))',
|
||||
}}
|
||||
>
|
||||
{player.emoji || '🌐'}
|
||||
</div>
|
||||
{player.emoji || '🌐'}
|
||||
|
||||
{/* Network icon badge - larger */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-6px',
|
||||
left: '-6px',
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid white',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 10px rgba(0,0,0,0.4)',
|
||||
zIndex: 1,
|
||||
animation: isOnline ? 'none' : 'offlinePulse 2s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
📡
|
||||
</div>
|
||||
{/* Score badge - bottom right (same position as network badge when no score) */}
|
||||
{hasGameState && 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>
|
||||
) : (
|
||||
/* Network icon badge - show when no score */
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-6px',
|
||||
left: '-6px',
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid white',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 10px rgba(0,0,0,0.4)',
|
||||
zIndex: 1,
|
||||
animation: isOnline ? 'none' : 'offlinePulse 2s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
📡
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streak badge - top right */}
|
||||
{hasGameState && 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>
|
||||
)}
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -110,6 +225,48 @@ export function NetworkPlayerIndicator({ player, shouldEmphasize }: NetworkPlaye
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes turnBadgePulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 4px 12px currentColor;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 6px 20px currentColor;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user