From b16386ede3f100ca556bfacbec705d18c2b9bea4 Mon Sep 17 00:00:00 2001 From: ejfn <148174+ejfn@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:48:19 +0930 Subject: [PATCH 1/3] refactor: convert trick completion data from ref to reactive state for improved UI synchronization --- src/components/AIPlayerView.tsx | 12 +-- src/components/AnimatedCard.tsx | 32 +++++--- src/components/CardPlayArea.tsx | 36 ++++----- src/components/ExpandableTrumpDeclaration.tsx | 14 +++- src/components/GameStatus.tsx | 77 ++++++++++--------- src/components/HumanHandAnimated.tsx | 14 ++-- src/components/HumanPlayerView.tsx | 2 +- src/components/RoundCompleteModal.tsx | 32 ++++---- src/components/ThinkingIndicator.tsx | 5 +- src/hooks/useAnimations.ts | 11 +-- src/hooks/useGameState.ts | 27 +++---- src/locales/zh/trumpDeclaration.json | 2 +- src/screens/GameScreenController.tsx | 55 +++---------- src/screens/GameScreenView.tsx | 32 ++++---- 14 files changed, 157 insertions(+), 194 deletions(-) diff --git a/src/components/AIPlayerView.tsx b/src/components/AIPlayerView.tsx index 23ab933..47caf43 100644 --- a/src/components/AIPlayerView.tsx +++ b/src/components/AIPlayerView.tsx @@ -125,9 +125,9 @@ const AIPlayerView: React.FC = ({ {[...Array(Math.min(10, player.hand.length))].map((_, i) => ( - + ))} @@ -170,16 +170,10 @@ const styles = StyleSheet.create({ flexDirection: "column", alignItems: "center", }, - botCardSmall: { + botCardWrapper: { width: 36, height: 50, - backgroundColor: "#4169E1", - borderRadius: 3, - borderWidth: 1, - borderColor: "white", zIndex: 5, - justifyContent: "center", - alignItems: "center", position: "absolute", }, }); diff --git a/src/components/AnimatedCard.tsx b/src/components/AnimatedCard.tsx index 94638c8..85c2c19 100644 --- a/src/components/AnimatedCard.tsx +++ b/src/components/AnimatedCard.tsx @@ -93,10 +93,11 @@ export const AnimatedCard: React.FC = ({ // Selection animation - improved with higher pop-up for better visibility useEffect(() => { + let timerId: ReturnType; if (selected) { // Use a tiny delay to ensure all cards start animating at the same time // This helps when multiple cards are selected simultaneously via auto-selection - setTimeout(() => { + timerId = setTimeout(() => { translateY.value = withTiming(-20 * cardScale, { duration: 120, // Slightly longer duration for smoother animation easing: Easing.out(Easing.cubic), @@ -109,7 +110,7 @@ export const AnimatedCard: React.FC = ({ }, 0); // Use setTimeout with 0ms to synchronize animations } else { // Quick deselection with consistent timing - setTimeout(() => { + timerId = setTimeout(() => { translateY.value = withTiming(0, { duration: 120, easing: Easing.inOut(Easing.cubic), @@ -121,13 +122,14 @@ export const AnimatedCard: React.FC = ({ opacity.value = 1; }, 0); } + return () => clearTimeout(timerId); }, [selected, translateY, scale, opacity, cardScale]); // Play animation - improved for cleaner, more refined appearance with better Bot3 support useEffect(() => { if (isPlayed) { // Delay animations for sequential effect - setTimeout(() => { + const timerId = setTimeout(() => { // Set rotation to 0 for a neat stack with improved easing rotate.value = withTiming("0deg", { duration: 200, // Reduced duration for smoother Bot3 animations @@ -153,6 +155,7 @@ export const AnimatedCard: React.FC = ({ // Always set opacity to 1 immediately to prevent any transparency opacity.value = 1; }, delay); + return () => clearTimeout(timerId); } }, [isPlayed, delay, rotate, opacity, scale, onAnimationComplete]); @@ -320,7 +323,11 @@ export const AnimatedCard: React.FC = ({ if (faceDown) { return ( - + {/* Card back with simplified 3x3 grid pattern */} @@ -419,7 +426,11 @@ export const AnimatedCard: React.FC = ({ if (card.joker) { return ( - + = ({ // Render normal card with enhanced styling return ( - + = ({ const [totalAnimationsNeeded, setTotalAnimationsNeeded] = useState(0); const [animationCompleted, setAnimationCompleted] = useState(false); - // Track changes to lastCompletedTrick - useEffect(() => { - // Removed debug logging - }, [lastCompletedTrick]); + // Handler for individual card animations completing - explicitly typed as a function const handleCardAnimationComplete = (): void => { @@ -85,7 +82,8 @@ const CardPlayArea: React.FC = ({ setTotalAnimationsNeeded(0); setAnimationCompleted(false); } - }, [currentTrick, animationCompleted]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentTrick]); // Add sequence information to cards type CardWithSequence = CardType & { @@ -132,11 +130,13 @@ const CardPlayArea: React.FC = ({ // All animations complete - trigger callback // Add a small delay to ensure all visual animations are complete - setTimeout(() => { + const timerId = setTimeout(() => { if (typeof onAnimationComplete === "function") { onAnimationComplete(); } }, ANIMATION_COMPLETION_DELAY); // Delay to ensure cards are rendered properly + + return () => clearTimeout(timerId); } }, [ completedAnimations, @@ -200,7 +200,11 @@ const CardPlayArea: React.FC = ({ playerSequenceMap[play.playerId] = sequence; play.cards.forEach((card, idx) => { - const cardWithSequence = Object.assign(card, { + const cardCopy = Object.create( + Object.getPrototypeOf(card), + Object.getOwnPropertyDescriptors(card), + ); + const cardWithSequence: CardWithSequence = Object.assign(cardCopy, { playSequence: sequence, cardIndex: idx, // Index within this player's combo }); @@ -248,9 +252,7 @@ const CardPlayArea: React.FC = ({ marginLeft: 15, // Smaller shift for tighter centering }), // Enhanced winner highlighting with card-matching radius - ...(isWinning( - players.find((p) => p.id === PlayerId.Bot2)?.id as PlayerId, - ) && styles.winningCard), + ...(isWinning(PlayerId.Bot2) && styles.winningCard), }} /> ))} @@ -287,10 +289,7 @@ const CardPlayArea: React.FC = ({ marginLeft: 15, // Smaller shift for tighter centering }), // Enhanced winner highlighting with card-matching radius - ...(isWinning( - players.find((p) => p.id === PlayerId.Bot3) - ?.id as PlayerId, - ) && styles.winningCard), + ...(isWinning(PlayerId.Bot3) && styles.winningCard), }} /> ))} @@ -330,10 +329,7 @@ const CardPlayArea: React.FC = ({ marginLeft: 15, // Smaller shift for tighter centering }), // Enhanced winner highlighting with card-matching radius - ...(isWinning( - players.find((p) => p.id === PlayerId.Bot1) - ?.id as PlayerId, - ) && styles.winningCard), + ...(isWinning(PlayerId.Bot1) && styles.winningCard), }} /> ))} @@ -369,9 +365,7 @@ const CardPlayArea: React.FC = ({ marginLeft: 15, // Smaller shift for tighter centering }), // Enhanced winner highlighting with card-matching radius - ...(isWinning( - players.find((p) => p.isHuman)?.id as PlayerId, - ) && styles.winningCard), + ...(isWinning(PlayerId.Human) && styles.winningCard), }} /> ))} diff --git a/src/components/ExpandableTrumpDeclaration.tsx b/src/components/ExpandableTrumpDeclaration.tsx index 4d7924f..6b1afdf 100644 --- a/src/components/ExpandableTrumpDeclaration.tsx +++ b/src/components/ExpandableTrumpDeclaration.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Animated, StyleSheet, @@ -55,6 +55,14 @@ export function ExpandableTrumpDeclaration({ const [isExpanded, setIsExpanded] = useState(false); const [animatedHeight] = useState(new Animated.Value(0)); const [isCollapsing, setIsCollapsing] = useState(false); + const collapseTimerRef = useRef | null>(null); + + // Cleanup collapse timer on unmount + useEffect(() => { + return () => { + if (collapseTimerRef.current) clearTimeout(collapseTimerRef.current); + }; + }, []); // Get all the data we need const dealingProgress = getDealingProgress(gameState); @@ -115,7 +123,7 @@ export function ExpandableTrumpDeclaration({ }).start(() => { onContinue(); // Keep collapsing flag longer to prevent re-expansion - setTimeout(() => { + collapseTimerRef.current = setTimeout(() => { setIsCollapsing(false); }, 500); }); @@ -135,7 +143,7 @@ export function ExpandableTrumpDeclaration({ }).start(() => { onDeclaration(declaration); // Keep collapsing flag longer to prevent re-expansion - setTimeout(() => { + collapseTimerRef.current = setTimeout(() => { setIsCollapsing(false); }, 500); }); diff --git a/src/components/GameStatus.tsx b/src/components/GameStatus.tsx index 497a005..6ac67e4 100644 --- a/src/components/GameStatus.tsx +++ b/src/components/GameStatus.tsx @@ -5,6 +5,7 @@ import { Text, View, TextStyle, + StyleProp, TouchableWithoutFeedback, Modal, TouchableOpacity, @@ -70,43 +71,45 @@ const AnimatedProgressBar: React.FC<{ // Rolling number animation component const RollingNumber: React.FC<{ value: number; - isAttackingTeam: boolean; - style?: TextStyle; -}> = ({ value, isAttackingTeam, style }) => { + style?: StyleProp; +}> = ({ value, style }) => { const [displayValue, setDisplayValue] = useState(value); - const [previousValue, setPreviousValue] = useState(value); + const previousValue = useRef(value); useEffect(() => { - if (!isAttackingTeam || value === previousValue) { - setDisplayValue(value); - return; - } - - // Only animate when attacking team gains points - if (value > previousValue) { - let currentValue = previousValue; - const increment = - value > previousValue + 10 - ? Math.ceil((value - previousValue) / 20) - : 1; - const duration = Math.min(1000, (value - previousValue) * 50); // Max 1 second - const stepTime = duration / (value - previousValue); + // Animate when points increase, otherwise snap + if (value > previousValue.current) { + let currentValue = previousValue.current; + const diff = value - previousValue.current; + const increment = diff > 10 ? Math.ceil(diff / 20) : 1; + const duration = Math.min(1000, diff * 50); // Max 1 second + const steps = Math.ceil(diff / increment); + const stepTime = duration / steps; + + const timerId: { current: ReturnType | null } = { + current: null, + }; const animateValue = () => { if (currentValue < value) { currentValue = Math.min(currentValue + increment, value); setDisplayValue(currentValue); - setTimeout(animateValue, stepTime); + timerId.current = setTimeout(animateValue, stepTime); } }; animateValue(); - } else { - setDisplayValue(value); + previousValue.current = value; + + // Cleanup: cancel pending timeout on unmount or re-run + return () => { + if (timerId.current) clearTimeout(timerId.current); + }; } - setPreviousValue(value); - }, [value, isAttackingTeam, previousValue]); + setDisplayValue(value); + previousValue.current = value; + }, [value]); return {displayValue}/80; }; @@ -128,6 +131,13 @@ const GameStatus: React.FC = ({ const [showNewGameModal, setShowNewGameModal] = useState(false); const tapTimeoutRef = useRef | null>(null); + // Cleanup tap timeout on unmount + useEffect(() => { + return () => { + if (tapTimeoutRef.current) clearTimeout(tapTimeoutRef.current); + }; + }, []); + const handleTrumpTap = () => { const newCount = tapCount + 1; setTapCount(newCount); @@ -152,7 +162,7 @@ const GameStatus: React.FC = ({ const phaseAnimation = useRef(new Animated.Value(0)).current; useEffect(() => { - Animated.sequence([ + const animation = Animated.sequence([ Animated.timing(phaseAnimation, { toValue: 1, duration: 500, @@ -164,7 +174,9 @@ const GameStatus: React.FC = ({ useNativeDriver: true, delay: 1500, }), - ]).start(); + ]); + animation.start(); + return () => animation.stop(); }, [gamePhase, phaseAnimation]); // Shared scale animation for both phase and trump @@ -273,16 +285,11 @@ const GameStatus: React.FC = ({ {tGame("status.points")} = 80 - ? { - ...styles.statValue, - ...styles.pointsValue, - ...styles.winningPoints, - } - : { ...styles.statValue, ...styles.pointsValue } - } + style={[ + styles.statValue, + styles.pointsValue, + team.points >= 80 && styles.winningPoints, + ]} /> )} diff --git a/src/components/HumanHandAnimated.tsx b/src/components/HumanHandAnimated.tsx index 7bfb772..9e5466c 100644 --- a/src/components/HumanHandAnimated.tsx +++ b/src/components/HumanHandAnimated.tsx @@ -95,6 +95,11 @@ const HumanHandAnimated: React.FC = ({ // Determine if player can interact with cards and buttons const canInteract = canPlay && isValidPlay; + const canSelect = + isCurrentPlayer && + ((!showTrickResult && !lastCompletedTrick) || + gamePhase === GamePhase.KittySwap); + // Constants for card layout const cardWidth = 65; const cardOverlap = 40; @@ -137,14 +142,7 @@ const HumanHandAnimated: React.FC = ({ > = ({ gamePhase === GamePhase.Playing && !showTrickResult && !lastCompletedTrick && - !(currentTrick?.winningPlayerId === player.id && currentTrick) && ( + currentTrick?.winningPlayerId !== player.id && ( void; onNewGame?: () => void; // For game over scenarios - fadeAnim?: Animated.Value; - scaleAnim?: Animated.Value; kittyCards?: Card[]; // Kitty cards to display roundResult: RoundResult; // Round result containing message and winning team data humanTeamId?: string; // Team ID of the human player for win/loss detection @@ -272,9 +270,16 @@ const RoundCompleteModal: React.FC = ({ ), ]; - Animated.parallel([...basicAnimations, ...celebrationAnimations]).start(); + const compositeAnimation = Animated.parallel([ + ...basicAnimations, + ...celebrationAnimations, + ]); + compositeAnimation.start(); + return () => compositeAnimation.stop(); } else { - Animated.parallel(basicAnimations).start(); + const compositeAnimation = Animated.parallel(basicAnimations); + compositeAnimation.start(); + return () => compositeAnimation.stop(); } }, [ bounceAnim, @@ -558,7 +563,7 @@ const RoundCompleteModal: React.FC = ({ key={card.id} style={[ styles.kittyCardWrapper, - index === 0 ? { marginLeft: 0 } : {}, + { marginLeft: index === 0 ? 0 : -14 } ]} > = ({ styles.buttonGradient, roundResult.gameOver && styles.gameOverButtonGradient, roundResult.gameOver && - isHumanLoss && - styles.lossButtonGradient, + isHumanLoss && + styles.lossButtonGradient, ]} > = ({ testID, isLLM = false, }) => { - // Track indicator visibility changes - React.useEffect(() => { - // Monitor when thinking indicator visibility changes - }, [visible]); + if (!visible) return null; diff --git a/src/hooks/useAnimations.ts b/src/hooks/useAnimations.ts index d7b917b..ff4f4a2 100644 --- a/src/hooks/useAnimations.ts +++ b/src/hooks/useAnimations.ts @@ -12,7 +12,6 @@ const { width: SCREEN_WIDTH } = Dimensions.get("window"); export function useUIAnimations(showScreen: boolean) { // Initialize with visible values for first render const fadeAnim = useRef(new Animated.Value(showScreen ? 1 : 0)).current; - const scaleAnim = useRef(new Animated.Value(showScreen ? 1 : 0.95)).current; const slideAnim = useRef( new Animated.Value(showScreen ? 0 : SCREEN_WIDTH), ).current; @@ -23,7 +22,6 @@ export function useUIAnimations(showScreen: boolean) { if (!showScreen) { // Reset animations for next show fadeAnim.setValue(0); - scaleAnim.setValue(0.95); slideAnim.setValue(SCREEN_WIDTH); } else { // Game screen animations with a slight delay to ensure component is mounted @@ -34,12 +32,6 @@ export function useUIAnimations(showScreen: boolean) { duration: 800, useNativeDriver: true, }), - Animated.spring(scaleAnim, { - toValue: 1, - friction: 8, - tension: 40, - useNativeDriver: true, - }), Animated.timing(slideAnim, { toValue: 0, duration: 500, @@ -48,11 +40,10 @@ export function useUIAnimations(showScreen: boolean) { ]).start(); }, 100); } - }, [showScreen, fadeAnim, scaleAnim, slideAnim]); + }, [showScreen, fadeAnim, slideAnim]); return { fadeAnim, - scaleAnim, slideAnim, }; } diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts index a8ffe70..157fbe0 100644 --- a/src/hooks/useGameState.ts +++ b/src/hooks/useGameState.ts @@ -27,7 +27,7 @@ import { import { useGameStatePersistence } from "./useGameStatePersistence"; // Interface for trick completion data -interface TrickCompletionData { +export interface TrickCompletionData { winnerId: PlayerId; points: number; completedTrick: Trick; @@ -57,8 +57,9 @@ export function useGameState() { const [showRoundComplete, setShowRoundComplete] = useState(false); const pendingStateRef = useRef(null); - // Ref for trick completion data (used for communication with other hooks) - const trickCompletionDataRef = useRef(null); + // State for trick completion data (triggers reactive updates in controller) + const [trickCompletionData, setTrickCompletionData] = + useState(null); // Ref to store kitty cards for pre-selection const kittyCardsRef = useRef([]); @@ -117,12 +118,12 @@ export function useGameState() { ) { // We have a completed trick - set up trick completion data and clear it const completedTrick = result.gameState.currentTrick; - trickCompletionDataRef.current = { + setTrickCompletionData({ winnerId: completedTrick.winningPlayerId, points: completedTrick.points, completedTrick: completedTrick, timestamp: Date.now(), - }; + }); // Clear the completed trick after a short delay setTimeout(() => { @@ -357,13 +358,10 @@ export function useGameState() { if (result.trickComplete && result.trickWinnerId && result.completedTrick) { // Trick completed - winner and points recorded - // IMPORTANT: Store trick data in ref BEFORE updating state + // IMPORTANT: Store trick data BEFORE updating state // This ensures the trick result handler can access it immediately if (result.completedTrick) { - // Store trick completion data in a ref - // IMPORTANT: A completed trick has leadingCombo (first play) + plays (follow plays) - // For a 4-player game, the plays array should have exactly 3 entries when complete - trickCompletionDataRef.current = { + setTrickCompletionData({ winnerId: result.trickWinnerId, points: result.trickPoints || 0, completedTrick: { @@ -372,10 +370,7 @@ export function useGameState() { plays: [...result.completedTrick.plays], }, timestamp: Date.now(), - }; - - // Completed trick should have plays from all players except the leader - // This ensures the trick structure is correct for display + }); } // Check for end of round (no cards left) BEFORE updating state @@ -492,8 +487,8 @@ export function useGameState() { isProcessingPlay, isInitializing, - // Trick completion data ref (for communication with other hooks) - trickCompletionDataRef, + // Trick completion data (reactive state for controller) + trickCompletionData, // Round result ref (for round complete modal) roundResultRef, diff --git a/src/locales/zh/trumpDeclaration.json b/src/locales/zh/trumpDeclaration.json index 5af83a9..6df5d1f 100644 --- a/src/locales/zh/trumpDeclaration.json +++ b/src/locales/zh/trumpDeclaration.json @@ -12,7 +12,7 @@ }, "messages": { "currentDeclaration": "{{playerName}} 亮主:{{declaration}}", - "trumpDeclarationLabel": "已亮主牌:{{declaration}}({{playerName}})", + "trumpDeclarationLabel": "已亮主牌:{{declaration}},{{playerName}}", "noDeclarations": "无人亮主", "noValidDeclarations": "当前手牌无有效亮主选项", "needMatchingCards": "需要级牌或双王来亮主/反主", diff --git a/src/screens/GameScreenController.tsx b/src/screens/GameScreenController.tsx index db1aa3e..c0a8531 100644 --- a/src/screens/GameScreenController.tsx +++ b/src/screens/GameScreenController.tsx @@ -28,7 +28,7 @@ import GameScreenView from "./GameScreenView"; */ const GameScreenController: React.FC = () => { // Animations - const { fadeAnim, scaleAnim, slideAnim } = useUIAnimations(true); + const { fadeAnim, slideAnim } = useUIAnimations(true); // Get thinking dots for AI thinking animation const { dots: thinkingDots } = useThinkingDots(); @@ -39,7 +39,7 @@ const GameScreenController: React.FC = () => { selectedCards, showRoundComplete, isProcessingPlay, - trickCompletionDataRef, + trickCompletionData, roundResultRef, initGame, @@ -49,7 +49,7 @@ const GameScreenController: React.FC = () => { handleProcessPlay, handleNextRound, startNewGame, - handleTrickResultComplete, // Make sure this is imported + handleTrickResultComplete, setGameState, } = useGameState(); @@ -120,8 +120,6 @@ const GameScreenController: React.FC = () => { useEffect(() => { initGame(); - // Set up the trick result completion callback - // This will be called when it's safe to clear currentTrick from the game state // Set up callback for when trick result display is complete setTrickResultCompleteCallback(() => { handleTrickResultComplete(); @@ -135,64 +133,36 @@ const GameScreenController: React.FC = () => { } }, [gameState?.gamePhase, startDealing, isDealingInProgress]); - // Note: Trump declaration finalization is now handled by the progressive dealing hook - // when the user clicks "Continue" or "Start Playing" in the ExpandableTrumpDeclaration component - - // We've removed the player change detector - keeping it simple - // Find human player index const humanPlayerIndex = gameState?.players.findIndex((p) => p.isHuman) ?? -1; // Use a ref to track the last processed trick completion timestamp const lastProcessedTrickTimestampRef = useRef(0); - // Monitor the trick completion data ref for changes + // Process trick completion reactively when trickCompletionData changes useEffect(() => { - if (!trickCompletionDataRef.current) return; + if (!trickCompletionData) return; // Only process if this is a new trick completion (check timestamp) - if ( - trickCompletionDataRef.current.timestamp > - lastProcessedTrickTimestampRef.current - ) { + if (trickCompletionData.timestamp > lastProcessedTrickTimestampRef.current) { const { winnerId, points, completedTrick, timestamp } = - trickCompletionDataRef.current; - // Detected completed trick + trickCompletionData; // Update the last processed timestamp lastProcessedTrickTimestampRef.current = timestamp; - // FUNDAMENTALLY IMPORTANT: Process completed trick immediately and synchronously - // No delays or state transitions that could cause flickering - if (completedTrick && handleTrickCompletion && setLastCompletedTrick) { - // 1. First save the completed trick at the CardPlayArea level - absolutely critical - // IMPORTANT: In a 4-player game, a completed trick has: - // - One leading play stored in leadingCombo - // - Three follow plays stored in the plays array - // So we expect exactly 3 plays for a 4-player game (not 4, not 5) - // 1. First save the completed trick + // Save the completed trick and show the trick result + if (completedTrick) { setLastCompletedTrick(completedTrick); - - // 2. Now show the trick result handleTrickCompletion(winnerId, points, completedTrick); - - // No extra defensive timers needed anymore - keeping it simple } } - }, [ - gameState?.currentPlayerIndex, - trickCompletionDataRef, - handleTrickCompletion, - setLastCompletedTrick, - gameState?.currentTrick, - handleTrickResultComplete, - ]); + }, [trickCompletionData, handleTrickCompletion, setLastCompletedTrick]); // When card animations in play area are complete - const onAnimationComplete = () => { - // Handle completed card animations + const onAnimationComplete = useCallback(() => { handleTrickAnimationComplete(); - }; + }, [handleTrickAnimationComplete]); return ( { isProcessingPlay={isProcessingPlay} // Animations fadeAnim={fadeAnim} - scaleAnim={scaleAnim} slideAnim={slideAnim} thinkingDots={thinkingDots} // AI config diff --git a/src/screens/GameScreenView.tsx b/src/screens/GameScreenView.tsx index 172fa9b..11604db 100644 --- a/src/screens/GameScreenView.tsx +++ b/src/screens/GameScreenView.tsx @@ -49,7 +49,6 @@ interface GameScreenViewProps { // Animations fadeAnim: Animated.Value; - scaleAnim: Animated.Value; slideAnim: Animated.Value; thinkingDots: { dot1: Animated.Value; @@ -99,7 +98,6 @@ const GameScreenView: React.FC = ({ // Animations fadeAnim, - scaleAnim, slideAnim, thinkingDots, @@ -125,6 +123,18 @@ const GameScreenView: React.FC = ({ }) => { const { t: tCommon } = useCommonTranslation(); + // Team ID for each player - memoized to prevent recreation on every render + // Defined at the top to satisfy Rules of Hooks (runs before early returns) + const getPlayerTeam = React.useCallback( + (playerId: PlayerId) => { + if (!gameState) return undefined; + const player = gameState.players.find((p) => p.id === playerId); + if (!player) return undefined; + return gameState.teams.find((t) => t.id === player.team); + }, + [gameState?.players, gameState?.teams], + ); + // Loading state if (!gameState) { return ( @@ -152,13 +162,6 @@ const GameScreenView: React.FC = ({ const isValidPlay = selectedCards.length > 0 && validatePlay(gameState, selectedCards); - // Team ID for each player - const getPlayerTeam = (playerId: PlayerId) => { - const player = gameState.players.find((p) => p.id === playerId); - if (!player) return undefined; - return gameState.teams.find((t) => t.id === player.team); - }; - const ai1Team = getPlayerTeam(PlayerId.Bot1); const ai2Team = getPlayerTeam(PlayerId.Bot2); const ai3Team = getPlayerTeam(PlayerId.Bot3); @@ -208,8 +211,7 @@ const GameScreenView: React.FC = ({ player={ai2} isDefending={ai2Team.isDefending} isCurrentPlayer={ - gameState.currentPlayerIndex === - gameState.players.findIndex((p) => p.id === PlayerId.Bot2) + gameState.currentPlayerIndex === ai2Index } waitingForAI={ waitingForAI && waitingPlayerId === PlayerId.Bot2 @@ -230,8 +232,7 @@ const GameScreenView: React.FC = ({ player={ai3} isDefending={ai3Team.isDefending} isCurrentPlayer={ - gameState.currentPlayerIndex === - gameState.players.findIndex((p) => p.id === PlayerId.Bot3) + gameState.currentPlayerIndex === ai3Index } waitingForAI={ waitingForAI && waitingPlayerId === PlayerId.Bot3 @@ -252,8 +253,7 @@ const GameScreenView: React.FC = ({ player={ai1} isDefending={ai1Team.isDefending} isCurrentPlayer={ - gameState.currentPlayerIndex === - gameState.players.findIndex((p) => p.id === PlayerId.Bot1) + gameState.currentPlayerIndex === ai1Index } waitingForAI={ waitingForAI && waitingPlayerId === PlayerId.Bot1 @@ -336,8 +336,6 @@ const GameScreenView: React.FC = ({ roundResult={roundResultRef.current} onNextRound={onNextRound} onNewGame={onStartNewGame} - fadeAnim={fadeAnim} - scaleAnim={scaleAnim} kittyCards={sortCards(gameState.kittyCards, gameState.trumpInfo)} humanTeamId={humanPlayer.team} /> From 35bbc8fad6c6cce560a35dc7561ce68d23b6eddc Mon Sep 17 00:00:00 2001 From: ejfn <148174+ejfn@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:56:46 +0930 Subject: [PATCH 2/3] Fix last kitty card clipped by modal overflow hidden --- src/components/RoundCompleteModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/RoundCompleteModal.tsx b/src/components/RoundCompleteModal.tsx index 9c9e1ad..0b9e02d 100644 --- a/src/components/RoundCompleteModal.tsx +++ b/src/components/RoundCompleteModal.tsx @@ -563,7 +563,8 @@ const RoundCompleteModal: React.FC = ({ key={card.id} style={[ styles.kittyCardWrapper, - { marginLeft: index === 0 ? 0 : -14 } + { marginLeft: index === 0 ? 0 : -14 }, + index === kittyCards.length - 1 && { width: 72 }, ]} > Date: Thu, 4 Jun 2026 07:58:31 +0930 Subject: [PATCH 3/3] Fix lint errors (prettier formatting + exhaustive-deps warning) --- src/components/CardPlayArea.tsx | 4 +--- src/components/RoundCompleteModal.tsx | 10 +++++----- src/components/ThinkingIndicator.tsx | 2 -- src/screens/GameScreenController.tsx | 4 +++- src/screens/GameScreenView.tsx | 14 ++++---------- 5 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/components/CardPlayArea.tsx b/src/components/CardPlayArea.tsx index 87edcce..c743a80 100644 --- a/src/components/CardPlayArea.tsx +++ b/src/components/CardPlayArea.tsx @@ -30,8 +30,6 @@ const CardPlayArea: React.FC = ({ const [totalAnimationsNeeded, setTotalAnimationsNeeded] = useState(0); const [animationCompleted, setAnimationCompleted] = useState(false); - - // Handler for individual card animations completing - explicitly typed as a function const handleCardAnimationComplete = (): void => { // Increment the animation counter @@ -82,7 +80,7 @@ const CardPlayArea: React.FC = ({ setTotalAnimationsNeeded(0); setAnimationCompleted(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTrick]); // Add sequence information to cards diff --git a/src/components/RoundCompleteModal.tsx b/src/components/RoundCompleteModal.tsx index 0b9e02d..b21f290 100644 --- a/src/components/RoundCompleteModal.tsx +++ b/src/components/RoundCompleteModal.tsx @@ -76,9 +76,9 @@ function generateModalMessage( roundResult.finalPoints === 0 ? tModals("roundResult.heldToPoints", { points: 0, otherTeamName }) : tModals("roundResult.defendedWithPoints", { - points: roundResult.finalPoints, - otherTeamName, - }); + points: roundResult.finalPoints, + otherTeamName, + }); if (roundResult.gameOver) { return ( @@ -594,8 +594,8 @@ const RoundCompleteModal: React.FC = ({ styles.buttonGradient, roundResult.gameOver && styles.gameOverButtonGradient, roundResult.gameOver && - isHumanLoss && - styles.lossButtonGradient, + isHumanLoss && + styles.lossButtonGradient, ]} > = ({ testID, isLLM = false, }) => { - - if (!visible) return null; const containerStyle = isLLM diff --git a/src/screens/GameScreenController.tsx b/src/screens/GameScreenController.tsx index c0a8531..6980121 100644 --- a/src/screens/GameScreenController.tsx +++ b/src/screens/GameScreenController.tsx @@ -144,7 +144,9 @@ const GameScreenController: React.FC = () => { if (!trickCompletionData) return; // Only process if this is a new trick completion (check timestamp) - if (trickCompletionData.timestamp > lastProcessedTrickTimestampRef.current) { + if ( + trickCompletionData.timestamp > lastProcessedTrickTimestampRef.current + ) { const { winnerId, points, completedTrick, timestamp } = trickCompletionData; diff --git a/src/screens/GameScreenView.tsx b/src/screens/GameScreenView.tsx index 11604db..fabb122 100644 --- a/src/screens/GameScreenView.tsx +++ b/src/screens/GameScreenView.tsx @@ -132,7 +132,7 @@ const GameScreenView: React.FC = ({ if (!player) return undefined; return gameState.teams.find((t) => t.id === player.team); }, - [gameState?.players, gameState?.teams], + [gameState], ); // Loading state @@ -210,9 +210,7 @@ const GameScreenView: React.FC = ({ position="top" player={ai2} isDefending={ai2Team.isDefending} - isCurrentPlayer={ - gameState.currentPlayerIndex === ai2Index - } + isCurrentPlayer={gameState.currentPlayerIndex === ai2Index} waitingForAI={ waitingForAI && waitingPlayerId === PlayerId.Bot2 } @@ -231,9 +229,7 @@ const GameScreenView: React.FC = ({ position="left" player={ai3} isDefending={ai3Team.isDefending} - isCurrentPlayer={ - gameState.currentPlayerIndex === ai3Index - } + isCurrentPlayer={gameState.currentPlayerIndex === ai3Index} waitingForAI={ waitingForAI && waitingPlayerId === PlayerId.Bot3 } @@ -252,9 +248,7 @@ const GameScreenView: React.FC = ({ position="right" player={ai1} isDefending={ai1Team.isDefending} - isCurrentPlayer={ - gameState.currentPlayerIndex === ai1Index - } + isCurrentPlayer={gameState.currentPlayerIndex === ai1Index} waitingForAI={ waitingForAI && waitingPlayerId === PlayerId.Bot1 }