From 10633bf5ee66c8f7cf7847fdb16556bb365900ff Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 21 Mar 2026 13:32:44 +0100 Subject: [PATCH 01/55] refactor: wrappers to oop entities --- src/client/components/board/Board.tsx | 18 +-- .../board/card-animation/CardAnimation.tsx | 6 +- .../board/overlay-manager/OverlayManager.tsx | 2 +- .../SeeTheFutureOverlay.tsx | 4 +- .../board/table/PendingPlayStack.tsx | 4 +- src/client/components/board/table/Table.tsx | 4 +- .../components/lobby/CreateMatchModal.tsx | 2 +- src/client/components/rulebook/Rulebook.tsx | 18 +-- src/client/context/MatchDetailsContext.tsx | 4 +- src/client/hooks/useCardAnimations.tsx | 14 +-- src/client/hooks/useExplosionEvents.tsx | 104 ----------------- src/client/hooks/useGameLogic.tsx | 105 ++++++++++++++++++ src/client/hooks/useGameState.tsx | 6 +- src/client/model/PlayerState.ts | 8 +- src/client/models/client.model.ts | 10 +- src/client/types/client-context.ts | 9 ++ src/client/types/component-props.ts | 8 +- src/common/constants/card-types.ts | 18 +-- src/common/constants/deck-types.ts | 4 + src/common/constants/decks.ts | 4 - src/common/entities/card-type.ts | 20 ++-- .../{cards => card-types}/attack-card.ts | 4 +- .../{cards => card-types}/cat-card.ts | 22 ++-- .../{cards => card-types}/defuse-card.ts | 4 +- .../exploding-kitten-card.ts | 0 .../{cards => card-types}/favor-card.ts | 8 +- .../{cards => card-types}/nope-card.ts | 12 +- .../see-the-future-card.ts | 4 +- .../{cards => card-types}/shuffle-card.ts | 4 +- .../{cards => card-types}/skip-card.ts | 4 +- src/common/entities/deck-type.ts | 25 +++++ .../{decks => deck-types}/original-deck.ts | 14 +-- src/common/entities/deck.ts | 25 ----- src/common/entities/game-state.ts | 26 +++++ .../game-logic.ts => entities/game.ts} | 62 ++++++----- src/common/entities/piles.ts | 45 ++++++++ .../player-wrapper.ts => entities/player.ts} | 45 ++++---- .../player-logic.ts => entities/players.ts} | 32 +++--- src/common/game.ts | 27 +++-- src/common/index.ts | 16 +-- src/common/models/card.model.ts | 2 +- src/common/models/context.model.ts | 12 +- src/common/models/game-state.model.ts | 22 ++-- src/common/models/player-api.model.ts | 12 +- src/common/models/player.model.ts | 10 +- src/common/models/players.model.ts | 6 +- src/common/models/plugin-apis.model.ts | 6 +- src/common/moves/defuse-exploding-kitten.ts | 32 ++++++ src/common/moves/draw-move.ts | 38 ++----- src/common/moves/favor-card-move.ts | 12 +- src/common/moves/play-card-move.ts | 24 ++-- src/common/moves/see-future-move.ts | 4 +- src/common/moves/steal-card-move.ts | 8 +- src/common/moves/system-moves.ts | 8 +- src/common/plugins/player-plugin.ts | 4 +- src/common/setup/game-setup.ts | 6 +- src/common/setup/player-setup.ts | 30 ++--- src/common/utils/action-validation.ts | 10 +- src/common/utils/card-sorting.ts | 10 +- src/common/utils/turn-order.ts | 10 +- src/common/wrappers/deck-logic.ts | 45 -------- src/common/wrappers/game-state-logic.ts | 26 ----- 62 files changed, 563 insertions(+), 525 deletions(-) delete mode 100644 src/client/hooks/useExplosionEvents.tsx create mode 100644 src/client/hooks/useGameLogic.tsx create mode 100644 src/client/types/client-context.ts create mode 100644 src/common/constants/deck-types.ts delete mode 100644 src/common/constants/decks.ts rename src/common/entities/{cards => card-types}/attack-card.ts (89%) rename src/common/entities/{cards => card-types}/cat-card.ts (73%) rename src/common/entities/{cards => card-types}/defuse-card.ts (66%) rename src/common/entities/{cards => card-types}/exploding-kitten-card.ts (100%) rename src/common/entities/{cards => card-types}/favor-card.ts (84%) rename src/common/entities/{cards => card-types}/nope-card.ts (75%) rename src/common/entities/{cards => card-types}/see-the-future-card.ts (78%) rename src/common/entities/{cards => card-types}/shuffle-card.ts (74%) rename src/common/entities/{cards => card-types}/skip-card.ts (72%) create mode 100644 src/common/entities/deck-type.ts rename src/common/entities/{decks => deck-types}/original-deck.ts (79%) delete mode 100644 src/common/entities/deck.ts create mode 100644 src/common/entities/game-state.ts rename src/common/{wrappers/game-logic.ts => entities/game.ts} (54%) create mode 100644 src/common/entities/piles.ts rename src/common/{wrappers/player-wrapper.ts => entities/player.ts} (70%) rename src/common/{wrappers/player-logic.ts => entities/players.ts} (59%) create mode 100644 src/common/moves/defuse-exploding-kitten.ts delete mode 100644 src/common/wrappers/deck-logic.ts delete mode 100644 src/common/wrappers/game-state-logic.ts diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index 2ad3740..3edb68e 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -1,9 +1,6 @@ import './Board.css'; -import {BoardProps} from 'boardgame.io/react'; -import {GameState} from '../../../common'; import {useCardAnimations} from '../../hooks/useCardAnimations'; import {useGameState} from '../../hooks/useGameState'; -import {BoardPlugins} from '../../models/client.model'; import {GameContext, PlayerStateBundle, OverlayStateBundle} from '../../types/component-props'; import Table from './table/Table'; import PlayerList from './player-list/PlayerList'; @@ -13,10 +10,7 @@ import GameStatusList from './game-status/GameStatusList'; import {useEffect} from 'react'; import {Chat} from '../chat/Chat'; import {useMatchDetails} from "../../context/MatchDetailsContext.tsx"; - -type BoardPropsWithPlugins = Omit, 'plugins'> & { - plugins: BoardPlugins; -} +import {IClientContext} from "../../types/client-context.ts"; /** * Main game board component @@ -29,7 +23,7 @@ export default function ExplodingKittensBoard({ playerID, chatMessages, sendChatMessage -}: BoardPropsWithPlugins) { +}: IClientContext) { const { matchDetails, setPollingInterval } = useMatchDetails(); const isInLobby = ctx.phase === 'lobby'; @@ -45,12 +39,12 @@ export default function ExplodingKittensBoard({ ctx, G, moves, - playerID, + playerID: playerID ?? null, matchData: matchDetails?.players }; // Derive game state properties - const gameState = useGameState(ctx, G, allPlayers, playerID); + const gameState = useGameState(ctx, G, allPlayers, playerID ?? null); const selfPlayer = gameState.selfPlayerId !== null && allPlayers[gameState.selfPlayerId] ? allPlayers[gameState.selfPlayerId] : null; const selfHand = selfPlayer ? selfPlayer.hand : []; @@ -103,7 +97,7 @@ export default function ExplodingKittensBoard({ }; // Handle card animations - const {AnimationLayer, triggerCardMovement} = useCardAnimations(G, allPlayers, playerID); + const {AnimationLayer, triggerCardMovement} = useCardAnimations(G, allPlayers, playerID ?? null); /** * Handle player selection for stealing/requesting a card @@ -191,7 +185,7 @@ export default function ExplodingKittensBoard({ /> diff --git a/src/client/components/board/card-animation/CardAnimation.tsx b/src/client/components/board/card-animation/CardAnimation.tsx index 326aed4..a0d3ce3 100644 --- a/src/client/components/board/card-animation/CardAnimation.tsx +++ b/src/client/components/board/card-animation/CardAnimation.tsx @@ -1,10 +1,10 @@ import './CardAnimation.css'; import React, {useEffect, useState} from 'react'; -import {Card} from '../../../../common'; +import {ICard} from '../../../../common'; export interface CardAnimationData { id: string; - card: Card | null; // null means face-down + card: ICard | null; // null means face-down from: { x: number; y: number }; to: { x: number; y: number }; duration: number; @@ -20,7 +20,7 @@ export default function CardAnimation({animation, onComplete}: CardAnimationProp const [isVisible, setIsVisible] = useState(false); const cardImage = animation.card ? `/assets/cards/${animation.card.name}/${animation.card.index}.png` - : '/assets/cards/back/0.jpg'; + : '/assets/card-types/back/0.jpg'; useEffect(() => { // Start animation immediately diff --git a/src/client/components/board/overlay-manager/OverlayManager.tsx b/src/client/components/board/overlay-manager/OverlayManager.tsx index 935a86b..212171d 100644 --- a/src/client/components/board/overlay-manager/OverlayManager.tsx +++ b/src/client/components/board/overlay-manager/OverlayManager.tsx @@ -38,7 +38,7 @@ export default function OverlayManager({ } } - // Get the top 3 cards from the draw pile for the see the future overlay + // Get the top 3 card-types from the draw pile for the see the future overlay const futureCards = isViewingFuture ? G.drawPile.slice(0, 3) : []; return ( diff --git a/src/client/components/board/see-future-overlay/SeeTheFutureOverlay.tsx b/src/client/components/board/see-future-overlay/SeeTheFutureOverlay.tsx index 1bdda1f..08655bc 100644 --- a/src/client/components/board/see-future-overlay/SeeTheFutureOverlay.tsx +++ b/src/client/components/board/see-future-overlay/SeeTheFutureOverlay.tsx @@ -1,8 +1,8 @@ import './SeeTheFutureOverlay.css'; -import {Card} from '../../../../common'; +import {ICard} from '../../../../common'; interface SeeTheFutureOverlayProps { - cards: Card[]; + cards: ICard[]; onClose: () => void; } diff --git a/src/client/components/board/table/PendingPlayStack.tsx b/src/client/components/board/table/PendingPlayStack.tsx index d75c086..e9f9fb7 100644 --- a/src/client/components/board/table/PendingPlayStack.tsx +++ b/src/client/components/board/table/PendingPlayStack.tsx @@ -1,11 +1,11 @@ -import {PendingCardPlay} from '../../../../common'; +import {IPendingCardPlay} from '../../../../common'; import '../card/Card.css'; // Import the shared card styles import './PendingPlayStack.css'; import {useRef, useState} from 'react'; import HoverCardPreview from '../card/HoverCardPreview'; interface PendingPlayStackProps { - pendingPlay: PendingCardPlay; + pendingPlay: IPendingCardPlay; canNope: boolean; onNope: () => void; } diff --git a/src/client/components/board/table/Table.tsx b/src/client/components/board/table/Table.tsx index 0f97097..334538f 100644 --- a/src/client/components/board/table/Table.tsx +++ b/src/client/components/board/table/Table.tsx @@ -2,7 +2,7 @@ import back from '/assets/cards/back/0.jpg'; import './Table.css'; import {useEffect, useState, useRef} from "react"; import {GameContext} from "../../../types/component-props"; -import {Card, canPlayerNope} from '../../../../common'; +import {ICard, canPlayerNope} from '../../../../common'; import PendingPlayStack from './PendingPlayStack'; import TurnBadge from '../turn-badge/TurnBadge'; @@ -13,7 +13,7 @@ import {useResponsive} from "../../../context/ResponsiveContext.tsx"; interface TableProps { gameContext: GameContext; - playerHand?: Card[]; + playerHand?: ICard[]; } export default function Table({gameContext, playerHand = []}: TableProps) { diff --git a/src/client/components/lobby/CreateMatchModal.tsx b/src/client/components/lobby/CreateMatchModal.tsx index 1461bea..cf751b9 100644 --- a/src/client/components/lobby/CreateMatchModal.tsx +++ b/src/client/components/lobby/CreateMatchModal.tsx @@ -1,4 +1,4 @@ -import { Modal } from '../common/Modal'; +import { Modal } from '../common'; import '../common/Button.css'; import '../common/Form.css'; import {useResponsive} from "../../context/ResponsiveContext.tsx"; diff --git a/src/client/components/rulebook/Rulebook.tsx b/src/client/components/rulebook/Rulebook.tsx index 653dc9d..c4b419e 100644 --- a/src/client/components/rulebook/Rulebook.tsx +++ b/src/client/components/rulebook/Rulebook.tsx @@ -4,15 +4,15 @@ import './Rulebook.css'; // Card Images const CARD_IMAGES = { - attack: '/assets/cards/attack/0.png', - skip: '/assets/cards/skip/0.png', - favor: '/assets/cards/favor/0.png', - shuffle: '/assets/cards/shuffle/0.png', - seeFuture: '/assets/cards/see_the_future/0.png', - nope: '/assets/cards/nope/0.png', - defuse: '/assets/cards/defuse/0.png', - exploding: '/assets/cards/exploding_kitten/0.png', - catCard: '/assets/cards/cat_card/0.png', + attack: '/assets/card-types/attack/0.png', + skip: '/assets/card-types/skip/0.png', + favor: '/assets/card-types/favor/0.png', + shuffle: '/assets/card-types/shuffle/0.png', + seeFuture: '/assets/card-types/see_the_future/0.png', + nope: '/assets/card-types/nope/0.png', + defuse: '/assets/card-types/defuse/0.png', + exploding: '/assets/card-types/exploding_kitten/0.png', + catCard: '/assets/card-types/cat_card/0.png', }; export function RulebookModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { diff --git a/src/client/context/MatchDetailsContext.tsx b/src/client/context/MatchDetailsContext.tsx index 14914e6..aebae1a 100644 --- a/src/client/context/MatchDetailsContext.tsx +++ b/src/client/context/MatchDetailsContext.tsx @@ -7,7 +7,7 @@ export interface MatchDetails { players: MatchPlayer[]; numPlayers: number; matchName: string; - gameover?: any; + gameOver?: any; } interface MatchDetailsContextType { @@ -46,7 +46,7 @@ export function MatchDetailsProvider({ matchID, children }: MatchDetailsProvider matchName: match.setupData?.matchName || 'Match', numPlayers: match.setupData?.maxPlayers || match.players.length, players: match.players, - gameover: match.gameover, + gameOver: match.gameover, }); setError(null); } catch (err: any) { diff --git a/src/client/hooks/useCardAnimations.tsx b/src/client/hooks/useCardAnimations.tsx index 3c3811d..d7e76cd 100644 --- a/src/client/hooks/useCardAnimations.tsx +++ b/src/client/hooks/useCardAnimations.tsx @@ -1,11 +1,11 @@ import React, {useState, useCallback, useRef, useEffect} from 'react'; import CardAnimation, {CardAnimationData} from '../components/board/card-animation/CardAnimation'; -import {Card, GameState, Players} from '../../common'; +import {ICard, IGameState, IPlayers} from '../../common'; interface UseCardAnimationsReturn { animations: CardAnimationData[]; AnimationLayer: () => React.JSX.Element; - triggerCardMovement: (card: Card | null, fromId: string, toId: string) => void; + triggerCardMovement: (card: ICard | null, fromId: string, toId: string) => void; } type PlayerHandCounts = Record; @@ -15,13 +15,13 @@ interface HandChange { delta: number; } -export const useCardAnimations = (G: GameState, players: Players, selfPlayerId: string | null): UseCardAnimationsReturn => { +export const useCardAnimations = (G: IGameState, players: IPlayers, selfPlayerId: string | null): UseCardAnimationsReturn => { const [animations, setAnimations] = useState([]); const animationIdCounter = useRef(0); const previousDrawPileLength = useRef(G.client.drawPileLength); const previousDiscardPileLength = useRef(G.discardPile.length); const previousPlayerHands = useRef({}); - const previousLocalHand = useRef([]); + const previousLocalHand = useRef([]); const getElementCenter = useCallback((id: string): { x: number; y: number } | null => { const element = document.querySelector(`[data-animation-id="${id}"]`) as HTMLElement; @@ -38,7 +38,7 @@ export const useCardAnimations = (G: GameState, players: Players, selfPlayerId: }, []); const triggerCardMovement = useCallback(( - card: Card | null, + card: ICard | null, fromId: string, toId: string, delay = 0 @@ -98,7 +98,7 @@ export const useCardAnimations = (G: GameState, players: Players, selfPlayerId: handChanges .filter(change => change.delta > 0) .forEach(change => { - let card: Card | null = null; + let card: ICard | null = null; // If local player gained a card, check their hand for the new card if (change.playerId === selfPlayerId && players[selfPlayerId]) { const hand = players[selfPlayerId].hand; @@ -122,7 +122,7 @@ export const useCardAnimations = (G: GameState, players: Players, selfPlayerId: const playerLost = handChanges.find(change => change.delta < 0); if (playerGained && playerLost) { - let card: Card | null = null; + let card: ICard | null = null; // If local player gained the transferred card, look it up if (playerGained.playerId === selfPlayerId && players[selfPlayerId]) { const hand = players[selfPlayerId].hand; diff --git a/src/client/hooks/useExplosionEvents.tsx b/src/client/hooks/useExplosionEvents.tsx deleted file mode 100644 index a57123b..0000000 --- a/src/client/hooks/useExplosionEvents.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import {useState, useEffect, useRef} from 'react'; -import {GameState, Players} from '../../common'; -import {ExplosionEvent} from '../components/board/explosion-overlay/ExplosionOverlay'; -import {MatchPlayer, getPlayerName} from '../utils/matchData'; - -interface ExplosionEventData { - event: ExplosionEvent; - playerName: string; - isSelf: boolean; -} - -/** - * Hook to detect and manage explosion/defuse events in the game - */ -export const useExplosionEvents = ( - G: GameState, - allPlayers: Players, - playerID: string | null, - matchData?: MatchPlayer[] -): ExplosionEventData & { clearEvent: () => void } => { - const [explosionEvent, setExplosionEvent] = useState(null); - const [explosionPlayerName, setExplosionPlayerName] = useState(''); - const [explosionIsSelf, setExplosionIsSelf] = useState(false); - - const previousDiscardPileLength = useRef(0); - const previousPlayerAliveStatus = useRef<{[key: string]: boolean}>({}); - const previousPlayerHandCounts = useRef<{[key: string]: number}>({}); - const lastDefuseEventId = useRef(''); - const lastExplosionEventId = useRef(''); - - useEffect(() => { - // Detect defuse card being played - if (G.discardPile.length > previousDiscardPileLength.current) { - const newCards = G.discardPile.slice(previousDiscardPileLength.current); - const defuseCard = newCards.find(card => card.name === 'defuse'); - - if (defuseCard) { - // Find who played the defuse card by checking whose hand decreased - let defusePlayerID: string | null = null; - - for (const pid of Object.keys(allPlayers)) { - const currentHandCount = allPlayers[pid].client.handCount; - const previousHandCount = previousPlayerHandCounts.current[pid] ?? currentHandCount; - - // The player who defused will have lost a card from their hand - if (currentHandCount < previousHandCount) { - defusePlayerID = pid; - break; - } - } - - const eventId = `defuse-${G.discardPile.length}`; - - if (defusePlayerID && eventId !== lastDefuseEventId.current && !explosionEvent) { - const isSelf = defusePlayerID === playerID; - const playerName = isSelf ? 'You' : getPlayerName(defusePlayerID, matchData); - - lastDefuseEventId.current = eventId; - setExplosionEvent('defused'); - setExplosionPlayerName(playerName); - setExplosionIsSelf(isSelf); - } - } - } - - // Detect player death (explosion) - for (const pid of Object.keys(allPlayers)) { - const player = allPlayers[pid]; - const wasAlive = previousPlayerAliveStatus.current[pid] ?? true; - const isNowDead = !player.isAlive; - - if (wasAlive && isNowDead) { - const eventId = `explode-${pid}-${G.discardPile.length}`; - - if (eventId !== lastExplosionEventId.current && !explosionEvent) { - const isSelf = pid === playerID; - const playerName = isSelf ? 'You' : getPlayerName(pid, matchData); - - lastExplosionEventId.current = eventId; - setExplosionEvent('exploding'); - setExplosionPlayerName(playerName); - setExplosionIsSelf(isSelf); - } - } - - previousPlayerAliveStatus.current[pid] = player.isAlive; - } - - // Update previous hand counts for all players - for (const pid of Object.keys(allPlayers)) { - previousPlayerHandCounts.current[pid] = allPlayers[pid].client.handCount; - } - - previousDiscardPileLength.current = G.discardPile.length; - }, [G.discardPile, G.discardPile.length, allPlayers, playerID, explosionEvent]); - - return { - event: explosionEvent, - playerName: explosionPlayerName, - isSelf: explosionIsSelf, - clearEvent: () => setExplosionEvent(null), - }; -}; - diff --git a/src/client/hooks/useGameLogic.tsx b/src/client/hooks/useGameLogic.tsx new file mode 100644 index 0000000..b01d653 --- /dev/null +++ b/src/client/hooks/useGameLogic.tsx @@ -0,0 +1,105 @@ +import {useMemo} from 'react'; +import {Ctx} from 'boardgame.io'; +import {IGameState, IPlayers} from '../../common'; + +interface GameStateData { + isSpectator: boolean; + selfPlayerId: number | null; + isSelfDead: boolean; + isSelfSpectator: boolean; + isGameOver: boolean; + currentPlayer: number; + isSelectingPlayer: boolean; + isChoosingCardToGive: boolean; + isViewingFuture: boolean; + isInNowCardStage: boolean; + isAwaitingNowCardResolution: boolean; + alivePlayers: string[]; + alivePlayersSorted: string[]; +} + +/** + * Hook to derive and memoize game state properties + */ +export const useGameState = ( + ctx: Ctx, + G: IGameState, + allPlayers: IPlayers, + playerID: string | null +): GameStateData => { + const isSpectator = playerID == null; + const selfPlayerId = isSpectator ? null : parseInt(playerID || '0'); + + const isSelfDead = useMemo(() => { + return !isSpectator && + selfPlayerId !== null && + !allPlayers[selfPlayerId.toString()]?.isAlive; + }, [isSpectator, selfPlayerId, allPlayers]); + + const isSelfSpectator = useMemo(() => { + return isSpectator || + (isSelfDead && G.gameRules.spectatorsSeeCards) || + G.gameRules.openCards; + }, [isSpectator, isSelfDead, G.gameRules]); + + const isGameOver = ctx.phase === 'gameover'; + const currentPlayer = parseInt(ctx.currentPlayer); + + const isSelectingPlayer = useMemo(() => { + const stage = ctx.activePlayers?.[playerID || '']; + return !isSpectator && + selfPlayerId !== null && + selfPlayerId === currentPlayer && + (stage === 'choosePlayerToStealFrom' || stage === 'choosePlayerToRequestFrom'); + }, [isSpectator, selfPlayerId, currentPlayer, ctx.activePlayers, playerID]); + + const isChoosingCardToGive = useMemo(() => { + return !isSpectator && + selfPlayerId !== null && + ctx.activePlayers?.[playerID || ''] === 'chooseCardToGive'; + }, [isSpectator, selfPlayerId, ctx.activePlayers, playerID]); + + const isViewingFuture = useMemo(() => { + return !isSpectator && + selfPlayerId !== null && + ctx.activePlayers?.[playerID || ''] === 'viewingFuture'; + }, [isSpectator, selfPlayerId, ctx.activePlayers, playerID]); + + const selfStage = ctx.activePlayers?.[playerID || '']; + + const isInNowCardStage = useMemo(() => { + return !isSpectator && + selfPlayerId !== null && + (selfStage === 'respondWithNowCard' || selfStage === 'awaitingNowCards'); + }, [isSpectator, selfPlayerId, selfStage]); + + const isAwaitingNowCardResolution = useMemo(() => { + return !isSpectator && + selfPlayerId !== null && + selfStage === 'awaitingNowCards'; + }, [isSpectator, selfPlayerId, selfStage]); + + const alivePlayers = useMemo(() => { + return Object.keys(ctx.playOrder).filter(player => allPlayers[player]?.isAlive); + }, [ctx.playOrder, allPlayers]); + + const alivePlayersSorted = useMemo(() => { + return [...alivePlayers].sort((a, b) => parseInt(a) - parseInt(b)); + }, [alivePlayers]); + + return { + isSpectator, + selfPlayerId, + isSelfDead, + isSelfSpectator, + isGameOver, + currentPlayer, + isSelectingPlayer, + isChoosingCardToGive, + isViewingFuture, + isInNowCardStage, + isAwaitingNowCardResolution, + alivePlayers, + alivePlayersSorted, + }; +}; diff --git a/src/client/hooks/useGameState.tsx b/src/client/hooks/useGameState.tsx index aa6697c..b01d653 100644 --- a/src/client/hooks/useGameState.tsx +++ b/src/client/hooks/useGameState.tsx @@ -1,6 +1,6 @@ import {useMemo} from 'react'; import {Ctx} from 'boardgame.io'; -import {GameState, Players} from '../../common'; +import {IGameState, IPlayers} from '../../common'; interface GameStateData { isSpectator: boolean; @@ -23,8 +23,8 @@ interface GameStateData { */ export const useGameState = ( ctx: Ctx, - G: GameState, - allPlayers: Players, + G: IGameState, + allPlayers: IPlayers, playerID: string | null ): GameStateData => { const isSpectator = playerID == null; diff --git a/src/client/model/PlayerState.ts b/src/client/model/PlayerState.ts index 3f7d601..a553ea4 100644 --- a/src/client/model/PlayerState.ts +++ b/src/client/model/PlayerState.ts @@ -1,6 +1,6 @@ -import {Card, sortCards} from "../../common"; +import {ICard, sortCards} from "../../common"; -export interface CardWithServerIndex extends Card { +export interface CardWithServerIndex extends ICard { serverIndex: number; } @@ -12,14 +12,14 @@ export default class PlayerState { handCount: number; hand: CardWithServerIndex[]; - constructor(isSelfSpectator: boolean, isSelf: boolean, isAlive: boolean, isTurn: boolean, handCount: number, hand: Card[]) { + constructor(isSelfSpectator: boolean, isSelf: boolean, isAlive: boolean, isTurn: boolean, handCount: number, hand: ICard[]) { this.isSelfSpectator = isSelfSpectator; this.isSelf = isSelf; this.isAlive = isAlive; this.isTurn = isTurn; this.handCount = handCount; - // Create cards with server indices before sorting + // Create card-types with server indices before sorting const cardsWithIndices: CardWithServerIndex[] = hand.map((card, index) => ({ ...card, serverIndex: index diff --git a/src/client/models/client.model.ts b/src/client/models/client.model.ts index 22f17c0..48674fc 100644 --- a/src/client/models/client.model.ts +++ b/src/client/models/client.model.ts @@ -1,19 +1,19 @@ -import {Player} from '../../common'; +import {IPlayer} from '../../common'; /** * Type definitions for the client */ -export interface PlayerPlugin { +export interface IPlayerPlugin { data: { players: { - [key: string]: Player + [key: string]: IPlayer }; }; } -export interface BoardPlugins { +export interface IBoardPlugins { [pluginName: string]: any; - player: PlayerPlugin; + player: IPlayerPlugin; } diff --git a/src/client/types/client-context.ts b/src/client/types/client-context.ts new file mode 100644 index 0000000..56af80d --- /dev/null +++ b/src/client/types/client-context.ts @@ -0,0 +1,9 @@ +import type { BoardProps } from 'boardgame.io/react'; +import type { IBoardPlugins } from '../models/client.model'; +import {IContext, IGameState} from "../../common"; + +export type IClientContext = + Pick & + Pick, 'moves' | 'chatMessages' | 'sendChatMessage'> & { + plugins: IBoardPlugins; +}; diff --git a/src/client/types/component-props.ts b/src/client/types/component-props.ts index 7667364..4d4339e 100644 --- a/src/client/types/component-props.ts +++ b/src/client/types/component-props.ts @@ -4,7 +4,7 @@ */ import {Ctx} from 'boardgame.io'; -import {GameState, Players, Card} from '../../common'; +import {IGameState, IPlayers, ICard} from '../../common'; import {MatchPlayer} from '../utils/matchData'; /** @@ -12,7 +12,7 @@ import {MatchPlayer} from '../utils/matchData'; */ export interface GameContext { ctx: Ctx; - G: GameState; + G: IGameState; moves: any; playerID: string | null; matchData?: MatchPlayer[]; @@ -22,7 +22,7 @@ export interface GameContext { * Player state bundle - contains player-specific state */ export interface PlayerStateBundle { - allPlayers: Players; + allPlayers: IPlayers; selfPlayerId: number | null; currentPlayer: number; isSelfDead: boolean; @@ -54,7 +54,7 @@ export interface ExplosionEventBundle { * Animation callbacks bundle - contains animation-related functions */ export interface AnimationCallbacks { - triggerCardMovement: (card: Card | null, fromId: string, toId: string) => void; + triggerCardMovement: (card: ICard | null, fromId: string, toId: string) => void; } /** diff --git a/src/common/constants/card-types.ts b/src/common/constants/card-types.ts index 259a09d..2bb5076 100644 --- a/src/common/constants/card-types.ts +++ b/src/common/constants/card-types.ts @@ -1,14 +1,14 @@ import {CardType} from '../entities/card-type'; -import {DefuseCard} from '../entities/cards/defuse-card'; -import {CatCard} from '../entities/cards/cat-card'; -import {ExplodingKittenCard} from '../entities/cards/exploding-kitten-card'; -import {SkipCard} from "../entities/cards/skip-card"; -import {ShuffleCard} from "../entities/cards/shuffle-card"; +import {DefuseCard} from '../entities/card-types/defuse-card'; +import {CatCard} from '../entities/card-types/cat-card'; +import {ExplodingKittenCard} from '../entities/card-types/exploding-kitten-card'; +import {SkipCard} from "../entities/card-types/skip-card"; +import {ShuffleCard} from "../entities/card-types/shuffle-card"; import {Registry} from "../registry/registry"; -import {AttackCard} from "../entities/cards/attack-card"; -import {NopeCard} from "../entities/cards/nope-card"; -import {SeeTheFutureCard} from "../entities/cards/see-the-future-card"; -import {FavorCard} from "../entities/cards/favor-card"; +import {AttackCard} from "../entities/card-types/attack-card"; +import {NopeCard} from "../entities/card-types/nope-card"; +import {SeeTheFutureCard} from "../entities/card-types/see-the-future-card"; +import {FavorCard} from "../entities/card-types/favor-card"; // Registry for card models export const cardTypeRegistry = new Registry(); diff --git a/src/common/constants/deck-types.ts b/src/common/constants/deck-types.ts new file mode 100644 index 0000000..87cf1a6 --- /dev/null +++ b/src/common/constants/deck-types.ts @@ -0,0 +1,4 @@ +import {OriginalDeck} from '../entities/deck-types/original-deck'; + +export const ORIGINAL = new OriginalDeck(); + diff --git a/src/common/constants/decks.ts b/src/common/constants/decks.ts deleted file mode 100644 index d6e3a08..0000000 --- a/src/common/constants/decks.ts +++ /dev/null @@ -1,4 +0,0 @@ -import {OriginalDeck} from '../entities/decks/original-deck'; - -export const ORIGINAL = new OriginalDeck(); - diff --git a/src/common/entities/card-type.ts b/src/common/entities/card-type.ts index 26f5365..a620d92 100644 --- a/src/common/entities/card-type.ts +++ b/src/common/entities/card-type.ts @@ -1,4 +1,4 @@ -import type {Card, FnContext} from '../models'; +import type {ICard, IContext} from '../models'; export class CardType { name: string; @@ -11,20 +11,20 @@ export class CardType { return false; } - createCard(index: number): Card { + createCard(index: number): ICard { return {name: this.name, index}; } - canBePlayed(_context: FnContext, _card: Card): boolean { + canBePlayed(_context: IContext, _card: ICard): boolean { return true; } - isNowCard(_context: FnContext, _card: Card): boolean { + isNowCard(_context: IContext, _card: ICard): boolean { return false; } - setupPendingState(context: FnContext) { + setupPendingState(context: IContext) { context.events.setActivePlayers({ currentPlayer: 'awaitingNowCards', others: { @@ -33,13 +33,15 @@ export class CardType { }); } - cleanupPendingState(context: FnContext) { - context.events.setActivePlayers({value: {}}); + cleanupPendingState(context: IContext) { + const { events } = context; + events.endStage(); + events.setActivePlayers({value: {}}); } - afterPlay(_context: FnContext, _card: Card): void {} + afterPlay(_context: IContext, _card: ICard): void {} - onPlayed(_context: FnContext, _card: Card): void {} + onPlayed(_context: IContext, _card: ICard): void {} /** * Returns the sort order for this card type. diff --git a/src/common/entities/cards/attack-card.ts b/src/common/entities/card-types/attack-card.ts similarity index 89% rename from src/common/entities/cards/attack-card.ts rename to src/common/entities/card-types/attack-card.ts index 4f71346..6cef44a 100644 --- a/src/common/entities/cards/attack-card.ts +++ b/src/common/entities/card-types/attack-card.ts @@ -1,5 +1,5 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; +import {ICard, IContext} from "../../models"; export class AttackCard extends CardType { @@ -7,7 +7,7 @@ export class AttackCard extends CardType { super(name); } - onPlayed(context: FnContext, _card: Card) { + onPlayed(context: IContext, _card: ICard) { const { G, ctx, events } = context; // Add 3 to turnsRemaining for proper 2, 4, 6, 8 stacking diff --git a/src/common/entities/cards/cat-card.ts b/src/common/entities/card-types/cat-card.ts similarity index 73% rename from src/common/entities/cards/cat-card.ts rename to src/common/entities/card-types/cat-card.ts index a83d5e6..2368181 100644 --- a/src/common/entities/cards/cat-card.ts +++ b/src/common/entities/card-types/cat-card.ts @@ -1,5 +1,5 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; +import {ICard, IContext} from "../../models"; import {stealCard} from '../../moves/steal-card-move'; export class CatCard extends CardType { @@ -9,24 +9,24 @@ export class CatCard extends CardType { } /** - * Cat cards can only be played in pairs + * Cat card-types can only be played in pairs */ - canBePlayed(context: FnContext, card: Card): boolean { + canBePlayed(context: IContext, card: ICard): boolean { const { player, ctx } = context; const playerData = player.get(); - // Count how many cat cards with the same index the player has + // Count how many cat card-types with the same index the player has const matchingCards = playerData.hand.filter( - (c: Card) => c.name === card.name && c.index === card.index + (c: ICard) => c.name === card.name && c.index === card.index ); - // Need at least 2 matching cat cards to play + // Need at least 2 matching cat card-types to play if (matchingCards.length < 2) { return false; } - // Check if there is at least one other player with cards + // Check if there is at least one other player with card-types return Object.keys(player.state).some((playerId) => { if (playerId === ctx.currentPlayer) { return false; // Can't target yourself @@ -39,7 +39,7 @@ export class CatCard extends CardType { /** * Prompt player to choose a target after pair cost is already consumed. */ - onPlayed(context: FnContext, _card: Card) { + onPlayed(context: IContext, _card: ICard) { const { events, player, ctx } = context; const candidates = Object.keys(player.state).filter((playerId) => { @@ -61,12 +61,12 @@ export class CatCard extends CardType { /** * Immediately consume the second matching cat card after the first is played. */ - afterPlay(context: FnContext, card: Card) { + afterPlay(context: IContext, card: ICard) { const {G, player} = context; const playerData = player.get(); const secondCardIndex = playerData.hand.findIndex( - (c: Card) => c.name === card.name && c.index === card.index + (c: ICard) => c.name === card.name && c.index === card.index ); if (secondCardIndex === -1) { @@ -74,7 +74,7 @@ export class CatCard extends CardType { } const secondCard = playerData.hand[secondCardIndex]; - const newHand = playerData.hand.filter((_: Card, index: number) => index !== secondCardIndex); + const newHand = playerData.hand.filter((_: ICard, index: number) => index !== secondCardIndex); player.set({ ...playerData, diff --git a/src/common/entities/cards/defuse-card.ts b/src/common/entities/card-types/defuse-card.ts similarity index 66% rename from src/common/entities/cards/defuse-card.ts rename to src/common/entities/card-types/defuse-card.ts index e1737a6..037b85d 100644 --- a/src/common/entities/cards/defuse-card.ts +++ b/src/common/entities/card-types/defuse-card.ts @@ -1,5 +1,5 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; +import {ICard, IContext} from "../../models"; export class DefuseCard extends CardType { @@ -7,7 +7,7 @@ export class DefuseCard extends CardType { super(name); } - canBePlayed(_context: FnContext, _card: Card): boolean { + canBePlayed(_context: IContext, _card: ICard): boolean { return false; } diff --git a/src/common/entities/cards/exploding-kitten-card.ts b/src/common/entities/card-types/exploding-kitten-card.ts similarity index 100% rename from src/common/entities/cards/exploding-kitten-card.ts rename to src/common/entities/card-types/exploding-kitten-card.ts diff --git a/src/common/entities/cards/favor-card.ts b/src/common/entities/card-types/favor-card.ts similarity index 84% rename from src/common/entities/cards/favor-card.ts rename to src/common/entities/card-types/favor-card.ts index 7afcded..416a269 100644 --- a/src/common/entities/cards/favor-card.ts +++ b/src/common/entities/card-types/favor-card.ts @@ -1,5 +1,5 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; +import {ICard, IContext} from "../../models"; import {requestCard} from '../../moves/favor-card-move'; export class FavorCard extends CardType { @@ -8,10 +8,10 @@ export class FavorCard extends CardType { super(name); } - canBePlayed(context: FnContext, _card: Card): boolean { + canBePlayed(context: IContext, _card: ICard): boolean { const { player, ctx } = context; - // Check if there is at least one other player with cards + // Check if there is at least one other player with card-types return Object.keys(player.state).some((playerId) => { if (playerId === ctx.currentPlayer) { return false; // Can't target yourself @@ -21,7 +21,7 @@ export class FavorCard extends CardType { }); } - onPlayed(context: FnContext, _card: Card) { + onPlayed(context: IContext, _card: ICard) { const { events, player, ctx } = context; const candidates = Object.keys(player.state).filter((playerId) => { diff --git a/src/common/entities/cards/nope-card.ts b/src/common/entities/card-types/nope-card.ts similarity index 75% rename from src/common/entities/cards/nope-card.ts rename to src/common/entities/card-types/nope-card.ts index 015ebfe..57645fb 100644 --- a/src/common/entities/cards/nope-card.ts +++ b/src/common/entities/card-types/nope-card.ts @@ -1,7 +1,7 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; +import {ICard, IContext} from "../../models"; import {validateNope} from '../../utils/action-validation'; -import {GameLogic} from '../../wrappers/game-logic'; +import {Game} from '../game'; export class NopeCard extends CardType { @@ -9,17 +9,17 @@ export class NopeCard extends CardType { super(name); } - isNowCard(_context: FnContext, _card: Card): boolean { + isNowCard(_context: IContext, _card: ICard): boolean { return true; } - canBePlayed(context: FnContext, _card: Card): boolean { + canBePlayed(context: IContext, _card: ICard): boolean { const {G, playerID} = context; return validateNope(G, playerID); } - onPlayed(context: FnContext, _card: Card): void { - const game = new GameLogic(context); + onPlayed(context: IContext, _card: ICard): void { + const game = new Game(context); const pendingCardPlay = game.pendingCardPlay; const player = game.actingPlayer; diff --git a/src/common/entities/cards/see-the-future-card.ts b/src/common/entities/card-types/see-the-future-card.ts similarity index 78% rename from src/common/entities/cards/see-the-future-card.ts rename to src/common/entities/card-types/see-the-future-card.ts index bfa1c50..932abc4 100644 --- a/src/common/entities/cards/see-the-future-card.ts +++ b/src/common/entities/card-types/see-the-future-card.ts @@ -1,5 +1,5 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; +import {ICard, IContext} from "../../models"; export class SeeTheFutureCard extends CardType { @@ -7,7 +7,7 @@ export class SeeTheFutureCard extends CardType { super(name); } - onPlayed(context: FnContext, _card: Card) { + onPlayed(context: IContext, _card: ICard) { const { events } = context; // Set stage to view the future diff --git a/src/common/entities/cards/shuffle-card.ts b/src/common/entities/card-types/shuffle-card.ts similarity index 74% rename from src/common/entities/cards/shuffle-card.ts rename to src/common/entities/card-types/shuffle-card.ts index 9047d88..a6b34e1 100644 --- a/src/common/entities/cards/shuffle-card.ts +++ b/src/common/entities/card-types/shuffle-card.ts @@ -1,5 +1,5 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; +import {ICard, IContext} from "../../models"; export class ShuffleCard extends CardType { @@ -7,7 +7,7 @@ export class ShuffleCard extends CardType { super(name); } - onPlayed(context: FnContext, _card: Card) { + onPlayed(context: IContext, _card: ICard) { const { G, random } = context; G.drawPile = random.Shuffle(G.drawPile) } diff --git a/src/common/entities/cards/skip-card.ts b/src/common/entities/card-types/skip-card.ts similarity index 72% rename from src/common/entities/cards/skip-card.ts rename to src/common/entities/card-types/skip-card.ts index 10535eb..3445aa1 100644 --- a/src/common/entities/cards/skip-card.ts +++ b/src/common/entities/card-types/skip-card.ts @@ -1,5 +1,5 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; +import {ICard, IContext} from "../../models"; export class SkipCard extends CardType { @@ -7,7 +7,7 @@ export class SkipCard extends CardType { super(name); } - onPlayed(context: FnContext, _card: Card) { + onPlayed(context: IContext, _card: ICard) { const { events } = context; events.endTurn(); } diff --git a/src/common/entities/deck-type.ts b/src/common/entities/deck-type.ts new file mode 100644 index 0000000..876c8f8 --- /dev/null +++ b/src/common/entities/deck-type.ts @@ -0,0 +1,25 @@ +import type {ICard} from '../models'; + +export abstract class DeckType { + name: string; + + protected constructor(name: string) { + this.name = name; + } + + /** Cards that form the base deck before dealing */ + abstract buildBaseDeck(): ICard[]; + + /** How many card-types each player starts with */ + abstract startingHandSize(): number; + + /** Cards automatically added to each player's hand */ + startingHandForcedCards(_player_index: number): ICard[] { + return []; + } + + /** Extra card-types added after the players are dealt */ + addPostDealCards(_pile: ICard[], _playerCount: number): void { + } +} + diff --git a/src/common/entities/decks/original-deck.ts b/src/common/entities/deck-types/original-deck.ts similarity index 79% rename from src/common/entities/decks/original-deck.ts rename to src/common/entities/deck-types/original-deck.ts index ff12fd5..b7d5018 100644 --- a/src/common/entities/decks/original-deck.ts +++ b/src/common/entities/deck-types/original-deck.ts @@ -1,5 +1,5 @@ -import {Deck} from '../deck'; -import type {Card} from '../../models'; +import {DeckType} from '../deck-type'; +import type {ICard} from '../../models'; import { ATTACK, @@ -17,7 +17,7 @@ const TOTAL_DEFUSE_CARDS = 6; const MAX_DECK_DEFUSE_CARDS = 2; const EXPLODING_KITTENS = 4; -export class OriginalDeck extends Deck { +export class OriginalDeck extends DeckType { constructor() { super('original'); } @@ -26,12 +26,12 @@ export class OriginalDeck extends Deck { return STARTING_HAND_SIZE; } - startingHandForcedCards(index: number): Card[] { + startingHandForcedCards(index: number): ICard[] { return [DEFUSE.createCard(index)]; } - buildBaseDeck(): Card[] { - const pile: Card[] = []; + buildBaseDeck(): ICard[] { + const pile: ICard[] = []; for (let i = 0; i < 4; i++) { pile.push(ATTACK.createCard(i)); @@ -51,7 +51,7 @@ export class OriginalDeck extends Deck { return pile; } - addPostDealCards(pile: Card[], playerCount: number): void { + addPostDealCards(pile: ICard[], playerCount: number): void { const remaining = Math.min(TOTAL_DEFUSE_CARDS - playerCount, MAX_DECK_DEFUSE_CARDS); for (let i = 0; i < remaining; i++) { diff --git a/src/common/entities/deck.ts b/src/common/entities/deck.ts deleted file mode 100644 index b635d1c..0000000 --- a/src/common/entities/deck.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type {Card} from '../models'; - -export abstract class Deck { - name: string; - - protected constructor(name: string) { - this.name = name; - } - - /** Cards that form the base deck before dealing */ - abstract buildBaseDeck(): Card[]; - - /** How many cards each player starts with */ - abstract startingHandSize(): number; - - /** Cards automatically added to each player's hand */ - startingHandForcedCards(_player_index: number): Card[] { - return []; - } - - /** Extra cards added after the players are dealt */ - addPostDealCards(_pile: Card[], _playerCount: number): void { - } -} - diff --git a/src/common/entities/game-state.ts b/src/common/entities/game-state.ts new file mode 100644 index 0000000..77fadbb --- /dev/null +++ b/src/common/entities/game-state.ts @@ -0,0 +1,26 @@ +import {IPendingCardPlay, IGameRules} from '../models'; +import {Game} from "./game"; + +export class GameState { + constructor(private game: Game) {} + + /** + * Get pending card play + */ + get pendingCardPlay(): IPendingCardPlay | null { + return this.game.context.G.pendingCardPlay; + } + + set pendingCardPlay(pending: IPendingCardPlay | null) { + this.game.context.G.pendingCardPlay = pending; + } + + set lobbyReady(ready: boolean) { + this.game.context.G.lobbyReady = ready; + } + + get gameRules(): IGameRules { + return this.game.context.G.gameRules; + } +} + diff --git a/src/common/wrappers/game-logic.ts b/src/common/entities/game.ts similarity index 54% rename from src/common/wrappers/game-logic.ts rename to src/common/entities/game.ts index db62882..0c060dc 100644 --- a/src/common/wrappers/game-logic.ts +++ b/src/common/entities/game.ts @@ -1,34 +1,36 @@ -import {FnContext} from '../models'; -import {PlayerWrapper} from './player-wrapper'; -import {DeckLogic} from './deck-logic'; -import {GameStateLogic} from './game-state-logic'; -import {PlayerLogic} from './player-logic'; -import {Card} from '../models'; -import {PendingCardPlay, GameRules} from '../models'; - -export class GameLogic { - public readonly deck: DeckLogic; - public readonly state: GameStateLogic; - public readonly players: PlayerLogic; - - constructor(context: FnContext) { - this.deck = new DeckLogic(context); - this.state = new GameStateLogic(context); - this.players = new PlayerLogic(context); +import {IContext} from '../models'; +import {Player} from './player'; +import {Piles} from './piles'; +import {GameState} from './game-state'; +import {Players} from './players'; +import {ICard} from '../models'; +import {IPendingCardPlay, IGameRules} from '../models'; + +export class Game { + public readonly context: IContext; + public readonly deck: Piles; + public readonly state: GameState; + public readonly players: Players; + + constructor(context: IContext) { + this.context = context; + this.deck = new Piles(this); + this.state = new GameState(this); + this.players = new Players(this); } /** * Get a player wrapper instance for a specific player ID. * Throws if player data not found. */ - getPlayer(id: string): PlayerWrapper { + getPlayer(id: string): Player { return this.players.getPlayer(id); } /** * Get a wrapper for the current player based on context.currentPlayer */ - get currentPlayer(): PlayerWrapper { + get currentPlayer(): Player { return this.players.currentPlayer; } @@ -36,49 +38,49 @@ export class GameLogic { * Get a wrapper for the player executing the move (if playerID available in context) * Falls back to currentPlayer if playerID not set */ - get actingPlayer(): PlayerWrapper { + get actingPlayer(): Player { return this.players.actingPlayer; } /** * Get all players as wrappers */ - get allPlayers(): PlayerWrapper[] { + get allPlayers(): Player[] { return this.players.allPlayers; } /** * Get pending card play */ - get pendingCardPlay(): PendingCardPlay | null { + get pendingCardPlay(): IPendingCardPlay | null { return this.state.pendingCardPlay; } /** * Add a card to the discard pile */ - discardCard(card: Card): void { + discardCard(card: ICard): void { this.deck.discardCard(card); } /** * Get the last discarded card */ - get lastDiscardedCard(): Card | null { + get lastDiscardedCard(): ICard | null { return this.deck.lastDiscardedCard; } /** * Draw a card from the top of the draw pile */ - drawCardFromPile(): Card | undefined { + drawCardFromPile(): ICard | undefined { return this.deck.drawCardFromPile(); } /** * Insert a card into the draw pile at a specific index */ - insertCardIntoDrawPile(card: Card, index: number): void { + insertCardIntoDrawPile(card: ICard, index: number): void { this.deck.insertCardIntoDrawPile(card, index); } @@ -89,7 +91,7 @@ export class GameLogic { return this.deck.drawPileSize; } - set pendingCardPlay(pending: PendingCardPlay | null) { + set pendingCardPlay(pending: IPendingCardPlay | null) { this.state.pendingCardPlay = pending; } @@ -97,15 +99,15 @@ export class GameLogic { this.state.lobbyReady = ready; } - get gameRules(): GameRules { + get gameRules(): IGameRules { return this.state.gameRules; } /** * Validate if a player is a valid target for an action. - * Checks if target is alive, has cards, and is not the current player. + * Checks if target is alive, has card-types, and is not the current player. */ - validateTarget(targetPlayerId: string): PlayerWrapper { + validateTarget(targetPlayerId: string): Player { return this.players.validateTarget(targetPlayerId); } } diff --git a/src/common/entities/piles.ts b/src/common/entities/piles.ts new file mode 100644 index 0000000..15310cb --- /dev/null +++ b/src/common/entities/piles.ts @@ -0,0 +1,45 @@ +import {ICard} from '../models'; +import {Game} from "./game"; + +export class Piles { + constructor(private game: Game) {} + + /** + * Add a card to the discard pile + */ + discardCard(card: ICard): void { + // Clone to avoid Proxy issues + this.game.context.G.discardPile.push({...card}); + } + + /** + * Get the last discarded card + */ + get lastDiscardedCard(): ICard | null { + const pile = this.game.context.G.discardPile; + return pile.length > 0 ? pile[pile.length - 1] : null; + } + + /** + * Draw a card from the top of the draw pile + */ + drawCardFromPile(): ICard | undefined { + return this.game.context.G.drawPile.shift(); + } + + /** + * Insert a card into the draw pile at a specific index + */ + insertCardIntoDrawPile(card: ICard, index: number): void { + // Clone to avoid Proxy issues + this.game.context.G.drawPile.splice(index, 0, {...card}); + } + + /** + * Get draw pile size + */ + get drawPileSize(): number { + return this.game.context.G.drawPile.length; + } +} + diff --git a/src/common/wrappers/player-wrapper.ts b/src/common/entities/player.ts similarity index 70% rename from src/common/wrappers/player-wrapper.ts rename to src/common/entities/player.ts index afa9c2b..774b2c2 100644 --- a/src/common/wrappers/player-wrapper.ts +++ b/src/common/entities/player.ts @@ -1,9 +1,10 @@ -import {Player} from '../models'; -import {Card} from '../models'; +import {ICard, IPlayer} from '../models'; +import {Game} from "./game"; -export class PlayerWrapper { +export class Player { constructor( - private _state: Player, + private game: Game, + private _state: IPlayer, public readonly id: string ) {} @@ -11,14 +12,14 @@ export class PlayerWrapper { * Get the underlying raw state object. * Useful if direct property access or modification is needed. */ - get state(): Player { + get state(): IPlayer { return this._state; } /** - * Get the player's hand of cards + * Get the player's hand of card-types */ - get hand(): Card[] { + get hand(): ICard[] { return this._state.hand; } @@ -37,15 +38,15 @@ export class PlayerWrapper { } /** - * Get all cards of a specific type from hand, or all cards if no type specified + * Get all card-types of a specific type from hand, or all card-types if no type specified */ - getCards(cardName?: string): Card[] { + getCards(cardName?: string): ICard[] { if (!cardName) return this._state.hand; return this._state.hand.filter(c => c.name === cardName); } /** - * Get the count of cards in hand + * Get the count of card-types in hand */ getCardCount(): number { return this._state.hand.length; @@ -54,7 +55,7 @@ export class PlayerWrapper { /** * Add a card to the player's hand */ - addCard(card: Card): void { + addCard(card: ICard): void { // Clone to avoid Proxy issues this._state.hand.push({...card}); this._updateClientState(); @@ -64,7 +65,7 @@ export class PlayerWrapper { * Remove a card at a specific index * @returns The removed card, or undefined if index invalid */ - removeCardAt(index: number): Card | undefined { + removeCardAt(index: number): ICard | undefined { if (index < 0 || index >= this._state.hand.length) return undefined; const [card] = this._state.hand.splice(index, 1); this._updateClientState(); @@ -75,18 +76,18 @@ export class PlayerWrapper { * Remove the first occurrence of a specific card type * @returns The removed card, or undefined if not found */ - removeCard(cardName: string): Card | undefined { + removeCard(cardName: string): ICard | undefined { const index = this._state.hand.findIndex(c => c.name === cardName); if (index === -1) return undefined; return this.removeCardAt(index); } /** - * Remove all cards of a specific type - * @returns Array of removed cards + * Remove all card-types of a specific type + * @returns Array of removed card-types */ - removeAllCards(cardName: string): Card[] { - const removed: Card[] = []; + removeAllCards(cardName: string): ICard[] { + const removed: ICard[] = []; // Iterate backwards to safely remove for (let i = this._state.hand.length - 1; i >= 0; i--) { if (this._state.hand[i].name === cardName) { @@ -101,10 +102,10 @@ export class PlayerWrapper { } /** - * Remove all cards from hand - * @returns Array of all removed cards + * Remove all card-types from hand + * @returns Array of all removed card-types */ - removeAllCardsFromHand(): Card[] { + removeAllCardsFromHand(): ICard[] { const removed = [...this._state.hand]; this._state.hand = []; this._updateClientState(); @@ -113,12 +114,14 @@ export class PlayerWrapper { eliminate(): void { this._state.isAlive = false; + // put all hand card-types in discard pile + this._state.hand.forEach(card => this.game.discardCard(card)); } /** * Transfers a card at specific index to another playerWrapper */ - giveCard(cardIndex: number, recipient: PlayerWrapper): Card { + giveCard(cardIndex: number, recipient: Player): ICard { const card = this.removeCardAt(cardIndex); if (!card) { throw new Error("Card not found or invalid index"); diff --git a/src/common/wrappers/player-logic.ts b/src/common/entities/players.ts similarity index 59% rename from src/common/wrappers/player-logic.ts rename to src/common/entities/players.ts index 1ae2473..1c0af05 100644 --- a/src/common/wrappers/player-logic.ts +++ b/src/common/entities/players.ts @@ -1,51 +1,51 @@ -import {FnContext} from '../models'; -import {PlayerWrapper} from './player-wrapper'; +import {Player} from './player'; +import {Game} from "./game"; -export class PlayerLogic { - constructor(private context: FnContext) {} +export class Players { + constructor(private game: Game) {} /** * Get a player wrapper instance for a specific player ID. * Throws if player data not found. */ - getPlayer(id: string): PlayerWrapper { + getPlayer(id: string): Player { // boardgame.io player plugin structure - const playerData = this.context.player.state?.[id]; + const playerData = this.game.context.player.state?.[id]; if (!playerData) { throw new Error(`Player data not found for ID: ${id}`); } - return new PlayerWrapper(playerData, id); + return new Player(this.game, playerData, id); } /** * Get a wrapper for the current player based on context.currentPlayer */ - get currentPlayer(): PlayerWrapper { - return this.getPlayer(this.context.ctx.currentPlayer); + get currentPlayer(): Player { + return this.getPlayer(this.game.context.ctx.currentPlayer); } /** * Get a wrapper for the player executing the move (if playerID available in context) * Falls back to currentPlayer if playerID not set */ - get actingPlayer(): PlayerWrapper { - const id = this.context.playerID ?? this.context.ctx.currentPlayer; + get actingPlayer(): Player { + const id = this.game.context.playerID ?? this.game.context.ctx.currentPlayer; return this.getPlayer(id); } /** * Get all players as wrappers */ - get allPlayers(): PlayerWrapper[] { - const playerIDs = Object.keys(this.context.player.state || {}); + get allPlayers(): Player[] { + const playerIDs = Object.keys(this.game.context.player.state || {}); return playerIDs.map(id => this.getPlayer(id)); } /** * Validate if a player is a valid target for an action. - * Checks if target is alive, has cards, and is not the current player. + * Checks if target is alive, has card-types, and is not the current player. */ - validateTarget(targetPlayerId: string): PlayerWrapper { + validateTarget(targetPlayerId: string): Player { const target = this.getPlayer(targetPlayerId); let current; @@ -64,7 +64,7 @@ export class PlayerLogic { } if (target.getCardCount() === 0) { - throw new Error('Target player has no cards'); + throw new Error('Target player has no card-types'); } return target; diff --git a/src/common/game.ts b/src/common/game.ts index c72739f..4f14bfe 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -1,17 +1,18 @@ import {Ctx, Game} from 'boardgame.io'; import {createPlayerPlugin} from './plugins/player-plugin'; import {setupGame} from './setup/game-setup'; -import type {Card, FnContext, GameState, PluginAPIs} from './models'; +import type {ICard, IContext, IGameState, IPluginAPIs} from './models'; import {drawCard} from "./moves/draw-move"; import {playCard, playNowCard, resolvePendingCard} from "./moves/play-card-move"; import {stealCard} from "./moves/steal-card-move"; import {requestCard, giveCard} from "./moves/favor-card-move"; import {closeFutureView} from "./moves/see-future-move"; import {turnOrder} from "./utils/turn-order"; -import {OriginalDeck} from './entities/decks/original-deck'; +import {OriginalDeck} from './entities/deck-types/original-deck'; import {dealHands} from './setup/player-setup'; +import {defuseExplodingKitten} from "./moves/defuse-exploding-kitten"; -export const ExplodingKittens: Game = { +export const ExplodingKittens: Game = { name: "Exploding-Kittens", plugins: [createPlayerPlugin()], @@ -20,11 +21,11 @@ export const ExplodingKittens: Game = { disableUndo: true, - playerView: ({G, ctx, playerID}: {G: GameState; ctx: Ctx; playerID: any}) => { + playerView: ({G, ctx, playerID}: {G: IGameState; ctx: Ctx; playerID: any}) => { // The player plugin's playerView will handle filtering the player data // We need to pass G through so it's available - let viewableDrawPile: Card[] = []; + let viewableDrawPile: ICard[] = []; if (ctx.activePlayers?.[playerID!] === 'viewingFuture') { viewableDrawPile = G.drawPile.slice(0, 3); @@ -45,14 +46,14 @@ export const ExplodingKittens: Game = { lobby: { start: true, next: 'play', - onBegin: ({G}: FnContext) => { + onBegin: ({G}: IContext) => { // Reset game state for lobby G.lobbyReady = false; }, onEnd: ({G, ctx, player}) => { - // Deal cards when leaving lobby phase + // Deal card-types when leaving lobby phase const deck = new OriginalDeck(); - const pile: Card[] = deck.buildBaseDeck().sort(() => Math.random() - 0.5); + const pile: ICard[] = deck.buildBaseDeck().sort(() => Math.random() - 0.5); dealHands(pile, player.state, deck); deck.addPostDealCards(pile, Object.keys(ctx.playOrder).length); @@ -73,7 +74,7 @@ export const ExplodingKittens: Game = { 'waitingForStart': { moves: { startGame: { - move: ({G}: FnContext) => { + move: ({G}: IContext) => { // Need to trust players since there is no api to see who is currently connected // Only the client has that info exposed by boardgame.io for some reason G.lobbyReady = true; @@ -102,6 +103,14 @@ export const ExplodingKittens: Game = { } }, stages: { + defuseExplodingKitten: { + moves: { + defuseExplodingKitten: { + move: defuseExplodingKitten, + client: false + }, + } + }, choosePlayerToStealFrom: { moves: { stealCard: { diff --git a/src/common/index.ts b/src/common/index.ts index 0a77a8e..76e4243 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -6,11 +6,11 @@ export * from './models'; // Entities export type {CardType} from './entities/card-type'; -export type {Deck} from './entities/deck'; -export type {CatCard} from './entities/cards/cat-card'; -export type {DefuseCard} from './entities/cards/defuse-card'; -export type {ExplodingKittenCard} from './entities/cards/exploding-kitten-card'; -export type {OriginalDeck} from './entities/decks/original-deck'; +export type {DeckType} from './entities/deck-type'; +export type {CatCard} from './entities/card-types/cat-card'; +export type {DefuseCard} from './entities/card-types/defuse-card'; +export type {ExplodingKittenCard} from './entities/card-types/exploding-kitten-card'; +export type {OriginalDeck} from './entities/deck-types/original-deck'; // Constants - Card models export { @@ -27,7 +27,7 @@ export { } from './constants/card-types'; // Constants - Decks -export {ORIGINAL} from './constants/decks'; +export {ORIGINAL} from './constants/deck-types'; // Setup functions export {setupGame} from './setup/game-setup'; @@ -41,5 +41,5 @@ export {sortCards} from './utils/card-sorting'; export {canPlayerNope, validateNope} from './utils/action-validation'; // Wrappers -export {PlayerWrapper} from './wrappers/player-wrapper'; -export {GameLogic} from './wrappers/game-logic'; +export {Player} from './entities/player'; +export {Game} from './entities/game'; diff --git a/src/common/models/card.model.ts b/src/common/models/card.model.ts index e4f83c2..42f0233 100644 --- a/src/common/models/card.model.ts +++ b/src/common/models/card.model.ts @@ -1,4 +1,4 @@ -export interface Card { +export interface ICard { name: string; index: number; } diff --git a/src/common/models/context.model.ts b/src/common/models/context.model.ts index 27e0dae..54e80b5 100644 --- a/src/common/models/context.model.ts +++ b/src/common/models/context.model.ts @@ -1,14 +1,14 @@ -import type {GameState} from "./game-state.model"; +import type {IGameState} from "./game-state.model"; import {Ctx} from "boardgame.io"; -import {PlayerAPI} from "./player-api.model"; +import {IPlayerAPI} from "./player-api.model"; import {RandomAPI} from "boardgame.io/dist/types/src/plugins/random/random"; import {EventsAPI} from "boardgame.io/dist/types/src/plugins/events/events"; -export type FnContext = Record & { - G: GameState; +export type IContext = Record & { + G: IGameState; ctx: Ctx; - player: PlayerAPI; - events: EventsAPI; // TODO: Define proper type for events + player: IPlayerAPI; + events: EventsAPI; random: RandomAPI; playerID?: string; }; diff --git a/src/common/models/game-state.model.ts b/src/common/models/game-state.model.ts index 836f228..76a9369 100644 --- a/src/common/models/game-state.model.ts +++ b/src/common/models/game-state.model.ts @@ -1,18 +1,18 @@ -import type {Card} from './card.model'; +import type {ICard} from './card.model'; import type {PlayerID} from 'boardgame.io'; -export interface ClientGameState { +export interface IClientGameState { drawPileLength: number; } -export interface GameRules { +export interface IGameRules { spectatorsSeeCards: boolean; openCards: boolean; pendingTimerMs: number; } -export interface PendingCardPlay { - card: Card; +export interface IPendingCardPlay { + card: ICard; playedBy: PlayerID; startedAtMs: number; expiresAtMs: number; @@ -21,14 +21,14 @@ export interface PendingCardPlay { isNoped: boolean; } -export interface GameState { +export interface IGameState { winner: PlayerID | null; - drawPile: Card[]; - discardPile: Card[]; - pendingCardPlay: PendingCardPlay | null; + drawPile: ICard[]; + discardPile: ICard[]; + pendingCardPlay: IPendingCardPlay | null; turnsRemaining: number; - gameRules: GameRules; + gameRules: IGameRules; deckType: string; - client: ClientGameState; + client: IClientGameState; lobbyReady: boolean; } diff --git a/src/common/models/player-api.model.ts b/src/common/models/player-api.model.ts index a8ef43a..146a639 100644 --- a/src/common/models/player-api.model.ts +++ b/src/common/models/player-api.model.ts @@ -1,8 +1,8 @@ -import type {Players} from './players.model'; -import {Player} from "./player.model"; +import type {IPlayers} from './players.model'; +import {IPlayer} from "./player.model"; -export interface PlayerAPI { - state: Players; - get(): Player; - set(value: Player): Player; +export interface IPlayerAPI { + state: IPlayers; + get(): IPlayer; + set(value: IPlayer): IPlayer; } diff --git a/src/common/models/player.model.ts b/src/common/models/player.model.ts index 1d7d5fb..be6ec79 100644 --- a/src/common/models/player.model.ts +++ b/src/common/models/player.model.ts @@ -1,12 +1,12 @@ -import type {Card} from './card.model'; +import type {ICard} from './card.model'; -export interface ClientPlayer { +export interface IClientPlayer { handCount: number; } -export interface Player { - hand: Card[]; +export interface IPlayer { + hand: ICard[]; // On server this is always 0! Do not use anywhere else than on the client frontend for when player . isAlive: boolean; - client: ClientPlayer + client: IClientPlayer } diff --git a/src/common/models/players.model.ts b/src/common/models/players.model.ts index 6d7c1eb..f2e8f9c 100644 --- a/src/common/models/players.model.ts +++ b/src/common/models/players.model.ts @@ -1,5 +1,5 @@ -import type {Player} from './player.model'; +import type {IPlayer} from './player.model'; -export interface Players { - [playerID: string]: Player; +export interface IPlayers { + [playerID: string]: IPlayer; } diff --git a/src/common/models/plugin-apis.model.ts b/src/common/models/plugin-apis.model.ts index c26914e..2d7e9a7 100644 --- a/src/common/models/plugin-apis.model.ts +++ b/src/common/models/plugin-apis.model.ts @@ -1,5 +1,5 @@ -import type {PlayerAPI} from './player-api.model'; +import type {IPlayerAPI} from './player-api.model'; -export type PluginAPIs = Record & { - player: PlayerAPI; +export type IPluginAPIs = Record & { + player: IPlayerAPI; }; diff --git a/src/common/moves/defuse-exploding-kitten.ts b/src/common/moves/defuse-exploding-kitten.ts new file mode 100644 index 0000000..e86f320 --- /dev/null +++ b/src/common/moves/defuse-exploding-kitten.ts @@ -0,0 +1,32 @@ +import {IContext} from "../models"; +import {Game} from "../entities/game"; +import {DEFUSE, EXPLODING_KITTEN} from "../constants/card-types"; + +export const defuseExplodingKitten = (context: IContext, insertIndex: number) => { + const { events } = context; + const game = new Game(context); + const player = game.actingPlayer; + + if (insertIndex < 0 || insertIndex >= game.drawPileSize) { + console.error('Invalid insert index:', insertIndex); + return; + } + + const defuseCard = player.removeCard(DEFUSE.name); + const explodingKittenCard = player.removeCard(EXPLODING_KITTEN.name); + + if (!defuseCard || !explodingKittenCard) { + console.error('Player does not have required card-types to defuse Exploding Kitten'); + // should not happen given game flow, but just in case, eliminate player + player.eliminate(); + events.endStage(); + events.endTurn(); + return; + } + + game.discardCard(defuseCard); + game.insertCardIntoDrawPile(explodingKittenCard, insertIndex); + + events.endStage(); + events.endTurn(); +}; diff --git a/src/common/moves/draw-move.ts b/src/common/moves/draw-move.ts index 24e3eb0..36e6012 100644 --- a/src/common/moves/draw-move.ts +++ b/src/common/moves/draw-move.ts @@ -1,14 +1,14 @@ -import {FnContext} from "../models"; +import {IContext} from "../models"; import {EXPLODING_KITTEN, DEFUSE} from "../constants/card-types"; -import {GameLogic} from "../wrappers/game-logic"; +import {Game} from "../entities/game"; -export const drawCard = (context: FnContext) => { - const { events, random } = context; - const game = new GameLogic(context); +export const drawCard = (context: IContext) => { + const { events } = context; + const game = new Game(context); const player = game.actingPlayer; if (!player.isAlive) { - throw new Error('Dead player cannot draw cards'); + throw new Error('Dead player cannot draw card-types'); } const cardToDraw = game.drawCardFromPile(); @@ -16,38 +16,24 @@ export const drawCard = (context: FnContext) => { throw new Error('No card to draw'); } + player.addCard(cardToDraw); + // Handle Exploding Kitten if (cardToDraw.name === EXPLODING_KITTEN.name) { // Check for Defuse - const defuseCard = player.removeCard(DEFUSE.name); - - if (defuseCard) { - // Player had a Defuse! - // 1. Play Defuse to discard - game.discardCard(defuseCard); - - // 2. Put Exploding Kitten back into draw pile at random position - const insertIndex = Math.floor(random.Number() * (game.drawPileSize + 1)); - game.insertCardIntoDrawPile(cardToDraw, insertIndex); - - // Player is safe, turn ends - events.endTurn(); + const hasDefuse = player.hasCard(DEFUSE.name); + + if (hasDefuse) { + events.setStage('defuseExplodingKitten') return; } - // Discard all cards from hand - const handCards = player.removeAllCardsFromHand(); - handCards.forEach(c => game.discardCard(c)); - // No Defuse - Player Explodes! - game.discardCard(cardToDraw); - player.eliminate(); events.endTurn(); return; } // Safe card - player.addCard(cardToDraw); events.endTurn(); }; diff --git a/src/common/moves/favor-card-move.ts b/src/common/moves/favor-card-move.ts index c3ba991..18588ff 100644 --- a/src/common/moves/favor-card-move.ts +++ b/src/common/moves/favor-card-move.ts @@ -1,13 +1,13 @@ -import {FnContext} from "../models"; +import {IContext} from "../models"; import {PlayerID} from "boardgame.io"; -import {GameLogic} from "../wrappers/game-logic"; +import {Game} from "../entities/game"; /** * Request a card from a target player (favor card - first stage) */ -export const requestCard = (context: FnContext, targetPlayerId: PlayerID) => { +export const requestCard = (context: IContext, targetPlayerId: PlayerID) => { const {events} = context; - const game = new GameLogic(context); + const game = new Game(context); // Validate target player game.validateTarget(targetPlayerId); @@ -26,9 +26,9 @@ export const requestCard = (context: FnContext, targetPlayerId: PlayerID) => { /** * Give a card to the requesting player (favor card - second stage) */ -export const giveCard = (context: FnContext, cardIndex: number) => { +export const giveCard = (context: IContext, cardIndex: number) => { const {ctx, events} = context; - const game = new GameLogic(context); + const game = new Game(context); // Find who is giving the card (the player in the chooseCardToGive stage) const givingPlayerId = Object.keys(ctx.activePlayers || {}).find( diff --git a/src/common/moves/play-card-move.ts b/src/common/moves/play-card-move.ts index 7cd3355..d674277 100644 --- a/src/common/moves/play-card-move.ts +++ b/src/common/moves/play-card-move.ts @@ -1,10 +1,10 @@ -import {FnContext} from "../models"; +import {IContext} from "../models"; import {cardTypeRegistry, NOPE} from "../constants/card-types"; -import {GameLogic} from "../wrappers/game-logic"; +import {Game} from "../entities/game"; -export const playCard = (context: FnContext, cardIndex: number) => { - const game = new GameLogic(context); +export const playCard = (context: IContext, cardIndex: number) => { + const game = new Game(context); const player = game.actingPlayer; const hand = player.hand; @@ -35,12 +35,12 @@ export const playCard = (context: FnContext, cardIndex: number) => { cardType.afterPlay(context, cardToPlay); if (cardType.isNowCard(context, cardToPlay)) { - // Immediate resolution for Now cards (like Nope) + // Immediate resolution for Now card-types (like Nope) cardType.onPlayed(context, cardToPlay); return; } - // For normal action cards, setup pending state + // For normal action card-types, setup pending state const actingPlayerID = player.id; const startedAtMs = Date.now(); @@ -54,7 +54,7 @@ export const playCard = (context: FnContext, cardIndex: number) => { isNoped: false, }; - // Skip wait if playing with open cards and no one else can Nope + // Skip wait if playing with open card-types and no one else can Nope if (game.gameRules.openCards) { const otherPlayers = game.allPlayers.filter(p => p.id !== actingPlayerID && p.isAlive); const canSomeoneNope = otherPlayers.some(p => p.hasCard(NOPE.name)); @@ -68,8 +68,8 @@ export const playCard = (context: FnContext, cardIndex: number) => { cardType.setupPendingState(context); }; -export const playNowCard = (context: FnContext, cardIndex: number) => { - const game = new GameLogic(context); +export const playNowCard = (context: IContext, cardIndex: number) => { + const game = new Game(context); const player = game.actingPlayer; const hand = player.hand; @@ -96,7 +96,7 @@ export const playNowCard = (context: FnContext, cardIndex: number) => { game.discardCard(cardToPlay); cardType.onPlayed(context, cardToPlay); - // Optimization: If open cards are enabled and no one else can Nope back, resolve immediately + // Optimization: If open card-types are enabled and no one else can Nope back, resolve immediately if (game.gameRules.openCards) { const pending = game.pendingCardPlay; if (pending) { @@ -113,8 +113,8 @@ export const playNowCard = (context: FnContext, cardIndex: number) => { } }; -export const resolvePendingCard = (context: FnContext) => { - const game = new GameLogic(context); +export const resolvePendingCard = (context: IContext) => { + const game = new Game(context); const pendingCardPlay = game.pendingCardPlay; // Check if we have a pending card to resolve diff --git a/src/common/moves/see-future-move.ts b/src/common/moves/see-future-move.ts index 2a4d282..41cd9dc 100644 --- a/src/common/moves/see-future-move.ts +++ b/src/common/moves/see-future-move.ts @@ -1,9 +1,9 @@ -import type {FnContext} from "../models"; +import type {IContext} from "../models"; /** * Close the see the future overlay */ -export const closeFutureView = (context: FnContext) => { +export const closeFutureView = (context: IContext) => { const {events} = context; // End the viewing stage diff --git a/src/common/moves/steal-card-move.ts b/src/common/moves/steal-card-move.ts index c26a314..00eae2f 100644 --- a/src/common/moves/steal-card-move.ts +++ b/src/common/moves/steal-card-move.ts @@ -1,10 +1,10 @@ -import {FnContext} from "../models"; +import {IContext} from "../models"; import {PlayerID} from "boardgame.io"; -import {GameLogic} from "../wrappers/game-logic"; +import {Game} from "../entities/game"; -export const stealCard = (context: FnContext, targetPlayerId: PlayerID) => { +export const stealCard = (context: IContext, targetPlayerId: PlayerID) => { const {events, random} = context; - const game = new GameLogic(context); + const game = new Game(context); // Validate target player const targetPlayer = game.validateTarget(targetPlayerId); diff --git a/src/common/moves/system-moves.ts b/src/common/moves/system-moves.ts index d587cab..8959cbb 100644 --- a/src/common/moves/system-moves.ts +++ b/src/common/moves/system-moves.ts @@ -1,12 +1,12 @@ -import {FnContext} from '../models'; -import {GameLogic} from '../wrappers/game-logic'; +import {IContext} from '../models'; +import {Game} from '../entities/game'; /** * Mark the lobby as ready to start * This should be called when all players have joined */ -export const setLobbyReady = (context: FnContext) => { - const game = new GameLogic(context); +export const setLobbyReady = (context: IContext) => { + const game = new Game(context); game.lobbyReady = true; }; diff --git a/src/common/plugins/player-plugin.ts b/src/common/plugins/player-plugin.ts index b21bb9c..bb6c530 100644 --- a/src/common/plugins/player-plugin.ts +++ b/src/common/plugins/player-plugin.ts @@ -1,7 +1,7 @@ import {PluginPlayer} from 'boardgame.io/dist/cjs/plugins.js'; import {createPlayerState, filterPlayerView} from '../setup/player-setup'; import type {Plugin} from 'boardgame.io'; -import type {GameState} from '../models'; +import type {IGameState} from '../models'; export const createPlayerPlugin = (): Plugin => { const basePlugin = PluginPlayer({ @@ -11,7 +11,7 @@ export const createPlayerPlugin = (): Plugin => { // Wrap the playerView to access G from the State return { ...basePlugin, - playerView: ({G, data, playerID}: {G: GameState, data: any, playerID?: string | null}) => { + playerView: ({G, data, playerID}: {G: IGameState, data: any, playerID?: string | null}) => { // Use our custom filterPlayerView that has access to G const filteredPlayers = filterPlayerView(G, data.players, playerID ?? null); return { players: filteredPlayers }; diff --git a/src/common/setup/game-setup.ts b/src/common/setup/game-setup.ts index 3f2b904..c482adb 100644 --- a/src/common/setup/game-setup.ts +++ b/src/common/setup/game-setup.ts @@ -1,4 +1,4 @@ -import type {GameState} from '../models'; +import type {IGameState} from '../models'; interface SetupData { matchName?: string; @@ -8,8 +8,8 @@ interface SetupData { deckType?: string; } -export const setupGame = (_context: any, setupData?: SetupData): GameState => { - // Don't deal cards yet - will be done when lobby phase ends +export const setupGame = (_context: any, setupData?: SetupData): IGameState => { + // Don't deal card-types yet - will be done when lobby phase ends return { winner: null, drawPile: [], diff --git a/src/common/setup/player-setup.ts b/src/common/setup/player-setup.ts index 5102247..99befd1 100644 --- a/src/common/setup/player-setup.ts +++ b/src/common/setup/player-setup.ts @@ -1,9 +1,9 @@ -import type {Card, GameState, Player, Players} from '../models'; -import type {Deck} from '../entities/deck'; +import type {ICard, IGameState, IPlayer, IPlayers} from '../models'; +import type {DeckType} from '../entities/deck-type'; import {cardTypeRegistry} from "../constants/card-types"; import {CardType} from "../entities/card-type"; -export const createPlayerState = (): Player => ({ +export const createPlayerState = (): IPlayer => ({ hand: [], isAlive: true, client: { @@ -14,7 +14,7 @@ export const createPlayerState = (): Player => ({ /** * Create a full view of a player (used for self-view and spectators) */ -const createFullPlayerView = (player: Player): Player => ({ +const createFullPlayerView = (player: IPlayer): IPlayer => ({ ...player, client: { handCount: player.hand.length @@ -24,7 +24,7 @@ const createFullPlayerView = (player: Player): Player => ({ /** * Create a limited view of a player (used for opponent views) */ -const createLimitedPlayerView = (player: Player): Player => ({ +const createLimitedPlayerView = (player: IPlayer): IPlayer => ({ hand: [], isAlive: player.isAlive, client: { @@ -33,31 +33,31 @@ const createLimitedPlayerView = (player: Player): Player => ({ }); /** - * Check if the viewing player should see all cards (spectator or dead player) + * Check if the viewing player should see all card-types (spectator or dead player) */ const shouldSeeAllCards = ( - G: GameState, - players: Players, + G: IGameState, + players: IPlayers, playerID?: string | null, ): boolean => { - // If openCards rule is enabled, everyone sees all cards + // If openCards rule is enabled, everyone sees all card-types if (G.gameRules.openCards) return true; - // Spectators (no playerID) see all cards ONLY if rule allows + // Spectators (no playerID) see all card-types ONLY if rule allows if (!playerID) return G.gameRules.spectatorsSeeCards; const currentPlayer = players[playerID]; const isCurrentPlayerDead = currentPlayer && !currentPlayer.isAlive; const spectatorsCanSeeAll = G.gameRules.spectatorsSeeCards; - // Dead players with permission see all cards + // Dead players with permission see all card-types return isCurrentPlayerDead && spectatorsCanSeeAll; }; -export const filterPlayerView = (G: GameState, players: Players, playerID?: string | null): Players => { +export const filterPlayerView = (G: IGameState, players: IPlayers, playerID?: string | null): IPlayers => { const canSeeAllCards = shouldSeeAllCards(G, players, playerID); - const view: Players = {}; + const view: IPlayers = {}; Object.entries(players).forEach(([id, pdata]) => { if (canSeeAllCards || id === playerID) { view[id] = createFullPlayerView(pdata); @@ -69,14 +69,14 @@ export const filterPlayerView = (G: GameState, players: Players, playerID?: stri return view; }; -export function dealHands(pile: Card[], players: Players, deck: Deck) { +export function dealHands(pile: ICard[], players: IPlayers, deck: DeckType) { const handSize = deck.startingHandSize(); Object.values(players).forEach((player, index) => { player.hand = pile.splice(0, handSize); const forcedCards = deck.startingHandForcedCards(index); - // Add any cards that are in testing mode (e.g. for development or QA purposes) + // Add any card-types that are in testing mode (e.g. for development or QA purposes) cardTypeRegistry.getAll().forEach((card: CardType) => { if (card.inTesting()) { for (let i = 0; i < 3; i++) { diff --git a/src/common/utils/action-validation.ts b/src/common/utils/action-validation.ts index 1de8686..ef459c7 100644 --- a/src/common/utils/action-validation.ts +++ b/src/common/utils/action-validation.ts @@ -1,11 +1,11 @@ -import {GameState} from '../models/game-state.model'; -import {Card} from '../models/card.model'; +import {IGameState} from '../models/game-state.model'; +import {ICard} from '../models/card.model'; /** * Validates if a player can play a Nope card against the current game state. * This is pure game logic validation. */ -export function validateNope(G: GameState, playerID: string | null | undefined): boolean { +export function validateNope(G: IGameState, playerID: string | null | undefined): boolean { if (!playerID) return false; if (!G.pendingCardPlay) return false; @@ -33,9 +33,9 @@ export function validateNope(G: GameState, playerID: string | null | undefined): } export function canPlayerNope( - G: GameState, + G: IGameState, playerID: string | null | undefined, - playerHand: Card[] + playerHand: ICard[] ): boolean { const nopeCardIndex = playerHand.findIndex(c => c.name === 'nope'); if (nopeCardIndex === -1) return false; diff --git a/src/common/utils/card-sorting.ts b/src/common/utils/card-sorting.ts index cdc2fb6..f16d428 100644 --- a/src/common/utils/card-sorting.ts +++ b/src/common/utils/card-sorting.ts @@ -1,12 +1,12 @@ -import type {Card} from '../models'; +import type {ICard} from '../models'; import {cardTypeRegistry} from '../constants/card-types'; /** - * Sorts cards by card type sort order and card index - * @param cards Array of cards to sort - * @returns Sorted array of cards (does not mutate original array) + * Sorts card-types by card type sort order and card index + * @param cards Array of card-types to sort + * @returns Sorted array of card-types (does not mutate original array) */ -export function sortCards(cards: T[]): T[] { +export function sortCards(cards: T[]): T[] { return [...cards].sort((a, b) => { const cardTypeA = cardTypeRegistry.get(a.name); const cardTypeB = cardTypeRegistry.get(b.name); diff --git a/src/common/utils/turn-order.ts b/src/common/utils/turn-order.ts index 11b5b5c..3ab895c 100644 --- a/src/common/utils/turn-order.ts +++ b/src/common/utils/turn-order.ts @@ -1,7 +1,7 @@ -import {FnContext} from '../models'; +import {IContext} from '../models'; const findNextAlivePlayer = ( - ctx: FnContext['ctx'], + ctx: IContext['ctx'], players: Record, startPos: number ): number | undefined => { @@ -24,7 +24,7 @@ const findNextAlivePlayer = ( }; export const turnOrder = { - first: ({ctx, player}: FnContext): number => { + first: ({ctx, player}: IContext): number => { const nextAlive = findNextAlivePlayer(ctx, player.state, 0); // Fallback to first player if no one is alive (shouldn't happen) return nextAlive ?? 0; @@ -34,7 +34,7 @@ export const turnOrder = { * Get the next alive player, considering turnsRemaining counter * Note: We only read G.turnsRemaining here, the decrement happens in turn.onEnd */ - next: ({G, ctx, player}: FnContext): number | undefined => { + next: ({G, ctx, player}: IContext): number | undefined => { // If there are still turns remaining (> 1 because we check before decrement), stay with the current player if (G.turnsRemaining > 1) { return ctx.playOrderPos; @@ -48,7 +48,7 @@ export const turnOrder = { /** * Shuffle the play order at the start of the play phase */ - playOrder: ({ ctx, random }: FnContext): string[] => { + playOrder: ({ ctx, random }: IContext): string[] => { const ids = Array.from({ length: ctx.numPlayers }, (_, i) => String(i)); return random.Shuffle(ids); }, diff --git a/src/common/wrappers/deck-logic.ts b/src/common/wrappers/deck-logic.ts deleted file mode 100644 index 97d6511..0000000 --- a/src/common/wrappers/deck-logic.ts +++ /dev/null @@ -1,45 +0,0 @@ -import {FnContext} from '../models'; -import {Card} from '../models'; - -export class DeckLogic { - constructor(private context: FnContext) {} - - /** - * Add a card to the discard pile - */ - discardCard(card: Card): void { - // Clone to avoid Proxy issues - this.context.G.discardPile.push({...card}); - } - - /** - * Get the last discarded card - */ - get lastDiscardedCard(): Card | null { - const pile = this.context.G.discardPile; - return pile.length > 0 ? pile[pile.length - 1] : null; - } - - /** - * Draw a card from the top of the draw pile - */ - drawCardFromPile(): Card | undefined { - return this.context.G.drawPile.shift(); - } - - /** - * Insert a card into the draw pile at a specific index - */ - insertCardIntoDrawPile(card: Card, index: number): void { - // Clone to avoid Proxy issues - this.context.G.drawPile.splice(index, 0, {...card}); - } - - /** - * Get draw pile size - */ - get drawPileSize(): number { - return this.context.G.drawPile.length; - } -} - diff --git a/src/common/wrappers/game-state-logic.ts b/src/common/wrappers/game-state-logic.ts deleted file mode 100644 index 693e089..0000000 --- a/src/common/wrappers/game-state-logic.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {FnContext} from '../models'; -import {PendingCardPlay, GameRules} from '../models'; - -export class GameStateLogic { - constructor(private context: FnContext) {} - - /** - * Get pending card play - */ - get pendingCardPlay(): PendingCardPlay | null { - return this.context.G.pendingCardPlay; - } - - set pendingCardPlay(pending: PendingCardPlay | null) { - this.context.G.pendingCardPlay = pending; - } - - set lobbyReady(ready: boolean) { - this.context.G.lobbyReady = ready; - } - - get gameRules(): GameRules { - return this.context.G.gameRules; - } -} - From 89bb639d4814ca48f80961248008de89b14d8bb5 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:48:42 +0100 Subject: [PATCH 02/55] refactor: use api in common --- .../board/card-animation/CardAnimation.tsx | 2 + .../board/overlay-manager/OverlayManager.tsx | 2 +- src/client/components/board/table/Table.tsx | 12 +- src/client/hooks/useCardAnimations.tsx | 14 +- src/common/entities/card-type.ts | 24 +-- src/common/entities/card-types/attack-card.ts | 15 +- src/common/entities/card-types/cat-card.ts | 67 +++----- src/common/entities/card-types/defuse-card.ts | 6 +- src/common/entities/card-types/favor-card.ts | 30 ++-- src/common/entities/card-types/nope-card.ts | 16 +- .../card-types/see-the-future-card.ts | 12 +- .../entities/card-types/shuffle-card.ts | 9 +- src/common/entities/card-types/skip-card.ts | 9 +- src/common/entities/card.ts | 40 +++++ src/common/entities/game-state.ts | 26 ---- src/common/entities/game.ts | 121 +++++---------- src/common/entities/piles.ts | 21 ++- src/common/entities/player.ts | 117 +++++++++++++- src/common/entities/players.ts | 59 ++++---- src/common/entities/turn-manager.ts | 30 ++++ src/common/game.ts | 23 +-- src/common/index.ts | 2 +- src/common/models/game-state.model.ts | 8 +- src/common/moves/defuse-exploding-kitten.ts | 34 +---- src/common/moves/draw-move.ts | 41 +---- src/common/moves/favor-card-move.ts | 25 ++- src/common/moves/play-card-move.ts | 143 ++---------------- src/common/moves/steal-card-move.ts | 21 +-- src/common/moves/system-moves.ts | 4 +- src/common/setup/game-setup.ts | 6 +- 30 files changed, 434 insertions(+), 505 deletions(-) create mode 100644 src/common/entities/card.ts delete mode 100644 src/common/entities/game-state.ts create mode 100644 src/common/entities/turn-manager.ts diff --git a/src/client/components/board/card-animation/CardAnimation.tsx b/src/client/components/board/card-animation/CardAnimation.tsx index a0d3ce3..e5d3cf7 100644 --- a/src/client/components/board/card-animation/CardAnimation.tsx +++ b/src/client/components/board/card-animation/CardAnimation.tsx @@ -22,6 +22,8 @@ export default function CardAnimation({animation, onComplete}: CardAnimationProp ? `/assets/cards/${animation.card.name}/${animation.card.index}.png` : '/assets/card-types/back/0.jpg'; + console.log(cardImage); + useEffect(() => { // Start animation immediately setIsVisible(true); diff --git a/src/client/components/board/overlay-manager/OverlayManager.tsx b/src/client/components/board/overlay-manager/OverlayManager.tsx index 212171d..ff7eb93 100644 --- a/src/client/components/board/overlay-manager/OverlayManager.tsx +++ b/src/client/components/board/overlay-manager/OverlayManager.tsx @@ -39,7 +39,7 @@ export default function OverlayManager({ } // Get the top 3 card-types from the draw pile for the see the future overlay - const futureCards = isViewingFuture ? G.drawPile.slice(0, 3) : []; + const futureCards = isViewingFuture ? G.piles.drawPile.slice(0, 3) : []; return ( <> diff --git a/src/client/components/board/table/Table.tsx b/src/client/components/board/table/Table.tsx index 334538f..9e02615 100644 --- a/src/client/components/board/table/Table.tsx +++ b/src/client/components/board/table/Table.tsx @@ -24,12 +24,12 @@ export default function Table({gameContext, playerHand = []}: TableProps) { const [isDrawing, setIsDrawing] = useState(false); const [isShuffling, setIsShuffling] = useState(false); const [lastDrawPileLength, setLastDrawPileLength] = useState(G.client.drawPileLength); - const [lastDiscardPileLength, setLastDiscardPileLength] = useState(G.discardPile.length); + const [lastDiscardPileLength, setLastDiscardPileLength] = useState(G.piles.discardPile.length); const [isHoveringDrawPile, setIsHoveringDrawPile] = useState(false); const [isDiscardPileSelected, setIsDiscardPileSelected] = useState(false); const discardPileRef = useRef(null); - const discardCard = G.discardPile[G.discardPile.length - 1]; + const discardCard = G.piles.discardPile[G.piles.discardPile.length - 1]; const discardImage = discardCard ? `/assets/cards/${discardCard.name}/${discardCard.index}.png` : "None"; // Check for Nope card in hand @@ -65,14 +65,14 @@ export default function Table({gameContext, playerHand = []}: TableProps) { pendingCardRef.current = null; // Check if discard pile changed - const discardChanged = G.discardPile.length > lastDiscardPileLength; + const discardChanged = G.piles.discardPile.length > lastDiscardPileLength; // Check if we just finished a pending play that was a Shuffle and NOT noped const resolvedShuffle = wasPending && !wasPending.isNoped && wasPending.card.name === 'shuffle'; - const lastCard = G.discardPile[G.discardPile.length - 1]; + const lastCard = G.piles.discardPile[G.piles.discardPile.length - 1]; // Trigger if newly placed shuffle // OR if delayed resolution happened @@ -85,9 +85,9 @@ export default function Table({gameContext, playerHand = []}: TableProps) { } if (!G.pendingCardPlay) { - setLastDiscardPileLength(G.discardPile.length); + setLastDiscardPileLength(G.piles.discardPile.length); } - }, [G.discardPile.length, lastDiscardPileLength, G.discardPile, G.pendingCardPlay]); + }, [G.piles.discardPile.length, lastDiscardPileLength, G.piles.discardPile, G.pendingCardPlay]); const handleDrawClick = () => { if (!isDrawing) { diff --git a/src/client/hooks/useCardAnimations.tsx b/src/client/hooks/useCardAnimations.tsx index d7e76cd..9b339e0 100644 --- a/src/client/hooks/useCardAnimations.tsx +++ b/src/client/hooks/useCardAnimations.tsx @@ -19,7 +19,7 @@ export const useCardAnimations = (G: IGameState, players: IPlayers, selfPlayerId const [animations, setAnimations] = useState([]); const animationIdCounter = useRef(0); const previousDrawPileLength = useRef(G.client.drawPileLength); - const previousDiscardPileLength = useRef(G.discardPile.length); + const previousDiscardPileLength = useRef(G.piles.discardPile.length); const previousPlayerHands = useRef({}); const previousLocalHand = useRef([]); @@ -90,9 +90,9 @@ export const useCardAnimations = (G: IGameState, players: IPlayers, selfPlayerId const handChanges = getHandChanges(currentHandCounts, previousPlayerHands.current); const drawPileDecreased = G.client.drawPileLength < previousDrawPileLength.current; - const discardPileIncreased = G.discardPile.length > previousDiscardPileLength.current; + const discardPileIncreased = G.piles.discardPile.length > previousDiscardPileLength.current; const pilesUnchanged = G.client.drawPileLength === previousDrawPileLength.current && - G.discardPile.length === previousDiscardPileLength.current; + G.piles.discardPile.length === previousDiscardPileLength.current; if (drawPileDecreased) { handChanges @@ -111,7 +111,7 @@ export const useCardAnimations = (G: IGameState, players: IPlayers, selfPlayerId } if (discardPileIncreased) { - const lastCard = G.discardPile[G.discardPile.length - 1]; + const lastCard = G.piles.discardPile[G.piles.discardPile.length - 1]; handChanges .filter(change => change.delta < 0) .forEach(change => triggerCardMovement(lastCard, `player-${change.playerId}`, 'discard-pile')); @@ -144,13 +144,13 @@ export const useCardAnimations = (G: IGameState, players: IPlayers, selfPlayerId card = lostCard; } } - + triggerCardMovement(card, `player-${playerLost.playerId}`, `player-${playerGained.playerId}`); } } previousDrawPileLength.current = G.client.drawPileLength; - previousDiscardPileLength.current = G.discardPile.length; + previousDiscardPileLength.current = G.piles.discardPile.length; previousPlayerHands.current = currentHandCounts; if (selfPlayerId && players[selfPlayerId]) { @@ -158,7 +158,7 @@ export const useCardAnimations = (G: IGameState, players: IPlayers, selfPlayerId } else { previousLocalHand.current = []; } - }, [G.client.drawPileLength, G.discardPile.length, G.discardPile, triggerCardMovement, players, selfPlayerId]); + }, [G.client.drawPileLength, G.piles.discardPile.length, G.piles.discardPile, triggerCardMovement, players, selfPlayerId]); const AnimationLayer = useCallback(() => ( <> diff --git a/src/common/entities/card-type.ts b/src/common/entities/card-type.ts index a620d92..c889aa2 100644 --- a/src/common/entities/card-type.ts +++ b/src/common/entities/card-type.ts @@ -1,4 +1,6 @@ -import type {ICard, IContext} from '../models'; +import type {ICard} from '../models'; +import {TheGame} from './game'; +import {Card} from './card'; export class CardType { name: string; @@ -15,17 +17,17 @@ export class CardType { return {name: this.name, index}; } - canBePlayed(_context: IContext, _card: ICard): boolean { + canBePlayed(_game: TheGame, _card: Card): boolean { return true; } - isNowCard(_context: IContext, _card: ICard): boolean { + isNowCard(_game: TheGame, _card: Card): boolean { return false; } - setupPendingState(context: IContext) { - context.events.setActivePlayers({ + setupPendingState(game: TheGame) { + game.turnManager.setActivePlayers({ currentPlayer: 'awaitingNowCards', others: { stage: 'respondWithNowCard', @@ -33,15 +35,15 @@ export class CardType { }); } - cleanupPendingState(context: IContext) { - const { events } = context; - events.endStage(); - events.setActivePlayers({value: {}}); + cleanupPendingState(game: TheGame) { + game.turnManager.endStage(); + game.turnManager.setActivePlayers({value: {}}); } - afterPlay(_context: IContext, _card: ICard): void {} + afterPlay(_game: TheGame, _card: Card): void {} + + onPlayed(_game: TheGame, _card: Card): void {} - onPlayed(_context: IContext, _card: ICard): void {} /** * Returns the sort order for this card type. diff --git a/src/common/entities/card-types/attack-card.ts b/src/common/entities/card-types/attack-card.ts index 6cef44a..1662632 100644 --- a/src/common/entities/card-types/attack-card.ts +++ b/src/common/entities/card-types/attack-card.ts @@ -1,5 +1,6 @@ import {CardType} from '../card-type'; -import {ICard, IContext} from "../../models"; +import {TheGame} from '../game'; +import {Card} from '../card'; export class AttackCard extends CardType { @@ -7,22 +8,24 @@ export class AttackCard extends CardType { super(name); } - onPlayed(context: IContext, _card: ICard) { - const { G, ctx, events } = context; + onPlayed(game: TheGame, _card: Card) { + const { turnManager } = game; + const { ctx } = game.context; // Add 3 to turnsRemaining for proper 2, 4, 6, 8 stacking // Formula: current + 2 (attack bonus) + 1 (compensate for onEnd decrement) // Example: turnsRemaining = 1 → 4, after onEnd = 3, next player takes 2 turns // Stacking: turnsRemaining = 2 → 5, after onEnd = 4, next player takes 4 turns (2+2) - const remaining = G.turnsRemaining; - G.turnsRemaining = remaining + 3; + const remaining = turnManager.turnsRemaining; + turnManager.turnsRemaining = remaining + 3; // End turn and force move to next player const nextPlayer = ctx.playOrderPos + 1; const nextPlayerIndex = nextPlayer % ctx.numPlayers; - events.endTurn({ next: nextPlayerIndex + "" }); + turnManager.endTurn({ next: nextPlayerIndex + "" }); } + sortOrder(): number { return 1; } diff --git a/src/common/entities/card-types/cat-card.ts b/src/common/entities/card-types/cat-card.ts index 2368181..e942aed 100644 --- a/src/common/entities/card-types/cat-card.ts +++ b/src/common/entities/card-types/cat-card.ts @@ -1,6 +1,7 @@ import {CardType} from '../card-type'; -import {ICard, IContext} from "../../models"; -import {stealCard} from '../../moves/steal-card-move'; +import {TheGame} from '../game'; +import {Card} from '../card'; +import {stealCard} from "../../moves/steal-card-move"; export class CatCard extends CardType { @@ -11,77 +12,45 @@ export class CatCard extends CardType { /** * Cat card-types can only be played in pairs */ - canBePlayed(context: IContext, card: ICard): boolean { - const { player, ctx } = context; - - const playerData = player.get(); - - // Count how many cat card-types with the same index the player has - const matchingCards = playerData.hand.filter( - (c: ICard) => c.name === card.name && c.index === card.index - ); + canBePlayed(game: TheGame, card: Card): boolean { + const player = game.players.actingPlayer; // Need at least 2 matching cat card-types to play + const matchingCards = player.getMatchingCards(card); if (matchingCards.length < 2) { return false; } - // Check if there is at least one other player with card-types - return Object.keys(player.state).some((playerId) => { - if (playerId === ctx.currentPlayer) { - return false; // Can't target yourself - } - const targetPlayerData = player.state[playerId]; - return targetPlayerData.isAlive && targetPlayerData.hand.length > 0; - }); + const candidates = game.players.getValidCardActionTargets(game.players.actingPlayer); + return candidates.length > 0; } /** * Prompt player to choose a target after pair cost is already consumed. */ - onPlayed(context: IContext, _card: ICard) { - const { events, player, ctx } = context; - - const candidates = Object.keys(player.state).filter((playerId) => { - const p = player.state[playerId]; - return playerId !== ctx.currentPlayer && p.isAlive && p.hand.length > 0; - }); + onPlayed(game: TheGame, _card: Card) { + const candidates = game.players.getValidCardActionTargets(game.players.actingPlayer); if (candidates.length === 1) { // Automatically choose the only valid opponent - stealCard(context, candidates[0]); + stealCard(game, candidates[0].id); } else if (candidates.length > 1) { - // Set stage to choose a player to steal from - events.setActivePlayers({ - currentPlayer: 'choosePlayerToStealFrom', - }); + game.turnManager.setStage('choosePlayerToStealFrom'); } } /** * Immediately consume the second matching cat card after the first is played. */ - afterPlay(context: IContext, card: ICard) { - const {G, player} = context; - const playerData = player.get(); - - const secondCardIndex = playerData.hand.findIndex( - (c: ICard) => c.name === card.name && c.index === card.index - ); + afterPlay(game: TheGame, card: Card) { + const player = game.players.actingPlayer; + const secondCard = player.removeCard(card.name); - if (secondCardIndex === -1) { + if (!secondCard) { + console.log("Error: Expected to find a second cat card to consume, but none found."); return; } - - const secondCard = playerData.hand[secondCardIndex]; - const newHand = playerData.hand.filter((_: ICard, index: number) => index !== secondCardIndex); - - player.set({ - ...playerData, - hand: newHand, - }); - - G.discardPile.push(secondCard); + game.piles.discardCard(secondCard); } sortOrder(): number { diff --git a/src/common/entities/card-types/defuse-card.ts b/src/common/entities/card-types/defuse-card.ts index 037b85d..96e816f 100644 --- a/src/common/entities/card-types/defuse-card.ts +++ b/src/common/entities/card-types/defuse-card.ts @@ -1,5 +1,6 @@ import {CardType} from '../card-type'; -import {ICard, IContext} from "../../models"; +import {TheGame} from '../game'; +import {Card} from '../card'; export class DefuseCard extends CardType { @@ -7,10 +8,11 @@ export class DefuseCard extends CardType { super(name); } - canBePlayed(_context: IContext, _card: ICard): boolean { + canBePlayed(_game: TheGame, _card: Card): boolean { return false; } + sortOrder(): number { return 99; } diff --git a/src/common/entities/card-types/favor-card.ts b/src/common/entities/card-types/favor-card.ts index 416a269..4659e7f 100644 --- a/src/common/entities/card-types/favor-card.ts +++ b/src/common/entities/card-types/favor-card.ts @@ -1,5 +1,6 @@ import {CardType} from '../card-type'; -import {ICard, IContext} from "../../models"; +import {TheGame} from '../game'; +import {Card} from '../card'; import {requestCard} from '../../moves/favor-card-move'; export class FavorCard extends CardType { @@ -8,36 +9,33 @@ export class FavorCard extends CardType { super(name); } - canBePlayed(context: IContext, _card: ICard): boolean { - const { player, ctx } = context; + canBePlayed(game: TheGame, _card: Card): boolean { + const { ctx } = game.context; // Check if there is at least one other player with card-types - return Object.keys(player.state).some((playerId) => { - if (playerId === ctx.currentPlayer) { + return game.players.allPlayers.some((target) => { + if (target.id === ctx.currentPlayer) { return false; // Can't target yourself } - const targetPlayerData = player.state[playerId]; - return targetPlayerData.isAlive && targetPlayerData.hand.length > 0; + return target.isAlive && target.getCardCount() > 0; }); } - onPlayed(context: IContext, _card: ICard) { - const { events, player, ctx } = context; + onPlayed(game: TheGame, _card: Card) { + const { ctx } = game.context; - const candidates = Object.keys(player.state).filter((playerId) => { - const p = player.state[playerId]; - return playerId !== ctx.currentPlayer && p.isAlive && p.hand.length > 0; + const candidates = game.players.allPlayers.filter((target) => { + return target.id !== ctx.currentPlayer && target.isAlive && target.getCardCount() > 0; }); if (candidates.length === 1) { // Automatically choose the only valid opponent - requestCard(context, candidates[0]); + requestCard(game.context, candidates[0].id); } else if (candidates.length > 1) { // Set stage to choose a player to request a card from - events.setActivePlayers({ - currentPlayer: 'choosePlayerToRequestFrom', - }); + game.turnManager.setStage("choosePlayerToRequestFrom") } + } sortOrder(): number { diff --git a/src/common/entities/card-types/nope-card.ts b/src/common/entities/card-types/nope-card.ts index 57645fb..892010e 100644 --- a/src/common/entities/card-types/nope-card.ts +++ b/src/common/entities/card-types/nope-card.ts @@ -1,7 +1,7 @@ import {CardType} from '../card-type'; -import {ICard, IContext} from "../../models"; +import {TheGame} from '../game'; +import {Card} from '../card'; import {validateNope} from '../../utils/action-validation'; -import {Game} from '../game'; export class NopeCard extends CardType { @@ -9,25 +9,25 @@ export class NopeCard extends CardType { super(name); } - isNowCard(_context: IContext, _card: ICard): boolean { + isNowCard(_game: TheGame, _card: Card): boolean { return true; } - canBePlayed(context: IContext, _card: ICard): boolean { - const {G, playerID} = context; + canBePlayed(game: TheGame, _card: Card): boolean { + const {G, playerID} = game.context; return validateNope(G, playerID); } - onPlayed(context: IContext, _card: ICard): void { - const game = new Game(context); + onPlayed(game: TheGame, _card: Card) { const pendingCardPlay = game.pendingCardPlay; - const player = game.actingPlayer; + const player = game.players.actingPlayer; if (!pendingCardPlay) { console.log("No pending card play to nope"); return; } + const nowMs = Date.now(); const windowDurationMs = Math.max(0, pendingCardPlay.expiresAtMs - pendingCardPlay.startedAtMs); diff --git a/src/common/entities/card-types/see-the-future-card.ts b/src/common/entities/card-types/see-the-future-card.ts index 932abc4..4a04b7d 100644 --- a/src/common/entities/card-types/see-the-future-card.ts +++ b/src/common/entities/card-types/see-the-future-card.ts @@ -1,5 +1,6 @@ import {CardType} from '../card-type'; -import {ICard, IContext} from "../../models"; +import {TheGame} from '../game'; +import {Card} from '../card'; export class SeeTheFutureCard extends CardType { @@ -7,15 +8,12 @@ export class SeeTheFutureCard extends CardType { super(name); } - onPlayed(context: IContext, _card: ICard) { - const { events } = context; - + onPlayed(game: TheGame, _card: Card) { // Set stage to view the future - events.setActivePlayers({ - currentPlayer: 'viewingFuture', - }); + game.turnManager.setStage("viewingFuture") } + sortOrder(): number { return 5; } diff --git a/src/common/entities/card-types/shuffle-card.ts b/src/common/entities/card-types/shuffle-card.ts index a6b34e1..871d12d 100644 --- a/src/common/entities/card-types/shuffle-card.ts +++ b/src/common/entities/card-types/shuffle-card.ts @@ -1,5 +1,6 @@ import {CardType} from '../card-type'; -import {ICard, IContext} from "../../models"; +import {TheGame} from '../game'; +import {Card} from '../card'; export class ShuffleCard extends CardType { @@ -7,11 +8,11 @@ export class ShuffleCard extends CardType { super(name); } - onPlayed(context: IContext, _card: ICard) { - const { G, random } = context; - G.drawPile = random.Shuffle(G.drawPile) + onPlayed(game: TheGame, _card: Card) { + game.piles.shuffleDrawPile(); } + sortOrder(): number { return 4; } diff --git a/src/common/entities/card-types/skip-card.ts b/src/common/entities/card-types/skip-card.ts index 3445aa1..df129d4 100644 --- a/src/common/entities/card-types/skip-card.ts +++ b/src/common/entities/card-types/skip-card.ts @@ -1,5 +1,6 @@ import {CardType} from '../card-type'; -import {ICard, IContext} from "../../models"; +import {TheGame} from '../game'; +import {Card} from '../card'; export class SkipCard extends CardType { @@ -7,11 +8,11 @@ export class SkipCard extends CardType { super(name); } - onPlayed(context: IContext, _card: ICard) { - const { events } = context; - events.endTurn(); + onPlayed(game: TheGame, _card: Card) { + game.turnManager.endTurn(); } + sortOrder(): number { return 2; } diff --git a/src/common/entities/card.ts b/src/common/entities/card.ts new file mode 100644 index 0000000..e60f495 --- /dev/null +++ b/src/common/entities/card.ts @@ -0,0 +1,40 @@ +import {ICard} from '../models'; +import {TheGame} from './game'; +import {cardTypeRegistry} from '../constants/card-types'; +import {CardType} from './card-type'; + +export class Card { + public name: string; + public index: number; + + constructor( + private game: TheGame, + _data: ICard + ) { + this.name = _data.name; + this.index = _data.index; + } + + get type(): CardType { + const t = cardTypeRegistry.get(this.name); + if (!t) throw new Error(`Unknown card type: ${this.name}`); + return t; + } + + canPlay(): boolean { + return this.type.canBePlayed(this.game, this); + } + + play(): void { + this.type.onPlayed(this.game, this); + } + + afterPlay(): void { + this.type.afterPlay(this.game, this); + } + + isNowCard(): boolean { + return this.type.isNowCard(this.game, this); + } +} + diff --git a/src/common/entities/game-state.ts b/src/common/entities/game-state.ts deleted file mode 100644 index 77fadbb..0000000 --- a/src/common/entities/game-state.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {IPendingCardPlay, IGameRules} from '../models'; -import {Game} from "./game"; - -export class GameState { - constructor(private game: Game) {} - - /** - * Get pending card play - */ - get pendingCardPlay(): IPendingCardPlay | null { - return this.game.context.G.pendingCardPlay; - } - - set pendingCardPlay(pending: IPendingCardPlay | null) { - this.game.context.G.pendingCardPlay = pending; - } - - set lobbyReady(ready: boolean) { - this.game.context.G.lobbyReady = ready; - } - - get gameRules(): IGameRules { - return this.game.context.G.gameRules; - } -} - diff --git a/src/common/entities/game.ts b/src/common/entities/game.ts index 0c060dc..45769f5 100644 --- a/src/common/entities/game.ts +++ b/src/common/entities/game.ts @@ -1,113 +1,76 @@ -import {IContext} from '../models'; -import {Player} from './player'; +import {IContext, IGameState} from '../models'; import {Piles} from './piles'; -import {GameState} from './game-state'; import {Players} from './players'; -import {ICard} from '../models'; +import {TurnManager} from './turn-manager'; import {IPendingCardPlay, IGameRules} from '../models'; +import {Card} from "./card"; +import {RandomAPI} from "boardgame.io/dist/types/src/plugins/random/random"; +import {EventsAPI} from "boardgame.io/dist/types/src/plugins/events/events"; -export class Game { + +export class TheGame { public readonly context: IContext; - public readonly deck: Piles; - public readonly state: GameState; + public readonly gameState: IGameState; + public readonly events: EventsAPI; + public readonly random: RandomAPI; + + public readonly piles: Piles; public readonly players: Players; + public readonly turnManager: TurnManager; constructor(context: IContext) { this.context = context; - this.deck = new Piles(this); - this.state = new GameState(this); - this.players = new Players(this); - } + this.gameState = context.G; + this.events = context.events; + this.random = context.random; - /** - * Get a player wrapper instance for a specific player ID. - * Throws if player data not found. - */ - getPlayer(id: string): Player { - return this.players.getPlayer(id); + this.piles = new Piles(this, this.gameState.piles); + this.players = new Players(this, this.context.player); + this.turnManager = new TurnManager(this); } - /** - * Get a wrapper for the current player based on context.currentPlayer - */ - get currentPlayer(): Player { - return this.players.currentPlayer; - } - - /** - * Get a wrapper for the player executing the move (if playerID available in context) - * Falls back to currentPlayer if playerID not set - */ - get actingPlayer(): Player { - return this.players.actingPlayer; - } - - /** - * Get all players as wrappers - */ - get allPlayers(): Player[] { - return this.players.allPlayers; - } /** * Get pending card play */ get pendingCardPlay(): IPendingCardPlay | null { - return this.state.pendingCardPlay; + return this.gameState.pendingCardPlay; } - /** - * Add a card to the discard pile - */ - discardCard(card: ICard): void { - this.deck.discardCard(card); + set pendingCardPlay(pending: IPendingCardPlay | null) { + this.gameState.pendingCardPlay = pending; } - /** - * Get the last discarded card - */ - get lastDiscardedCard(): ICard | null { - return this.deck.lastDiscardedCard; + set lobbyReady(ready: boolean) { + this.gameState.lobbyReady = ready; } - /** - * Draw a card from the top of the draw pile - */ - drawCardFromPile(): ICard | undefined { - return this.deck.drawCardFromPile(); + get gameRules(): IGameRules { + return this.gameState.gameRules; } /** - * Insert a card into the draw pile at a specific index + * Resolve any pending card play if the window (timer) has expired. */ - insertCardIntoDrawPile(card: ICard, index: number): void { - this.deck.insertCardIntoDrawPile(card, index); - } + resolvePendingCard(): void { + const pendingCardPlay = this.pendingCardPlay; - /** - * Get draw pile size - */ - get drawPileSize(): number { - return this.deck.drawPileSize; - } + if (!pendingCardPlay) { + return; + } - set pendingCardPlay(pending: IPendingCardPlay | null) { - this.state.pendingCardPlay = pending; - } + // Check if the timer has expired + if (Date.now() < pendingCardPlay.expiresAtMs) { + return; + } - set lobbyReady(ready: boolean) { - this.state.lobbyReady = ready; - } + this.pendingCardPlay = null; - get gameRules(): IGameRules { - return this.state.gameRules; - } + const card = new Card(this, pendingCardPlay.card); + card.type.cleanupPendingState(this); - /** - * Validate if a player is a valid target for an action. - * Checks if target is alive, has card-types, and is not the current player. - */ - validateTarget(targetPlayerId: string): Player { - return this.players.validateTarget(targetPlayerId); + if (!pendingCardPlay.isNoped) { + card.play(); + } } } diff --git a/src/common/entities/piles.ts b/src/common/entities/piles.ts index 15310cb..5d8cbf9 100644 --- a/src/common/entities/piles.ts +++ b/src/common/entities/piles.ts @@ -1,22 +1,23 @@ -import {ICard} from '../models'; -import {Game} from "./game"; +import {ICard, IPiles} from '../models'; +import {TheGame} from "./game"; export class Piles { - constructor(private game: Game) {} + constructor(private game: TheGame, public state: IPiles) { + } /** * Add a card to the discard pile */ discardCard(card: ICard): void { // Clone to avoid Proxy issues - this.game.context.G.discardPile.push({...card}); + this.state.discardPile.push({...card}); } /** * Get the last discarded card */ get lastDiscardedCard(): ICard | null { - const pile = this.game.context.G.discardPile; + const pile = this.state.discardPile; return pile.length > 0 ? pile[pile.length - 1] : null; } @@ -24,7 +25,7 @@ export class Piles { * Draw a card from the top of the draw pile */ drawCardFromPile(): ICard | undefined { - return this.game.context.G.drawPile.shift(); + return this.state.drawPile.shift(); } /** @@ -32,14 +33,18 @@ export class Piles { */ insertCardIntoDrawPile(card: ICard, index: number): void { // Clone to avoid Proxy issues - this.game.context.G.drawPile.splice(index, 0, {...card}); + this.state.drawPile.splice(index, 0, {...card}); } /** * Get draw pile size */ get drawPileSize(): number { - return this.game.context.G.drawPile.length; + return this.state.drawPile.length; + } + + shuffleDrawPile(): void { + this.state.drawPile = this.game.random.Shuffle(this.state.drawPile); } } diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 774b2c2..fc316b2 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -1,9 +1,11 @@ import {ICard, IPlayer} from '../models'; -import {Game} from "./game"; +import {TheGame} from "./game"; +import {Card} from "./card"; +import {EXPLODING_KITTEN, DEFUSE} from "../constants/card-types"; export class Player { constructor( - private game: Game, + private game: TheGame, private _state: IPlayer, public readonly id: string ) {} @@ -40,9 +42,16 @@ export class Player { /** * Get all card-types of a specific type from hand, or all card-types if no type specified */ - getCards(cardName?: string): ICard[] { - if (!cardName) return this._state.hand; - return this._state.hand.filter(c => c.name === cardName); + getCards(cardName: Card | string): Card[] { + const name = typeof cardName === 'string' ? cardName : cardName.name; + return this._state.hand.filter(c => c.name === name).map(c => new Card(this.game, c)); + } + + /** + * Get all card-types that match both name and index of the given card. + */ + getMatchingCards(card: Card): Card[] { + return this.getCards(card.name).filter(c => c.index === card.index); } /** @@ -115,7 +124,7 @@ export class Player { eliminate(): void { this._state.isAlive = false; // put all hand card-types in discard pile - this._state.hand.forEach(card => this.game.discardCard(card)); + this._state.hand.forEach(card => this.game.piles.discardCard(card)); } /** @@ -130,6 +139,102 @@ export class Player { return card; } + playCard(cardIndex: number): void { + if (cardIndex < 0 || cardIndex >= this.hand.length) { + throw new Error(`Invalid card index: ${cardIndex}`); + } + + const cardData = this.hand[cardIndex]; + // Create Card wrapper + const card = new Card(this.game, cardData); + + if (!card.type.canBePlayed(this.game, card)) { + throw new Error(`Card cannot be played: ${card.name}`); + } + + // Remove card from hand + const playedCardData = this.removeCardAt(cardIndex); + if (!playedCardData) return; // Should not happen + + this.game.piles.discardCard(playedCardData); + card.afterPlay(); + + if (card.isNowCard()) { + card.play(); + return; + } + + // Setup pending state for non-immediate cards + const startedAtMs = Date.now(); + this.game.pendingCardPlay = { + card: {...playedCardData}, + playedBy: this.id, + startedAtMs, + expiresAtMs: startedAtMs + (this.game.gameRules.pendingTimerMs || 5000), + lastNopeBy: null, + nopeCount: 0, + isNoped: false + }; + + // Note: card.type.setupPendingState logic might need valid PendingCardPlay to be set first? + // The original setupPendingState in CardType calls setActivePlayers. + card.type.setupPendingState(this.game); + } + + draw(): void { + if (!this.isAlive) throw new Error("Dead player cannot draw"); + + const cardData = this.game.piles.drawCardFromPile(); + if (!cardData) throw new Error("No cards left to draw"); + + this.addCard(cardData); + + if (cardData.name === EXPLODING_KITTEN.name) { + const hasDefuse = this.hasCard(DEFUSE.name); + if (hasDefuse) { + this.game.turnManager.setStage('defuseExplodingKitten'); + } else { + this.eliminate(); + this.game.turnManager.endTurn(); + } + } else { + this.game.turnManager.endTurn(); + } + } + + defuseExplodingKitten(insertIndex: number): void { + if (insertIndex < 0 || insertIndex > this.game.piles.drawPileSize) { + throw new Error('Invalid insert index'); + } + + const defuseCard = this.removeCard(DEFUSE.name); + const kittenCard = this.removeCard(EXPLODING_KITTEN.name); + + if (!defuseCard || !kittenCard) { + // Should not happen if UI is correct, but safer to eliminate + this.eliminate(); + this.game.turnManager.endStage(); + this.game.turnManager.endTurn(); + return; + } + + this.game.piles.discardCard(defuseCard); + this.game.piles.insertCardIntoDrawPile(kittenCard, insertIndex); + + this.game.turnManager.endStage(); + this.game.turnManager.endTurn(); + } + + stealRandomCardFrom(target: Player): ICard { + const count = target.getCardCount(); + if (count === 0) throw new Error("Target has no cards"); + + // Use game context random + const index = Math.floor(this.game.context.random.Number() * count); + // Give card from target to this player + return target.giveCard(index, this); + } + private _updateClientState() { if (this._state.client) { this._state.client.handCount = this._state.hand.length; diff --git a/src/common/entities/players.ts b/src/common/entities/players.ts index 1c0af05..a866731 100644 --- a/src/common/entities/players.ts +++ b/src/common/entities/players.ts @@ -1,8 +1,10 @@ import {Player} from './player'; -import {Game} from "./game"; +import {TheGame} from "./game"; +import {PlayerID} from "boardgame.io"; +import {IPlayerAPI} from "../models"; export class Players { - constructor(private game: Game) {} + constructor(private game: TheGame, private playerAPI: IPlayerAPI) {} /** * Get a player wrapper instance for a specific player ID. @@ -10,7 +12,7 @@ export class Players { */ getPlayer(id: string): Player { // boardgame.io player plugin structure - const playerData = this.game.context.player.state?.[id]; + const playerData = this.playerAPI.state?.[id]; if (!playerData) { throw new Error(`Player data not found for ID: ${id}`); } @@ -25,7 +27,7 @@ export class Players { } /** - * Get a wrapper for the player executing the move (if playerID available in context) + * Get the player executing the move (if playerID available in context) * Falls back to currentPlayer if playerID not set */ get actingPlayer(): Player { @@ -34,39 +36,44 @@ export class Players { } /** - * Get all players as wrappers + * Get all players */ get allPlayers(): Player[] { - const playerIDs = Object.keys(this.game.context.player.state || {}); + const playerIDs = Object.keys(this.playerAPI.state || {}); return playerIDs.map(id => this.getPlayer(id)); } /** - * Validate if a player is a valid target for an action. - * Checks if target is alive, has card-types, and is not the current player. + * Get all alive players */ - validateTarget(targetPlayerId: string): Player { - const target = this.getPlayer(targetPlayerId); - - let current; - try { - current = this.currentPlayer; - } catch(e) { - // Current player might be undefined in some contexts - } + get alivePlayers(): Player[] { + return this.allPlayers.filter(player => player.isAlive); + } - if (current && target.id === current.id) { - throw new Error('Cannot target yourself'); - } + /** + * Get all alive players who have at least one card in hand + */ + get playersWithCards(): Player[] { + return this.alivePlayers.filter(player => player.getCardCount() > 0); + } - if (!target.isAlive) { - throw new Error('Target player is dead'); - } + /** + * Get all alive players who have at least one card in hand + */ + getValidCardActionTargets(exclude: PlayerID | Player): Player[] { + return this.playersWithCards.filter(player => (player.id !== (exclude instanceof Player ? exclude.id : exclude))); + } - if (target.getCardCount() === 0) { - throw new Error('Target player has no card-types'); + /** + * Validate if a player is a valid target for an action. + * Checks if target is alive, has card-types, and is not the current player. + */ + validateTarget(targetPlayerId: string): Player { + const validTargets = this.getValidCardActionTargets(this.game.context.ctx.currentPlayer); + const target = validTargets.find(player => player.id === targetPlayerId); + if (!target) { + throw new Error(`Invalid target player ID: ${targetPlayerId}`); } - return target; } } diff --git a/src/common/entities/turn-manager.ts b/src/common/entities/turn-manager.ts new file mode 100644 index 0000000..e057f7a --- /dev/null +++ b/src/common/entities/turn-manager.ts @@ -0,0 +1,30 @@ +import {TheGame} from "./game"; + +export class TurnManager { + constructor(private game: TheGame) {} + + get turnsRemaining(): number { + return this.game.context.G.turnsRemaining; + } + + set turnsRemaining(value: number) { + this.game.context.G.turnsRemaining = value; + } + + endTurn(arg?: any): void { + this.game.context.events.endTurn(arg); + } + + setStage(stage: string): void { + this.game.context.events.setStage(stage); + } + + endStage(): void { + this.game.context.events.endStage(); + } + + setActivePlayers(arg: any): void { + this.game.context.events.setActivePlayers(arg); + } +} + diff --git a/src/common/game.ts b/src/common/game.ts index 4f14bfe..4d9fd03 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -1,16 +1,17 @@ -import {Ctx, Game} from 'boardgame.io'; +import {Game, PlayerID} from 'boardgame.io'; import {createPlayerPlugin} from './plugins/player-plugin'; import {setupGame} from './setup/game-setup'; import type {ICard, IContext, IGameState, IPluginAPIs} from './models'; import {drawCard} from "./moves/draw-move"; import {playCard, playNowCard, resolvePendingCard} from "./moves/play-card-move"; -import {stealCard} from "./moves/steal-card-move"; import {requestCard, giveCard} from "./moves/favor-card-move"; import {closeFutureView} from "./moves/see-future-move"; import {turnOrder} from "./utils/turn-order"; import {OriginalDeck} from './entities/deck-types/original-deck'; import {dealHands} from './setup/player-setup'; import {defuseExplodingKitten} from "./moves/defuse-exploding-kitten"; +import {TheGame} from "./entities/game"; +import {stealCard} from "./moves/steal-card-move"; export const ExplodingKittens: Game = { name: "Exploding-Kittens", @@ -21,21 +22,21 @@ export const ExplodingKittens: Game = { disableUndo: true, - playerView: ({G, ctx, playerID}: {G: IGameState; ctx: Ctx; playerID: any}) => { + playerView: ({G, ctx, playerID}) => { // The player plugin's playerView will handle filtering the player data // We need to pass G through so it's available let viewableDrawPile: ICard[] = []; if (ctx.activePlayers?.[playerID!] === 'viewingFuture') { - viewableDrawPile = G.drawPile.slice(0, 3); + viewableDrawPile = G.piles.drawPile.slice(0, 3); } return { ...G, drawPile: viewableDrawPile, client: { - drawPileLength: G.drawPile.length + drawPileLength: G.piles.drawPile.length } }; }, @@ -50,15 +51,17 @@ export const ExplodingKittens: Game = { // Reset game state for lobby G.lobbyReady = false; }, - onEnd: ({G, ctx, player}) => { + onEnd: (context: IContext) => { + const game = new TheGame(context) + // Deal card-types when leaving lobby phase const deck = new OriginalDeck(); const pile: ICard[] = deck.buildBaseDeck().sort(() => Math.random() - 0.5); - dealHands(pile, player.state, deck); - deck.addPostDealCards(pile, Object.keys(ctx.playOrder).length); + dealHands(pile, game.context.player.state, deck); // TODO: use api wrapper + deck.addPostDealCards(pile, Object.keys(game.context.ctx.playOrder).length); - G.drawPile = pile.sort(() => Math.random() - 0.5); + game.piles.state.drawPile = pile.sort(() => Math.random() - 0.5); }, endIf: ({G}) => { // Move to play phase only when lobbyReady flag is explicitly set @@ -114,7 +117,7 @@ export const ExplodingKittens: Game = { choosePlayerToStealFrom: { moves: { stealCard: { - move: stealCard, + move: (context: IContext, targetPlayerId: PlayerID) => stealCard(new TheGame(context), targetPlayerId), client: false }, }, diff --git a/src/common/index.ts b/src/common/index.ts index 76e4243..0b0f1a5 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -42,4 +42,4 @@ export {canPlayerNope, validateNope} from './utils/action-validation'; // Wrappers export {Player} from './entities/player'; -export {Game} from './entities/game'; +export {TheGame} from './entities/game'; diff --git a/src/common/models/game-state.model.ts b/src/common/models/game-state.model.ts index 76a9369..efa7f38 100644 --- a/src/common/models/game-state.model.ts +++ b/src/common/models/game-state.model.ts @@ -21,10 +21,14 @@ export interface IPendingCardPlay { isNoped: boolean; } -export interface IGameState { - winner: PlayerID | null; +export interface IPiles { drawPile: ICard[]; discardPile: ICard[]; +} + +export interface IGameState { + winner: PlayerID | null; + piles: IPiles; pendingCardPlay: IPendingCardPlay | null; turnsRemaining: number; gameRules: IGameRules; diff --git a/src/common/moves/defuse-exploding-kitten.ts b/src/common/moves/defuse-exploding-kitten.ts index e86f320..70e1bb4 100644 --- a/src/common/moves/defuse-exploding-kitten.ts +++ b/src/common/moves/defuse-exploding-kitten.ts @@ -1,32 +1,12 @@ import {IContext} from "../models"; -import {Game} from "../entities/game"; -import {DEFUSE, EXPLODING_KITTEN} from "../constants/card-types"; +import {TheGame} from "../entities/game"; export const defuseExplodingKitten = (context: IContext, insertIndex: number) => { - const { events } = context; - const game = new Game(context); - const player = game.actingPlayer; - - if (insertIndex < 0 || insertIndex >= game.drawPileSize) { - console.error('Invalid insert index:', insertIndex); - return; - } - - const defuseCard = player.removeCard(DEFUSE.name); - const explodingKittenCard = player.removeCard(EXPLODING_KITTEN.name); - - if (!defuseCard || !explodingKittenCard) { - console.error('Player does not have required card-types to defuse Exploding Kitten'); - // should not happen given game flow, but just in case, eliminate player - player.eliminate(); - events.endStage(); - events.endTurn(); - return; + const game = new TheGame(context); + try { + game.players.actingPlayer.defuseExplodingKitten(insertIndex); + } catch (e) { + console.error("Failed to defuse exploding kitten", e); } - - game.discardCard(defuseCard); - game.insertCardIntoDrawPile(explodingKittenCard, insertIndex); - - events.endStage(); - events.endTurn(); }; + diff --git a/src/common/moves/draw-move.ts b/src/common/moves/draw-move.ts index 36e6012..aa7cce5 100644 --- a/src/common/moves/draw-move.ts +++ b/src/common/moves/draw-move.ts @@ -1,39 +1,12 @@ import {IContext} from "../models"; -import {EXPLODING_KITTEN, DEFUSE} from "../constants/card-types"; -import {Game} from "../entities/game"; +import {TheGame} from "../entities/game"; export const drawCard = (context: IContext) => { - const { events } = context; - const game = new Game(context); - const player = game.actingPlayer; - - if (!player.isAlive) { - throw new Error('Dead player cannot draw card-types'); - } - - const cardToDraw = game.drawCardFromPile(); - if (!cardToDraw) { - throw new Error('No card to draw'); - } - - player.addCard(cardToDraw); - - // Handle Exploding Kitten - if (cardToDraw.name === EXPLODING_KITTEN.name) { - // Check for Defuse - const hasDefuse = player.hasCard(DEFUSE.name); - - if (hasDefuse) { - events.setStage('defuseExplodingKitten') - return; - } - - // No Defuse - Player Explodes! - player.eliminate(); - events.endTurn(); - return; + const game = new TheGame(context); + try { + game.players.actingPlayer.draw(); + } catch (e) { + console.error("Failed to draw card", e); } - - // Safe card - events.endTurn(); }; + diff --git a/src/common/moves/favor-card-move.ts b/src/common/moves/favor-card-move.ts index 18588ff..74a8028 100644 --- a/src/common/moves/favor-card-move.ts +++ b/src/common/moves/favor-card-move.ts @@ -1,22 +1,19 @@ import {IContext} from "../models"; import {PlayerID} from "boardgame.io"; -import {Game} from "../entities/game"; +import {TheGame} from "../entities/game"; /** * Request a card from a target player (favor card - first stage) */ export const requestCard = (context: IContext, targetPlayerId: PlayerID) => { - const {events} = context; - const game = new Game(context); + const game = new TheGame(context); // Validate target player - game.validateTarget(targetPlayerId); + game.players.validateTarget(targetPlayerId); // End current player's stage and set the target player to choose a card to give - events.endStage(); - - // Set target player to choose a card to give - events.setActivePlayers({ + game.turnManager.endStage(); + game.turnManager.setActivePlayers({ value: { [targetPlayerId]: 'chooseCardToGive', }, @@ -27,8 +24,8 @@ export const requestCard = (context: IContext, targetPlayerId: PlayerID) => { * Give a card to the requesting player (favor card - second stage) */ export const giveCard = (context: IContext, cardIndex: number) => { - const {ctx, events} = context; - const game = new Game(context); + const {ctx} = context; + const game = new TheGame(context); // Find who is giving the card (the player in the chooseCardToGive stage) const givingPlayerId = Object.keys(ctx.activePlayers || {}).find( @@ -39,13 +36,15 @@ export const giveCard = (context: IContext, cardIndex: number) => { throw Error('No player is in the card giving stage'); } - const givingPlayer = game.getPlayer(givingPlayerId); - const requestingPlayer = game.currentPlayer; + const givingPlayer = game.players.getPlayer(givingPlayerId); + const requestingPlayer = game.players.currentPlayer; // Transfer the card from giving player to requesting player givingPlayer.giveCard(cardIndex, requestingPlayer); // End all stages - events.endStage(); + game.turnManager.endStage(); }; + + diff --git a/src/common/moves/play-card-move.ts b/src/common/moves/play-card-move.ts index d674277..9f8f4f9 100644 --- a/src/common/moves/play-card-move.ts +++ b/src/common/moves/play-card-move.ts @@ -1,144 +1,21 @@ import {IContext} from "../models"; -import {cardTypeRegistry, NOPE} from "../constants/card-types"; -import {Game} from "../entities/game"; - +import {TheGame} from "../entities/game"; export const playCard = (context: IContext, cardIndex: number) => { - const game = new Game(context); - const player = game.actingPlayer; - const hand = player.hand; - - if (cardIndex < 0 || cardIndex >= hand.length) { - console.error('Invalid card index:', cardIndex); - return; - } - - // Peek at the card first to check validity - const cardPeek = hand[cardIndex]; - const cardType = cardTypeRegistry.get(cardPeek.name); - - if (!cardType) { - throw new Error('Unknown card type: ' + cardPeek.name); + const game = new TheGame(context); + try { + game.players.actingPlayer.playCard(cardIndex); + } catch (e) { + console.error('Failed to play card', e); } - - if (!cardType.canBePlayed(context, cardPeek)) { - return; - } - - // Actually remove the card - const cardToPlay = player.removeCardAt(cardIndex); - if (!cardToPlay) { - return; // Should not happen given checks above - } - - game.discardCard(cardToPlay); - cardType.afterPlay(context, cardToPlay); - - if (cardType.isNowCard(context, cardToPlay)) { - // Immediate resolution for Now card-types (like Nope) - cardType.onPlayed(context, cardToPlay); - return; - } - - // For normal action card-types, setup pending state - const actingPlayerID = player.id; - const startedAtMs = Date.now(); - - game.pendingCardPlay = { - card: {...cardToPlay}, - playedBy: actingPlayerID, - startedAtMs, - expiresAtMs: startedAtMs + game.gameRules.pendingTimerMs, - lastNopeBy: null, - nopeCount: 0, - isNoped: false, - }; - - // Skip wait if playing with open card-types and no one else can Nope - if (game.gameRules.openCards) { - const otherPlayers = game.allPlayers.filter(p => p.id !== actingPlayerID && p.isAlive); - const canSomeoneNope = otherPlayers.some(p => p.hasCard(NOPE.name)); - - if (!canSomeoneNope) { - // Expire immediately to allow client to animate the play before resolution - game.pendingCardPlay!.expiresAtMs = startedAtMs; - } - } - - cardType.setupPendingState(context); }; export const playNowCard = (context: IContext, cardIndex: number) => { - const game = new Game(context); - const player = game.actingPlayer; - const hand = player.hand; - - if (cardIndex < 0 || cardIndex >= hand.length) { - return; - } - - const selectedCard = hand[cardIndex]; - const cardType = cardTypeRegistry.get(selectedCard.name); - - if (!cardType || !cardType.isNowCard(context, selectedCard)) { - return; - } - - // Validate playability (e.g. can we Nope?) - if (!cardType.canBePlayed(context, selectedCard)) { - return; - } - - // Remove and play - const cardToPlay = player.removeCardAt(cardIndex); - if (!cardToPlay) return; - - game.discardCard(cardToPlay); - cardType.onPlayed(context, cardToPlay); - - // Optimization: If open card-types are enabled and no one else can Nope back, resolve immediately - if (game.gameRules.openCards) { - const pending = game.pendingCardPlay; - if (pending) { - const lastNopeBy = pending.lastNopeBy; - // Anyone EXCEPT the last noper can potentially nope back - const potentialNopers = game.allPlayers.filter(p => p.id !== lastNopeBy && p.isAlive); - const canAnyoneNope = potentialNopers.some(p => p.hasCard(NOPE.name)); - - if (!canAnyoneNope) { - // Expire immediately - pending.expiresAtMs = Date.now(); - } - } - } + playCard(context, cardIndex); }; export const resolvePendingCard = (context: IContext) => { - const game = new Game(context); - const pendingCardPlay = game.pendingCardPlay; - - // Check if we have a pending card to resolve - if (!pendingCardPlay) { - return; - } - - // Check if the timer has expired - // We allow ANY player to trigger resolution if the timer has expired - // This prevents the game from getting stuck if the current player disconnects - if (Date.now() < pendingCardPlay.expiresAtMs) { - return; - } - - game.pendingCardPlay = null; - - const cardType = cardTypeRegistry.get(pendingCardPlay.card.name); - if (!cardType) { - return; - } - - cardType.cleanupPendingState(context); - - if (!pendingCardPlay.isNoped) { - cardType.onPlayed(context, pendingCardPlay.card); - } + const game = new TheGame(context); + game.resolvePendingCard(); }; + diff --git a/src/common/moves/steal-card-move.ts b/src/common/moves/steal-card-move.ts index 00eae2f..51e66c2 100644 --- a/src/common/moves/steal-card-move.ts +++ b/src/common/moves/steal-card-move.ts @@ -1,20 +1,11 @@ -import {IContext} from "../models"; import {PlayerID} from "boardgame.io"; -import {Game} from "../entities/game"; - -export const stealCard = (context: IContext, targetPlayerId: PlayerID) => { - const {events, random} = context; - const game = new Game(context); +import {TheGame} from "../entities/game"; +export function stealCard(game: TheGame, targetPlayerId: PlayerID) { // Validate target player - const targetPlayer = game.validateTarget(targetPlayerId); - - // Pick a random card index from target player's hand - const randomIndex = Math.floor(random.Number() * targetPlayer.getCardCount()); - - // Transfer the card from target to current player - targetPlayer.giveCard(randomIndex, game.currentPlayer); + const targetPlayer = game.players.validateTarget(targetPlayerId); - events.endStage(); -}; + game.players.actingPlayer.stealRandomCardFrom(targetPlayer); + game.turnManager.endStage(); +} diff --git a/src/common/moves/system-moves.ts b/src/common/moves/system-moves.ts index 8959cbb..5e23864 100644 --- a/src/common/moves/system-moves.ts +++ b/src/common/moves/system-moves.ts @@ -1,12 +1,12 @@ import {IContext} from '../models'; -import {Game} from '../entities/game'; +import {TheGame} from '../entities/game'; /** * Mark the lobby as ready to start * This should be called when all players have joined */ export const setLobbyReady = (context: IContext) => { - const game = new Game(context); + const game = new TheGame(context); game.lobbyReady = true; }; diff --git a/src/common/setup/game-setup.ts b/src/common/setup/game-setup.ts index c482adb..d5fbb6b 100644 --- a/src/common/setup/game-setup.ts +++ b/src/common/setup/game-setup.ts @@ -12,8 +12,10 @@ export const setupGame = (_context: any, setupData?: SetupData): IGameState => { // Don't deal card-types yet - will be done when lobby phase ends return { winner: null, - drawPile: [], - discardPile: [], + piles: { + drawPile: [], + discardPile: [], + }, pendingCardPlay: null, turnsRemaining: 1, gameRules: { From 45d598c90953c82503f26078ee073fcde45fe303 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:57:35 +0100 Subject: [PATCH 03/55] refactor: use proper objects --- src/common/entities/piles.ts | 17 ++++++++----- src/common/entities/player.ts | 45 ++++++++++++++--------------------- 2 files changed, 29 insertions(+), 33 deletions(-) diff --git a/src/common/entities/piles.ts b/src/common/entities/piles.ts index 5d8cbf9..3577f2f 100644 --- a/src/common/entities/piles.ts +++ b/src/common/entities/piles.ts @@ -1,5 +1,6 @@ import {ICard, IPiles} from '../models'; import {TheGame} from "./game"; +import {Card} from "./card"; export class Piles { constructor(private game: TheGame, public state: IPiles) { @@ -8,24 +9,28 @@ export class Piles { /** * Add a card to the discard pile */ - discardCard(card: ICard): void { + discardCard(card: Card | ICard): void { + const cardData: ICard = {name: card.name, index: card.index}; + // Clone to avoid Proxy issues - this.state.discardPile.push({...card}); + this.state.discardPile.push({...cardData}); } /** * Get the last discarded card */ - get lastDiscardedCard(): ICard | null { + get lastDiscardedCard(): Card | null { const pile = this.state.discardPile; - return pile.length > 0 ? pile[pile.length - 1] : null; + const iCard = pile[pile.length - 1]; + return pile.length > 0 ? new Card(this.game, iCard) : null; } /** * Draw a card from the top of the draw pile */ - drawCardFromPile(): ICard | undefined { - return this.state.drawPile.shift(); + drawCardFromPile(): Card | null { + const shift = this.state.drawPile.shift(); + return shift ? new Card(this.game, shift) : null; } /** diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index fc316b2..6e12a83 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -10,19 +10,11 @@ export class Player { public readonly id: string ) {} - /** - * Get the underlying raw state object. - * Useful if direct property access or modification is needed. - */ - get state(): IPlayer { - return this._state; - } - /** * Get the player's hand of card-types */ - get hand(): ICard[] { - return this._state.hand; + get hand(): Card[] { + return this._state.hand.map(c => new Card(this.game, c)); } /** @@ -64,9 +56,11 @@ export class Player { /** * Add a card to the player's hand */ - addCard(card: ICard): void { + addCard(card: Card | ICard): void { + const cardData: ICard = {name: card.name, index: card.index}; + // Clone to avoid Proxy issues - this._state.hand.push({...card}); + this._state.hand.push({...cardData}); this._updateClientState(); } @@ -74,18 +68,18 @@ export class Player { * Remove a card at a specific index * @returns The removed card, or undefined if index invalid */ - removeCardAt(index: number): ICard | undefined { + removeCardAt(index: number): Card | undefined { if (index < 0 || index >= this._state.hand.length) return undefined; const [card] = this._state.hand.splice(index, 1); this._updateClientState(); - return card; + return new Card(this.game, card); } /** * Remove the first occurrence of a specific card type * @returns The removed card, or undefined if not found */ - removeCard(cardName: string): ICard | undefined { + removeCard(cardName: string): Card | undefined { const index = this._state.hand.findIndex(c => c.name === cardName); if (index === -1) return undefined; return this.removeCardAt(index); @@ -95,13 +89,13 @@ export class Player { * Remove all card-types of a specific type * @returns Array of removed card-types */ - removeAllCards(cardName: string): ICard[] { - const removed: ICard[] = []; + removeAllCards(cardName: string): Card[] { + const removed: Card[] = []; // Iterate backwards to safely remove for (let i = this._state.hand.length - 1; i >= 0; i--) { if (this._state.hand[i].name === cardName) { const [card] = this._state.hand.splice(i, 1); - removed.push(card); + removed.push(new Card(this.game, card)); } } if (removed.length > 0) { @@ -114,11 +108,11 @@ export class Player { * Remove all card-types from hand * @returns Array of all removed card-types */ - removeAllCardsFromHand(): ICard[] { + removeAllCardsFromHand(): Card[] { const removed = [...this._state.hand]; this._state.hand = []; this._updateClientState(); - return removed; + return removed.map(c => new Card(this.game, c)); } eliminate(): void { @@ -130,7 +124,7 @@ export class Player { /** * Transfers a card at specific index to another playerWrapper */ - giveCard(cardIndex: number, recipient: Player): ICard { + giveCard(cardIndex: number, recipient: Player): Card { const card = this.removeCardAt(cardIndex); if (!card) { throw new Error("Card not found or invalid index"); @@ -145,7 +139,6 @@ export class Player { } const cardData = this.hand[cardIndex]; - // Create Card wrapper const card = new Card(this.game, cardData); if (!card.type.canBePlayed(this.game, card)) { @@ -164,7 +157,7 @@ export class Player { return; } - // Setup pending state for non-immediate cards + // Setup pending state const startedAtMs = Date.now(); this.game.pendingCardPlay = { card: {...playedCardData}, @@ -176,8 +169,6 @@ export class Player { isNoped: false }; - // Note: card.type.setupPendingState logic might need valid PendingCardPlay to be set first? - // The original setupPendingState in CardType calls setActivePlayers. card.type.setupPendingState(this.game); } @@ -225,12 +216,12 @@ export class Player { this.game.turnManager.endTurn(); } - stealRandomCardFrom(target: Player): ICard { + stealRandomCardFrom(target: Player): Card { const count = target.getCardCount(); if (count === 0) throw new Error("Target has no cards"); // Use game context random - const index = Math.floor(this.game.context.random.Number() * count); + const index = Math.floor(this.game.random.Number() * count); // Give card from target to this player return target.giveCard(index, this); } From 03b0bc1e91dd519b2ef34beaf6d0408381255056 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:17:30 +0100 Subject: [PATCH 04/55] refactor: use constants --- src/client/components/board/Board.tsx | 155 +++++++++++------- src/client/context/GameContext.tsx | 20 +++ src/client/entities/game-client.ts | 34 ++++ src/client/types/client-context.ts | 7 +- src/common/constants/phases.ts | 4 + src/common/constants/stages.ts | 9 + src/common/entities/card-type.ts | 7 +- src/common/entities/card-types/cat-card.ts | 3 +- src/common/entities/card-types/favor-card.ts | 3 +- src/common/entities/card-types/nope-card.ts | 2 +- .../card-types/see-the-future-card.ts | 3 +- src/common/entities/card.ts | 10 +- .../entities/deck-types/original-deck.ts | 2 +- src/common/entities/game.ts | 24 ++- src/common/entities/player.ts | 18 +- src/common/entities/players.ts | 8 +- src/common/game.ts | 28 +--- src/common/index.ts | 4 +- src/common/models/game-state.model.ts | 3 +- src/common/moves/favor-card-move.ts | 5 +- src/common/moves/play-card-move.ts | 6 +- src/common/moves/system-moves.ts | 12 -- .../card-registry.ts} | 2 +- .../deck-registry.ts} | 0 .../{registry => registries}/registry.ts | 0 src/common/setup/game-setup.ts | 3 +- src/common/setup/player-setup.ts | 2 +- src/common/utils/card-sorting.ts | 2 +- 28 files changed, 230 insertions(+), 146 deletions(-) create mode 100644 src/client/context/GameContext.tsx create mode 100644 src/client/entities/game-client.ts create mode 100644 src/common/constants/phases.ts create mode 100644 src/common/constants/stages.ts delete mode 100644 src/common/moves/system-moves.ts rename src/common/{constants/card-types.ts => registries/card-registry.ts} (96%) rename src/common/{constants/deck-types.ts => registries/deck-registry.ts} (100%) rename src/common/{registry => registries}/registry.ts (100%) diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index 3edb68e..b9bde9c 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -11,26 +11,55 @@ import {useEffect} from 'react'; import {Chat} from '../chat/Chat'; import {useMatchDetails} from "../../context/MatchDetailsContext.tsx"; import {IClientContext} from "../../types/client-context.ts"; +import type { BoardProps } from 'boardgame.io/react'; +import {IContext, IGameState} from "../../../common"; +import {TheGameClient} from "../../entities/game-client.ts"; +import {GameProvider} from "../../context/GameContext.tsx"; + +export default function ExplodingKittensBoard(props: BoardProps & { plugins: any }) { + + const context: IContext = { + G: props.G, + ctx: props.ctx, + playerID: props.playerID || undefined, + matchID: props.matchID, + events: props.events as IContext['events'], + random: props.plugins.random, + player: props.plugins.player, + }; -/** - * Main game board component - */ -export default function ExplodingKittensBoard({ - ctx, - G, - moves, - plugins, - playerID, - chatMessages, - sendChatMessage -}: IClientContext) { - const { matchDetails, setPollingInterval } = useMatchDetails(); + const game = new TheGameClient( + context, + props.moves, + props.matchID, + props.playerID || null, + props.matchData, + props.sendChatMessage, + props.chatMessages, + props.isMultiplayer + ); + + // Create clientContext from BoardProps + const clientContext: IClientContext = { + ...props, + plugins: props.plugins, + player: props.plugins?.player?.data || {}, + }; - const isInLobby = ctx.phase === 'lobby'; + const { + ctx, + G, + moves, + plugins, + playerID, + chatMessages, + sendChatMessage + } = clientContext; + const { matchDetails, setPollingInterval } = useMatchDetails(); useEffect(() => { - setPollingInterval(isInLobby ? 500 : 3000); - }, [isInLobby, setPollingInterval]); + setPollingInterval(game.isLobbyPhase() ? 500 : 3000); + }, [game.isLobbyPhase(), setPollingInterval]); const allPlayers = plugins.player.data.players; @@ -45,7 +74,7 @@ export default function ExplodingKittensBoard({ // Derive game state properties const gameState = useGameState(ctx, G, allPlayers, playerID ?? null); - + const selfPlayer = gameState.selfPlayerId !== null && allPlayers[gameState.selfPlayerId] ? allPlayers[gameState.selfPlayerId] : null; const selfHand = selfPlayer ? selfPlayer.hand : []; @@ -61,7 +90,7 @@ export default function ExplodingKittensBoard({ }; const remainingMs = Math.max(0, G.pendingCardPlay.expiresAtMs - Date.now()); - + // Primary trigger const timeoutId = window.setTimeout(checkAndResolve, remainingMs); @@ -143,52 +172,54 @@ export default function ExplodingKittensBoard({ return ( <> - - -
-
- - - + + + +
+
+
+ + + - - - - - {isInLobby && ( - + + {game.isLobbyPhase() && ( + + )} + + + + - )} - - - - + ); } diff --git a/src/client/context/GameContext.tsx b/src/client/context/GameContext.tsx new file mode 100644 index 0000000..91109ac --- /dev/null +++ b/src/client/context/GameContext.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { TheGameClient } from '../entities/game-client'; + +const GameContext = createContext(null); + +export function GameProvider({ game, children }: { game: TheGameClient; children: ReactNode }) { + return ( + + {children} + + ); +} + +export function useGame(): TheGameClient { + const game = useContext(GameContext); + if (!game) { + throw new Error('useGame must be used within a GameProvider'); + } + return game; +} diff --git a/src/client/entities/game-client.ts b/src/client/entities/game-client.ts new file mode 100644 index 0000000..bdcd27e --- /dev/null +++ b/src/client/entities/game-client.ts @@ -0,0 +1,34 @@ +import { TheGame } from '../../common'; +import { IContext } from '../../common'; + +export class TheGameClient extends TheGame { + public readonly moves: Record void>; + public readonly matchID: string; + public readonly playerID: string | null; + public readonly matchData: any; + public readonly sendChatMessage: (message: string) => void; + public readonly chatMessages: any[]; + public readonly isMultiplayer: boolean; + + constructor( + context: IContext, + moves: Record void>, + matchID: string, + playerID: string | null, + matchData: any, + sendChatMessage: (message: string) => void, + chatMessages: any[], + isMultiplayer: boolean + ) { + super(context); + + this.moves = moves; + this.matchID = matchID; + this.playerID = playerID; + this.matchData = matchData; + this.sendChatMessage = sendChatMessage; + this.chatMessages = chatMessages; + this.isMultiplayer = isMultiplayer; + } +} + diff --git a/src/client/types/client-context.ts b/src/client/types/client-context.ts index 56af80d..57338ec 100644 --- a/src/client/types/client-context.ts +++ b/src/client/types/client-context.ts @@ -1,9 +1,8 @@ import type { BoardProps } from 'boardgame.io/react'; import type { IBoardPlugins } from '../models/client.model'; -import {IContext, IGameState} from "../../common"; +import { IGameState, IPlayerAPI } from "../../common"; -export type IClientContext = - Pick & - Pick, 'moves' | 'chatMessages' | 'sendChatMessage'> & { +export type IClientContext = BoardProps & { plugins: IBoardPlugins; + player: IPlayerAPI; }; diff --git a/src/common/constants/phases.ts b/src/common/constants/phases.ts new file mode 100644 index 0000000..ebc27c9 --- /dev/null +++ b/src/common/constants/phases.ts @@ -0,0 +1,4 @@ +export const + LOBBY = 'lobby', + PLAY = 'play', + GAME_OVER = 'gameOver'; diff --git a/src/common/constants/stages.ts b/src/common/constants/stages.ts new file mode 100644 index 0000000..ab20fbc --- /dev/null +++ b/src/common/constants/stages.ts @@ -0,0 +1,9 @@ +export const + WAITING_FOR_START = 'waitingForStart', + VIEWING_FUTURE = 'viewingFuture', + CHOOSE_PLAYER_TO_STEAL_FROM = 'choosePlayerToStealFrom', + CHOOSE_PLAYER_TO_REQUEST_FROM = 'choosePlayerToRequestFrom', + CHOOSE_CARD_TO_GIVE = 'chooseCardToGive', + DEFUSE_EXPLODING_KITTEN = 'defuseExplodingKitten', + RESPOND_WITH_NOW_CARD = 'respondWithNowCard', + AWAITING_NOW_CARDS = 'awaitingNowCards'; diff --git a/src/common/entities/card-type.ts b/src/common/entities/card-type.ts index c889aa2..70e3ede 100644 --- a/src/common/entities/card-type.ts +++ b/src/common/entities/card-type.ts @@ -1,6 +1,7 @@ import type {ICard} from '../models'; import {TheGame} from './game'; import {Card} from './card'; +import {AWAITING_NOW_CARDS, RESPOND_WITH_NOW_CARD} from "../constants/stages"; export class CardType { name: string; @@ -21,16 +22,16 @@ export class CardType { return true; } - isNowCard(_game: TheGame, _card: Card): boolean { + isNowCard(): boolean { return false; } setupPendingState(game: TheGame) { game.turnManager.setActivePlayers({ - currentPlayer: 'awaitingNowCards', + currentPlayer: AWAITING_NOW_CARDS, others: { - stage: 'respondWithNowCard', + stage: RESPOND_WITH_NOW_CARD, }, }); } diff --git a/src/common/entities/card-types/cat-card.ts b/src/common/entities/card-types/cat-card.ts index e942aed..324bfa6 100644 --- a/src/common/entities/card-types/cat-card.ts +++ b/src/common/entities/card-types/cat-card.ts @@ -2,6 +2,7 @@ import {CardType} from '../card-type'; import {TheGame} from '../game'; import {Card} from '../card'; import {stealCard} from "../../moves/steal-card-move"; +import {CHOOSE_PLAYER_TO_STEAL_FROM} from "../../constants/stages"; export class CatCard extends CardType { @@ -35,7 +36,7 @@ export class CatCard extends CardType { // Automatically choose the only valid opponent stealCard(game, candidates[0].id); } else if (candidates.length > 1) { - game.turnManager.setStage('choosePlayerToStealFrom'); + game.turnManager.setStage(CHOOSE_PLAYER_TO_STEAL_FROM); } } diff --git a/src/common/entities/card-types/favor-card.ts b/src/common/entities/card-types/favor-card.ts index 4659e7f..a8960e8 100644 --- a/src/common/entities/card-types/favor-card.ts +++ b/src/common/entities/card-types/favor-card.ts @@ -2,6 +2,7 @@ import {CardType} from '../card-type'; import {TheGame} from '../game'; import {Card} from '../card'; import {requestCard} from '../../moves/favor-card-move'; +import {CHOOSE_PLAYER_TO_REQUEST_FROM} from "../../constants/stages"; export class FavorCard extends CardType { @@ -33,7 +34,7 @@ export class FavorCard extends CardType { requestCard(game.context, candidates[0].id); } else if (candidates.length > 1) { // Set stage to choose a player to request a card from - game.turnManager.setStage("choosePlayerToRequestFrom") + game.turnManager.setStage(CHOOSE_PLAYER_TO_REQUEST_FROM) } } diff --git a/src/common/entities/card-types/nope-card.ts b/src/common/entities/card-types/nope-card.ts index 892010e..f6d1d00 100644 --- a/src/common/entities/card-types/nope-card.ts +++ b/src/common/entities/card-types/nope-card.ts @@ -9,7 +9,7 @@ export class NopeCard extends CardType { super(name); } - isNowCard(_game: TheGame, _card: Card): boolean { + isNowCard(): boolean { return true; } diff --git a/src/common/entities/card-types/see-the-future-card.ts b/src/common/entities/card-types/see-the-future-card.ts index 4a04b7d..ed301f3 100644 --- a/src/common/entities/card-types/see-the-future-card.ts +++ b/src/common/entities/card-types/see-the-future-card.ts @@ -1,6 +1,7 @@ import {CardType} from '../card-type'; import {TheGame} from '../game'; import {Card} from '../card'; +import {VIEWING_FUTURE} from "../../constants/stages"; export class SeeTheFutureCard extends CardType { @@ -10,7 +11,7 @@ export class SeeTheFutureCard extends CardType { onPlayed(game: TheGame, _card: Card) { // Set stage to view the future - game.turnManager.setStage("viewingFuture") + game.turnManager.setStage(VIEWING_FUTURE) } diff --git a/src/common/entities/card.ts b/src/common/entities/card.ts index e60f495..58e77d5 100644 --- a/src/common/entities/card.ts +++ b/src/common/entities/card.ts @@ -1,6 +1,6 @@ import {ICard} from '../models'; import {TheGame} from './game'; -import {cardTypeRegistry} from '../constants/card-types'; +import {cardTypeRegistry} from '../registries/card-registry'; import {CardType} from './card-type'; export class Card { @@ -15,6 +15,10 @@ export class Card { this.index = _data.index; } + get data(): ICard { + return {name: this.name, index: this.index}; + } + get type(): CardType { const t = cardTypeRegistry.get(this.name); if (!t) throw new Error(`Unknown card type: ${this.name}`); @@ -32,9 +36,5 @@ export class Card { afterPlay(): void { this.type.afterPlay(this.game, this); } - - isNowCard(): boolean { - return this.type.isNowCard(this.game, this); - } } diff --git a/src/common/entities/deck-types/original-deck.ts b/src/common/entities/deck-types/original-deck.ts index b7d5018..fc44f3d 100644 --- a/src/common/entities/deck-types/original-deck.ts +++ b/src/common/entities/deck-types/original-deck.ts @@ -10,7 +10,7 @@ import { SEE_THE_FUTURE, SHUFFLE, SKIP, -} from '../../constants/card-types'; +} from '../../registries/card-registry'; const STARTING_HAND_SIZE = 7; const TOTAL_DEFUSE_CARDS = 6; diff --git a/src/common/entities/game.ts b/src/common/entities/game.ts index 45769f5..d78dc82 100644 --- a/src/common/entities/game.ts +++ b/src/common/entities/game.ts @@ -1,4 +1,4 @@ -import {IContext, IGameState} from '../models'; +import {IContext, IGameState, IPlayerAPI, IPlayers} from '../models'; import {Piles} from './piles'; import {Players} from './players'; import {TurnManager} from './turn-manager'; @@ -6,11 +6,14 @@ import {IPendingCardPlay, IGameRules} from '../models'; import {Card} from "./card"; import {RandomAPI} from "boardgame.io/dist/types/src/plugins/random/random"; import {EventsAPI} from "boardgame.io/dist/types/src/plugins/events/events"; +import {Ctx} from "boardgame.io"; +import {LOBBY} from "../constants/phases"; export class TheGame { public readonly context: IContext; - public readonly gameState: IGameState; + private readonly gameState: IGameState; + private readonly bgContext: Ctx; public readonly events: EventsAPI; public readonly random: RandomAPI; @@ -21,14 +24,25 @@ export class TheGame { constructor(context: IContext) { this.context = context; this.gameState = context.G; + this.bgContext = context.ctx; this.events = context.events; this.random = context.random; this.piles = new Piles(this, this.gameState.piles); - this.players = new Players(this, this.context.player); + this.players = new Players( + this, + this.context.player.state ?? (this.context.player as IPlayerAPI & { data: { players: IPlayers } }).data.players + ); this.turnManager = new TurnManager(this); } + get phase(): string { + return this.bgContext.phase; + } + + isLobbyPhase(): boolean { + return this.phase === LOBBY; + } /** * Get pending card play @@ -41,10 +55,6 @@ export class TheGame { this.gameState.pendingCardPlay = pending; } - set lobbyReady(ready: boolean) { - this.gameState.lobbyReady = ready; - } - get gameRules(): IGameRules { return this.gameState.gameRules; } diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 6e12a83..6f52ece 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -1,7 +1,8 @@ import {ICard, IPlayer} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; -import {EXPLODING_KITTEN, DEFUSE} from "../constants/card-types"; +import {EXPLODING_KITTEN, DEFUSE} from "../registries/card-registry"; +import {DEFUSE_EXPLODING_KITTEN} from "../constants/stages"; export class Player { constructor( @@ -138,21 +139,20 @@ export class Player { throw new Error(`Invalid card index: ${cardIndex}`); } - const cardData = this.hand[cardIndex]; - const card = new Card(this.game, cardData); + const card = this.hand[cardIndex]; if (!card.type.canBePlayed(this.game, card)) { throw new Error(`Card cannot be played: ${card.name}`); } // Remove card from hand - const playedCardData = this.removeCardAt(cardIndex); - if (!playedCardData) return; // Should not happen + const playedCard = this.removeCardAt(cardIndex); + if (!playedCard) return; // Should not happen - this.game.piles.discardCard(playedCardData); + this.game.piles.discardCard(playedCard); card.afterPlay(); - if (card.isNowCard()) { + if (card.type.isNowCard()) { card.play(); return; } @@ -160,7 +160,7 @@ export class Player { // Setup pending state const startedAtMs = Date.now(); this.game.pendingCardPlay = { - card: {...playedCardData}, + card: {...playedCard.data}, playedBy: this.id, startedAtMs, expiresAtMs: startedAtMs + (this.game.gameRules.pendingTimerMs || 5000), @@ -183,7 +183,7 @@ export class Player { if (cardData.name === EXPLODING_KITTEN.name) { const hasDefuse = this.hasCard(DEFUSE.name); if (hasDefuse) { - this.game.turnManager.setStage('defuseExplodingKitten'); + this.game.turnManager.setStage(DEFUSE_EXPLODING_KITTEN); } else { this.eliminate(); this.game.turnManager.endTurn(); diff --git a/src/common/entities/players.ts b/src/common/entities/players.ts index a866731..6587a80 100644 --- a/src/common/entities/players.ts +++ b/src/common/entities/players.ts @@ -1,10 +1,10 @@ import {Player} from './player'; import {TheGame} from "./game"; import {PlayerID} from "boardgame.io"; -import {IPlayerAPI} from "../models"; +import {IPlayers} from "../models"; export class Players { - constructor(private game: TheGame, private playerAPI: IPlayerAPI) {} + constructor(private game: TheGame, private players: IPlayers) {} /** * Get a player wrapper instance for a specific player ID. @@ -12,7 +12,7 @@ export class Players { */ getPlayer(id: string): Player { // boardgame.io player plugin structure - const playerData = this.playerAPI.state?.[id]; + const playerData = this.players?.[id]; if (!playerData) { throw new Error(`Player data not found for ID: ${id}`); } @@ -39,7 +39,7 @@ export class Players { * Get all players */ get allPlayers(): Player[] { - const playerIDs = Object.keys(this.playerAPI.state || {}); + const playerIDs = Object.keys(this.players || {}); return playerIDs.map(id => this.getPlayer(id)); } diff --git a/src/common/game.ts b/src/common/game.ts index 4d9fd03..03d594a 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -12,6 +12,8 @@ import {dealHands} from './setup/player-setup'; import {defuseExplodingKitten} from "./moves/defuse-exploding-kitten"; import {TheGame} from "./entities/game"; import {stealCard} from "./moves/steal-card-move"; +import {GAME_OVER, PLAY} from "./constants/phases"; +import {VIEWING_FUTURE, WAITING_FOR_START} from "./constants/stages"; export const ExplodingKittens: Game = { name: "Exploding-Kittens", @@ -28,7 +30,7 @@ export const ExplodingKittens: Game = { let viewableDrawPile: ICard[] = []; - if (ctx.activePlayers?.[playerID!] === 'viewingFuture') { + if (ctx.activePlayers?.[playerID!] === VIEWING_FUTURE) { viewableDrawPile = G.piles.drawPile.slice(0, 3); } @@ -47,10 +49,6 @@ export const ExplodingKittens: Game = { lobby: { start: true, next: 'play', - onBegin: ({G}: IContext) => { - // Reset game state for lobby - G.lobbyReady = false; - }, onEnd: (context: IContext) => { const game = new TheGame(context) @@ -63,24 +61,16 @@ export const ExplodingKittens: Game = { game.piles.state.drawPile = pile.sort(() => Math.random() - 0.5); }, - endIf: ({G}) => { - // Move to play phase only when lobbyReady flag is explicitly set - if (G.lobbyReady) { - return {next: 'play'}; - } - }, turn: { activePlayers: { - all: 'waitingForStart', + all: WAITING_FOR_START, }, stages: { - 'waitingForStart': { + waitingForStart: { moves: { startGame: { - move: ({G}: IContext) => { - // Need to trust players since there is no api to see who is currently connected - // Only the client has that info exposed by boardgame.io for some reason - G.lobbyReady = true; + move: (context: IContext) => { + context.events.setPhase(PLAY); }, client: false, }, @@ -179,7 +169,7 @@ export const ExplodingKittens: Game = { // End phase when only one player is alive if (alivePlayers.length === 1) { - return {next: 'gameover'}; + return {next: GAME_OVER}; } }, onEnd: ({G, player}) => { @@ -193,7 +183,7 @@ export const ExplodingKittens: Game = { } }, }, - gameover: {}, + gameOver: {}, }, }; diff --git a/src/common/index.ts b/src/common/index.ts index 0b0f1a5..15eaaad 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -24,10 +24,10 @@ export { DEFUSE, EXPLODING_KITTEN, cardTypeRegistry -} from './constants/card-types'; +} from './registries/card-registry'; // Constants - Decks -export {ORIGINAL} from './constants/deck-types'; +export {ORIGINAL} from './registries/deck-registry'; // Setup functions export {setupGame} from './setup/game-setup'; diff --git a/src/common/models/game-state.model.ts b/src/common/models/game-state.model.ts index efa7f38..b6aef83 100644 --- a/src/common/models/game-state.model.ts +++ b/src/common/models/game-state.model.ts @@ -33,6 +33,5 @@ export interface IGameState { turnsRemaining: number; gameRules: IGameRules; deckType: string; - client: IClientGameState; - lobbyReady: boolean; + client: IClientGameState; // todo: remove } diff --git a/src/common/moves/favor-card-move.ts b/src/common/moves/favor-card-move.ts index 74a8028..007a7b5 100644 --- a/src/common/moves/favor-card-move.ts +++ b/src/common/moves/favor-card-move.ts @@ -1,6 +1,7 @@ import {IContext} from "../models"; import {PlayerID} from "boardgame.io"; import {TheGame} from "../entities/game"; +import {CHOOSE_CARD_TO_GIVE} from "../constants/stages"; /** * Request a card from a target player (favor card - first stage) @@ -15,7 +16,7 @@ export const requestCard = (context: IContext, targetPlayerId: PlayerID) => { game.turnManager.endStage(); game.turnManager.setActivePlayers({ value: { - [targetPlayerId]: 'chooseCardToGive', + [targetPlayerId]: CHOOSE_CARD_TO_GIVE, }, }); }; @@ -29,7 +30,7 @@ export const giveCard = (context: IContext, cardIndex: number) => { // Find who is giving the card (the player in the chooseCardToGive stage) const givingPlayerId = Object.keys(ctx.activePlayers || {}).find( - playerId => ctx.activePlayers?.[playerId] === 'chooseCardToGive' + playerId => ctx.activePlayers?.[playerId] === CHOOSE_CARD_TO_GIVE ); if (!givingPlayerId) { diff --git a/src/common/moves/play-card-move.ts b/src/common/moves/play-card-move.ts index 9f8f4f9..857e693 100644 --- a/src/common/moves/play-card-move.ts +++ b/src/common/moves/play-card-move.ts @@ -3,11 +3,7 @@ import {TheGame} from "../entities/game"; export const playCard = (context: IContext, cardIndex: number) => { const game = new TheGame(context); - try { - game.players.actingPlayer.playCard(cardIndex); - } catch (e) { - console.error('Failed to play card', e); - } + game.players.actingPlayer.playCard(cardIndex); }; export const playNowCard = (context: IContext, cardIndex: number) => { diff --git a/src/common/moves/system-moves.ts b/src/common/moves/system-moves.ts deleted file mode 100644 index 5e23864..0000000 --- a/src/common/moves/system-moves.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {IContext} from '../models'; -import {TheGame} from '../entities/game'; - -/** - * Mark the lobby as ready to start - * This should be called when all players have joined - */ -export const setLobbyReady = (context: IContext) => { - const game = new TheGame(context); - game.lobbyReady = true; -}; - diff --git a/src/common/constants/card-types.ts b/src/common/registries/card-registry.ts similarity index 96% rename from src/common/constants/card-types.ts rename to src/common/registries/card-registry.ts index 2bb5076..ee69daf 100644 --- a/src/common/constants/card-types.ts +++ b/src/common/registries/card-registry.ts @@ -4,7 +4,7 @@ import {CatCard} from '../entities/card-types/cat-card'; import {ExplodingKittenCard} from '../entities/card-types/exploding-kitten-card'; import {SkipCard} from "../entities/card-types/skip-card"; import {ShuffleCard} from "../entities/card-types/shuffle-card"; -import {Registry} from "../registry/registry"; +import {Registry} from "./registry"; import {AttackCard} from "../entities/card-types/attack-card"; import {NopeCard} from "../entities/card-types/nope-card"; import {SeeTheFutureCard} from "../entities/card-types/see-the-future-card"; diff --git a/src/common/constants/deck-types.ts b/src/common/registries/deck-registry.ts similarity index 100% rename from src/common/constants/deck-types.ts rename to src/common/registries/deck-registry.ts diff --git a/src/common/registry/registry.ts b/src/common/registries/registry.ts similarity index 100% rename from src/common/registry/registry.ts rename to src/common/registries/registry.ts diff --git a/src/common/setup/game-setup.ts b/src/common/setup/game-setup.ts index d5fbb6b..ee00a64 100644 --- a/src/common/setup/game-setup.ts +++ b/src/common/setup/game-setup.ts @@ -26,7 +26,6 @@ export const setupGame = (_context: any, setupData?: SetupData): IGameState => { deckType: setupData?.deckType ?? 'original', client: { drawPileLength: 0 - }, - lobbyReady: false, + } }; }; diff --git a/src/common/setup/player-setup.ts b/src/common/setup/player-setup.ts index 99befd1..b38ec8e 100644 --- a/src/common/setup/player-setup.ts +++ b/src/common/setup/player-setup.ts @@ -1,6 +1,6 @@ import type {ICard, IGameState, IPlayer, IPlayers} from '../models'; import type {DeckType} from '../entities/deck-type'; -import {cardTypeRegistry} from "../constants/card-types"; +import {cardTypeRegistry} from "../registries/card-registry"; import {CardType} from "../entities/card-type"; export const createPlayerState = (): IPlayer => ({ diff --git a/src/common/utils/card-sorting.ts b/src/common/utils/card-sorting.ts index f16d428..b447bc9 100644 --- a/src/common/utils/card-sorting.ts +++ b/src/common/utils/card-sorting.ts @@ -1,5 +1,5 @@ import type {ICard} from '../models'; -import {cardTypeRegistry} from '../constants/card-types'; +import {cardTypeRegistry} from '../registries/card-registry'; /** * Sorts card-types by card type sort order and card index From ae1376fca737187f2cbbfd95707ee3f579852a6f Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:43:47 +0100 Subject: [PATCH 05/55] refactor: use game api in client --- src/client/components/board/Board.tsx | 14 ++--- src/client/components/board/table/Table.tsx | 39 ++++++------ src/client/entities/game-client.ts | 17 +++++- src/common/constants/cards.ts | 10 ++++ src/common/entities/card-types/favor-card.ts | 4 +- .../entities/card-types/shuffle-card.ts | 3 +- src/common/entities/game.ts | 4 +- src/common/entities/pile.ts | 40 +++++++++++++ src/common/entities/piles.ts | 47 ++++----------- src/common/entities/player.ts | 60 +++++++++++++++---- src/common/entities/players.ts | 6 +- src/common/game.ts | 2 +- src/common/index.ts | 2 +- src/common/utils/action-validation.ts | 11 ---- 14 files changed, 158 insertions(+), 101 deletions(-) create mode 100644 src/common/constants/cards.ts create mode 100644 src/common/entities/pile.ts diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index b9bde9c..a097ac9 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -50,7 +50,6 @@ export default function ExplodingKittensBoard(props: BoardProps & { ctx, G, moves, - plugins, playerID, chatMessages, sendChatMessage @@ -61,8 +60,6 @@ export default function ExplodingKittensBoard(props: BoardProps & { setPollingInterval(game.isLobbyPhase() ? 500 : 3000); }, [game.isLobbyPhase(), setPollingInterval]); - const allPlayers = plugins.player.data.players; - // Bundle game context const gameContext: GameContext = { ctx, @@ -73,10 +70,7 @@ export default function ExplodingKittensBoard(props: BoardProps & { }; // Derive game state properties - const gameState = useGameState(ctx, G, allPlayers, playerID ?? null); - - const selfPlayer = gameState.selfPlayerId !== null && allPlayers[gameState.selfPlayerId] ? allPlayers[gameState.selfPlayerId] : null; - const selfHand = selfPlayer ? selfPlayer.hand : []; + const gameState = useGameState(ctx, G, game.players.players, playerID ?? null); useEffect(() => { if (!gameState.isInNowCardStage || !G.pendingCardPlay || !moves.resolvePendingCard) { @@ -109,7 +103,7 @@ export default function ExplodingKittensBoard(props: BoardProps & { // Bundle player state const playerState: PlayerStateBundle = { - allPlayers, + allPlayers: game.players.players, selfPlayerId: gameState.selfPlayerId, currentPlayer: gameState.currentPlayer, isSelfDead: gameState.isSelfDead, @@ -126,7 +120,7 @@ export default function ExplodingKittensBoard(props: BoardProps & { }; // Handle card animations - const {AnimationLayer, triggerCardMovement} = useCardAnimations(G, allPlayers, playerID ?? null); + const {AnimationLayer, triggerCardMovement} = useCardAnimations(G, game.players.players, playerID ?? null); /** * Handle player selection for stealing/requesting a card @@ -177,7 +171,7 @@ export default function ExplodingKittensBoard(props: BoardProps & {
-
+
(null); - const discardCard = G.piles.discardPile[G.piles.discardPile.length - 1]; + const discardCard = game.piles.discardPile.topCard; const discardImage = discardCard ? `/assets/cards/${discardCard.name}/${discardCard.index}.png` : "None"; - // Check for Nope card in hand - const nopeCardIndex = playerHand.findIndex(c => c.name === 'nope'); - - const canNope = canPlayerNope(G, gameContext.playerID, playerHand); + const canNope = game.selfPlayer?.canNope || false; const handlePlayNope = () => { - if (nopeCardIndex !== -1 && moves.playNowCard) { - moves.playNowCard(nopeCardIndex); + if (canNope && moves.playNowCard) { + moves.playNowCard(game.selfPlayer?.findCardIndex(NAME_NOPE)); } }; // Detect when a card is drawn useEffect(() => { - if (G.client.drawPileLength< lastDrawPileLength) { + if (game.piles.drawPile.size < lastDrawPileLength) { setIsDrawing(true); setTimeout(() => setIsDrawing(false), 400); } - setLastDrawPileLength(G.client.drawPileLength); - }, [G.client.drawPileLength, lastDrawPileLength]); + setLastDrawPileLength(game.piles.drawPile.size); + }, [game.piles.drawPile.size, lastDrawPileLength]); // Detect when a shuffle card is played - const pendingCardRef = useRef(G.pendingCardPlay); + const pendingCardRef = useRef(game.pendingCardPlay); useEffect(() => { // If there is an active pending play, do not trigger shuffle yet - if (G.pendingCardPlay) { - pendingCardRef.current = G.pendingCardPlay; + if (game.pendingCardPlay) { + pendingCardRef.current = game.pendingCardPlay; return; } @@ -65,18 +62,18 @@ export default function Table({gameContext, playerHand = []}: TableProps) { pendingCardRef.current = null; // Check if discard pile changed - const discardChanged = G.piles.discardPile.length > lastDiscardPileLength; + const discardChanged = game.piles.discardPile.size > lastDiscardPileLength; // Check if we just finished a pending play that was a Shuffle and NOT noped const resolvedShuffle = wasPending && !wasPending.isNoped && - wasPending.card.name === 'shuffle'; + wasPending.card.name === NAME_SHUFFLE; - const lastCard = G.piles.discardPile[G.piles.discardPile.length - 1]; + const lastCard = game.piles.discardPile.topCard; // Trigger if newly placed shuffle // OR if delayed resolution happened - if ((discardChanged || resolvedShuffle) && lastCard?.name === 'shuffle') { + if ((discardChanged || resolvedShuffle) && lastCard?.name === NAME_SHUFFLE) { // Double check it wasn't noped if it came from pending if (!(wasPending && wasPending.isNoped)) { setIsShuffling(true); diff --git a/src/client/entities/game-client.ts b/src/client/entities/game-client.ts index bdcd27e..bdeffa5 100644 --- a/src/client/entities/game-client.ts +++ b/src/client/entities/game-client.ts @@ -1,5 +1,6 @@ -import { TheGame } from '../../common'; +import {Player, TheGame} from '../../common'; import { IContext } from '../../common'; +import {PlayerID} from "boardgame.io"; export class TheGameClient extends TheGame { public readonly moves: Record void>; @@ -30,5 +31,19 @@ export class TheGameClient extends TheGame { this.chatMessages = chatMessages; this.isMultiplayer = isMultiplayer; } + + get isSpectator(): boolean { + return this.playerID === null; + } + + get selfPlayerId(): PlayerID | null { + return this.isSpectator ? null : this.playerID; + } + + get selfPlayer(): Player | null { + if (this.isSpectator) return null; + return this.players.getPlayer(this.selfPlayerId!); + } + } diff --git a/src/common/constants/cards.ts b/src/common/constants/cards.ts new file mode 100644 index 0000000..605c68f --- /dev/null +++ b/src/common/constants/cards.ts @@ -0,0 +1,10 @@ +export const + NAME_ATTACK = 'attack', + NAME_CAT_CARD = 'cat_card', + NAME_SKIP = 'skip', + NAME_SHUFFLE = 'shuffle', + NAME_FAVOR = 'favor', + NAME_NOPE = 'nope', + NAME_SEE_THE_FUTURE = 'see_the_future', + NAME_DEFUSE = 'defuse', + NAME_EXPLODING_KITTEN = 'exploding_kitten'; diff --git a/src/common/entities/card-types/favor-card.ts b/src/common/entities/card-types/favor-card.ts index a8960e8..b51b614 100644 --- a/src/common/entities/card-types/favor-card.ts +++ b/src/common/entities/card-types/favor-card.ts @@ -18,7 +18,7 @@ export class FavorCard extends CardType { if (target.id === ctx.currentPlayer) { return false; // Can't target yourself } - return target.isAlive && target.getCardCount() > 0; + return target.isAlive && target.cardCount > 0; }); } @@ -26,7 +26,7 @@ export class FavorCard extends CardType { const { ctx } = game.context; const candidates = game.players.allPlayers.filter((target) => { - return target.id !== ctx.currentPlayer && target.isAlive && target.getCardCount() > 0; + return target.id !== ctx.currentPlayer && target.isAlive && target.cardCount > 0; }); if (candidates.length === 1) { diff --git a/src/common/entities/card-types/shuffle-card.ts b/src/common/entities/card-types/shuffle-card.ts index 871d12d..670fb60 100644 --- a/src/common/entities/card-types/shuffle-card.ts +++ b/src/common/entities/card-types/shuffle-card.ts @@ -9,10 +9,9 @@ export class ShuffleCard extends CardType { } onPlayed(game: TheGame, _card: Card) { - game.piles.shuffleDrawPile(); + game.piles.drawPile.shuffle(); } - sortOrder(): number { return 4; } diff --git a/src/common/entities/game.ts b/src/common/entities/game.ts index d78dc82..f5bea4f 100644 --- a/src/common/entities/game.ts +++ b/src/common/entities/game.ts @@ -12,8 +12,8 @@ import {LOBBY} from "../constants/phases"; export class TheGame { public readonly context: IContext; - private readonly gameState: IGameState; - private readonly bgContext: Ctx; + protected readonly gameState: IGameState; + protected readonly bgContext: Ctx; public readonly events: EventsAPI; public readonly random: RandomAPI; diff --git a/src/common/entities/pile.ts b/src/common/entities/pile.ts new file mode 100644 index 0000000..088e79f --- /dev/null +++ b/src/common/entities/pile.ts @@ -0,0 +1,40 @@ +import {ICard} from '../models'; +import {TheGame} from "./game"; +import {Card} from "./card"; + +export class Pile { + constructor(private game: TheGame, public state: ICard[]) { + } + + addCard(card: Card | ICard): void { + const cardData: ICard = {name: card.name, index: card.index}; + + // Clone to avoid Proxy issues + this.state.push({...cardData}); + } + + get topCard(): Card | null { + const iCard = this.state[this.state.length - 1]; + return this.state.length > 0 ? new Card(this.game, iCard) : null; + } + + drawCard(): Card | null { + const shift = this.state.shift(); + return shift ? new Card(this.game, shift) : null; + } + + insertCard(card: ICard, index: number): void { + const cardData: ICard = {name: card.name, index: card.index}; + // Clone to avoid Proxy issues + this.state.splice(index, 0, {...cardData}); + } + + get size(): number { + return this.state.length; + } + + shuffle(): void { + this.state = this.game.random.Shuffle(this.state); + } +} + diff --git a/src/common/entities/piles.ts b/src/common/entities/piles.ts index 3577f2f..f7755c4 100644 --- a/src/common/entities/piles.ts +++ b/src/common/entities/piles.ts @@ -1,55 +1,32 @@ import {ICard, IPiles} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; +import {Pile} from "./pile"; export class Piles { - constructor(private game: TheGame, public state: IPiles) { + constructor(private game: TheGame, public cards: IPiles) { } - /** - * Add a card to the discard pile - */ - discardCard(card: Card | ICard): void { - const cardData: ICard = {name: card.name, index: card.index}; - - // Clone to avoid Proxy issues - this.state.discardPile.push({...cardData}); + get drawPile(): Pile { + return new Pile(this.game, this.cards.drawPile); } - /** - * Get the last discarded card - */ - get lastDiscardedCard(): Card | null { - const pile = this.state.discardPile; - const iCard = pile[pile.length - 1]; - return pile.length > 0 ? new Card(this.game, iCard) : null; + get discardPile(): Pile { + return new Pile(this.game, this.cards.discardPile); } /** - * Draw a card from the top of the draw pile - */ - drawCardFromPile(): Card | null { - const shift = this.state.drawPile.shift(); - return shift ? new Card(this.game, shift) : null; - } - - /** - * Insert a card into the draw pile at a specific index + * Add a card to the discard pile */ - insertCardIntoDrawPile(card: ICard, index: number): void { - // Clone to avoid Proxy issues - this.state.drawPile.splice(index, 0, {...card}); + discardCard(card: Card | ICard): void { + this.discardPile.addCard(card); } /** - * Get draw pile size + * Draw a card from the top of the draw pile */ - get drawPileSize(): number { - return this.state.drawPile.length; - } - - shuffleDrawPile(): void { - this.state.drawPile = this.game.random.Shuffle(this.state.drawPile); + drawCard(): Card | null { + return this.drawPile.drawCard(); } } diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 6f52ece..c3f6cb7 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -3,12 +3,14 @@ import {TheGame} from "./game"; import {Card} from "./card"; import {EXPLODING_KITTEN, DEFUSE} from "../registries/card-registry"; import {DEFUSE_EXPLODING_KITTEN} from "../constants/stages"; +import {PlayerID} from "boardgame.io"; +import {NAME_NOPE} from "../constants/cards"; export class Player { constructor( private game: TheGame, private _state: IPlayer, - public readonly id: string + public readonly id: PlayerID ) {} /** @@ -25,6 +27,43 @@ export class Player { return this._state.isAlive; } + /** + * Get the count of card-types in hand + */ + get cardCount(): number { + return this._state.hand.length; + } + + get canNope(): boolean { + if (!this.hand.some(c => c.name === NAME_NOPE)) { + return false; + } + if (!this.game.pendingCardPlay) { + return false; + } + + const pending = this.game.pendingCardPlay; + + // Player cannot nope their own card play if it hasn't been noped yet + // If it HAS been noped, they can re-nope (un-nope) it, unless they were the last person to nope it + if (!pending.isNoped && pending.playedBy === this.id) { + return false; + } + + // Player cannot nope their own nope card (cannot double-nope themselves immediately) + if (pending.lastNopeBy === this.id) { + return false; + } + + // Check expiration + // Note: Date.now() on client might differ from server, but usually this is acceptable for UI state + if (Date.now() > pending.expiresAtMs) { + return false; + } + + return true; + } + /** * Check if the player has at least one card of the given type */ @@ -32,6 +71,10 @@ export class Player { return this._state.hand.some(c => c.name === cardName); } + findCardIndex(cardName: string): number { + return this._state.hand.findIndex(c => c.name === cardName); + } + /** * Get all card-types of a specific type from hand, or all card-types if no type specified */ @@ -47,13 +90,6 @@ export class Player { return this.getCards(card.name).filter(c => c.index === card.index); } - /** - * Get the count of card-types in hand - */ - getCardCount(): number { - return this._state.hand.length; - } - /** * Add a card to the player's hand */ @@ -175,7 +211,7 @@ export class Player { draw(): void { if (!this.isAlive) throw new Error("Dead player cannot draw"); - const cardData = this.game.piles.drawCardFromPile(); + const cardData = this.game.piles.drawCard(); if (!cardData) throw new Error("No cards left to draw"); this.addCard(cardData); @@ -194,7 +230,7 @@ export class Player { } defuseExplodingKitten(insertIndex: number): void { - if (insertIndex < 0 || insertIndex > this.game.piles.drawPileSize) { + if (insertIndex < 0 || insertIndex > this.game.piles.drawPile.size) { throw new Error('Invalid insert index'); } @@ -210,14 +246,14 @@ export class Player { } this.game.piles.discardCard(defuseCard); - this.game.piles.insertCardIntoDrawPile(kittenCard, insertIndex); + this.game.piles.drawPile.insertCard(kittenCard, insertIndex); this.game.turnManager.endStage(); this.game.turnManager.endTurn(); } stealRandomCardFrom(target: Player): Card { - const count = target.getCardCount(); + const count = target.cardCount; if (count === 0) throw new Error("Target has no cards"); // Use game context random diff --git a/src/common/entities/players.ts b/src/common/entities/players.ts index 6587a80..0b8d171 100644 --- a/src/common/entities/players.ts +++ b/src/common/entities/players.ts @@ -4,13 +4,13 @@ import {PlayerID} from "boardgame.io"; import {IPlayers} from "../models"; export class Players { - constructor(private game: TheGame, private players: IPlayers) {} + constructor(private game: TheGame, public players: IPlayers) {} /** * Get a player wrapper instance for a specific player ID. * Throws if player data not found. */ - getPlayer(id: string): Player { + getPlayer(id: PlayerID): Player { // boardgame.io player plugin structure const playerData = this.players?.[id]; if (!playerData) { @@ -54,7 +54,7 @@ export class Players { * Get all alive players who have at least one card in hand */ get playersWithCards(): Player[] { - return this.alivePlayers.filter(player => player.getCardCount() > 0); + return this.alivePlayers.filter(player => player.cardCount > 0); } /** diff --git a/src/common/game.ts b/src/common/game.ts index 03d594a..fa3e08e 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -59,7 +59,7 @@ export const ExplodingKittens: Game = { dealHands(pile, game.context.player.state, deck); // TODO: use api wrapper deck.addPostDealCards(pile, Object.keys(game.context.ctx.playOrder).length); - game.piles.state.drawPile = pile.sort(() => Math.random() - 0.5); + game.piles.cards.drawPile = pile.sort(() => Math.random() - 0.5); }, turn: { activePlayers: { diff --git a/src/common/index.ts b/src/common/index.ts index 15eaaad..f5bedc9 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -38,7 +38,7 @@ export {createPlayerPlugin} from './plugins/player-plugin'; // Utilities export {sortCards} from './utils/card-sorting'; -export {canPlayerNope, validateNope} from './utils/action-validation'; +export {validateNope} from './utils/action-validation'; // Wrappers export {Player} from './entities/player'; diff --git a/src/common/utils/action-validation.ts b/src/common/utils/action-validation.ts index ef459c7..9def025 100644 --- a/src/common/utils/action-validation.ts +++ b/src/common/utils/action-validation.ts @@ -1,5 +1,4 @@ import {IGameState} from '../models/game-state.model'; -import {ICard} from '../models/card.model'; /** * Validates if a player can play a Nope card against the current game state. @@ -32,13 +31,3 @@ export function validateNope(G: IGameState, playerID: string | null | undefined) return true; } -export function canPlayerNope( - G: IGameState, - playerID: string | null | undefined, - playerHand: ICard[] -): boolean { - const nopeCardIndex = playerHand.findIndex(c => c.name === 'nope'); - if (nopeCardIndex === -1) return false; - - return validateNope(G, playerID); -} From f778f71ac33ae68e212f6e7289e7834dc9c21f35 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:44:24 +0100 Subject: [PATCH 06/55] chore: warnings --- src/common/entities/player.ts | 6 +----- src/common/utils/action-validation.ts | 8 ++------ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index c3f6cb7..b2287a2 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -57,11 +57,7 @@ export class Player { // Check expiration // Note: Date.now() on client might differ from server, but usually this is acceptable for UI state - if (Date.now() > pending.expiresAtMs) { - return false; - } - - return true; + return Date.now() <= pending.expiresAtMs; } /** diff --git a/src/common/utils/action-validation.ts b/src/common/utils/action-validation.ts index 9def025..72f20fe 100644 --- a/src/common/utils/action-validation.ts +++ b/src/common/utils/action-validation.ts @@ -1,4 +1,4 @@ -import {IGameState} from '../models/game-state.model'; +import {IGameState} from '../models'; /** * Validates if a player can play a Nope card against the current game state. @@ -24,10 +24,6 @@ export function validateNope(G: IGameState, playerID: string | null | undefined) // Check expiration // Note: Date.now() on client might differ from server, but usually this is acceptable for UI state - if (Date.now() > pending.expiresAtMs) { - return false; - } - - return true; + return Date.now() <= pending.expiresAtMs; } From 8237159e1a9d61e0aa69cd75c8b586198f9d90c6 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:48:26 +0100 Subject: [PATCH 07/55] refactor: table uses game instance --- src/client/components/board/Board.tsx | 10 +-- .../board/card-animation/CardAnimation.tsx | 2 - src/client/components/board/table/Table.tsx | 84 +++++++------------ .../table/{ => pending}/PendingPlayStack.css | 0 .../table/{ => pending}/PendingPlayStack.tsx | 27 +++--- .../components/board/turn-badge/TurnBadge.tsx | 16 ++-- src/client/entities/game-client.ts | 49 ++++++++++- src/common/entities/card-types/nope-card.ts | 6 +- src/common/entities/game.ts | 41 +-------- src/common/entities/pile.ts | 16 ++-- src/common/entities/piles.ts | 53 +++++++++++- src/common/entities/player.ts | 41 +++++++-- src/common/entities/turn-manager.ts | 16 ++-- src/common/game.ts | 7 +- src/common/index.ts | 1 - src/common/models/game-state.model.ts | 2 +- src/common/moves/play-card-move.ts | 9 +- src/common/setup/game-setup.ts | 6 +- src/common/utils/action-validation.ts | 29 ------- 19 files changed, 221 insertions(+), 194 deletions(-) rename src/client/components/board/table/{ => pending}/PendingPlayStack.css (100%) rename src/client/components/board/table/{ => pending}/PendingPlayStack.tsx (67%) delete mode 100644 src/common/utils/action-validation.ts diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index a097ac9..2134cb2 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -73,17 +73,17 @@ export default function ExplodingKittensBoard(props: BoardProps & { const gameState = useGameState(ctx, G, game.players.players, playerID ?? null); useEffect(() => { - if (!gameState.isInNowCardStage || !G.pendingCardPlay || !moves.resolvePendingCard) { + if (!gameState.isInNowCardStage || !game.piles.pendingCard || !moves.resolvePendingCard) { return; } const checkAndResolve = () => { - if (G.pendingCardPlay && Date.now() >= G.pendingCardPlay.expiresAtMs) { + if (game.piles.pendingCard && Date.now() >= game.piles.pendingCard.expiresAtMs) { moves.resolvePendingCard(); } }; - const remainingMs = Math.max(0, G.pendingCardPlay.expiresAtMs - Date.now()); + const remainingMs = Math.max(0, game.piles.pendingCard.expiresAtMs - Date.now()); // Primary trigger const timeoutId = window.setTimeout(checkAndResolve, remainingMs); @@ -97,7 +97,7 @@ export default function ExplodingKittensBoard(props: BoardProps & { }; }, [ gameState.isInNowCardStage, - G.pendingCardPlay?.expiresAtMs, + game.piles.pendingCard?.expiresAtMs, moves, ]); @@ -171,7 +171,7 @@ export default function ExplodingKittensBoard(props: BoardProps & {
-
+
{ // Start animation immediately setIsVisible(true); diff --git a/src/client/components/board/table/Table.tsx b/src/client/components/board/table/Table.tsx index f9aae94..53d5f91 100644 --- a/src/client/components/board/table/Table.tsx +++ b/src/client/components/board/table/Table.tsx @@ -1,44 +1,28 @@ import back from '/assets/cards/back/0.jpg'; import './Table.css'; import {useEffect, useState, useRef} from "react"; -import {GameContext} from "../../../types/component-props"; -import PendingPlayStack from './PendingPlayStack'; +import PendingPlayStack from './pending/PendingPlayStack.tsx'; import TurnBadge from '../turn-badge/TurnBadge'; - -// Import CSS for card preview to be modular import '../card/Card.css'; import HoverCardPreview from '../card/HoverCardPreview'; import {useResponsive} from "../../../context/ResponsiveContext.tsx"; -import {NAME_NOPE, NAME_SHUFFLE} from "../../../../common/constants/cards.ts"; +import {NAME_SHUFFLE} from "../../../../common/constants/cards.ts"; import {useGame} from "../../../context/GameContext.tsx"; -interface TableProps { - gameContext: GameContext; -} -export default function Table({gameContext}: TableProps) { +export default function Table() { const game = useGame() const { isMobile } = useResponsive(); - const {G, moves, ctx} = gameContext; const [isDrawing, setIsDrawing] = useState(false); const [isShuffling, setIsShuffling] = useState(false); - const [lastDrawPileLength, setLastDrawPileLength] = useState(G.client.drawPileLength); - const [lastDiscardPileLength, setLastDiscardPileLength] = useState(G.piles.discardPile.length); + const [lastDrawPileLength, setLastDrawPileLength] = useState(game.piles.drawPile.size); + const [lastDiscardPileLength, setLastDiscardPileLength] = useState(game.piles.discardPile.size); const [isHoveringDrawPile, setIsHoveringDrawPile] = useState(false); const [isDiscardPileSelected, setIsDiscardPileSelected] = useState(false); const discardPileRef = useRef(null); const discardCard = game.piles.discardPile.topCard; - const discardImage = discardCard ? `/assets/cards/${discardCard.name}/${discardCard.index}.png` : "None"; - - const canNope = game.selfPlayer?.canNope || false; - - const handlePlayNope = () => { - if (canNope && moves.playNowCard) { - moves.playNowCard(game.selfPlayer?.findCardIndex(NAME_NOPE)); - } - }; // Detect when a card is drawn useEffect(() => { @@ -50,11 +34,11 @@ export default function Table({gameContext}: TableProps) { }, [game.piles.drawPile.size, lastDrawPileLength]); // Detect when a shuffle card is played - const pendingCardRef = useRef(game.pendingCardPlay); + const pendingCardRef = useRef(game.piles.pendingCard); useEffect(() => { // If there is an active pending play, do not trigger shuffle yet - if (game.pendingCardPlay) { - pendingCardRef.current = game.pendingCardPlay; + if (game.piles.pendingCard) { + pendingCardRef.current = game.piles.pendingCard; return; } @@ -81,63 +65,59 @@ export default function Table({gameContext}: TableProps) { } } - if (!G.pendingCardPlay) { - setLastDiscardPileLength(G.piles.discardPile.length); + if (!game.piles.pendingCard) { + setLastDiscardPileLength(game.piles.discardPile.size); } - }, [G.piles.discardPile.length, lastDiscardPileLength, G.piles.discardPile, G.pendingCardPlay]); + }, [game.piles.discardPile.size, lastDiscardPileLength, game.piles.discardPile, game.piles.pendingCard]); const handleDrawClick = () => { + // wait for previous draw animation to finish before allowing another draw if (!isDrawing) { - moves.drawCard(); + game.playDrawCard(); } }; // Timer calculation const [timeLeftMs, setTimeLeftMs] = useState(0); useEffect(() => { - if (!G.pendingCardPlay) { + if (!game.piles.pendingCard) { setTimeLeftMs(0); return; } - const update = () => setTimeLeftMs(Math.max(0, G.pendingCardPlay!.expiresAtMs - Date.now())); + const update = () => setTimeLeftMs(Math.max(0, game.piles.pendingCard!.expiresAtMs - Date.now())); update(); const i = setInterval(update, 50); return () => clearInterval(i); - }, [G.pendingCardPlay?.expiresAtMs, G.pendingCardPlay?.startedAtMs]); + }, [game.piles.pendingCard?.expiresAtMs, game.piles.pendingCard?.startedAtMs]); // Calculate generic progress 0-1 - const windowDuration = G.pendingCardPlay ? (G.pendingCardPlay.expiresAtMs - G.pendingCardPlay.startedAtMs) : 3000; - const progressRatio = G.pendingCardPlay ? (timeLeftMs / windowDuration) : 0; + const windowDuration = game.piles.pendingCard ? (game.piles.pendingCard.expiresAtMs - game.piles.pendingCard.startedAtMs) : 3000; + const progressRatio = game.piles.pendingCard ? (timeLeftMs / windowDuration) : 0; const degrees = progressRatio * 360; - const turnsRemaining = (G.turnsRemaining || 1) - 1; - const isCurrentPlayer = ctx.currentPlayer === (gameContext.playerID || ''); - const isNoped = G.pendingCardPlay?.isNoped ?? false; + const isNoped = game.piles.pendingCard?.isNoped ?? false; return (
- {/* Turns remaining badge (for attack) */} - - - {/* Nope Button moved to PendingPlayStack */} +
- {!G.pendingCardPlay && ( + {!game.piles.pendingCard && ( <>
{ if (!isMobile) setIsDiscardPileSelected(true); @@ -150,7 +130,7 @@ export default function Table({gameContext}: TableProps) { }} /> setIsDiscardPileSelected(false)} @@ -158,13 +138,9 @@ export default function Table({gameContext}: TableProps) { )} - {G.pendingCardPlay && ( - /* Replaces discard pile */ - + {game.piles.pendingCard && ( + /* Replaces discard pile during pending card play */ + )}
setIsHoveringDrawPile(false)} data-animation-id="draw-pile" > - {isHoveringDrawPile && G.client.drawPileLength > 0 && ( + {isHoveringDrawPile && game.piles.drawPile.size > 0 && (
- {G.client.drawPileLength} + {game.piles.drawPile.size}
)}
diff --git a/src/client/components/board/table/PendingPlayStack.css b/src/client/components/board/table/pending/PendingPlayStack.css similarity index 100% rename from src/client/components/board/table/PendingPlayStack.css rename to src/client/components/board/table/pending/PendingPlayStack.css diff --git a/src/client/components/board/table/PendingPlayStack.tsx b/src/client/components/board/table/pending/PendingPlayStack.tsx similarity index 67% rename from src/client/components/board/table/PendingPlayStack.tsx rename to src/client/components/board/table/pending/PendingPlayStack.tsx index e9f9fb7..85c7e74 100644 --- a/src/client/components/board/table/PendingPlayStack.tsx +++ b/src/client/components/board/table/pending/PendingPlayStack.tsx @@ -1,18 +1,17 @@ -import {IPendingCardPlay} from '../../../../common'; -import '../card/Card.css'; // Import the shared card styles +import '../../card/Card.css'; import './PendingPlayStack.css'; import {useRef, useState} from 'react'; -import HoverCardPreview from '../card/HoverCardPreview'; +import HoverCardPreview from '../../card/HoverCardPreview.tsx'; +import {useGame} from "../../../../context/GameContext.tsx"; -interface PendingPlayStackProps { - pendingPlay: IPendingCardPlay; - canNope: boolean; - onNope: () => void; -} +export default function PendingPlayStack() { + const game = useGame(); + + const pendingCard = game.piles.pendingCard; + if (!pendingCard) return null; -export default function PendingPlayStack({pendingPlay, canNope, onNope}: PendingPlayStackProps) { - const targetCard = pendingPlay.card; - const isNoped = pendingPlay.isNoped; + const targetCard = pendingCard.card; + const isNoped = pendingCard.isNoped; const [isHovered, setIsHovered] = useState(false); const pileRef = useRef(null); @@ -38,17 +37,17 @@ export default function PendingPlayStack({pendingPlay, canNope, onNope}: Pending /> {/*Only show when at least one nope card been played*/} - {pendingPlay.nopeCount > 0 && ( + {pendingCard.nopeCount > 0 && (
{isNoped ? 'Noped' : 'Un-Noped'}
)} {/* Nope Button */} - {canNope && ( + {game.selfPlayer?.canNope && ( diff --git a/src/client/components/board/turn-badge/TurnBadge.tsx b/src/client/components/board/turn-badge/TurnBadge.tsx index ee7801c..3546866 100644 --- a/src/client/components/board/turn-badge/TurnBadge.tsx +++ b/src/client/components/board/turn-badge/TurnBadge.tsx @@ -1,22 +1,22 @@ import './TurnBadge.css'; - -interface TurnBadgeProps { - turnsRemaining: number; - isCurrentPlayer: boolean; -} +import {useGame} from "../../../context/GameContext.tsx"; /** * Visual badge showing how many turns the current player has remaining * Prominently displayed when player has multiple turns (attacked) */ -export default function TurnBadge({ turnsRemaining, isCurrentPlayer }: TurnBadgeProps) { +export default function TurnBadge() { + const game = useGame(); + + const turnsRemaining = (game.turnManager.turnsRemaining || 1) - 1; + // Don't show anything if only 1 turn remaining (normal gameplay) if (turnsRemaining <= 1) { return null; } return ( -
+
⚔️
@@ -26,7 +26,7 @@ export default function TurnBadge({ turnsRemaining, isCurrentPlayer }: TurnBadge
- {isCurrentPlayer && ( + {game.isSelfCurrentPlayer && (
You were attacked! Complete {turnsRemaining} turns.
diff --git a/src/client/entities/game-client.ts b/src/client/entities/game-client.ts index bdeffa5..03152eb 100644 --- a/src/client/entities/game-client.ts +++ b/src/client/entities/game-client.ts @@ -1,6 +1,8 @@ -import {Player, TheGame} from '../../common'; +import {ICard, Player, TheGame} from '../../common'; import { IContext } from '../../common'; import {PlayerID} from "boardgame.io"; +import {Card} from "../../common/entities/card.ts"; +import {NAME_NOPE} from "../../common/constants/cards.ts"; export class TheGameClient extends TheGame { public readonly moves: Record void>; @@ -45,5 +47,50 @@ export class TheGameClient extends TheGame { return this.players.getPlayer(this.selfPlayerId!); } + get isSelfCurrentPlayer(): boolean { + return this.selfPlayerId === this.players.currentPlayer.id; + } + + playDrawCard() { + if (!this.isSpectator && this.moves.drawCard) { + this.moves.drawCard(); + } + } + + playCard(cardIndex: number) { + if (this.selfPlayer && this.moves.playCard) { + const cardAt = this.selfPlayer.getCardAt(cardIndex); + if (!cardAt) { + console.error(`No card at index ${cardIndex} in player's hand`); + return; + } + if (!cardAt.canPlay()) { + console.error(`Card ${cardAt.name} at index ${cardIndex} cannot be played right now`); + return; + } + this.moves.playCard(cardIndex); + } + } + + playNope() { + if (this.selfPlayer?.canNope && this.piles.pendingCard && this.moves.playNowCard) { + this.moves.playNowCard(this.selfPlayer?.findCardIndex(NAME_NOPE)) + } + } + + getDiscardCardTexture(): string { + return TheGameClient.getCardTexture(this.piles.discardPile.topCard); + } + + getCardTexture(card: Card | ICard | null): string { + return TheGameClient.getCardTexture(card); + } + + static getCardTexture(card: Card | ICard | null): string { + if (!card) { + return "/assets/cards/backside.png"; + } + return `/assets/cards/${card.name}/${card.index}.png`; + } } diff --git a/src/common/entities/card-types/nope-card.ts b/src/common/entities/card-types/nope-card.ts index f6d1d00..4c11133 100644 --- a/src/common/entities/card-types/nope-card.ts +++ b/src/common/entities/card-types/nope-card.ts @@ -1,7 +1,6 @@ import {CardType} from '../card-type'; import {TheGame} from '../game'; import {Card} from '../card'; -import {validateNope} from '../../utils/action-validation'; export class NopeCard extends CardType { @@ -14,12 +13,11 @@ export class NopeCard extends CardType { } canBePlayed(game: TheGame, _card: Card): boolean { - const {G, playerID} = game.context; - return validateNope(G, playerID); + return game.players.actingPlayer.canNope } onPlayed(game: TheGame, _card: Card) { - const pendingCardPlay = game.pendingCardPlay; + const pendingCardPlay = game.piles.pendingCard; const player = game.players.actingPlayer; if (!pendingCardPlay) { diff --git a/src/common/entities/game.ts b/src/common/entities/game.ts index f5bea4f..e7a7fc2 100644 --- a/src/common/entities/game.ts +++ b/src/common/entities/game.ts @@ -2,8 +2,7 @@ import {IContext, IGameState, IPlayerAPI, IPlayers} from '../models'; import {Piles} from './piles'; import {Players} from './players'; import {TurnManager} from './turn-manager'; -import {IPendingCardPlay, IGameRules} from '../models'; -import {Card} from "./card"; +import {IGameRules} from '../models'; import {RandomAPI} from "boardgame.io/dist/types/src/plugins/random/random"; import {EventsAPI} from "boardgame.io/dist/types/src/plugins/events/events"; import {Ctx} from "boardgame.io"; @@ -33,7 +32,7 @@ export class TheGame { this, this.context.player.state ?? (this.context.player as IPlayerAPI & { data: { players: IPlayers } }).data.players ); - this.turnManager = new TurnManager(this); + this.turnManager = new TurnManager(this.context); } get phase(): string { @@ -44,43 +43,7 @@ export class TheGame { return this.phase === LOBBY; } - /** - * Get pending card play - */ - get pendingCardPlay(): IPendingCardPlay | null { - return this.gameState.pendingCardPlay; - } - - set pendingCardPlay(pending: IPendingCardPlay | null) { - this.gameState.pendingCardPlay = pending; - } - get gameRules(): IGameRules { return this.gameState.gameRules; } - - /** - * Resolve any pending card play if the window (timer) has expired. - */ - resolvePendingCard(): void { - const pendingCardPlay = this.pendingCardPlay; - - if (!pendingCardPlay) { - return; - } - - // Check if the timer has expired - if (Date.now() < pendingCardPlay.expiresAtMs) { - return; - } - - this.pendingCardPlay = null; - - const card = new Card(this, pendingCardPlay.card); - card.type.cleanupPendingState(this); - - if (!pendingCardPlay.isNoped) { - card.play(); - } - } } diff --git a/src/common/entities/pile.ts b/src/common/entities/pile.ts index 088e79f..f4b4cd3 100644 --- a/src/common/entities/pile.ts +++ b/src/common/entities/pile.ts @@ -3,38 +3,38 @@ import {TheGame} from "./game"; import {Card} from "./card"; export class Pile { - constructor(private game: TheGame, public state: ICard[]) { + constructor(private game: TheGame, public cards: ICard[]) { } addCard(card: Card | ICard): void { const cardData: ICard = {name: card.name, index: card.index}; // Clone to avoid Proxy issues - this.state.push({...cardData}); + this.cards.push({...cardData}); } get topCard(): Card | null { - const iCard = this.state[this.state.length - 1]; - return this.state.length > 0 ? new Card(this.game, iCard) : null; + const iCard = this.cards[this.cards.length - 1]; + return this.cards.length > 0 ? new Card(this.game, iCard) : null; } drawCard(): Card | null { - const shift = this.state.shift(); + const shift = this.cards.shift(); return shift ? new Card(this.game, shift) : null; } insertCard(card: ICard, index: number): void { const cardData: ICard = {name: card.name, index: card.index}; // Clone to avoid Proxy issues - this.state.splice(index, 0, {...cardData}); + this.cards.splice(index, 0, {...cardData}); } get size(): number { - return this.state.length; + return this.cards.length; } shuffle(): void { - this.state = this.game.random.Shuffle(this.state); + this.cards = this.game.random.Shuffle(this.cards); } } diff --git a/src/common/entities/piles.ts b/src/common/entities/piles.ts index f7755c4..1386a9c 100644 --- a/src/common/entities/piles.ts +++ b/src/common/entities/piles.ts @@ -1,18 +1,26 @@ -import {ICard, IPiles} from '../models'; +import {ICard, IPendingCardPlay, IPiles} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; import {Pile} from "./pile"; export class Piles { - constructor(private game: TheGame, public cards: IPiles) { + constructor(private game: TheGame, public piles: IPiles) { } get drawPile(): Pile { - return new Pile(this.game, this.cards.drawPile); + return new Pile(this.game, this.piles.drawPile); + } + + set drawPile(pile: ICard[]) { + this.piles.drawPile = pile; } get discardPile(): Pile { - return new Pile(this.game, this.cards.discardPile); + return new Pile(this.game, this.piles.discardPile); + } + + set discardPile(pile: ICard[]) { + this.piles.discardPile = pile; } /** @@ -28,5 +36,42 @@ export class Piles { drawCard(): Card | null { return this.drawPile.drawCard(); } + + /** + * Pending card logic + */ + + get pendingCard(): IPendingCardPlay | null { + return this.piles.pendingCardPlay; + } + + set pendingCard(pending: IPendingCardPlay | null) { + this.piles.pendingCardPlay = pending; + } + + /** + * Resolve any pending card play if the time window has expired. + */ + resolvePendingCard(): void { + const pendingCardPlay = this.pendingCard; + + if (!pendingCardPlay) { + return; + } + + // Check if the timer has expired + if (Date.now() < pendingCardPlay.expiresAtMs) { + return; + } + + this.pendingCard = null; + + const card = new Card(this.game, pendingCardPlay.card); + card.type.cleanupPendingState(this.game); + + if (!pendingCardPlay.isNoped) { + card.play(); + } + } } diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index b2287a2..137a168 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -34,15 +34,23 @@ export class Player { return this._state.hand.length; } + get isCurrentPlayer(): boolean { + return this.game.players.currentPlayer.id === this.id; + } + + get isActingPlayer(): boolean { + return this.game.players.actingPlayer.id === this.id; + } + get canNope(): boolean { if (!this.hand.some(c => c.name === NAME_NOPE)) { return false; } - if (!this.game.pendingCardPlay) { + if (!this.game.piles.pendingCard) { return false; } - const pending = this.game.pendingCardPlay; + const pending = this.game.piles.pendingCard; // Player cannot nope their own card play if it hasn't been noped yet // If it HAS been noped, they can re-nope (un-nope) it, unless they were the last person to nope it @@ -71,6 +79,11 @@ export class Player { return this._state.hand.findIndex(c => c.name === cardName); } + getCardAt(index: number): Card | null { + if (index < 0 || index >= this._state.hand.length) return null; + return new Card(this.game, this._state.hand[index]); + } + /** * Get all card-types of a specific type from hand, or all card-types if no type specified */ @@ -152,6 +165,12 @@ export class Player { this._state.isAlive = false; // put all hand card-types in discard pile this._state.hand.forEach(card => this.game.piles.discardCard(card)); + if (this.isActingPlayer) { + this.game.turnManager.endStage(); // could also involve other players but better than being stuck + } + if (this.isCurrentPlayer) { + this.game.turnManager.endTurn(); + } } /** @@ -167,6 +186,10 @@ export class Player { } playCard(cardIndex: number): void { + if (this.game.piles.pendingCard) { + throw new Error("Cannot play a card while another card play is pending resolution"); + } + if (cardIndex < 0 || cardIndex >= this.hand.length) { throw new Error(`Invalid card index: ${cardIndex}`); } @@ -174,7 +197,8 @@ export class Player { const card = this.hand[cardIndex]; if (!card.type.canBePlayed(this.game, card)) { - throw new Error(`Card cannot be played: ${card.name}`); + // todo: client feedback and should ideally be prevented by UI, but just ignore invalid play for now + return; } // Remove card from hand @@ -191,7 +215,7 @@ export class Player { // Setup pending state const startedAtMs = Date.now(); - this.game.pendingCardPlay = { + this.game.piles.pendingCard = { card: {...playedCard.data}, playedBy: this.id, startedAtMs, @@ -208,7 +232,11 @@ export class Player { if (!this.isAlive) throw new Error("Dead player cannot draw"); const cardData = this.game.piles.drawCard(); - if (!cardData) throw new Error("No cards left to draw"); + if (!cardData) { + console.error("Draw pile is empty, cannot draw"); + this.eliminate(); + return; + } this.addCard(cardData); @@ -218,7 +246,6 @@ export class Player { this.game.turnManager.setStage(DEFUSE_EXPLODING_KITTEN); } else { this.eliminate(); - this.game.turnManager.endTurn(); } } else { this.game.turnManager.endTurn(); @@ -236,8 +263,6 @@ export class Player { if (!defuseCard || !kittenCard) { // Should not happen if UI is correct, but safer to eliminate this.eliminate(); - this.game.turnManager.endStage(); - this.game.turnManager.endTurn(); return; } diff --git a/src/common/entities/turn-manager.ts b/src/common/entities/turn-manager.ts index e057f7a..2f8115e 100644 --- a/src/common/entities/turn-manager.ts +++ b/src/common/entities/turn-manager.ts @@ -1,30 +1,30 @@ -import {TheGame} from "./game"; +import {IContext} from "../models"; export class TurnManager { - constructor(private game: TheGame) {} + constructor(private context: IContext) {} get turnsRemaining(): number { - return this.game.context.G.turnsRemaining; + return this.context.G.turnsRemaining; } set turnsRemaining(value: number) { - this.game.context.G.turnsRemaining = value; + this.context.G.turnsRemaining = value; } endTurn(arg?: any): void { - this.game.context.events.endTurn(arg); + this.context.events.endTurn(arg); } setStage(stage: string): void { - this.game.context.events.setStage(stage); + this.context.events.setStage(stage); } endStage(): void { - this.game.context.events.endStage(); + this.context.events.endStage(); } setActivePlayers(arg: any): void { - this.game.context.events.setActivePlayers(arg); + this.context.events.setActivePlayers(arg); } } diff --git a/src/common/game.ts b/src/common/game.ts index fa3e08e..3e4c2b5 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -52,14 +52,15 @@ export const ExplodingKittens: Game = { onEnd: (context: IContext) => { const game = new TheGame(context) - // Deal card-types when leaving lobby phase + // Initialize the hands and piles const deck = new OriginalDeck(); - const pile: ICard[] = deck.buildBaseDeck().sort(() => Math.random() - 0.5); + const pile: ICard[] = deck.buildBaseDeck(); dealHands(pile, game.context.player.state, deck); // TODO: use api wrapper deck.addPostDealCards(pile, Object.keys(game.context.ctx.playOrder).length); - game.piles.cards.drawPile = pile.sort(() => Math.random() - 0.5); + game.piles.drawPile = pile; + game.piles.drawPile.shuffle(); }, turn: { activePlayers: { diff --git a/src/common/index.ts b/src/common/index.ts index f5bedc9..471996e 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -38,7 +38,6 @@ export {createPlayerPlugin} from './plugins/player-plugin'; // Utilities export {sortCards} from './utils/card-sorting'; -export {validateNope} from './utils/action-validation'; // Wrappers export {Player} from './entities/player'; diff --git a/src/common/models/game-state.model.ts b/src/common/models/game-state.model.ts index b6aef83..c10a60f 100644 --- a/src/common/models/game-state.model.ts +++ b/src/common/models/game-state.model.ts @@ -24,12 +24,12 @@ export interface IPendingCardPlay { export interface IPiles { drawPile: ICard[]; discardPile: ICard[]; + pendingCardPlay: IPendingCardPlay | null; } export interface IGameState { winner: PlayerID | null; piles: IPiles; - pendingCardPlay: IPendingCardPlay | null; turnsRemaining: number; gameRules: IGameRules; deckType: string; diff --git a/src/common/moves/play-card-move.ts b/src/common/moves/play-card-move.ts index 857e693..74ddbbb 100644 --- a/src/common/moves/play-card-move.ts +++ b/src/common/moves/play-card-move.ts @@ -3,7 +3,12 @@ import {TheGame} from "../entities/game"; export const playCard = (context: IContext, cardIndex: number) => { const game = new TheGame(context); - game.players.actingPlayer.playCard(cardIndex); + try { + game.players.actingPlayer.playCard(cardIndex); + } catch (e) { + console.error(e); + return; + } }; export const playNowCard = (context: IContext, cardIndex: number) => { @@ -12,6 +17,6 @@ export const playNowCard = (context: IContext, cardIndex: number) => { export const resolvePendingCard = (context: IContext) => { const game = new TheGame(context); - game.resolvePendingCard(); + game.piles.resolvePendingCard(); }; diff --git a/src/common/setup/game-setup.ts b/src/common/setup/game-setup.ts index ee00a64..0807a4e 100644 --- a/src/common/setup/game-setup.ts +++ b/src/common/setup/game-setup.ts @@ -13,10 +13,10 @@ export const setupGame = (_context: any, setupData?: SetupData): IGameState => { return { winner: null, piles: { - drawPile: [], - discardPile: [], + drawPile: [], + discardPile: [], + pendingCardPlay: null, }, - pendingCardPlay: null, turnsRemaining: 1, gameRules: { spectatorsSeeCards: setupData?.spectatorsSeeCards ?? false, diff --git a/src/common/utils/action-validation.ts b/src/common/utils/action-validation.ts deleted file mode 100644 index 72f20fe..0000000 --- a/src/common/utils/action-validation.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {IGameState} from '../models'; - -/** - * Validates if a player can play a Nope card against the current game state. - * This is pure game logic validation. - */ -export function validateNope(G: IGameState, playerID: string | null | undefined): boolean { - if (!playerID) return false; - - if (!G.pendingCardPlay) return false; - - const pending = G.pendingCardPlay; - - // Player cannot nope their own card play if it hasn't been noped yet - // If it HAS been noped, they can re-nope (un-nope) it, unless they were the last person to nope it - if (!pending.isNoped && pending.playedBy === playerID) { - return false; - } - - // Player cannot nope their own nope card (cannot double-nope themselves immediately) - if (pending.lastNopeBy === playerID) { - return false; - } - - // Check expiration - // Note: Date.now() on client might differ from server, but usually this is acceptable for UI state - return Date.now() <= pending.expiresAtMs; -} - From f427f3b9b410a48b6a60b79ad8d509f05c20eb5d Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:44:12 +0100 Subject: [PATCH 08/55] refactor: bulldoze parameters in dom --- src/client/components/board/Board.tsx | 69 +---------- src/client/components/board/card/Card.tsx | 56 +++------ .../board/lobby-overlay/LobbyOverlay.tsx | 5 +- .../board/overlay-manager/OverlayManager.tsx | 68 ++++------- .../components/board/player-area/Player.tsx | 78 ++++++------ .../board/player-cards/PlayerCards.tsx | 54 ++++----- .../board/player-list/PlayerList.tsx | 93 +++------------ src/client/components/board/table/Table.tsx | 17 ++- .../board/table/pending/PendingPlayStack.tsx | 2 +- .../board/winner-overlay/WinnerOverlay.tsx | 31 ----- .../dead-overlay/DeadOverlay.css | 0 .../dead-overlay/DeadOverlay.tsx | 11 ++ .../SeeTheFutureOverlay.css | 0 .../SeeTheFutureOverlay.tsx | 31 +++-- .../SpecialActionOverlay.css} | 0 .../SpecialActionOverlay.tsx} | 4 +- .../winner-overlay/WinnerOverlay.css | 0 .../overlay/winner-overlay/WinnerOverlay.tsx | 33 ++++++ src/client/entities/game-client.ts | 111 +++++++++++++++--- src/client/hooks/useCardAnimations.tsx | 33 +++--- src/client/model/PlayerState.ts | 31 ----- src/client/types/component-props.ts | 42 +------ src/client/utils/playerPositioning.ts | 69 +++-------- src/common/entities/card-types/favor-card.ts | 12 +- .../entities/deck-types/original-deck.ts | 7 +- src/common/entities/game.ts | 11 +- src/common/entities/pile.ts | 23 +++- src/common/entities/piles.ts | 14 ++- src/common/entities/player.ts | 46 +++++--- src/common/entities/players.ts | 25 ++-- src/common/entities/turn-manager.ts | 15 +++ src/common/game.ts | 40 +++---- src/common/models/game-state.model.ts | 14 +-- src/common/models/player.model.ts | 7 +- src/common/moves/defuse-exploding-kitten.ts | 10 +- src/common/moves/draw-move.ts | 10 +- src/common/moves/favor-card-move.ts | 12 +- src/common/moves/in-game.ts | 16 +++ src/common/moves/play-card-move.ts | 18 +-- src/common/moves/see-future-move.ts | 9 +- src/common/setup/game-setup.ts | 15 ++- src/common/setup/player-setup.ts | 20 +--- 42 files changed, 517 insertions(+), 645 deletions(-) delete mode 100644 src/client/components/board/winner-overlay/WinnerOverlay.tsx rename src/client/components/{board => overlay}/dead-overlay/DeadOverlay.css (100%) rename src/client/components/{board => overlay}/dead-overlay/DeadOverlay.tsx (63%) rename src/client/components/{board => overlay}/see-future-overlay/SeeTheFutureOverlay.css (100%) rename src/client/components/{board => overlay}/see-future-overlay/SeeTheFutureOverlay.tsx (51%) rename src/client/components/{board/player-selection-overlay/PlayerSelectionOverlay.css => overlay/special-action-overlay/SpecialActionOverlay.css} (100%) rename src/client/components/{board/player-selection-overlay/PlayerSelectionOverlay.tsx => overlay/special-action-overlay/SpecialActionOverlay.tsx} (55%) rename src/client/components/{board => overlay}/winner-overlay/WinnerOverlay.css (100%) create mode 100644 src/client/components/overlay/winner-overlay/WinnerOverlay.tsx delete mode 100644 src/client/model/PlayerState.ts create mode 100644 src/common/moves/in-game.ts diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index 2134cb2..fa4c4e7 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -1,7 +1,7 @@ import './Board.css'; import {useCardAnimations} from '../../hooks/useCardAnimations'; import {useGameState} from '../../hooks/useGameState'; -import {GameContext, PlayerStateBundle, OverlayStateBundle} from '../../types/component-props'; +import {GameContext, PlayerStateBundle} from '../../types/component-props'; import Table from './table/Table'; import PlayerList from './player-list/PlayerList'; import OverlayManager from './overlay-manager/OverlayManager'; @@ -111,49 +111,8 @@ export default function ExplodingKittensBoard(props: BoardProps & { isSelfTurn: gameState.selfPlayerId === gameState.currentPlayer }; - // Bundle overlay state - const overlayState: OverlayStateBundle = { - isSelectingPlayer: gameState.isSelectingPlayer, - isChoosingCardToGive: gameState.isChoosingCardToGive, - isViewingFuture: gameState.isViewingFuture, - isGameOver: gameState.isGameOver - }; - // Handle card animations - const {AnimationLayer, triggerCardMovement} = useCardAnimations(G, game.players.players, playerID ?? null); - - /** - * Handle player selection for stealing/requesting a card - */ - const handlePlayerSelect = (targetPlayerId: string) => { - if (!gameState.isSelectingPlayer) return; - - // Check which stage we're in and call the appropriate move - const stage = ctx.activePlayers?.[playerID || '']; - if (stage === 'choosePlayerToStealFrom' && moves.stealCard) { - moves.stealCard(targetPlayerId); - } else if (stage === 'choosePlayerToRequestFrom' && moves.requestCard) { - moves.requestCard(targetPlayerId); - } - }; - - /** - * Handle card selection when giving a card (favor card) - */ - const handleCardGive = (cardIndex: number) => { - if (gameState.isChoosingCardToGive && moves.giveCard) { - moves.giveCard(cardIndex); - } - }; - - /** - * Handle closing the see the future overlay - */ - const handleCloseFutureView = () => { - if (gameState.isViewingFuture && moves.closeFutureView) { - moves.closeFutureView(); - } - }; + const {AnimationLayer} = useCardAnimations(game); /** * Handle starting the game from lobby @@ -169,32 +128,14 @@ export default function ExplodingKittensBoard(props: BoardProps & { -
+
- - + - + {game.isLobbyPhase() && ( void; } export default function Card({ + owner, card, index, - count, angle, offsetX, offsetY, - moves, - isClickable, - isChoosingCardToGive = false, - isInNowCardStage = false, - onCardGive, }: CardProps) { + const game = useGame(); const { isMobile } = useResponsive(); const [isHovered, setIsHovered] = useState(false); @@ -41,32 +34,13 @@ export default function Card({ const cardImage = card ? `/assets/cards/${card.name}/${card.index}.png` : back; - const handleAction = () => { - if (!card) return; - - const serverIndex = card.serverIndex ?? index; + const couldBePlayed = game.isSelf(owner) && game.isSelfCurrentPlayer; - // If choosing a card to give (favor card flow) - if (isChoosingCardToGive && onCardGive) { - onCardGive(serverIndex); + const handleAction = () => { + if (!card) { return; } - - // Otherwise, play the card (normal turn or now-card reaction stage) - if (isClickable && moves) { - try { - if (isInNowCardStage && moves.playNowCard) { - moves.playNowCard(serverIndex); - } else { - moves.playCard(serverIndex); - } - - // Animation is now handled by useCardAnimations reacting to state changes - // This ensures animation only plays if the move was valid and server accepted it - } catch (error) { - console.error('Unexpected error playing card:', error); - } - } + game.selectCard(card.serverIndex); } const handleClick = () => { @@ -74,21 +48,21 @@ export default function Card({ setIsSelected(true) return; } - handleAction(); }; + return ( <>
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -98,8 +72,8 @@ export default function Card({ cardImage={cardImage} anchorRef={cardRef} isVisible={(isMobile ? isSelected : isHovered) && !!card} - actionLabel={isChoosingCardToGive ? "Give This Card" : "Play Card"} - canPlay={isClickable} + actionLabel={game.canGiveCard() ? "Give This Card" : "Play Card"} + canPlay={couldBePlayed} onAction={() => { setIsSelected(false); handleAction(); diff --git a/src/client/components/board/lobby-overlay/LobbyOverlay.tsx b/src/client/components/board/lobby-overlay/LobbyOverlay.tsx index 360fcb8..3a33b95 100644 --- a/src/client/components/board/lobby-overlay/LobbyOverlay.tsx +++ b/src/client/components/board/lobby-overlay/LobbyOverlay.tsx @@ -1,5 +1,6 @@ import './LobbyOverlay.css'; import {useMatchDetails} from "../../../context/MatchDetailsContext.tsx"; +import {useGame} from "../../../context/GameContext.tsx"; interface LobbyOverlayProps { playerID?: string | null; @@ -7,6 +8,8 @@ interface LobbyOverlayProps { } export default function LobbyOverlay({playerID, onStartGame}: LobbyOverlayProps) { + const game = useGame(); + const { matchDetails } = useMatchDetails(); const { players, numPlayers } = matchDetails || {players: [], numPlayers: 1}; @@ -49,7 +52,7 @@ export default function LobbyOverlay({playerID, onStartGame}: LobbyOverlayProps) {Array.from({length: numPlayers}).map((_, i) => { const player = players?.[i]; const hasJoined = player?.isConnected; - const isSelf = player?.id.toString() === playerID; + const isSelf = game.isSelf(player); return (
void; -} + CHOOSE_CARD_TO_GIVE, + CHOOSE_PLAYER_TO_REQUEST_FROM, + CHOOSE_PLAYER_TO_STEAL_FROM +} from "../../../../common/constants/stages.ts"; /** * Manages and renders all game overlays */ -export default function OverlayManager({ - gameContext, - playerState, - overlayState, - winnerID, - onCloseFutureView, -}: OverlayManagerProps) { - const {ctx, G, playerID, matchData} = gameContext; - const {isSelfDead} = playerState; - const {isSelectingPlayer, isChoosingCardToGive, isViewingFuture, isGameOver} = overlayState; +export default function OverlayManager() { + const game = useGame(); + // Determine the overlay message based on the current stage - let selectionMessage = "Select a player to steal a card from"; - if (isSelectingPlayer) { - const stage = ctx.activePlayers?.[playerID || '']; - if (stage === 'choosePlayerToRequestFrom') { - selectionMessage = "Select a player to request a card from"; - } + let selectionMessage = null; + if (game.selfPlayer?.isInStage(CHOOSE_PLAYER_TO_REQUEST_FROM)) { + selectionMessage = "Select a player to request a card from"; + } else if (game.selfPlayer?.isInStage(CHOOSE_PLAYER_TO_STEAL_FROM)) { + selectionMessage = "Select a player to steal a card from"; } - // Get the top 3 card-types from the draw pile for the see the future overlay - const futureCards = isViewingFuture ? G.piles.drawPile.slice(0, 3) : []; - return ( <> - {isSelectingPlayer && } - {isChoosingCardToGive && } - {isViewingFuture && ( - - )} - {isSelfDead && !isGameOver && } - {isGameOver && winnerID && ( - + {selectionMessage && ( + ) + } + {game.selfPlayer?.isInStage(CHOOSE_CARD_TO_GIVE) && ( + )} + + + ); } diff --git a/src/client/components/board/player-area/Player.tsx b/src/client/components/board/player-area/Player.tsx index 8903b6b..d570328 100644 --- a/src/client/components/board/player-area/Player.tsx +++ b/src/client/components/board/player-area/Player.tsx @@ -1,46 +1,48 @@ import './Player.css'; import PlayerCards from '../player-cards/PlayerCards'; -import PlayerState from "../../../model/PlayerState"; import {MatchPlayer, getPlayerName} from "../../../utils/matchData"; -import {PlayerPosition, AnimationCallbacks, PlayerInteractionHandlers} from "../../../types/component-props"; +import {PlayerPosition} from "../../../types/component-props"; +import {useGame} from "../../../context/GameContext.tsx"; +import {Player as PlayerModel} from "../../../../common"; +import { + CHOOSE_CARD_TO_GIVE, + CHOOSE_PLAYER_TO_REQUEST_FROM, + CHOOSE_PLAYER_TO_STEAL_FROM +} from "../../../../common/constants/stages.ts"; interface PlayerAreaProps { - playerID: string; - playerState: PlayerState; + player: PlayerModel; position: PlayerPosition; - moves: any; - isSelectable: boolean; - isChoosingCardToGive: boolean; - isInNowCardStage: boolean; - interactionHandlers: PlayerInteractionHandlers; - animationCallbacks: AnimationCallbacks; matchData?: MatchPlayer[]; - isWaitingOn?: boolean; } export default function Player({ - playerID, - playerState, + player, position, - moves, - isSelectable = false, - isChoosingCardToGive = false, - isInNowCardStage = false, - interactionHandlers, - animationCallbacks, matchData, - isWaitingOn = false }: PlayerAreaProps) { - const {cardPosition, infoPosition} = position; - const {onPlayerSelect} = interactionHandlers; + const game = useGame(); + const selfPlayer = game.selfPlayer; + + const playerId = player.id; + const isSelf = game.isSelf(playerId); + const isTurn = player.isCurrentPlayer; + const isSelectable = + player.isValidCardTarget && ( + selfPlayer?.isInStage(CHOOSE_PLAYER_TO_STEAL_FROM) || + selfPlayer?.isInStage(CHOOSE_PLAYER_TO_REQUEST_FROM)); + const isWaitingOn = player.isInStage(CHOOSE_CARD_TO_GIVE); + + const { cardPosition, infoPosition } = position; + const cardRotation = cardPosition.angle - 90; - const playerName = getPlayerName(playerID, matchData); + const playerName = getPlayerName(playerId, matchData); - const extraClasses = `${playerState.isSelf ? 'hand-interactable self' : ''} ${playerState.isTurn ? 'turn' : ''} ${isSelectable ? 'selectable' : ''} ${isWaitingOn ? 'waiting-on' : ''}` + const extraClasses = `${isSelf ? 'hand-interactable self' : ''} ${isTurn ? 'turn' : ''} ${isSelectable ? 'selectable' : ''} ${isWaitingOn ? 'waiting-on' : ''}` - const handleClick = () => { - if (isSelectable && onPlayerSelect) { - onPlayerSelect(playerID); + const handleInteract = () => { + if (isSelectable) { + game.selectPlayer(player); } }; @@ -53,17 +55,11 @@ export default function Player({ top: cardPosition.top, left: cardPosition.left, transform: `translate(-50%, -50%) rotate(${cardRotation}deg)`, - zIndex: playerState.isSelf ? 2 : 1, + zIndex: isSelf ? 2 : 1, }} >
@@ -76,18 +72,18 @@ export default function Player({ transform: 'translate(-50%, -50%)', zIndex: 3, }} - onClick={handleClick} - data-player-id={playerID} - data-hand-count={playerState.handCount} - data-animation-id={`player-${playerID}`} + onClick={handleInteract} + data-player-id={playerId} + data-hand-count={player.cardCount} + data-animation-id={`player-${playerId}`} >
{playerName} - {playerState.isSelf && ' (You)'} + {isSelf && ' (You)'}
- Cards: {playerState.handCount} + Cards: {player.cardCount}
diff --git a/src/client/components/board/player-cards/PlayerCards.tsx b/src/client/components/board/player-cards/PlayerCards.tsx index 83c6de3..fe8fef6 100644 --- a/src/client/components/board/player-cards/PlayerCards.tsx +++ b/src/client/components/board/player-cards/PlayerCards.tsx @@ -1,57 +1,51 @@ import Card from '../card/Card'; -import PlayerRenderState from "../../../model/PlayerState"; -import {AnimationCallbacks, PlayerInteractionHandlers} from "../../../types/component-props"; -import '../card/Card.css'; // Ensure Card CSS is available for reusing styles if needed +import '../card/Card.css'; +import {ICard, Player, sortCards} from "../../../../common"; +import {useGame} from "../../../context/GameContext.tsx"; + +export interface CardWithServerIndex extends ICard { + serverIndex: number; +} interface PlayerCardsProps { - playerState: PlayerRenderState; - moves?: any; - playerID: string; - isChoosingCardToGive: boolean; - isInNowCardStage: boolean; - animationCallbacks: AnimationCallbacks; - interactionHandlers: PlayerInteractionHandlers; + player: Player; } export default function PlayerCards({ - playerState, - moves, - isChoosingCardToGive, - isInNowCardStage, - interactionHandlers + player }: PlayerCardsProps) { + const game = useGame(); + + const cardCount = player.cardCount; + const fanSpread = game.isSpectator || game.isSelf(player) ? Math.min(cardCount * 6, 60) : Math.min(cardCount * 4, 40); + const angleStep = cardCount > 1 ? fanSpread / (cardCount - 1) : 0; + const baseOffset = cardCount > 1 ? -fanSpread / 2 : 0; + const spreadDistance = game.isSelf(player) ? 25 : game.isSpectator ? 10 : 5; - const {onCardGive} = interactionHandlers; - const {isSelfSpectator, isSelf, isTurn, handCount, hand} = playerState; - const fanSpread = isSelfSpectator || isSelf ? Math.min(handCount * 6, 60) : Math.min(handCount * 4, 40); - const angleStep = handCount > 1 ? fanSpread / (handCount - 1) : 0; - const baseOffset = handCount > 1 ? -fanSpread / 2 : 0; - const spreadDistance = isSelf ? 25 : isSelfSpectator ? 10 : 5; - const canPlay = (isSelf && (isTurn || isInNowCardStage)) || isChoosingCardToGive; + const cardsWithIndices: CardWithServerIndex[] = player.hand.map((card, index) => ({ + ...card, + serverIndex: index + })); + const hand = sortCards(cardsWithIndices) as CardWithServerIndex[]; return (
- {Array(handCount).fill(null).map((_, i) => { + {Array(cardCount).fill(null).map((_, i) => { const angle = baseOffset + (angleStep * i); - const offsetX = (i - (handCount - 1) / 2) * spreadDistance; + const offsetX = (i - (cardCount - 1) / 2) * spreadDistance; const offsetY = Math.abs(angle) * 0.3; const card = hand && hand.length > 0 ? hand[i] : null; return ( ); })} diff --git a/src/client/components/board/player-list/PlayerList.tsx b/src/client/components/board/player-list/PlayerList.tsx index 209ece3..e570c46 100644 --- a/src/client/components/board/player-list/PlayerList.tsx +++ b/src/client/components/board/player-list/PlayerList.tsx @@ -1,90 +1,33 @@ import Player from '../player-area/Player'; -import PlayerState from '../../../model/PlayerState'; -import {calculatePlayerPositions} from '../../../utils/playerPositioning'; -import { - GameContext, - PlayerStateBundle, - OverlayStateBundle, - AnimationCallbacks, - PlayerInteractionHandlers -} from '../../../types/component-props'; +import { calculatePlayerPositions } from '../../../utils/playerPositioning'; import './PlayerList.css'; +import { useGame } from '../../../context/GameContext.tsx'; -interface PlayerListProps { - alivePlayersSorted: string[]; - playerState: PlayerStateBundle; - overlayState: OverlayStateBundle; - isInNowCardStage: boolean; - animationCallbacks: AnimationCallbacks; - interactionHandlers: PlayerInteractionHandlers; - gameContext: GameContext; -} +export default function PlayerList() { + const game = useGame(); + const alivePlayers = game.players.alivePlayers; -/** - * Renders the list of alive players positioned around the table - */ -export default function PlayerList({ - alivePlayersSorted, - playerState, - overlayState, - isInNowCardStage, - animationCallbacks, - interactionHandlers, - gameContext, -}: PlayerListProps) { - const {allPlayers, selfPlayerId, isSelfDead, isSelfSpectator, currentPlayer} = playerState; - const {isSelectingPlayer, isChoosingCardToGive} = overlayState; - const {playerID, moves, matchData} = gameContext; + const selfIndex = !game.isSelfAlive + ? null + : alivePlayers.findIndex(p => p.id === game.selfPlayerId); return (
- {alivePlayersSorted.map((player) => { - const playerNumber = parseInt(player); - const playerInfo = allPlayers[player]; - const isSelf = selfPlayerId !== null && playerNumber === selfPlayerId; - - let {cardPosition, infoPosition} = calculatePlayerPositions( - player, - alivePlayersSorted, - selfPlayerId, - isSelfDead - ); + {alivePlayers.map((player, playerIndex) => { - const playerRenderState = new PlayerState( - isSelfSpectator, - isSelf, - playerInfo.isAlive, - playerNumber === currentPlayer, - playerInfo.client.handCount, - playerInfo.hand + const { cardPosition, infoPosition } = calculatePlayerPositions( + playerIndex, + alivePlayers.length, + selfIndex === -1 ? null : selfIndex, + !game.selfPlayer?.isAlive ); - const isSelectable = isSelectingPlayer - && player !== playerID - && playerRenderState.isAlive - && playerRenderState.handCount > 0; - - const isWaitingOn = gameContext.ctx.activePlayers?.[player] === 'chooseCardToGive'; - const isSelfChoosingCard = isChoosingCardToGive - && player === playerID; - - const isSelfInNowCardStage = isInNowCardStage - && player === playerID; - return ( ); })} diff --git a/src/client/components/board/table/Table.tsx b/src/client/components/board/table/Table.tsx index 53d5f91..0e95101 100644 --- a/src/client/components/board/table/Table.tsx +++ b/src/client/components/board/table/Table.tsx @@ -16,8 +16,8 @@ export default function Table() { const [isDrawing, setIsDrawing] = useState(false); const [isShuffling, setIsShuffling] = useState(false); - const [lastDrawPileLength, setLastDrawPileLength] = useState(game.piles.drawPile.size); - const [lastDiscardPileLength, setLastDiscardPileLength] = useState(game.piles.discardPile.size); + const [lastDrawPileSize, setLastDrawPileSize] = useState(game.piles.drawPile.size); + const [lastDiscardPileSize, setLastDiscardPileSize] = useState(game.piles.discardPile.size); const [isHoveringDrawPile, setIsHoveringDrawPile] = useState(false); const [isDiscardPileSelected, setIsDiscardPileSelected] = useState(false); const discardPileRef = useRef(null); @@ -26,12 +26,12 @@ export default function Table() { // Detect when a card is drawn useEffect(() => { - if (game.piles.drawPile.size < lastDrawPileLength) { + if (game.piles.drawPile.size < lastDrawPileSize) { setIsDrawing(true); setTimeout(() => setIsDrawing(false), 400); } - setLastDrawPileLength(game.piles.drawPile.size); - }, [game.piles.drawPile.size, lastDrawPileLength]); + setLastDrawPileSize(game.piles.drawPile.size); + }, [game.piles.drawPile.size, lastDrawPileSize]); // Detect when a shuffle card is played const pendingCardRef = useRef(game.piles.pendingCard); @@ -46,7 +46,7 @@ export default function Table() { pendingCardRef.current = null; // Check if discard pile changed - const discardChanged = game.piles.discardPile.size > lastDiscardPileLength; + const discardChanged = game.piles.discardPile.size > lastDiscardPileSize; // Check if we just finished a pending play that was a Shuffle and NOT noped const resolvedShuffle = wasPending && @@ -56,7 +56,6 @@ export default function Table() { const lastCard = game.piles.discardPile.topCard; // Trigger if newly placed shuffle - // OR if delayed resolution happened if ((discardChanged || resolvedShuffle) && lastCard?.name === NAME_SHUFFLE) { // Double check it wasn't noped if it came from pending if (!(wasPending && wasPending.isNoped)) { @@ -66,9 +65,9 @@ export default function Table() { } if (!game.piles.pendingCard) { - setLastDiscardPileLength(game.piles.discardPile.size); + setLastDiscardPileSize(game.piles.discardPile.size); } - }, [game.piles.discardPile.size, lastDiscardPileLength, game.piles.discardPile, game.piles.pendingCard]); + }, [game.piles.discardPile.size, lastDiscardPileSize, game.piles.discardPile, game.piles.pendingCard]); const handleDrawClick = () => { // wait for previous draw animation to finish before allowing another draw diff --git a/src/client/components/board/table/pending/PendingPlayStack.tsx b/src/client/components/board/table/pending/PendingPlayStack.tsx index 85c7e74..25645b0 100644 --- a/src/client/components/board/table/pending/PendingPlayStack.tsx +++ b/src/client/components/board/table/pending/PendingPlayStack.tsx @@ -47,7 +47,7 @@ export default function PendingPlayStack() { {game.selfPlayer?.canNope && ( diff --git a/src/client/components/board/winner-overlay/WinnerOverlay.tsx b/src/client/components/board/winner-overlay/WinnerOverlay.tsx deleted file mode 100644 index 48894ab..0000000 --- a/src/client/components/board/winner-overlay/WinnerOverlay.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import './WinnerOverlay.css'; -import {MatchPlayer, getPlayerName} from '../../../utils/matchData'; - -interface WinnerOverlayProps { - winnerID: string; - playerID: string | null; - matchData?: MatchPlayer[]; -} - -export default function WinnerOverlay({winnerID, playerID, matchData}: WinnerOverlayProps) { - const isWinner = playerID === winnerID; - const winnerName = getPlayerName(winnerID, matchData); - - return ( -
-
-
🏆
-
Game Over!
-
- {isWinner ? 'You Win!' : `${winnerName} Wins!`} -
-
- {isWinner - ? 'Congratulations!' - : 'Better luck next time!'} -
-
-
- ); -} - diff --git a/src/client/components/board/dead-overlay/DeadOverlay.css b/src/client/components/overlay/dead-overlay/DeadOverlay.css similarity index 100% rename from src/client/components/board/dead-overlay/DeadOverlay.css rename to src/client/components/overlay/dead-overlay/DeadOverlay.css diff --git a/src/client/components/board/dead-overlay/DeadOverlay.tsx b/src/client/components/overlay/dead-overlay/DeadOverlay.tsx similarity index 63% rename from src/client/components/board/dead-overlay/DeadOverlay.tsx rename to src/client/components/overlay/dead-overlay/DeadOverlay.tsx index 805e1a1..8ec4917 100644 --- a/src/client/components/board/dead-overlay/DeadOverlay.tsx +++ b/src/client/components/overlay/dead-overlay/DeadOverlay.tsx @@ -1,6 +1,17 @@ import './DeadOverlay.css'; +import {useGame} from "../../../context/GameContext.tsx"; export default function DeadOverlay() { + const game = useGame(); + + if (game.isSelfAlive) { + return null; + } + + if (!game.isPlayingPhase()) { + return null; + } + return (
diff --git a/src/client/components/board/see-future-overlay/SeeTheFutureOverlay.css b/src/client/components/overlay/see-future-overlay/SeeTheFutureOverlay.css similarity index 100% rename from src/client/components/board/see-future-overlay/SeeTheFutureOverlay.css rename to src/client/components/overlay/see-future-overlay/SeeTheFutureOverlay.css diff --git a/src/client/components/board/see-future-overlay/SeeTheFutureOverlay.tsx b/src/client/components/overlay/see-future-overlay/SeeTheFutureOverlay.tsx similarity index 51% rename from src/client/components/board/see-future-overlay/SeeTheFutureOverlay.tsx rename to src/client/components/overlay/see-future-overlay/SeeTheFutureOverlay.tsx index 08655bc..b05b11f 100644 --- a/src/client/components/board/see-future-overlay/SeeTheFutureOverlay.tsx +++ b/src/client/components/overlay/see-future-overlay/SeeTheFutureOverlay.tsx @@ -1,12 +1,29 @@ import './SeeTheFutureOverlay.css'; -import {ICard} from '../../../../common'; +import {useGame} from "../../../context/GameContext.tsx"; +import {VIEWING_FUTURE} from "../../../../common/constants/stages.ts"; +import {Card} from "../../../../common/entities/card.ts"; -interface SeeTheFutureOverlayProps { - cards: ICard[]; - onClose: () => void; -} +export default function SeeTheFutureOverlay() { + const game = useGame(); + + if (!game.selfPlayer?.isInStage(VIEWING_FUTURE)) { + return null; + } + + // Get the top 3 card-types from the draw pile for the see the future overlay + const cards: Card[] = game.piles.drawPile.peek(3); + + if (!cards || cards.length === 0) { + console.error("No cards available to see in the future! This shouldn't happen."); + return null; + } + + const handleFutureClose = () => { + if (game.moves.closeFutureView) { + game.moves.closeFutureView(); + } + } -export default function SeeTheFutureOverlay({ cards, onClose }: SeeTheFutureOverlayProps) { return (
@@ -27,7 +44,7 @@ export default function SeeTheFutureOverlay({ cards, onClose }: SeeTheFutureOver
))}
-
diff --git a/src/client/components/board/player-selection-overlay/PlayerSelectionOverlay.css b/src/client/components/overlay/special-action-overlay/SpecialActionOverlay.css similarity index 100% rename from src/client/components/board/player-selection-overlay/PlayerSelectionOverlay.css rename to src/client/components/overlay/special-action-overlay/SpecialActionOverlay.css diff --git a/src/client/components/board/player-selection-overlay/PlayerSelectionOverlay.tsx b/src/client/components/overlay/special-action-overlay/SpecialActionOverlay.tsx similarity index 55% rename from src/client/components/board/player-selection-overlay/PlayerSelectionOverlay.tsx rename to src/client/components/overlay/special-action-overlay/SpecialActionOverlay.tsx index d1015ae..27ab253 100644 --- a/src/client/components/board/player-selection-overlay/PlayerSelectionOverlay.tsx +++ b/src/client/components/overlay/special-action-overlay/SpecialActionOverlay.tsx @@ -1,10 +1,10 @@ -import './PlayerSelectionOverlay.css'; +import './SpecialActionOverlay.css'; interface PlayerSelectionOverlayProps { message?: string; } -export default function PlayerSelectionOverlay({ message = "Select a player to steal a card from" }: PlayerSelectionOverlayProps) { +export default function SpecialActionOverlay({ message = "Select a player to steal a card from" }: PlayerSelectionOverlayProps) { return (
diff --git a/src/client/components/board/winner-overlay/WinnerOverlay.css b/src/client/components/overlay/winner-overlay/WinnerOverlay.css similarity index 100% rename from src/client/components/board/winner-overlay/WinnerOverlay.css rename to src/client/components/overlay/winner-overlay/WinnerOverlay.css diff --git a/src/client/components/overlay/winner-overlay/WinnerOverlay.tsx b/src/client/components/overlay/winner-overlay/WinnerOverlay.tsx new file mode 100644 index 0000000..239277d --- /dev/null +++ b/src/client/components/overlay/winner-overlay/WinnerOverlay.tsx @@ -0,0 +1,33 @@ +import './WinnerOverlay.css'; +import {getPlayerName} from '../../../utils/matchData.ts'; +import {useGame} from "../../../context/GameContext.tsx"; + +export default function WinnerOverlay() { + const game = useGame(); + + const winner = game.players.winner; + if (!game.isGameOver() || !winner) { + return null; + } + + const isSelfWinner = game.selfPlayerId === winner.id; + const winnerName = getPlayerName(winner.id, game.matchData); + + return ( +
+
+
🏆
+
Game Over!
+
+ {isSelfWinner ? 'You Win!' : `${winnerName} Wins!`} +
+
+ {isSelfWinner + ? 'Congratulations!' + : 'Better luck next time!'} +
+
+
+ ); +} + diff --git a/src/client/entities/game-client.ts b/src/client/entities/game-client.ts index 03152eb..1d46be2 100644 --- a/src/client/entities/game-client.ts +++ b/src/client/entities/game-client.ts @@ -1,14 +1,20 @@ import {ICard, Player, TheGame} from '../../common'; import { IContext } from '../../common'; -import {PlayerID} from "boardgame.io"; import {Card} from "../../common/entities/card.ts"; import {NAME_NOPE} from "../../common/constants/cards.ts"; +import { + CHOOSE_CARD_TO_GIVE, + CHOOSE_PLAYER_TO_REQUEST_FROM, + CHOOSE_PLAYER_TO_STEAL_FROM +} from "../../common/constants/stages.ts"; +import {MatchPlayer} from "../utils/matchData.ts"; +import {PlayerID} from "boardgame.io"; export class TheGameClient extends TheGame { public readonly moves: Record void>; public readonly matchID: string; - public readonly playerID: string | null; - public readonly matchData: any; + public readonly selfPlayerId: string | null; + public readonly matchData: MatchPlayer[]; public readonly sendChatMessage: (message: string) => void; public readonly chatMessages: any[]; public readonly isMultiplayer: boolean; @@ -27,7 +33,7 @@ export class TheGameClient extends TheGame { this.moves = moves; this.matchID = matchID; - this.playerID = playerID; + this.selfPlayerId = playerID; this.matchData = matchData; this.sendChatMessage = sendChatMessage; this.chatMessages = chatMessages; @@ -35,28 +41,63 @@ export class TheGameClient extends TheGame { } get isSpectator(): boolean { - return this.playerID === null; + return this.selfPlayerId === null; } - get selfPlayerId(): PlayerID | null { - return this.isSpectator ? null : this.playerID; + get selfPlayer(): Player | null { + if (!this.selfPlayerId) { + return null; + } + return this.players.getPlayer(this.selfPlayerId); } - get selfPlayer(): Player | null { - if (this.isSpectator) return null; - return this.players.getPlayer(this.selfPlayerId!); + get isSelfAlive(): boolean { + const selfPlayer = this.selfPlayer; + return !!selfPlayer && selfPlayer.isAlive; } get isSelfCurrentPlayer(): boolean { return this.selfPlayerId === this.players.currentPlayer.id; } + isSelf(player: Player | PlayerID | string | null) { + if (!this.selfPlayerId || !player) { + return false; + } + const playerId = typeof player === 'string' ? player : player.id; + return this.selfPlayerId === playerId; + } + playDrawCard() { - if (!this.isSpectator && this.moves.drawCard) { + if (this.selfPlayer && this.moves.drawCard) { this.moves.drawCard(); } } + selectCard(cardIndex: number) { + if (!this.selfPlayer) { + console.error("No self player found"); + return; + } + if (this.canGiveCard()) { + this.giveCard(cardIndex); + } else { + this.playCard(cardIndex); + } + } + + canPlayCard(cardIndex: number): boolean { + if (!this.isSelfCurrentPlayer) { + return false; + } + const cardAt = this.selfPlayer?.getCardAt(cardIndex); + if (!cardAt) { + console.error(`No card at index ${cardIndex} in player's hand`); + return false; + } + return this.piles.canCardBePlayed(cardAt); + } + playCard(cardIndex: number) { if (this.selfPlayer && this.moves.playCard) { const cardAt = this.selfPlayer.getCardAt(cardIndex); @@ -64,7 +105,7 @@ export class TheGameClient extends TheGame { console.error(`No card at index ${cardIndex} in player's hand`); return; } - if (!cardAt.canPlay()) { + if (!this.canPlayCard(cardIndex)) { console.error(`Card ${cardAt.name} at index ${cardIndex} cannot be played right now`); return; } @@ -73,8 +114,50 @@ export class TheGameClient extends TheGame { } playNope() { - if (this.selfPlayer?.canNope && this.piles.pendingCard && this.moves.playNowCard) { - this.moves.playNowCard(this.selfPlayer?.findCardIndex(NAME_NOPE)) + if (this.selfPlayer && this.selfPlayer.canNope && this.piles.pendingCard && this.moves.playCard) { + const number = this.selfPlayer.findCardIndex(NAME_NOPE); + this.moves.playCard(number) + } + } + + canGiveCard(): boolean { + if (!this.selfPlayer) { + return false; + } + return this.selfPlayer.isInStage(CHOOSE_CARD_TO_GIVE); + } + + giveCard(cardIndex: number) { + if (!this.selfPlayer) { + console.error("No self player found"); + return; + } + + if (!this.canGiveCard()) { + console.error("Player cannot give a card right now"); + return; + } + + if (this.moves.giveCard) { + this.moves.giveCard(cardIndex); + } + } + + selectPlayer(player: Player) { + if (!this.selfPlayer) { + return false; + } + if (!this.selfPlayer.canSelectPlayer()) { + console.error("Player cannot be selected right now"); + return false; + } + if (!player.isValidCardTarget) { + return false; + } + if (this.selfPlayer.isInStage(CHOOSE_PLAYER_TO_STEAL_FROM) && this.moves.stealCard) { + this.moves.stealCard(player.id); + } else if (this.selfPlayer.isInStage(CHOOSE_PLAYER_TO_REQUEST_FROM) && this.moves.requestCard) { + this.moves.requestCard(player.id); } } diff --git a/src/client/hooks/useCardAnimations.tsx b/src/client/hooks/useCardAnimations.tsx index 9b339e0..d5277e0 100644 --- a/src/client/hooks/useCardAnimations.tsx +++ b/src/client/hooks/useCardAnimations.tsx @@ -1,6 +1,7 @@ import React, {useState, useCallback, useRef, useEffect} from 'react'; import CardAnimation, {CardAnimationData} from '../components/board/card-animation/CardAnimation'; -import {ICard, IGameState, IPlayers} from '../../common'; +import {ICard} from '../../common'; +import {TheGameClient} from "../entities/game-client.ts"; interface UseCardAnimationsReturn { animations: CardAnimationData[]; @@ -15,11 +16,14 @@ interface HandChange { delta: number; } -export const useCardAnimations = (G: IGameState, players: IPlayers, selfPlayerId: string | null): UseCardAnimationsReturn => { +export const useCardAnimations = (game: TheGameClient): UseCardAnimationsReturn => { + const players = game.players.players + const selfPlayerId = game.selfPlayerId; + const [animations, setAnimations] = useState([]); const animationIdCounter = useRef(0); - const previousDrawPileLength = useRef(G.client.drawPileLength); - const previousDiscardPileLength = useRef(G.piles.discardPile.length); + const previousDrawPileSize = useRef(game.piles.drawPile.size); + const previousDiscardPileSize = useRef(game.piles.discardPile.size); const previousPlayerHands = useRef({}); const previousLocalHand = useRef([]); @@ -89,10 +93,10 @@ export const useCardAnimations = (G: IGameState, players: IPlayers, selfPlayerId const currentHandCounts = getPlayerHandCounts(); const handChanges = getHandChanges(currentHandCounts, previousPlayerHands.current); - const drawPileDecreased = G.client.drawPileLength < previousDrawPileLength.current; - const discardPileIncreased = G.piles.discardPile.length > previousDiscardPileLength.current; - const pilesUnchanged = G.client.drawPileLength === previousDrawPileLength.current && - G.piles.discardPile.length === previousDiscardPileLength.current; + const drawPileDecreased = game.piles.drawPile.size < previousDrawPileSize.current; + const discardPileIncreased = game.piles.discardPile.size > previousDiscardPileSize.current; + const pilesUnchanged = game.piles.drawPile.size === previousDrawPileSize.current && + game.piles.discardPile.size === previousDiscardPileSize.current; if (drawPileDecreased) { handChanges @@ -111,10 +115,12 @@ export const useCardAnimations = (G: IGameState, players: IPlayers, selfPlayerId } if (discardPileIncreased) { - const lastCard = G.piles.discardPile[G.piles.discardPile.length - 1]; + const lastCard = game.piles.discardPile.topCard; handChanges .filter(change => change.delta < 0) - .forEach(change => triggerCardMovement(lastCard, `player-${change.playerId}`, 'discard-pile')); + .forEach(change => { + triggerCardMovement(lastCard, `player-${change.playerId}`, 'discard-pile') + }); } if (pilesUnchanged && handChanges.length > 0) { @@ -144,13 +150,12 @@ export const useCardAnimations = (G: IGameState, players: IPlayers, selfPlayerId card = lostCard; } } - triggerCardMovement(card, `player-${playerLost.playerId}`, `player-${playerGained.playerId}`); } } - previousDrawPileLength.current = G.client.drawPileLength; - previousDiscardPileLength.current = G.piles.discardPile.length; + previousDrawPileSize.current = game.piles.drawPile.size; + previousDiscardPileSize.current = game.piles.discardPile.size; previousPlayerHands.current = currentHandCounts; if (selfPlayerId && players[selfPlayerId]) { @@ -158,7 +163,7 @@ export const useCardAnimations = (G: IGameState, players: IPlayers, selfPlayerId } else { previousLocalHand.current = []; } - }, [G.client.drawPileLength, G.piles.discardPile.length, G.piles.discardPile, triggerCardMovement, players, selfPlayerId]); + }, [game.piles.drawPile.size, game.piles.discardPile.size, game.piles.drawPile, triggerCardMovement, players, selfPlayerId]); const AnimationLayer = useCallback(() => ( <> diff --git a/src/client/model/PlayerState.ts b/src/client/model/PlayerState.ts deleted file mode 100644 index a553ea4..0000000 --- a/src/client/model/PlayerState.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {ICard, sortCards} from "../../common"; - -export interface CardWithServerIndex extends ICard { - serverIndex: number; -} - -export default class PlayerState { - isSelfSpectator: boolean; - isSelf: boolean; - isAlive: boolean; - isTurn: boolean; - handCount: number; - hand: CardWithServerIndex[]; - - constructor(isSelfSpectator: boolean, isSelf: boolean, isAlive: boolean, isTurn: boolean, handCount: number, hand: ICard[]) { - this.isSelfSpectator = isSelfSpectator; - this.isSelf = isSelf; - this.isAlive = isAlive; - this.isTurn = isTurn; - this.handCount = handCount; - - // Create card-types with server indices before sorting - const cardsWithIndices: CardWithServerIndex[] = hand.map((card, index) => ({ - ...card, - serverIndex: index - })); - - // Sort the hand by card type sort order and card index - this.hand = sortCards(cardsWithIndices) as CardWithServerIndex[]; - } -} diff --git a/src/client/types/component-props.ts b/src/client/types/component-props.ts index 4d4339e..3170634 100644 --- a/src/client/types/component-props.ts +++ b/src/client/types/component-props.ts @@ -1,10 +1,5 @@ -/** - * Bundled prop types for cleaner component interfaces - * This reduces prop drilling and makes the codebase easier to maintain - */ - import {Ctx} from 'boardgame.io'; -import {IGameState, IPlayers, ICard} from '../../common'; +import {IGameState, IPlayers} from '../../common'; import {MatchPlayer} from '../utils/matchData'; /** @@ -30,41 +25,6 @@ export interface PlayerStateBundle { isSelfTurn: boolean; } -/** - * Overlay state bundle - contains all overlay visibility flags - */ -export interface OverlayStateBundle { - isSelectingPlayer: boolean; - isChoosingCardToGive: boolean; - isViewingFuture: boolean; - isGameOver: boolean; -} - -/** - * Explosion event bundle - contains explosion/defuse event data - */ -export interface ExplosionEventBundle { - event: 'exploding' | 'defused' | null; - playerName: string; - isSelf: boolean; - onComplete: () => void; -} - -/** - * Animation callbacks bundle - contains animation-related functions - */ -export interface AnimationCallbacks { - triggerCardMovement: (card: ICard | null, fromId: string, toId: string) => void; -} - -/** - * Player interaction bundle - contains player selection/interaction handlers - */ -export interface PlayerInteractionHandlers { - onPlayerSelect: (playerId: string) => void; - onCardGive: (cardIndex: number) => void; -} - /** * Position data for player rendering */ diff --git a/src/client/utils/playerPositioning.ts b/src/client/utils/playerPositioning.ts index 6a519d0..a38dc0b 100644 --- a/src/client/utils/playerPositioning.ts +++ b/src/client/utils/playerPositioning.ts @@ -1,7 +1,3 @@ -/** - * Position calculation utilities for player positioning around the table - */ - export interface Position { top: string; left: string; @@ -16,9 +12,6 @@ export interface PlayerPositions { infoPosition: Position; } -/** - * Calculate position on a circle given an angle and radius - */ export const calculateCircularPosition = ( angle: number, radius: string @@ -30,17 +23,10 @@ export const calculateCircularPosition = ( }; }; -/** - * Calculate angle from slot position - */ const calculateAngleFromSlot = (slotPosition: number, totalSlots: number): number => { - const angleStep = 360 / totalSlots; - return 180 + (slotPosition * angleStep); + return 180 + (slotPosition * (360 / totalSlots)); }; -/** - * Find relative position in a circular array - */ const getRelativePosition = ( targetIndex: number, referenceIndex: number, @@ -49,54 +35,30 @@ const getRelativePosition = ( return (targetIndex - referenceIndex + arrayLength) % arrayLength; }; -/** - * Calculate the angle for a player position around the table - * Takes into account whether the viewer is alive or dead - */ export const calculatePlayerAngle = ( - playerIdStr: string, - alivePlayers: string[], - selfPlayerId: number | null, + playerIndex: number, + totalPlayers: number, + selfIndex: number | null, isSelfDead: boolean ): number => { - const alivePlayerIds = [...alivePlayers] - .map(p => parseInt(p)) - .sort((a, b) => a - b); - - const playerId = parseInt(playerIdStr); - const playerIndex = alivePlayerIds.indexOf(playerId); - - // Self is alive or spectator: distribute alive players evenly - if (!isSelfDead || selfPlayerId === null) { - const selfIndex = selfPlayerId !== null - ? alivePlayerIds.indexOf(selfPlayerId) - : 0; - - const relativePosition = getRelativePosition(playerIndex, selfIndex, alivePlayerIds.length); - return calculateAngleFromSlot(relativePosition, alivePlayerIds.length); + if (isSelfDead) { + // Slot 0 (bottom) is always the empty seat for the dead viewer. + // Shift all alive players by 1 to leave that slot vacant. + const relativePosition = (playerIndex + 1) % (totalPlayers + 1); + return calculateAngleFromSlot(relativePosition, totalPlayers + 1); } - // Self is dead: leave empty slot at position 0 where self was - // Maintain relative positions based on original player IDs - const allPlayerIds = [...alivePlayerIds, selfPlayerId].sort((a, b) => a - b); - const selfIndex = allPlayerIds.indexOf(selfPlayerId); - const fullPlayerIndex = allPlayerIds.indexOf(playerId); - - const relativePosition = getRelativePosition(fullPlayerIndex, selfIndex, allPlayerIds.length); - return calculateAngleFromSlot(relativePosition, allPlayerIds.length); + const relativePosition = getRelativePosition(playerIndex, selfIndex ?? 0, totalPlayers); + return calculateAngleFromSlot(relativePosition, totalPlayers); }; -/** - * Calculate both card and info positions for a player - */ export const calculatePlayerPositions = ( - playerIdStr: string, - alivePlayers: string[], - selfPlayerId: number | null, + playerIndex: number, + totalPlayers: number, + selfIndex: number | null, isSelfDead: boolean ): PlayerPositions => { - const angle = calculatePlayerAngle(playerIdStr, alivePlayers, selfPlayerId, isSelfDead); - + const angle = calculatePlayerAngle(playerIndex, totalPlayers, selfIndex, isSelfDead); return { cardPosition: { ...calculateCircularPosition(angle, 'min(35vw, 35vh)'), @@ -105,4 +67,3 @@ export const calculatePlayerPositions = ( infoPosition: calculateCircularPosition(angle, 'min(45vw, 45vh)'), }; }; - diff --git a/src/common/entities/card-types/favor-card.ts b/src/common/entities/card-types/favor-card.ts index b51b614..0b49117 100644 --- a/src/common/entities/card-types/favor-card.ts +++ b/src/common/entities/card-types/favor-card.ts @@ -11,15 +11,7 @@ export class FavorCard extends CardType { } canBePlayed(game: TheGame, _card: Card): boolean { - const { ctx } = game.context; - - // Check if there is at least one other player with card-types - return game.players.allPlayers.some((target) => { - if (target.id === ctx.currentPlayer) { - return false; // Can't target yourself - } - return target.isAlive && target.cardCount > 0; - }); + return game.players.getValidCardActionTargets(game.players.actingPlayer).length > 0; } onPlayed(game: TheGame, _card: Card) { @@ -31,7 +23,7 @@ export class FavorCard extends CardType { if (candidates.length === 1) { // Automatically choose the only valid opponent - requestCard(game.context, candidates[0].id); + requestCard(game, candidates[0].id); } else if (candidates.length > 1) { // Set stage to choose a player to request a card from game.turnManager.setStage(CHOOSE_PLAYER_TO_REQUEST_FROM) diff --git a/src/common/entities/deck-types/original-deck.ts b/src/common/entities/deck-types/original-deck.ts index fc44f3d..badb6ba 100644 --- a/src/common/entities/deck-types/original-deck.ts +++ b/src/common/entities/deck-types/original-deck.ts @@ -4,7 +4,6 @@ import type {ICard} from '../../models'; import { ATTACK, CAT_CARD, - DEFUSE, EXPLODING_KITTEN, FAVOR, NOPE, SEE_THE_FUTURE, @@ -26,8 +25,8 @@ export class OriginalDeck extends DeckType { return STARTING_HAND_SIZE; } - startingHandForcedCards(index: number): ICard[] { - return [DEFUSE.createCard(index)]; + startingHandForcedCards(_index: number): ICard[] { + return []; } buildBaseDeck(): ICard[] { @@ -55,7 +54,7 @@ export class OriginalDeck extends DeckType { const remaining = Math.min(TOTAL_DEFUSE_CARDS - playerCount, MAX_DECK_DEFUSE_CARDS); for (let i = 0; i < remaining; i++) { - pile.push(DEFUSE.createCard(playerCount - 1 + i)); + // pile.push(DEFUSE.createCard(playerCount - 1 + i)); } for (let i = 0; i < EXPLODING_KITTENS; i++) { diff --git a/src/common/entities/game.ts b/src/common/entities/game.ts index e7a7fc2..093a3e2 100644 --- a/src/common/entities/game.ts +++ b/src/common/entities/game.ts @@ -6,7 +6,7 @@ import {IGameRules} from '../models'; import {RandomAPI} from "boardgame.io/dist/types/src/plugins/random/random"; import {EventsAPI} from "boardgame.io/dist/types/src/plugins/events/events"; import {Ctx} from "boardgame.io"; -import {LOBBY} from "../constants/phases"; +import {GAME_OVER, LOBBY, PLAY} from "../constants/phases"; export class TheGame { @@ -30,6 +30,7 @@ export class TheGame { this.piles = new Piles(this, this.gameState.piles); this.players = new Players( this, + this.gameState, this.context.player.state ?? (this.context.player as IPlayerAPI & { data: { players: IPlayers } }).data.players ); this.turnManager = new TurnManager(this.context); @@ -43,6 +44,14 @@ export class TheGame { return this.phase === LOBBY; } + isPlayingPhase(): boolean { + return this.phase === PLAY; + } + + isGameOver(): boolean { + return this.bgContext.phase === GAME_OVER; + } + get gameRules(): IGameRules { return this.gameState.gameRules; } diff --git a/src/common/entities/pile.ts b/src/common/entities/pile.ts index f4b4cd3..576f3a7 100644 --- a/src/common/entities/pile.ts +++ b/src/common/entities/pile.ts @@ -1,9 +1,12 @@ -import {ICard} from '../models'; +import {ICard, IPile} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; export class Pile { - constructor(private game: TheGame, public cards: ICard[]) { + public cards: ICard[]; + + constructor(private game: TheGame, public state: IPile) { + this.cards = state.cards; } addCard(card: Card | ICard): void { @@ -11,6 +14,7 @@ export class Pile { // Clone to avoid Proxy issues this.cards.push({...cardData}); + this.updateSize(); } get topCard(): Card | null { @@ -18,8 +22,14 @@ export class Pile { return this.cards.length > 0 ? new Card(this.game, iCard) : null; } + peek(amount: number): Card[] { + const peekedCards = this.cards.slice(0, amount); + return peekedCards.map(iCard => new Card(this.game, iCard)); + } + drawCard(): Card | null { const shift = this.cards.shift(); + this.updateSize(); return shift ? new Card(this.game, shift) : null; } @@ -27,14 +37,19 @@ export class Pile { const cardData: ICard = {name: card.name, index: card.index}; // Clone to avoid Proxy issues this.cards.splice(index, 0, {...cardData}); + this.updateSize(); } get size(): number { - return this.cards.length; + return this.state.size; } shuffle(): void { - this.cards = this.game.random.Shuffle(this.cards); + this.state.cards = this.game.random.Shuffle(this.cards); + } + + private updateSize(): void { + this.state.size = this.cards.length; } } diff --git a/src/common/entities/piles.ts b/src/common/entities/piles.ts index 1386a9c..6c621c5 100644 --- a/src/common/entities/piles.ts +++ b/src/common/entities/piles.ts @@ -4,7 +4,7 @@ import {Card} from "./card"; import {Pile} from "./pile"; export class Piles { - constructor(private game: TheGame, public piles: IPiles) { + constructor(private game: TheGame, private piles: IPiles) { } get drawPile(): Pile { @@ -12,7 +12,7 @@ export class Piles { } set drawPile(pile: ICard[]) { - this.piles.drawPile = pile; + this.piles.drawPile = { ...this.piles.drawPile, cards: pile, size: pile.length }; } get discardPile(): Pile { @@ -20,7 +20,7 @@ export class Piles { } set discardPile(pile: ICard[]) { - this.piles.discardPile = pile; + this.piles.discardPile = { ...this.piles.discardPile, cards: pile, size: pile.length }; } /** @@ -37,6 +37,14 @@ export class Piles { return this.drawPile.drawCard(); } + canCardBePlayed(card: Card): boolean { + if (this.game.piles.pendingCard && !card.type.isNowCard()) { + return false; + } + + return card.canPlay(); + } + /** * Pending card logic */ diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 137a168..3939fae 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -2,7 +2,7 @@ import {ICard, IPlayer} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; import {EXPLODING_KITTEN, DEFUSE} from "../registries/card-registry"; -import {DEFUSE_EXPLODING_KITTEN} from "../constants/stages"; +import {CHOOSE_PLAYER_TO_REQUEST_FROM, DEFUSE_EXPLODING_KITTEN} from "../constants/stages"; import {PlayerID} from "boardgame.io"; import {NAME_NOPE} from "../constants/cards"; @@ -31,7 +31,7 @@ export class Player { * Get the count of card-types in hand */ get cardCount(): number { - return this._state.hand.length; + return this._state.handSize; } get isCurrentPlayer(): boolean { @@ -42,6 +42,10 @@ export class Player { return this.game.players.actingPlayer.id === this.id; } + get isValidCardTarget(): boolean { + return this.isAlive && this.cardCount > 0; + } + get canNope(): boolean { if (!this.hand.some(c => c.name === NAME_NOPE)) { return false; @@ -68,6 +72,15 @@ export class Player { return Date.now() <= pending.expiresAtMs; } + canGiveCard(): boolean { + return this.isInStage(CHOOSE_PLAYER_TO_REQUEST_FROM); + } + + canSelectPlayer(): boolean { + return this.isInStage(CHOOSE_PLAYER_TO_REQUEST_FROM) + || this.isInStage(CHOOSE_PLAYER_TO_REQUEST_FROM); + } + /** * Check if the player has at least one card of the given type */ @@ -107,7 +120,7 @@ export class Player { // Clone to avoid Proxy issues this._state.hand.push({...cardData}); - this._updateClientState(); + this.updateHandSize(); } /** @@ -117,7 +130,7 @@ export class Player { removeCardAt(index: number): Card | undefined { if (index < 0 || index >= this._state.hand.length) return undefined; const [card] = this._state.hand.splice(index, 1); - this._updateClientState(); + this.updateHandSize(); return new Card(this.game, card); } @@ -145,7 +158,7 @@ export class Player { } } if (removed.length > 0) { - this._updateClientState(); + this.updateHandSize(); } return removed; } @@ -157,7 +170,7 @@ export class Player { removeAllCardsFromHand(): Card[] { const removed = [...this._state.hand]; this._state.hand = []; - this._updateClientState(); + this.updateHandSize(); return removed.map(c => new Card(this.game, c)); } @@ -173,6 +186,10 @@ export class Player { } } + isInStage(stage: string): boolean { + return this.game.turnManager.isInStage(this, stage); + } + /** * Transfers a card at specific index to another playerWrapper */ @@ -186,19 +203,14 @@ export class Player { } playCard(cardIndex: number): void { - if (this.game.piles.pendingCard) { - throw new Error("Cannot play a card while another card play is pending resolution"); - } - if (cardIndex < 0 || cardIndex >= this.hand.length) { throw new Error(`Invalid card index: ${cardIndex}`); } const card = this.hand[cardIndex]; - - if (!card.type.canBePlayed(this.game, card)) { - // todo: client feedback and should ideally be prevented by UI, but just ignore invalid play for now - return; + + if (!this.game.piles.canCardBePlayed(card)) { + throw new Error(`Cannot play card: ${card.name}`); } // Remove card from hand @@ -283,9 +295,7 @@ export class Player { return target.giveCard(index, this); } - private _updateClientState() { - if (this._state.client) { - this._state.client.handCount = this._state.hand.length; - } + private updateHandSize() { + this._state.handSize = this._state.hand.length; } } diff --git a/src/common/entities/players.ts b/src/common/entities/players.ts index 0b8d171..45ba824 100644 --- a/src/common/entities/players.ts +++ b/src/common/entities/players.ts @@ -1,10 +1,10 @@ import {Player} from './player'; import {TheGame} from "./game"; import {PlayerID} from "boardgame.io"; -import {IPlayers} from "../models"; +import {IGameState, IPlayers} from "../models"; export class Players { - constructor(private game: TheGame, public players: IPlayers) {} + constructor(private game: TheGame, private gamestate: IGameState, public players: IPlayers) {} /** * Get a player wrapper instance for a specific player ID. @@ -35,12 +35,17 @@ export class Players { return this.getPlayer(id); } + get winner(): Player | null { + return this.gamestate.winner ? this.getPlayer(this.gamestate.winner) : null; + } + /** * Get all players */ get allPlayers(): Player[] { - const playerIDs = Object.keys(this.players || {}); - return playerIDs.map(id => this.getPlayer(id)); + return this.game.turnManager.playOrder + .map(id => this.getPlayer(id)) + .filter(player => player !== null) as Player[]; } /** @@ -69,12 +74,14 @@ export class Players { * Checks if target is alive, has card-types, and is not the current player. */ validateTarget(targetPlayerId: string): Player { - const validTargets = this.getValidCardActionTargets(this.game.context.ctx.currentPlayer); - const target = validTargets.find(player => player.id === targetPlayerId); - if (!target) { - throw new Error(`Invalid target player ID: ${targetPlayerId}`); + const player = this.getPlayer(targetPlayerId); + if (!player) { + throw new Error(`Player with ID ${targetPlayerId} not found`); + } + if (!player.isValidCardTarget) { + throw new Error(`Player with ID ${targetPlayerId} is not a valid target (must be alive and have cards)`); } - return target; + return player; } } diff --git a/src/common/entities/turn-manager.ts b/src/common/entities/turn-manager.ts index 2f8115e..def4ad2 100644 --- a/src/common/entities/turn-manager.ts +++ b/src/common/entities/turn-manager.ts @@ -1,4 +1,6 @@ import {IContext} from "../models"; +import {Player} from "./player"; +import {PlayerID} from "boardgame.io"; export class TurnManager { constructor(private context: IContext) {} @@ -11,6 +13,19 @@ export class TurnManager { this.context.G.turnsRemaining = value; } + get playOrder(): PlayerID[] { + return this.context.ctx.playOrder; + } + + get activePlayers(): Record | null { + return this.context.ctx.activePlayers as Record | null; + } + + isInStage(player: Player | PlayerID, stage: string): boolean { + const playerId = typeof player === 'string' ? player : player.id; + return this.activePlayers?.[playerId] === stage; + } + endTurn(arg?: any): void { this.context.events.endTurn(arg); } diff --git a/src/common/game.ts b/src/common/game.ts index 3e4c2b5..29e0b99 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -1,11 +1,12 @@ -import {Game, PlayerID} from 'boardgame.io'; +import {Game} from 'boardgame.io'; import {createPlayerPlugin} from './plugins/player-plugin'; import {setupGame} from './setup/game-setup'; import type {ICard, IContext, IGameState, IPluginAPIs} from './models'; import {drawCard} from "./moves/draw-move"; -import {playCard, playNowCard, resolvePendingCard} from "./moves/play-card-move"; +import {playCard, resolvePendingCard} from "./moves/play-card-move"; import {requestCard, giveCard} from "./moves/favor-card-move"; import {closeFutureView} from "./moves/see-future-move"; +import {inGame} from "./moves/in-game"; import {turnOrder} from "./utils/turn-order"; import {OriginalDeck} from './entities/deck-types/original-deck'; import {dealHands} from './setup/player-setup'; @@ -31,15 +32,12 @@ export const ExplodingKittens: Game = { let viewableDrawPile: ICard[] = []; if (ctx.activePlayers?.[playerID!] === VIEWING_FUTURE) { - viewableDrawPile = G.piles.drawPile.slice(0, 3); + viewableDrawPile = G.piles.drawPile.cards.slice(0, 3); } return { ...G, - drawPile: viewableDrawPile, - client: { - drawPileLength: G.piles.drawPile.length - } + drawPile: viewableDrawPile }; }, @@ -54,10 +52,10 @@ export const ExplodingKittens: Game = { // Initialize the hands and piles const deck = new OriginalDeck(); - const pile: ICard[] = deck.buildBaseDeck(); + const pile: ICard[] = deck.buildBaseDeck().sort(() => Math.random() - 0.5); dealHands(pile, game.context.player.state, deck); // TODO: use api wrapper - deck.addPostDealCards(pile, Object.keys(game.context.ctx.playOrder).length); + deck.addPostDealCards(pile, Object.keys(game.context.ctx.playOrder).length); // TODO: use api wrapper game.piles.drawPile = pile; game.piles.drawPile.shuffle(); @@ -100,7 +98,7 @@ export const ExplodingKittens: Game = { defuseExplodingKitten: { moves: { defuseExplodingKitten: { - move: defuseExplodingKitten, + move: inGame(defuseExplodingKitten), client: false }, } @@ -108,7 +106,7 @@ export const ExplodingKittens: Game = { choosePlayerToStealFrom: { moves: { stealCard: { - move: (context: IContext, targetPlayerId: PlayerID) => stealCard(new TheGame(context), targetPlayerId), + move: inGame(stealCard), client: false }, }, @@ -116,37 +114,37 @@ export const ExplodingKittens: Game = { choosePlayerToRequestFrom: { moves: { requestCard: { - move: requestCard, + move: inGame(requestCard), client: false }, }, }, chooseCardToGive: { moves: { - giveCard: giveCard, + giveCard: inGame(giveCard), }, }, viewingFuture: { moves: { - closeFutureView: closeFutureView, + closeFutureView: inGame(closeFutureView), }, }, respondWithNowCard: { moves: { - playNowCard: { - move: playNowCard, + playCard: { + move: inGame(playCard), client: false, }, }, }, awaitingNowCards: { moves: { - playNowCard: { - move: playNowCard, + playCard: { + move: inGame(playCard), client: false, }, resolvePendingCard: { - move: resolvePendingCard, + move: inGame(resolvePendingCard), client: false, }, }, @@ -155,11 +153,11 @@ export const ExplodingKittens: Game = { }, moves: { drawCard: { - move: drawCard, + move: inGame(drawCard), client: false }, playCard: { - move: playCard, + move: inGame(playCard), client: false } }, diff --git a/src/common/models/game-state.model.ts b/src/common/models/game-state.model.ts index c10a60f..7499e3c 100644 --- a/src/common/models/game-state.model.ts +++ b/src/common/models/game-state.model.ts @@ -1,10 +1,6 @@ import type {ICard} from './card.model'; import type {PlayerID} from 'boardgame.io'; -export interface IClientGameState { - drawPileLength: number; -} - export interface IGameRules { spectatorsSeeCards: boolean; openCards: boolean; @@ -21,9 +17,14 @@ export interface IPendingCardPlay { isNoped: boolean; } +export interface IPile { + cards: ICard[]; + size: number; +} + export interface IPiles { - drawPile: ICard[]; - discardPile: ICard[]; + drawPile: IPile; + discardPile: IPile; pendingCardPlay: IPendingCardPlay | null; } @@ -33,5 +34,4 @@ export interface IGameState { turnsRemaining: number; gameRules: IGameRules; deckType: string; - client: IClientGameState; // todo: remove } diff --git a/src/common/models/player.model.ts b/src/common/models/player.model.ts index be6ec79..d22530a 100644 --- a/src/common/models/player.model.ts +++ b/src/common/models/player.model.ts @@ -1,12 +1,7 @@ import type {ICard} from './card.model'; -export interface IClientPlayer { - handCount: number; -} - export interface IPlayer { hand: ICard[]; - // On server this is always 0! Do not use anywhere else than on the client frontend for when player . + handSize: number; isAlive: boolean; - client: IClientPlayer } diff --git a/src/common/moves/defuse-exploding-kitten.ts b/src/common/moves/defuse-exploding-kitten.ts index 70e1bb4..e9713cd 100644 --- a/src/common/moves/defuse-exploding-kitten.ts +++ b/src/common/moves/defuse-exploding-kitten.ts @@ -1,12 +1,6 @@ -import {IContext} from "../models"; import {TheGame} from "../entities/game"; -export const defuseExplodingKitten = (context: IContext, insertIndex: number) => { - const game = new TheGame(context); - try { - game.players.actingPlayer.defuseExplodingKitten(insertIndex); - } catch (e) { - console.error("Failed to defuse exploding kitten", e); - } +export const defuseExplodingKitten = (game: TheGame, insertIndex: number) => { + game.players.actingPlayer.defuseExplodingKitten(insertIndex); }; diff --git a/src/common/moves/draw-move.ts b/src/common/moves/draw-move.ts index aa7cce5..c45d424 100644 --- a/src/common/moves/draw-move.ts +++ b/src/common/moves/draw-move.ts @@ -1,12 +1,6 @@ -import {IContext} from "../models"; import {TheGame} from "../entities/game"; -export const drawCard = (context: IContext) => { - const game = new TheGame(context); - try { - game.players.actingPlayer.draw(); - } catch (e) { - console.error("Failed to draw card", e); - } +export const drawCard = (game: TheGame) => { + game.players.actingPlayer.draw(); }; diff --git a/src/common/moves/favor-card-move.ts b/src/common/moves/favor-card-move.ts index 007a7b5..d8ad0b0 100644 --- a/src/common/moves/favor-card-move.ts +++ b/src/common/moves/favor-card-move.ts @@ -1,14 +1,11 @@ -import {IContext} from "../models"; -import {PlayerID} from "boardgame.io"; import {TheGame} from "../entities/game"; +import {PlayerID} from "boardgame.io"; import {CHOOSE_CARD_TO_GIVE} from "../constants/stages"; /** * Request a card from a target player (favor card - first stage) */ -export const requestCard = (context: IContext, targetPlayerId: PlayerID) => { - const game = new TheGame(context); - +export const requestCard = (game: TheGame, targetPlayerId: PlayerID) => { // Validate target player game.players.validateTarget(targetPlayerId); @@ -24,9 +21,8 @@ export const requestCard = (context: IContext, targetPlayerId: PlayerID) => { /** * Give a card to the requesting player (favor card - second stage) */ -export const giveCard = (context: IContext, cardIndex: number) => { - const {ctx} = context; - const game = new TheGame(context); +export const giveCard = (game: TheGame, cardIndex: number) => { + const {ctx} = game.context; // Find who is giving the card (the player in the chooseCardToGive stage) const givingPlayerId = Object.keys(ctx.activePlayers || {}).find( diff --git a/src/common/moves/in-game.ts b/src/common/moves/in-game.ts new file mode 100644 index 0000000..c7e0e86 --- /dev/null +++ b/src/common/moves/in-game.ts @@ -0,0 +1,16 @@ +import type {IContext} from '../models'; +import {TheGame} from '../entities/game'; + +type GameMove = (game: TheGame, ...args: Args) => void; + +export function inGame(move: GameMove) { + return (context: IContext, ...args: Args): void => { + try { + const game = new TheGame(context); + move(game, ...args); + } catch (error) { + console.error(`Move failed: ${move.name || 'anonymous move'}`, error, {args}); + } + }; +} + diff --git a/src/common/moves/play-card-move.ts b/src/common/moves/play-card-move.ts index 74ddbbb..da168db 100644 --- a/src/common/moves/play-card-move.ts +++ b/src/common/moves/play-card-move.ts @@ -1,22 +1,14 @@ -import {IContext} from "../models"; import {TheGame} from "../entities/game"; -export const playCard = (context: IContext, cardIndex: number) => { - const game = new TheGame(context); - try { - game.players.actingPlayer.playCard(cardIndex); - } catch (e) { - console.error(e); - return; - } +export const playCard = (game: TheGame, cardIndex: number) => { + game.players.actingPlayer.playCard(cardIndex); }; -export const playNowCard = (context: IContext, cardIndex: number) => { - playCard(context, cardIndex); +export const playNowCard = (game: TheGame, cardIndex: number) => { + playCard(game, cardIndex); }; -export const resolvePendingCard = (context: IContext) => { - const game = new TheGame(context); +export const resolvePendingCard = (game: TheGame) => { game.piles.resolvePendingCard(); }; diff --git a/src/common/moves/see-future-move.ts b/src/common/moves/see-future-move.ts index 41cd9dc..64c9fc3 100644 --- a/src/common/moves/see-future-move.ts +++ b/src/common/moves/see-future-move.ts @@ -1,12 +1,9 @@ -import type {IContext} from "../models"; +import {TheGame} from "../entities/game"; /** * Close the see the future overlay */ -export const closeFutureView = (context: IContext) => { - const {events} = context; - - // End the viewing stage - events.endStage(); +export const closeFutureView = (game: TheGame) => { + game.turnManager.endStage(); }; diff --git a/src/common/setup/game-setup.ts b/src/common/setup/game-setup.ts index 0807a4e..be34afb 100644 --- a/src/common/setup/game-setup.ts +++ b/src/common/setup/game-setup.ts @@ -13,8 +13,14 @@ export const setupGame = (_context: any, setupData?: SetupData): IGameState => { return { winner: null, piles: { - drawPile: [], - discardPile: [], + drawPile: { + cards: [], + size: 0, + }, + discardPile: { + cards: [], + size: 0, + }, pendingCardPlay: null, }, turnsRemaining: 1, @@ -23,9 +29,6 @@ export const setupGame = (_context: any, setupData?: SetupData): IGameState => { openCards: setupData?.openCards ?? false, pendingTimerMs: 3000, }, - deckType: setupData?.deckType ?? 'original', - client: { - drawPileLength: 0 - } + deckType: setupData?.deckType ?? 'original' }; }; diff --git a/src/common/setup/player-setup.ts b/src/common/setup/player-setup.ts index b38ec8e..6368f5c 100644 --- a/src/common/setup/player-setup.ts +++ b/src/common/setup/player-setup.ts @@ -5,31 +5,22 @@ import {CardType} from "../entities/card-type"; export const createPlayerState = (): IPlayer => ({ hand: [], - isAlive: true, - client: { - handCount: 0 - } + handSize: 0, + isAlive: true }); /** * Create a full view of a player (used for self-view and spectators) */ -const createFullPlayerView = (player: IPlayer): IPlayer => ({ - ...player, - client: { - handCount: player.hand.length - } -}); +const createFullPlayerView = (player: IPlayer): IPlayer => player; /** * Create a limited view of a player (used for opponent views) */ const createLimitedPlayerView = (player: IPlayer): IPlayer => ({ hand: [], - isAlive: player.isAlive, - client: { - handCount: player.hand.length - } + handSize: player.hand.length, + isAlive: player.isAlive }); /** @@ -86,5 +77,6 @@ export function dealHands(pile: ICard[], players: IPlayers, deck: DeckType) { }); player.hand.push(...forcedCards); + player.handSize = player.hand.length; }); } From be19b8aa150777006735a27b11e4a271df93013f Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:47:46 +0100 Subject: [PATCH 09/55] refactor: move folders --- src/client/components/board/Board.tsx | 8 ++++---- .../{card/HoverCardPreview.tsx => CardPreview.tsx} | 6 +++--- .../OverlayManager.tsx => overlay/BoardOverlays.tsx} | 10 +++++----- .../{ => board}/overlay/dead-overlay/DeadOverlay.css | 0 .../{ => board}/overlay/dead-overlay/DeadOverlay.tsx | 2 +- .../{ => overlay}/lobby-overlay/LobbyOverlay.css | 0 .../{ => overlay}/lobby-overlay/LobbyOverlay.tsx | 7 +++---- .../see-future-overlay/SeeTheFutureOverlay.css | 0 .../see-future-overlay/SeeTheFutureOverlay.tsx | 6 +++--- .../special-action-overlay/SpecialActionOverlay.css | 0 .../special-action-overlay/SpecialActionOverlay.tsx | 0 .../overlay/winner-overlay/WinnerOverlay.css | 0 .../overlay/winner-overlay/WinnerOverlay.tsx | 4 ++-- .../components/board/{ => player}/card/Card.css | 0 .../components/board/{ => player}/card/Card.tsx | 10 +++++----- .../board/{ => player}/player-area/Player.css | 0 .../board/{ => player}/player-area/Player.tsx | 12 ++++++------ .../board/{ => player}/player-cards/PlayerCards.tsx | 6 +++--- .../board/{ => player}/player-list/PlayerList.css | 0 .../board/{ => player}/player-list/PlayerList.tsx | 6 +++--- src/client/components/board/table/Table.tsx | 8 ++++---- .../board/table/pending/PendingPlayStack.tsx | 6 +++--- .../board/{ => table}/turn-badge/TurnBadge.css | 0 .../board/{ => table}/turn-badge/TurnBadge.tsx | 2 +- src/client/entities/game-client.ts | 2 +- 25 files changed, 47 insertions(+), 48 deletions(-) rename src/client/components/board/{card/HoverCardPreview.tsx => CardPreview.tsx} (96%) rename src/client/components/board/{overlay-manager/OverlayManager.tsx => overlay/BoardOverlays.tsx} (73%) rename src/client/components/{ => board}/overlay/dead-overlay/DeadOverlay.css (100%) rename src/client/components/{ => board}/overlay/dead-overlay/DeadOverlay.tsx (88%) rename src/client/components/board/{ => overlay}/lobby-overlay/LobbyOverlay.css (100%) rename src/client/components/board/{ => overlay}/lobby-overlay/LobbyOverlay.tsx (91%) rename src/client/components/{ => board}/overlay/see-future-overlay/SeeTheFutureOverlay.css (100%) rename src/client/components/{ => board}/overlay/see-future-overlay/SeeTheFutureOverlay.tsx (88%) rename src/client/components/{ => board}/overlay/special-action-overlay/SpecialActionOverlay.css (100%) rename src/client/components/{ => board}/overlay/special-action-overlay/SpecialActionOverlay.tsx (100%) rename src/client/components/{ => board}/overlay/winner-overlay/WinnerOverlay.css (100%) rename src/client/components/{ => board}/overlay/winner-overlay/WinnerOverlay.tsx (86%) rename src/client/components/board/{ => player}/card/Card.css (100%) rename src/client/components/board/{ => player}/card/Card.tsx (89%) rename src/client/components/board/{ => player}/player-area/Player.css (100%) rename src/client/components/board/{ => player}/player-area/Player.tsx (86%) rename src/client/components/board/{ => player}/player-cards/PlayerCards.tsx (89%) rename src/client/components/board/{ => player}/player-list/PlayerList.css (100%) rename src/client/components/board/{ => player}/player-list/PlayerList.tsx (80%) rename src/client/components/board/{ => table}/turn-badge/TurnBadge.css (100%) rename src/client/components/board/{ => table}/turn-badge/TurnBadge.tsx (94%) diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index fa4c4e7..b6b10ab 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -3,9 +3,9 @@ import {useCardAnimations} from '../../hooks/useCardAnimations'; import {useGameState} from '../../hooks/useGameState'; import {GameContext, PlayerStateBundle} from '../../types/component-props'; import Table from './table/Table'; -import PlayerList from './player-list/PlayerList'; -import OverlayManager from './overlay-manager/OverlayManager'; -import LobbyOverlay from './lobby-overlay/LobbyOverlay'; +import PlayerList from './player/player-list/PlayerList'; +import BoardOverlays from './overlay/BoardOverlays.tsx'; +import LobbyOverlay from './overlay/lobby-overlay/LobbyOverlay'; import GameStatusList from './game-status/GameStatusList'; import {useEffect} from 'react'; import {Chat} from '../chat/Chat'; @@ -135,7 +135,7 @@ export default function ExplodingKittensBoard(props: BoardProps & {
- + {game.isLobbyPhase() && ( void; } -export default function HoverCardPreview({ +export default function CardPreview({ cardImage, anchorRef, isVisible, diff --git a/src/client/components/board/overlay-manager/OverlayManager.tsx b/src/client/components/board/overlay/BoardOverlays.tsx similarity index 73% rename from src/client/components/board/overlay-manager/OverlayManager.tsx rename to src/client/components/board/overlay/BoardOverlays.tsx index 37b6bcb..cdfce70 100644 --- a/src/client/components/board/overlay-manager/OverlayManager.tsx +++ b/src/client/components/board/overlay/BoardOverlays.tsx @@ -1,7 +1,7 @@ -import WinnerOverlay from '../../overlay/winner-overlay/WinnerOverlay'; -import DeadOverlay from '../../overlay/dead-overlay/DeadOverlay'; -import SpecialActionOverlay from '../../overlay/special-action-overlay/SpecialActionOverlay.tsx'; -import SeeTheFutureOverlay from '../../overlay/see-future-overlay/SeeTheFutureOverlay'; +import WinnerOverlay from './winner-overlay/WinnerOverlay.tsx'; +import DeadOverlay from './dead-overlay/DeadOverlay.tsx'; +import SpecialActionOverlay from './special-action-overlay/SpecialActionOverlay.tsx'; +import SeeTheFutureOverlay from './see-future-overlay/SeeTheFutureOverlay.tsx'; import {useGame} from "../../../context/GameContext.tsx"; import { CHOOSE_CARD_TO_GIVE, @@ -12,7 +12,7 @@ import { /** * Manages and renders all game overlays */ -export default function OverlayManager() { +export default function BoardOverlays() { const game = useGame(); // Determine the overlay message based on the current stage diff --git a/src/client/components/overlay/dead-overlay/DeadOverlay.css b/src/client/components/board/overlay/dead-overlay/DeadOverlay.css similarity index 100% rename from src/client/components/overlay/dead-overlay/DeadOverlay.css rename to src/client/components/board/overlay/dead-overlay/DeadOverlay.css diff --git a/src/client/components/overlay/dead-overlay/DeadOverlay.tsx b/src/client/components/board/overlay/dead-overlay/DeadOverlay.tsx similarity index 88% rename from src/client/components/overlay/dead-overlay/DeadOverlay.tsx rename to src/client/components/board/overlay/dead-overlay/DeadOverlay.tsx index 8ec4917..4d97d62 100644 --- a/src/client/components/overlay/dead-overlay/DeadOverlay.tsx +++ b/src/client/components/board/overlay/dead-overlay/DeadOverlay.tsx @@ -1,5 +1,5 @@ import './DeadOverlay.css'; -import {useGame} from "../../../context/GameContext.tsx"; +import {useGame} from "../../../../context/GameContext.tsx"; export default function DeadOverlay() { const game = useGame(); diff --git a/src/client/components/board/lobby-overlay/LobbyOverlay.css b/src/client/components/board/overlay/lobby-overlay/LobbyOverlay.css similarity index 100% rename from src/client/components/board/lobby-overlay/LobbyOverlay.css rename to src/client/components/board/overlay/lobby-overlay/LobbyOverlay.css diff --git a/src/client/components/board/lobby-overlay/LobbyOverlay.tsx b/src/client/components/board/overlay/lobby-overlay/LobbyOverlay.tsx similarity index 91% rename from src/client/components/board/lobby-overlay/LobbyOverlay.tsx rename to src/client/components/board/overlay/lobby-overlay/LobbyOverlay.tsx index 3a33b95..522e51b 100644 --- a/src/client/components/board/lobby-overlay/LobbyOverlay.tsx +++ b/src/client/components/board/overlay/lobby-overlay/LobbyOverlay.tsx @@ -1,13 +1,12 @@ import './LobbyOverlay.css'; -import {useMatchDetails} from "../../../context/MatchDetailsContext.tsx"; -import {useGame} from "../../../context/GameContext.tsx"; +import {useMatchDetails} from "../../../../context/MatchDetailsContext.tsx"; +import {useGame} from "../../../../context/GameContext.tsx"; interface LobbyOverlayProps { - playerID?: string | null; onStartGame?: () => void; } -export default function LobbyOverlay({playerID, onStartGame}: LobbyOverlayProps) { +export default function LobbyOverlay({onStartGame}: LobbyOverlayProps) { const game = useGame(); const { matchDetails } = useMatchDetails(); diff --git a/src/client/components/overlay/see-future-overlay/SeeTheFutureOverlay.css b/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.css similarity index 100% rename from src/client/components/overlay/see-future-overlay/SeeTheFutureOverlay.css rename to src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.css diff --git a/src/client/components/overlay/see-future-overlay/SeeTheFutureOverlay.tsx b/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx similarity index 88% rename from src/client/components/overlay/see-future-overlay/SeeTheFutureOverlay.tsx rename to src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx index b05b11f..fbd4d73 100644 --- a/src/client/components/overlay/see-future-overlay/SeeTheFutureOverlay.tsx +++ b/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx @@ -1,7 +1,7 @@ import './SeeTheFutureOverlay.css'; -import {useGame} from "../../../context/GameContext.tsx"; -import {VIEWING_FUTURE} from "../../../../common/constants/stages.ts"; -import {Card} from "../../../../common/entities/card.ts"; +import {useGame} from "../../../../context/GameContext.tsx"; +import {VIEWING_FUTURE} from "../../../../../common/constants/stages.ts"; +import {Card} from "../../../../../common/entities/card.ts"; export default function SeeTheFutureOverlay() { const game = useGame(); diff --git a/src/client/components/overlay/special-action-overlay/SpecialActionOverlay.css b/src/client/components/board/overlay/special-action-overlay/SpecialActionOverlay.css similarity index 100% rename from src/client/components/overlay/special-action-overlay/SpecialActionOverlay.css rename to src/client/components/board/overlay/special-action-overlay/SpecialActionOverlay.css diff --git a/src/client/components/overlay/special-action-overlay/SpecialActionOverlay.tsx b/src/client/components/board/overlay/special-action-overlay/SpecialActionOverlay.tsx similarity index 100% rename from src/client/components/overlay/special-action-overlay/SpecialActionOverlay.tsx rename to src/client/components/board/overlay/special-action-overlay/SpecialActionOverlay.tsx diff --git a/src/client/components/overlay/winner-overlay/WinnerOverlay.css b/src/client/components/board/overlay/winner-overlay/WinnerOverlay.css similarity index 100% rename from src/client/components/overlay/winner-overlay/WinnerOverlay.css rename to src/client/components/board/overlay/winner-overlay/WinnerOverlay.css diff --git a/src/client/components/overlay/winner-overlay/WinnerOverlay.tsx b/src/client/components/board/overlay/winner-overlay/WinnerOverlay.tsx similarity index 86% rename from src/client/components/overlay/winner-overlay/WinnerOverlay.tsx rename to src/client/components/board/overlay/winner-overlay/WinnerOverlay.tsx index 239277d..332c3d3 100644 --- a/src/client/components/overlay/winner-overlay/WinnerOverlay.tsx +++ b/src/client/components/board/overlay/winner-overlay/WinnerOverlay.tsx @@ -1,6 +1,6 @@ import './WinnerOverlay.css'; -import {getPlayerName} from '../../../utils/matchData.ts'; -import {useGame} from "../../../context/GameContext.tsx"; +import {getPlayerName} from '../../../../utils/matchData.ts'; +import {useGame} from "../../../../context/GameContext.tsx"; export default function WinnerOverlay() { const game = useGame(); diff --git a/src/client/components/board/card/Card.css b/src/client/components/board/player/card/Card.css similarity index 100% rename from src/client/components/board/card/Card.css rename to src/client/components/board/player/card/Card.css diff --git a/src/client/components/board/card/Card.tsx b/src/client/components/board/player/card/Card.tsx similarity index 89% rename from src/client/components/board/card/Card.tsx rename to src/client/components/board/player/card/Card.tsx index 6b81cf6..0ccdbdb 100644 --- a/src/client/components/board/card/Card.tsx +++ b/src/client/components/board/player/card/Card.tsx @@ -1,10 +1,10 @@ import './Card.css'; import back from '/assets/cards/back/0.jpg'; import {CSSProperties, useRef, useState} from 'react'; -import HoverCardPreview from './HoverCardPreview'; -import {useResponsive} from "../../../context/ResponsiveContext.tsx"; -import {useGame} from "../../../context/GameContext.tsx"; -import {Player} from "../../../../common"; +import CardPreview from '../../CardPreview.tsx'; +import {useResponsive} from "../../../../context/ResponsiveContext.tsx"; +import {useGame} from "../../../../context/GameContext.tsx"; +import {Player} from "../../../../../common"; import {CardWithServerIndex} from "../player-cards/PlayerCards.tsx"; interface CardProps { @@ -68,7 +68,7 @@ export default function Card({ onMouseLeave={() => setIsHovered(false)} onClick={handleClick} /> - - setIsHovered(false)} /> - Date: Sun, 22 Mar 2026 18:23:31 +0100 Subject: [PATCH 10/55] refactor: enhance game logic and player management --- .../components/board/player/card/Card.tsx | 2 +- src/client/entities/game-client.ts | 7 ++-- src/common/entities/game.ts | 19 +++++++---- src/common/entities/player.ts | 4 +-- src/common/entities/players.ts | 21 +++++++++++- src/common/entities/turn-manager.ts | 4 +++ src/common/game.ts | 25 ++++++++------ src/common/plugins/player-plugin.ts | 18 +++++++--- src/common/setup/player-setup.ts | 34 ++++++++----------- src/common/utils/turn-order.ts | 33 ++++++++++-------- 10 files changed, 104 insertions(+), 63 deletions(-) diff --git a/src/client/components/board/player/card/Card.tsx b/src/client/components/board/player/card/Card.tsx index 0ccdbdb..5748765 100644 --- a/src/client/components/board/player/card/Card.tsx +++ b/src/client/components/board/player/card/Card.tsx @@ -34,7 +34,7 @@ export default function Card({ const cardImage = card ? `/assets/cards/${card.name}/${card.index}.png` : back; - const couldBePlayed = game.isSelf(owner) && game.isSelfCurrentPlayer; + const couldBePlayed = game.isSelf(owner) && ((card?.serverIndex && game.canPlayCard(card?.serverIndex)) || game.canGiveCard()); const handleAction = () => { if (!card) { diff --git a/src/client/entities/game-client.ts b/src/client/entities/game-client.ts index e0ec9bc..af1cbca 100644 --- a/src/client/entities/game-client.ts +++ b/src/client/entities/game-client.ts @@ -1,4 +1,4 @@ -import {ICard, Player, TheGame} from '../../common'; +import {ICard, IPlayerAPI, IPlayers, Player, TheGame} from '../../common'; import { IContext } from '../../common'; import {Card} from "../../common/entities/card.ts"; import {NAME_NOPE} from "../../common/constants/cards.ts"; @@ -30,7 +30,8 @@ export class TheGameClient extends TheGame { isMultiplayer: boolean ) { super(context); - + this.setPlayers((this.context.player as IPlayerAPI & { data: { players: IPlayers } }).data.players); + this.moves = moves; this.matchID = matchID; this.selfPlayerId = playerID; @@ -57,7 +58,7 @@ export class TheGameClient extends TheGame { } get isSelfCurrentPlayer(): boolean { - return this.selfPlayerId === this.players.currentPlayer.id; + return this.selfPlayerId === this.players.currentPlayerId; } isSelf(player: Player | PlayerID | MatchPlayer | null) { diff --git a/src/common/entities/game.ts b/src/common/entities/game.ts index 093a3e2..ce0ae95 100644 --- a/src/common/entities/game.ts +++ b/src/common/entities/game.ts @@ -1,4 +1,4 @@ -import {IContext, IGameState, IPlayerAPI, IPlayers} from '../models'; +import {IContext, IGameState, IPlayers} from '../models'; import {Piles} from './piles'; import {Players} from './players'; import {TurnManager} from './turn-manager'; @@ -17,8 +17,8 @@ export class TheGame { public readonly random: RandomAPI; public readonly piles: Piles; - public readonly players: Players; public readonly turnManager: TurnManager; + public players: Players; constructor(context: IContext) { this.context = context; @@ -28,12 +28,17 @@ export class TheGame { this.random = context.random; this.piles = new Piles(this, this.gameState.piles); - this.players = new Players( - this, - this.gameState, - this.context.player.state ?? (this.context.player as IPlayerAPI & { data: { players: IPlayers } }).data.players - ); this.turnManager = new TurnManager(this.context); + + if (this.context?.player?.state) { + this.players = new Players(this, this.gameState, this.context.player.state); + } else { + this.players = new Players(this, this.gameState, {}); + } + } + + setPlayers(players: IPlayers) { + this.players = new Players(this, this.gameState, players); } get phase(): string { diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 3939fae..8dba281 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -9,7 +9,7 @@ import {NAME_NOPE} from "../constants/cards"; export class Player { constructor( private game: TheGame, - private _state: IPlayer, + public _state: IPlayer, public readonly id: PlayerID ) {} @@ -35,7 +35,7 @@ export class Player { } get isCurrentPlayer(): boolean { - return this.game.players.currentPlayer.id === this.id; + return this.game.players.currentPlayerId === this.id; } get isActingPlayer(): boolean { diff --git a/src/common/entities/players.ts b/src/common/entities/players.ts index 45ba824..5ae7280 100644 --- a/src/common/entities/players.ts +++ b/src/common/entities/players.ts @@ -6,6 +6,10 @@ import {IGameState, IPlayers} from "../models"; export class Players { constructor(private game: TheGame, private gamestate: IGameState, public players: IPlayers) {} + get playerCount(): number { + return Object.keys(this.players).length; + } + /** * Get a player wrapper instance for a specific player ID. * Throws if player data not found. @@ -26,19 +30,34 @@ export class Players { return this.getPlayer(this.game.context.ctx.currentPlayer); } + get currentPlayerId(): PlayerID { + return this.game.context.ctx.currentPlayer; + } + /** * Get the player executing the move (if playerID available in context) * Falls back to currentPlayer if playerID not set */ get actingPlayer(): Player { - const id = this.game.context.playerID ?? this.game.context.ctx.currentPlayer; + const id = this.game.context.playerID; + if (!id) { + throw new Error('No playerID found in context; cannot determine acting player'); + } return this.getPlayer(id); } + get actingPlayerId(): PlayerID { + return this.game.context.playerID ?? this.game.context.ctx.currentPlayer; + } + get winner(): Player | null { return this.gamestate.winner ? this.getPlayer(this.gamestate.winner) : null; } + set winner(player: Player | PlayerID) { + this.gamestate.winner = typeof player === 'string' ? player : player.id; + } + /** * Get all players */ diff --git a/src/common/entities/turn-manager.ts b/src/common/entities/turn-manager.ts index def4ad2..b3550a4 100644 --- a/src/common/entities/turn-manager.ts +++ b/src/common/entities/turn-manager.ts @@ -17,6 +17,10 @@ export class TurnManager { return this.context.ctx.playOrder; } + get playOrderPos(): number { + return this.context.ctx.playOrderPos; + } + get activePlayers(): Record | null { return this.context.ctx.activePlayers as Record | null; } diff --git a/src/common/game.ts b/src/common/game.ts index 29e0b99..0792c73 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -26,6 +26,8 @@ export const ExplodingKittens: Game = { disableUndo: true, playerView: ({G, ctx, playerID}) => { + // Cannot use the game api because not the whole context is passed through. + // The player plugin's playerView will handle filtering the player data // We need to pass G through so it's available @@ -85,13 +87,15 @@ export const ExplodingKittens: Game = { play: { turn: { order: turnOrder, - onEnd: ({G}: any) => { + onEnd: (context: IContext) => { + const game = new TheGame(context); + // Decrement the turns remaining counter - G.turnsRemaining = G.turnsRemaining - 1; + game.turnManager.turnsRemaining = game.turnManager.turnsRemaining - 1; // If we're moving to the next player, reset the counter - if (G.turnsRemaining <= 0) { - G.turnsRemaining = 1; + if (game.turnManager.turnsRemaining <= 0) { + game.turnManager.turnsRemaining = 1; } }, stages: { @@ -171,14 +175,13 @@ export const ExplodingKittens: Game = { return {next: GAME_OVER}; } }, - onEnd: ({G, player}) => { - // Find the last alive player - const alivePlayers = Object.entries(player.state).filter( - ([_, p]) => p.isAlive - ); + onEnd: (context: IContext) => { + const game = new TheGame(context); - if (alivePlayers.length === 1) { - G.winner = alivePlayers[0][0]; + // Find the last alive player + const players = game.players.alivePlayers; + if (players.length === 1) { + game.players.winner = players[0]; } }, }, diff --git a/src/common/plugins/player-plugin.ts b/src/common/plugins/player-plugin.ts index bb6c530..8693db9 100644 --- a/src/common/plugins/player-plugin.ts +++ b/src/common/plugins/player-plugin.ts @@ -1,7 +1,8 @@ import {PluginPlayer} from 'boardgame.io/dist/cjs/plugins.js'; import {createPlayerState, filterPlayerView} from '../setup/player-setup'; -import type {Plugin} from 'boardgame.io'; -import type {IGameState} from '../models'; +import type {Ctx, Plugin} from 'boardgame.io'; +import type {IContext, IGameState} from '../models'; +import {TheGame} from "../entities/game"; export const createPlayerPlugin = (): Plugin => { const basePlugin = PluginPlayer({ @@ -11,9 +12,18 @@ export const createPlayerPlugin = (): Plugin => { // Wrap the playerView to access G from the State return { ...basePlugin, - playerView: ({G, data, playerID}: {G: IGameState, data: any, playerID?: string | null}) => { + playerView: ({G, ctx, data, playerID}: {G: IGameState, data: any, ctx: Ctx, playerID?: string | null}) => { + const context = { + G, + ctx, + playerID: playerID, + } as IContext; + + const game = new TheGame(context); + game.setPlayers(data.players); + // Use our custom filterPlayerView that has access to G - const filteredPlayers = filterPlayerView(G, data.players, playerID ?? null); + const filteredPlayers = filterPlayerView(game); return { players: filteredPlayers }; }, }; diff --git a/src/common/setup/player-setup.ts b/src/common/setup/player-setup.ts index 6368f5c..96b8a79 100644 --- a/src/common/setup/player-setup.ts +++ b/src/common/setup/player-setup.ts @@ -1,7 +1,8 @@ -import type {ICard, IGameState, IPlayer, IPlayers} from '../models'; +import type {ICard, IPlayer, IPlayers} from '../models'; import type {DeckType} from '../entities/deck-type'; import {cardTypeRegistry} from "../registries/card-registry"; import {CardType} from "../entities/card-type"; +import {TheGame} from "../entities/game"; export const createPlayerState = (): IPlayer => ({ hand: [], @@ -26,36 +27,31 @@ const createLimitedPlayerView = (player: IPlayer): IPlayer => ({ /** * Check if the viewing player should see all card-types (spectator or dead player) */ -const shouldSeeAllCards = ( - G: IGameState, - players: IPlayers, - playerID?: string | null, -): boolean => { +const shouldSeeAllCards = (game: TheGame): boolean => { // If openCards rule is enabled, everyone sees all card-types - if (G.gameRules.openCards) return true; + if (game.gameRules.openCards) return true; // Spectators (no playerID) see all card-types ONLY if rule allows - if (!playerID) return G.gameRules.spectatorsSeeCards; + if (!game.players.actingPlayerId || !game.players.actingPlayer.isAlive) { + return game.gameRules.spectatorsSeeCards; + } - const currentPlayer = players[playerID]; - const isCurrentPlayerDead = currentPlayer && !currentPlayer.isAlive; - const spectatorsCanSeeAll = G.gameRules.spectatorsSeeCards; - - // Dead players with permission see all card-types - return isCurrentPlayerDead && spectatorsCanSeeAll; + return false; }; -export const filterPlayerView = (G: IGameState, players: IPlayers, playerID?: string | null): IPlayers => { - const canSeeAllCards = shouldSeeAllCards(G, players, playerID); +export const filterPlayerView = (game: TheGame): IPlayers => { + const canSeeAllCards = shouldSeeAllCards(game); const view: IPlayers = {}; - Object.entries(players).forEach(([id, pdata]) => { - if (canSeeAllCards || id === playerID) { + game.players.allPlayers.forEach(value => { + const id = value.id; + const pdata = game.players.getPlayer(id)._state; + if (canSeeAllCards || id === game.players.actingPlayerId) { view[id] = createFullPlayerView(pdata); } else { view[id] = createLimitedPlayerView(pdata); } - }); + }) return view; }; diff --git a/src/common/utils/turn-order.ts b/src/common/utils/turn-order.ts index 3ab895c..4ad012c 100644 --- a/src/common/utils/turn-order.ts +++ b/src/common/utils/turn-order.ts @@ -1,18 +1,15 @@ import {IContext} from '../models'; +import {TheGame} from "../entities/game"; -const findNextAlivePlayer = ( - ctx: IContext['ctx'], - players: Record, - startPos: number -): number | undefined => { - const numPlayers = ctx.numPlayers; +const findNextAlivePlayer = (game: TheGame, startPos: number): number | undefined => { + const numPlayers = game.players.playerCount; let currentPos = startPos % numPlayers; // Check all players once to avoid infinite loops for (let i = 0; i < numPlayers; i++) { - const playerId = ctx.playOrder[currentPos]; + const playerId = game.turnManager.playOrder[currentPos]; - if (players[playerId]?.isAlive) { + if (game.players.getPlayer(playerId).isAlive) { return currentPos; } @@ -24,25 +21,31 @@ const findNextAlivePlayer = ( }; export const turnOrder = { - first: ({ctx, player}: IContext): number => { - const nextAlive = findNextAlivePlayer(ctx, player.state, 0); + first: (context: IContext): number => { + const game = new TheGame(context) + + const nextAlive = findNextAlivePlayer(game, 0); // Fallback to first player if no one is alive (shouldn't happen) return nextAlive ?? 0; }, /** * Get the next alive player, considering turnsRemaining counter - * Note: We only read G.turnsRemaining here, the decrement happens in turn.onEnd + * Note: We only read turnsRemaining here, the decrement happens in turn.onEnd */ - next: ({G, ctx, player}: IContext): number | undefined => { + next: (context: IContext): number | undefined => { + const game = new TheGame(context) + // If there are still turns remaining (> 1 because we check before decrement), stay with the current player - if (G.turnsRemaining > 1) { - return ctx.playOrderPos; + const playOrderPos = game.turnManager.playOrderPos; + if (game.turnManager.turnsRemaining > 1) { + return playOrderPos; } // Move to the next alive player - return findNextAlivePlayer(ctx, player.state, ctx.playOrderPos + 1); + // return findNextAlivePlayer(ctx, player.state, ctx.playOrderPos + 1); + return findNextAlivePlayer(game, playOrderPos + 1); }, /** From 64f1f987b710bb88c04c2c930fe5823f0b1b5a92 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:59:03 +0100 Subject: [PATCH 11/55] refactor: streamline game context management and enhance chat functionality --- src/client/components/board/Board.tsx | 80 +----------- .../board/game-status/GameStatusList.tsx | 122 +++++++----------- .../board/overlay/BoardOverlays.tsx | 2 + .../overlay/lobby-overlay/LobbyOverlay.tsx | 19 ++- src/client/components/chat/Chat.tsx | 33 ++--- src/client/context/MatchDetailsContext.tsx | 8 +- src/client/hooks/useGameLogic.tsx | 105 --------------- src/client/hooks/useGameState.tsx | 105 --------------- src/client/utils/matchData.ts | 10 +- src/common/entities/players.ts | 15 +++ src/common/setup/player-setup.ts | 4 +- 11 files changed, 110 insertions(+), 393 deletions(-) delete mode 100644 src/client/hooks/useGameLogic.tsx delete mode 100644 src/client/hooks/useGameState.tsx diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index b6b10ab..5ffc219 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -1,16 +1,12 @@ import './Board.css'; import {useCardAnimations} from '../../hooks/useCardAnimations'; -import {useGameState} from '../../hooks/useGameState'; -import {GameContext, PlayerStateBundle} from '../../types/component-props'; import Table from './table/Table'; import PlayerList from './player/player-list/PlayerList'; import BoardOverlays from './overlay/BoardOverlays.tsx'; -import LobbyOverlay from './overlay/lobby-overlay/LobbyOverlay'; import GameStatusList from './game-status/GameStatusList'; import {useEffect} from 'react'; import {Chat} from '../chat/Chat'; import {useMatchDetails} from "../../context/MatchDetailsContext.tsx"; -import {IClientContext} from "../../types/client-context.ts"; import type { BoardProps } from 'boardgame.io/react'; import {IContext, IGameState} from "../../../common"; import {TheGameClient} from "../../entities/game-client.ts"; @@ -39,47 +35,20 @@ export default function ExplodingKittensBoard(props: BoardProps & { props.isMultiplayer ); - // Create clientContext from BoardProps - const clientContext: IClientContext = { - ...props, - plugins: props.plugins, - player: props.plugins?.player?.data || {}, - }; - - const { - ctx, - G, - moves, - playerID, - chatMessages, - sendChatMessage - } = clientContext; - const { matchDetails, setPollingInterval } = useMatchDetails(); + const { setPollingInterval } = useMatchDetails(); useEffect(() => { setPollingInterval(game.isLobbyPhase() ? 500 : 3000); }, [game.isLobbyPhase(), setPollingInterval]); - // Bundle game context - const gameContext: GameContext = { - ctx, - G, - moves, - playerID: playerID ?? null, - matchData: matchDetails?.players - }; - - // Derive game state properties - const gameState = useGameState(ctx, G, game.players.players, playerID ?? null); - useEffect(() => { - if (!gameState.isInNowCardStage || !game.piles.pendingCard || !moves.resolvePendingCard) { + if (!game.piles.pendingCard || !game.piles.pendingCard || !game.moves.resolvePendingCard) { return; } const checkAndResolve = () => { if (game.piles.pendingCard && Date.now() >= game.piles.pendingCard.expiresAtMs) { - moves.resolvePendingCard(); + game.moves.resolvePendingCard(); } }; @@ -96,33 +65,14 @@ export default function ExplodingKittensBoard(props: BoardProps & { window.clearInterval(intervalId); }; }, [ - gameState.isInNowCardStage, + game.piles.pendingCard, game.piles.pendingCard?.expiresAtMs, - moves, + game.moves, ]); - // Bundle player state - const playerState: PlayerStateBundle = { - allPlayers: game.players.players, - selfPlayerId: gameState.selfPlayerId, - currentPlayer: gameState.currentPlayer, - isSelfDead: gameState.isSelfDead, - isSelfSpectator: gameState.isSelfSpectator, - isSelfTurn: gameState.selfPlayerId === gameState.currentPlayer - }; - // Handle card animations const {AnimationLayer} = useCardAnimations(game); - /** - * Handle starting the game from lobby - */ - const handleStartGame = () => { - if (moves.startGame) { - moves.startGame(); - } - }; - return ( <> @@ -136,24 +86,8 @@ export default function ExplodingKittensBoard(props: BoardProps & {
- - {game.isLobbyPhase() && ( - - )} - - - - + + ); diff --git a/src/client/components/board/game-status/GameStatusList.tsx b/src/client/components/board/game-status/GameStatusList.tsx index 86b40a1..11774da 100644 --- a/src/client/components/board/game-status/GameStatusList.tsx +++ b/src/client/components/board/game-status/GameStatusList.tsx @@ -1,55 +1,20 @@ -import {GameContext, PlayerStateBundle} from '../../../types/component-props'; import { useMatchDetails } from '../../../context/MatchDetailsContext'; import { useState } from 'react'; import './GameStatusList.css'; import { useResponsive } from '../../../context/ResponsiveContext'; +import {useGame} from "../../../context/GameContext.tsx"; -interface GameStatusListProps { - matchID?: string; - matchName?: string; - numPlayers?: number; - gameContext: GameContext; - playerState: PlayerStateBundle; -} - -export default function GameStatusList({ - matchName, - numPlayers, - gameContext, - playerState -}: GameStatusListProps) { +export default function GameStatusList() { + const game = useGame(); const { isMobile } = useResponsive(); + const [isCollapsed, setIsCollapsed] = useState(isMobile); const { matchDetails } = useMatchDetails(); - const {ctx} = gameContext; - const {allPlayers, currentPlayer, isSelfSpectator} = playerState; - + // Use matchPlayers from context, fallback to empty array const matchPlayers = matchDetails?.players || []; - // Merge game state (alive/dead) with match data (names/connection) - // Use ctx.playOrder to respect turn order - const displayPlayers = ctx.playOrder.map((pid: string) => { - const playerID = parseInt(pid); - const playerInfo = allPlayers[pid]; // From G - const matchInfo = matchPlayers.find(p => p.id === playerID); // From Lobby - - // If matchInfo matches but name is missing, it's an empty seat (or player left) - // Boardgame.io Lobby usually just removes the player object or sets name undefined - const hasPlayer = matchInfo && matchInfo.name; - - return { - id: pid, - name: hasPlayer ? matchInfo.name : 'Empty Seat', - isConnected: hasPlayer ? (matchInfo?.isConnected ?? true) : false, - isAlive: playerInfo?.isAlive ?? true, // Default to true if not found - isCurrent: playerID === currentPlayer, - isSelf: pid === gameContext.playerID, - isEmpty: !hasPlayer - }; - }); - const toggleCollapse = () => setIsCollapsed(!isCollapsed); return ( @@ -67,55 +32,62 @@ export default function GameStatusList({
- {matchName || matchDetails?.matchName || 'Match'} + {matchDetails?.matchName || 'Match'}
- {(numPlayers || ctx.numPlayers) && (
👥 - {numPlayers || ctx.numPlayers} + {game.players.playerCount}
- )}
{!isCollapsed && (
Players
- {displayPlayers.map((p: any) => ( -
- { + const player = game.players.getPlayer(orderEntry); + + const matchInfo = matchPlayers.find(p => p.id === player.id); + const isEmpty = !(matchInfo && matchInfo.name) + const isConnected = matchInfo ? (matchInfo.isConnected ?? true) : false; + const name = isEmpty ? 'Empty Seat' : matchInfo.name; + + return ( +
+ - - - {p.name} {p.isSelf ? '(You)' : ''} + + + {name} {game.isSelf(player) ? '(You)' : ''} - - {!p.isAlive && !p.isEmpty && ( - ☠️ - )} - - {p.isCurrent && p.isAlive && !p.isEmpty && ( - 🎲 - )} -
- ))} - + + {!player.isAlive && ( + ☠️ + )} + + {player.isCurrentPlayer && ( + 🎲 + )} +
+ ); + })} + {/* Spectators placeholder - can be implemented if G supports spectators list */} - {isSelfSpectator && ( + {game.isSpectator && (
You are spectating
diff --git a/src/client/components/board/overlay/BoardOverlays.tsx b/src/client/components/board/overlay/BoardOverlays.tsx index cdfce70..362bc36 100644 --- a/src/client/components/board/overlay/BoardOverlays.tsx +++ b/src/client/components/board/overlay/BoardOverlays.tsx @@ -8,6 +8,7 @@ import { CHOOSE_PLAYER_TO_REQUEST_FROM, CHOOSE_PLAYER_TO_STEAL_FROM } from "../../../../common/constants/stages.ts"; +import LobbyOverlay from "./lobby-overlay/LobbyOverlay.tsx"; /** * Manages and renders all game overlays @@ -25,6 +26,7 @@ export default function BoardOverlays() { return ( <> + {selectionMessage && ( ) } diff --git a/src/client/components/board/overlay/lobby-overlay/LobbyOverlay.tsx b/src/client/components/board/overlay/lobby-overlay/LobbyOverlay.tsx index 522e51b..5cf199f 100644 --- a/src/client/components/board/overlay/lobby-overlay/LobbyOverlay.tsx +++ b/src/client/components/board/overlay/lobby-overlay/LobbyOverlay.tsx @@ -2,13 +2,14 @@ import './LobbyOverlay.css'; import {useMatchDetails} from "../../../../context/MatchDetailsContext.tsx"; import {useGame} from "../../../../context/GameContext.tsx"; -interface LobbyOverlayProps { - onStartGame?: () => void; -} -export default function LobbyOverlay({onStartGame}: LobbyOverlayProps) { +export default function LobbyOverlay() { const game = useGame(); + if (!game.isLobbyPhase()) { + return null; + } + const { matchDetails } = useMatchDetails(); const { players, numPlayers } = matchDetails || {players: [], numPlayers: 1}; @@ -19,6 +20,12 @@ export default function LobbyOverlay({onStartGame}: LobbyOverlayProps) { navigator.clipboard.writeText(window.location.href); }; + const handStartButton = () => { + if (game.moves.startGame) { + game.moves.startGame(); + } + }; + return (
@@ -72,8 +79,8 @@ export default function LobbyOverlay({onStartGame}: LobbyOverlayProps) { })}
- {allPlayersFilled && onStartGame && ( - )} diff --git a/src/client/components/chat/Chat.tsx b/src/client/components/chat/Chat.tsx index d7784ab..9dbe8a5 100644 --- a/src/client/components/chat/Chat.tsx +++ b/src/client/components/chat/Chat.tsx @@ -1,20 +1,13 @@ import { useState, useEffect, useRef } from 'react'; import './Chat.css'; import {useMatchDetails} from "../../context/MatchDetailsContext.tsx"; +import {useGame} from "../../context/GameContext.tsx"; -interface ChatProps { - playerID: string | null; - chatMessages?: Array<{ - id: string; - sender: string; - payload: any; - }>; - sendChatMessage?: (message: any) => void; - isOpen?: boolean; - onToggle?: () => void; -} +export const Chat = () => { + const game = useGame(); -export const Chat = ({ playerID, chatMessages = [], sendChatMessage, isOpen: defaultOpen = false }: ChatProps) => { + const chatMessages = game.chatMessages; + const playerId = game.selfPlayerId; const { matchDetails } = useMatchDetails(); @@ -23,7 +16,7 @@ export const Chat = ({ playerID, chatMessages = [], sendChatMessage, isOpen: def return acc; }, {} as Record) || {}; - const [isOpen, setIsOpen] = useState(defaultOpen); + const [isOpen, setIsOpen] = useState(false); const [message, setMessage] = useState(''); const [unreadCount, setUnreadCount] = useState(0); const messagesEndRef = useRef(null); @@ -52,8 +45,8 @@ export const Chat = ({ playerID, chatMessages = [], sendChatMessage, isOpen: def const handleSend = (e: React.FormEvent) => { e.preventDefault(); - if (message.trim() && sendChatMessage) { - sendChatMessage(message.trim()); + if (message.trim() && game.sendChatMessage) { + game.sendChatMessage(message.trim()); setMessage(''); } }; @@ -63,7 +56,7 @@ export const Chat = ({ playerID, chatMessages = [], sendChatMessage, isOpen: def if (!isOpen) setUnreadCount(0); }; - if (!sendChatMessage && chatMessages.length === 0) return null; + if (!game.sendChatMessage && chatMessages.length === 0) return null; return (
@@ -79,7 +72,7 @@ export const Chat = ({ playerID, chatMessages = [], sendChatMessage, isOpen: def
No messages yet...
) : ( chatMessages.map((msg) => { - const isSelf = msg.sender === playerID; + const isSelf = msg.sender === playerId; const senderName = playerNames[msg.sender] || `Player ${msg.sender}`; return (
@@ -99,11 +92,11 @@ export const Chat = ({ playerID, chatMessages = [], sendChatMessage, isOpen: def type="text" value={message} onChange={(e) => setMessage(e.target.value)} - placeholder={playerID ? "Type a message..." : "Spectators can't chat"} - disabled={!playerID} + placeholder={playerId ? "Type a message..." : "Spectators can't chat"} + disabled={!playerId} className="chat-input" /> - diff --git a/src/client/context/MatchDetailsContext.tsx b/src/client/context/MatchDetailsContext.tsx index aebae1a..b78d031 100644 --- a/src/client/context/MatchDetailsContext.tsx +++ b/src/client/context/MatchDetailsContext.tsx @@ -40,12 +40,18 @@ export function MatchDetailsProvider({ matchID, children }: MatchDetailsProvider try { const lobbyClient = new LobbyClient({ server: SERVER_URL }); const match = await lobbyClient.getMatch(GAME_NAME, matchID); + + const matchPlayers: MatchPlayer[] = match.players.map((p: any) => ({ + id: p.id, + name: p.name, + isConnected: p.isConnected, + })); setMatchDetails({ matchID: match.matchID, matchName: match.setupData?.matchName || 'Match', numPlayers: match.setupData?.maxPlayers || match.players.length, - players: match.players, + players: matchPlayers, gameOver: match.gameover, }); setError(null); diff --git a/src/client/hooks/useGameLogic.tsx b/src/client/hooks/useGameLogic.tsx deleted file mode 100644 index b01d653..0000000 --- a/src/client/hooks/useGameLogic.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import {useMemo} from 'react'; -import {Ctx} from 'boardgame.io'; -import {IGameState, IPlayers} from '../../common'; - -interface GameStateData { - isSpectator: boolean; - selfPlayerId: number | null; - isSelfDead: boolean; - isSelfSpectator: boolean; - isGameOver: boolean; - currentPlayer: number; - isSelectingPlayer: boolean; - isChoosingCardToGive: boolean; - isViewingFuture: boolean; - isInNowCardStage: boolean; - isAwaitingNowCardResolution: boolean; - alivePlayers: string[]; - alivePlayersSorted: string[]; -} - -/** - * Hook to derive and memoize game state properties - */ -export const useGameState = ( - ctx: Ctx, - G: IGameState, - allPlayers: IPlayers, - playerID: string | null -): GameStateData => { - const isSpectator = playerID == null; - const selfPlayerId = isSpectator ? null : parseInt(playerID || '0'); - - const isSelfDead = useMemo(() => { - return !isSpectator && - selfPlayerId !== null && - !allPlayers[selfPlayerId.toString()]?.isAlive; - }, [isSpectator, selfPlayerId, allPlayers]); - - const isSelfSpectator = useMemo(() => { - return isSpectator || - (isSelfDead && G.gameRules.spectatorsSeeCards) || - G.gameRules.openCards; - }, [isSpectator, isSelfDead, G.gameRules]); - - const isGameOver = ctx.phase === 'gameover'; - const currentPlayer = parseInt(ctx.currentPlayer); - - const isSelectingPlayer = useMemo(() => { - const stage = ctx.activePlayers?.[playerID || '']; - return !isSpectator && - selfPlayerId !== null && - selfPlayerId === currentPlayer && - (stage === 'choosePlayerToStealFrom' || stage === 'choosePlayerToRequestFrom'); - }, [isSpectator, selfPlayerId, currentPlayer, ctx.activePlayers, playerID]); - - const isChoosingCardToGive = useMemo(() => { - return !isSpectator && - selfPlayerId !== null && - ctx.activePlayers?.[playerID || ''] === 'chooseCardToGive'; - }, [isSpectator, selfPlayerId, ctx.activePlayers, playerID]); - - const isViewingFuture = useMemo(() => { - return !isSpectator && - selfPlayerId !== null && - ctx.activePlayers?.[playerID || ''] === 'viewingFuture'; - }, [isSpectator, selfPlayerId, ctx.activePlayers, playerID]); - - const selfStage = ctx.activePlayers?.[playerID || '']; - - const isInNowCardStage = useMemo(() => { - return !isSpectator && - selfPlayerId !== null && - (selfStage === 'respondWithNowCard' || selfStage === 'awaitingNowCards'); - }, [isSpectator, selfPlayerId, selfStage]); - - const isAwaitingNowCardResolution = useMemo(() => { - return !isSpectator && - selfPlayerId !== null && - selfStage === 'awaitingNowCards'; - }, [isSpectator, selfPlayerId, selfStage]); - - const alivePlayers = useMemo(() => { - return Object.keys(ctx.playOrder).filter(player => allPlayers[player]?.isAlive); - }, [ctx.playOrder, allPlayers]); - - const alivePlayersSorted = useMemo(() => { - return [...alivePlayers].sort((a, b) => parseInt(a) - parseInt(b)); - }, [alivePlayers]); - - return { - isSpectator, - selfPlayerId, - isSelfDead, - isSelfSpectator, - isGameOver, - currentPlayer, - isSelectingPlayer, - isChoosingCardToGive, - isViewingFuture, - isInNowCardStage, - isAwaitingNowCardResolution, - alivePlayers, - alivePlayersSorted, - }; -}; diff --git a/src/client/hooks/useGameState.tsx b/src/client/hooks/useGameState.tsx deleted file mode 100644 index b01d653..0000000 --- a/src/client/hooks/useGameState.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import {useMemo} from 'react'; -import {Ctx} from 'boardgame.io'; -import {IGameState, IPlayers} from '../../common'; - -interface GameStateData { - isSpectator: boolean; - selfPlayerId: number | null; - isSelfDead: boolean; - isSelfSpectator: boolean; - isGameOver: boolean; - currentPlayer: number; - isSelectingPlayer: boolean; - isChoosingCardToGive: boolean; - isViewingFuture: boolean; - isInNowCardStage: boolean; - isAwaitingNowCardResolution: boolean; - alivePlayers: string[]; - alivePlayersSorted: string[]; -} - -/** - * Hook to derive and memoize game state properties - */ -export const useGameState = ( - ctx: Ctx, - G: IGameState, - allPlayers: IPlayers, - playerID: string | null -): GameStateData => { - const isSpectator = playerID == null; - const selfPlayerId = isSpectator ? null : parseInt(playerID || '0'); - - const isSelfDead = useMemo(() => { - return !isSpectator && - selfPlayerId !== null && - !allPlayers[selfPlayerId.toString()]?.isAlive; - }, [isSpectator, selfPlayerId, allPlayers]); - - const isSelfSpectator = useMemo(() => { - return isSpectator || - (isSelfDead && G.gameRules.spectatorsSeeCards) || - G.gameRules.openCards; - }, [isSpectator, isSelfDead, G.gameRules]); - - const isGameOver = ctx.phase === 'gameover'; - const currentPlayer = parseInt(ctx.currentPlayer); - - const isSelectingPlayer = useMemo(() => { - const stage = ctx.activePlayers?.[playerID || '']; - return !isSpectator && - selfPlayerId !== null && - selfPlayerId === currentPlayer && - (stage === 'choosePlayerToStealFrom' || stage === 'choosePlayerToRequestFrom'); - }, [isSpectator, selfPlayerId, currentPlayer, ctx.activePlayers, playerID]); - - const isChoosingCardToGive = useMemo(() => { - return !isSpectator && - selfPlayerId !== null && - ctx.activePlayers?.[playerID || ''] === 'chooseCardToGive'; - }, [isSpectator, selfPlayerId, ctx.activePlayers, playerID]); - - const isViewingFuture = useMemo(() => { - return !isSpectator && - selfPlayerId !== null && - ctx.activePlayers?.[playerID || ''] === 'viewingFuture'; - }, [isSpectator, selfPlayerId, ctx.activePlayers, playerID]); - - const selfStage = ctx.activePlayers?.[playerID || '']; - - const isInNowCardStage = useMemo(() => { - return !isSpectator && - selfPlayerId !== null && - (selfStage === 'respondWithNowCard' || selfStage === 'awaitingNowCards'); - }, [isSpectator, selfPlayerId, selfStage]); - - const isAwaitingNowCardResolution = useMemo(() => { - return !isSpectator && - selfPlayerId !== null && - selfStage === 'awaitingNowCards'; - }, [isSpectator, selfPlayerId, selfStage]); - - const alivePlayers = useMemo(() => { - return Object.keys(ctx.playOrder).filter(player => allPlayers[player]?.isAlive); - }, [ctx.playOrder, allPlayers]); - - const alivePlayersSorted = useMemo(() => { - return [...alivePlayers].sort((a, b) => parseInt(a) - parseInt(b)); - }, [alivePlayers]); - - return { - isSpectator, - selfPlayerId, - isSelfDead, - isSelfSpectator, - isGameOver, - currentPlayer, - isSelectingPlayer, - isChoosingCardToGive, - isViewingFuture, - isInNowCardStage, - isAwaitingNowCardResolution, - alivePlayers, - alivePlayersSorted, - }; -}; diff --git a/src/client/utils/matchData.ts b/src/client/utils/matchData.ts index 528a841..b6e0a9a 100644 --- a/src/client/utils/matchData.ts +++ b/src/client/utils/matchData.ts @@ -3,7 +3,7 @@ */ export interface MatchPlayer { - id: number; + id: string; name?: string; isConnected?: boolean; } @@ -13,13 +13,11 @@ export interface MatchPlayer { * Returns "Player X" if matchData is not available * Returns "Empty Seat" if matchData is available but player name is missing */ -export function getPlayerName(playerID: string | null, matchData?: MatchPlayer[]): string { - if (!playerID) return 'Unknown Player'; - - const playerId = parseInt(playerID); +export function getPlayerName(playerId: string | null, matchData?: MatchPlayer[]): string { + if (!playerId) return 'Unknown Player'; if (!matchData || matchData.length === 0) { - return `Player ${playerId + 1}`; + return `Player`; } const player = matchData.find(p => p.id === playerId); diff --git a/src/common/entities/players.ts b/src/common/entities/players.ts index 5ae7280..9c38e1f 100644 --- a/src/common/entities/players.ts +++ b/src/common/entities/players.ts @@ -23,6 +23,17 @@ export class Players { return new Player(this.game, playerData, id); } + getPlayerOptional(id: PlayerID | null | undefined): Player | null { + if (!id) { + return null; + } + const playerData = this.players?.[id]; + if (!playerData) { + return null; + } + return new Player(this.game, playerData, id); + } + /** * Get a wrapper for the current player based on context.currentPlayer */ @@ -46,6 +57,10 @@ export class Players { return this.getPlayer(id); } + get actingPlayerOptional(): Player | null { + return this.getPlayerOptional(this.game.context.playerID); + } + get actingPlayerId(): PlayerID { return this.game.context.playerID ?? this.game.context.ctx.currentPlayer; } diff --git a/src/common/setup/player-setup.ts b/src/common/setup/player-setup.ts index 96b8a79..7b85ec2 100644 --- a/src/common/setup/player-setup.ts +++ b/src/common/setup/player-setup.ts @@ -32,10 +32,10 @@ const shouldSeeAllCards = (game: TheGame): boolean => { if (game.gameRules.openCards) return true; // Spectators (no playerID) see all card-types ONLY if rule allows - if (!game.players.actingPlayerId || !game.players.actingPlayer.isAlive) { + const player = game.players.actingPlayerOptional; + if (!player || !player.isAlive) { return game.gameRules.spectatorsSeeCards; } - return false; }; From ee8df8e2665e071aec10924223cc3c6b147f3481 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:23:38 +0100 Subject: [PATCH 12/55] refactor: centralize card texture retrieval in TheGameClient --- src/client/components/board/card-animation/CardAnimation.tsx | 5 ++--- .../board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx | 3 ++- src/client/components/board/player/card/Card.tsx | 4 ++-- .../components/board/table/pending/PendingPlayStack.tsx | 3 ++- src/client/entities/game-client.ts | 3 ++- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/client/components/board/card-animation/CardAnimation.tsx b/src/client/components/board/card-animation/CardAnimation.tsx index a0d3ce3..e569569 100644 --- a/src/client/components/board/card-animation/CardAnimation.tsx +++ b/src/client/components/board/card-animation/CardAnimation.tsx @@ -1,6 +1,7 @@ import './CardAnimation.css'; import React, {useEffect, useState} from 'react'; import {ICard} from '../../../../common'; +import {TheGameClient} from "../../../entities/game-client.ts"; export interface CardAnimationData { id: string; @@ -18,9 +19,7 @@ interface CardAnimationProps { export default function CardAnimation({animation, onComplete}: CardAnimationProps) { const [isVisible, setIsVisible] = useState(false); - const cardImage = animation.card - ? `/assets/cards/${animation.card.name}/${animation.card.index}.png` - : '/assets/card-types/back/0.jpg'; + const cardImage = TheGameClient.getCardTexture(animation.card); useEffect(() => { // Start animation immediately diff --git a/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx b/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx index fbd4d73..3b3fd83 100644 --- a/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx +++ b/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx @@ -2,6 +2,7 @@ import './SeeTheFutureOverlay.css'; import {useGame} from "../../../../context/GameContext.tsx"; import {VIEWING_FUTURE} from "../../../../../common/constants/stages.ts"; import {Card} from "../../../../../common/entities/card.ts"; +import {TheGameClient} from "../../../../entities/game-client.ts"; export default function SeeTheFutureOverlay() { const game = useGame(); @@ -38,7 +39,7 @@ export default function SeeTheFutureOverlay() {
diff --git a/src/client/components/board/player/card/Card.tsx b/src/client/components/board/player/card/Card.tsx index 5748765..f8a1064 100644 --- a/src/client/components/board/player/card/Card.tsx +++ b/src/client/components/board/player/card/Card.tsx @@ -1,11 +1,11 @@ import './Card.css'; -import back from '/assets/cards/back/0.jpg'; import {CSSProperties, useRef, useState} from 'react'; import CardPreview from '../../CardPreview.tsx'; import {useResponsive} from "../../../../context/ResponsiveContext.tsx"; import {useGame} from "../../../../context/GameContext.tsx"; import {Player} from "../../../../../common"; import {CardWithServerIndex} from "../player-cards/PlayerCards.tsx"; +import {TheGameClient} from "../../../../entities/game-client.ts"; interface CardProps { owner: Player, @@ -32,7 +32,7 @@ export default function Card({ const [isSelected, setIsSelected] = useState(false); const cardRef = useRef(null); - const cardImage = card ? `/assets/cards/${card.name}/${card.index}.png` : back; + const cardImage = TheGameClient.getCardTexture(card); const couldBePlayed = game.isSelf(owner) && ((card?.serverIndex && game.canPlayCard(card?.serverIndex)) || game.canGiveCard()); diff --git a/src/client/components/board/table/pending/PendingPlayStack.tsx b/src/client/components/board/table/pending/PendingPlayStack.tsx index 758ef8e..753e1ae 100644 --- a/src/client/components/board/table/pending/PendingPlayStack.tsx +++ b/src/client/components/board/table/pending/PendingPlayStack.tsx @@ -3,6 +3,7 @@ import './PendingPlayStack.css'; import {useRef, useState} from 'react'; import CardPreview from '../../CardPreview.tsx'; import {useGame} from "../../../../context/GameContext.tsx"; +import {TheGameClient} from "../../../../entities/game-client.ts"; export default function PendingPlayStack() { const game = useGame(); @@ -15,7 +16,7 @@ export default function PendingPlayStack() { const [isHovered, setIsHovered] = useState(false); const pileRef = useRef(null); - const cardImage = `/assets/cards/${targetCard.name}/${targetCard.index}.png`; + const cardImage = TheGameClient.getCardTexture(targetCard); return (
diff --git a/src/client/entities/game-client.ts b/src/client/entities/game-client.ts index af1cbca..d58d481 100644 --- a/src/client/entities/game-client.ts +++ b/src/client/entities/game-client.ts @@ -9,6 +9,7 @@ import { } from "../../common/constants/stages.ts"; import {MatchPlayer} from "../utils/matchData.ts"; import {PlayerID} from "boardgame.io"; +import back from "/assets/cards/back/0.jpg"; export class TheGameClient extends TheGame { public readonly moves: Record void>; @@ -172,7 +173,7 @@ export class TheGameClient extends TheGame { static getCardTexture(card: Card | ICard | null): string { if (!card) { - return "/assets/cards/backside.png"; + return back; } return `/assets/cards/${card.name}/${card.index}.png`; } From 15422d95dc62480847913d1f8e047497801ca588 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:32:56 +0100 Subject: [PATCH 13/55] refactor: add comprehensive codebase guide for Exploding Kittens project --- AGENTS.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ca3b274 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# Exploding Kittens Codebase Guide + +## 🧠 Core Architecture + +This project implements Exploding Kittens using **React** (frontend) and **boardgame.io** (game engine). + +### Key Directories +- **`src/common/`**: Shared game logic, state definitions, and move implementations. Start here to understand game mechanics. +- **`src/client/`**: React frontend. +- **`src/server/`**: `boardgame.io` game server. + +### Game State Management +The project uses a custom wrapper pattern over standard `boardgame.io` state (`G` and `ctx`): +- **`TheGame` Class** (`src/common/entities/game.ts`): Wraps the raw context. Always instantiate this to interact with game state. +- **`IGameState`** (`src/common/models/game-state.model.ts`): Defines the shape of `G` (piles, deck type, etc.). +- **Moves with `inGame` HOC**: All game moves are wrapped with the `inGame` higher-order function (`src/common/moves/in-game.ts`). + - *Pattern*: Define moves as `(game: TheGame, ...args) => void`. The HOC handles context injection. + - *Example*: See `src/common/moves/draw-move.ts`. + +### Client Context +The React app accesses game state via `GameContext` (`src/client/context/GameContext.tsx`). +- **`TheGameClient`**: Extends `TheGame` with client-specific features (match ID, chat, multiplayer flags). +- Use `useGame()` hook to access the current game instance in components. + +## 🛠️ Developer Workflows + +### Running the Project +The project requires two concurrent processes: +1. **Game Server**: `npm run server:watch` (Runs on `http://localhost:51399`) +2. **Client**: `npm run dev` (Runs on `http://localhost:5173`) + +### Building +- **Client**: `npm run build` +- **Server**: `npm run build:server` + +### Testing +- Currently, there is **no automated test suite**. Verification is manual via playing the game in browsers. + +## 🧩 Patterns & Conventions + +### Move Mechanics +When implementing game moves (actions): +1. Create a function in `src/common/moves/`. +2. Function signature must be `(game: TheGame, ...args)`. +3. Mutate state via `game.piles`, `game.players`, or `game.gameState`. +4. Register the move in `src/common/game.ts` using `inGame(yourMove)`. + +**Example Move:** +```typescript +import { TheGame } from "../entities/game"; + +export const myMove = (game: TheGame, argument: string) => { + // Use entity helpers + if (game.players.actingPlayer.hasCard(argument)) { + game.players.actingPlayer.discard(argument); + } +}; +``` + +### Component Structure +- **Game View**: `src/client/components/game-view/` handles the main game screen. +- **Board**: `src/client/components/board/` handles the visual representation of the table. + +### Docker +- `docker-compose.yml` orchestrates both client and server containers. +- Environment variables are managed in `stack.env` and passed to containers. + From 756f07503b7e70d4d142656194f66258a738f261 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:33:13 +0100 Subject: [PATCH 14/55] refactor: reorganize animation system --- src/client/animations/cardAnimationTypes.ts | 38 ++++ src/client/animations/cardMovementDetector.ts | 162 +++++++++++++++ src/client/animations/useCardAnimations.tsx | 106 ++++++++++ src/client/components/board/Board.tsx | 2 +- .../SeeTheFutureOverlay.tsx | 2 +- src/client/hooks/useCardAnimations.tsx | 185 ------------------ src/common/game.ts | 23 +-- 7 files changed, 318 insertions(+), 200 deletions(-) create mode 100644 src/client/animations/cardAnimationTypes.ts create mode 100644 src/client/animations/cardMovementDetector.ts create mode 100644 src/client/animations/useCardAnimations.tsx delete mode 100644 src/client/hooks/useCardAnimations.tsx diff --git a/src/client/animations/cardAnimationTypes.ts b/src/client/animations/cardAnimationTypes.ts new file mode 100644 index 0000000..d3770a2 --- /dev/null +++ b/src/client/animations/cardAnimationTypes.ts @@ -0,0 +1,38 @@ +import {ICard} from "../../common"; + +export type AnimationEndpoint = + | { kind: 'pile'; id: 'draw-pile' | 'discard-pile' } + | { kind: 'player'; id: string }; + +export type CardVisibility = + | { type: 'public' } // everyone sees face + | { type: 'participants'; ids: string[] } // only these players see face + | { type: 'hidden' }; // everyone sees backside + +export interface CardMovement { + card: ICard | null; + from: AnimationEndpoint; + to: AnimationEndpoint; + visibility: CardVisibility; + staggerIndex: number; +} + +export interface StateSnapshot { + drawSize: number; + discardSize: number; + discardTop: ICard | null; + handCounts: Record; + selfHand: ICard[]; +} + +export interface HandChange { + playerId: string; + delta: number; +} + +export interface StateDiff { + drawDelta: number; + discardDelta: number; + gainers: HandChange[]; + losers: HandChange[]; +} diff --git a/src/client/animations/cardMovementDetector.ts b/src/client/animations/cardMovementDetector.ts new file mode 100644 index 0000000..8e4d981 --- /dev/null +++ b/src/client/animations/cardMovementDetector.ts @@ -0,0 +1,162 @@ +import {CardMovement, CardVisibility, HandChange, StateSnapshot, StateDiff} from './cardAnimationTypes'; +import {ICard} from "../../common"; + +const diffHands = ( + current: Record, + previous: Record +): HandChange[] => + Object.entries(current) + .map(([playerId, count]) => ({playerId, delta: count - (previous[playerId] ?? 0)})) + .filter(c => c.delta !== 0); + +export const diffSnapshots = (current: StateSnapshot, stable: StateSnapshot): StateDiff => ({ + drawDelta: current.drawSize - stable.drawSize, + discardDelta: current.discardSize - stable.discardSize, + gainers: diffHands(current.handCounts, stable.handCounts).filter(c => c.delta > 0), + losers: diffHands(current.handCounts, stable.handCounts).filter(c => c.delta < 0), +}); + +const diffCards = (from: ICard[], to: ICard[]): ICard[] => + from.filter(a => !to.some(b => b.name === a.name && b.index === a.index)); + +const resolveVisibility = ( + senderId: string | null, + receiverId: string | null, + isPublic: boolean, +): CardVisibility => { + if (isPublic) return {type: 'public'}; + const ids = [senderId, receiverId].filter(Boolean) as string[]; + return ids.length > 0 ? {type: 'participants', ids} : {type: 'hidden'}; +}; + +const resolveCard = ( + visibility: CardVisibility, + selfPlayerId: string | null, + candidates: (ICard | null | undefined)[] +): ICard | null => { + const card = candidates.find(c => c != null) ?? null; + if (visibility.type === 'hidden') return null; + if (visibility.type === 'public') return card; + if (visibility.type === 'participants') { + return selfPlayerId && visibility.ids.includes(selfPlayerId) ? card : null; + } + return null; +}; + +export const detectMovements = ( + diff: StateDiff, + stable: StateSnapshot, + current: StateSnapshot, + selfPlayerId: string | null, +): CardMovement[] => { + const movements: CardMovement[] = []; + let stagger = 0; + + const gainedBySelf = diffCards(current.selfHand, stable.selfHand); + const lostBySelf = diffCards(stable.selfHand, current.selfHand); + + // ── Draw pile → player hand ─────────────────────────────────────────────── + if (diff.drawDelta < 0 && diff.gainers.length > 0) { + diff.gainers.forEach(gainer => { + for (let i = 0; i < gainer.delta; i++) { + const isSelf = gainer.playerId === selfPlayerId; + const visibility = resolveVisibility(null, gainer.playerId, false); + const card = resolveCard(visibility, selfPlayerId, [isSelf ? gainedBySelf[i] : undefined]); + movements.push({ + card, staggerIndex: stagger++, + from: {kind: 'pile', id: 'draw-pile'}, + to: {kind: 'player', id: gainer.playerId}, + visibility, + }); + } + }); + } + + // ── Player hand → discard pile ──────────────────────────────────────────── + // Always use topCard — discard is public and it's the authoritative card identity. + // This also fixes the "wrong index" bug with duplicate card types. + if (diff.discardDelta > 0 && diff.losers.length > 0) { + diff.losers.forEach(loser => { + for (let i = 0; i < Math.abs(loser.delta); i++) { + const isSelf = loser.playerId === selfPlayerId; + const card = isSelf ? lostBySelf[i] : current.discardTop; + movements.push({ + card, + staggerIndex: stagger++, + from: {kind: 'player', id: loser.playerId}, + to: {kind: 'pile', id: 'discard-pile'}, + visibility: {type: 'public'}, + }); + } + }); + } + + // ── Discard pile → player hand ──────────────────────────────────────────── + if (diff.discardDelta < 0 && diff.gainers.length > 0 && diff.drawDelta === 0) { + diff.gainers.forEach(gainer => { + movements.push({ + card: stable.discardTop, // what was on top before the move + staggerIndex: stagger++, + from: {kind: 'pile', id: 'discard-pile'}, + to: {kind: 'player', id: gainer.playerId}, + visibility: {type: 'public'}, + }); + }); + } + + // ── Player hand → draw pile (defuse, insert) ────────────────────────────── + if (diff.drawDelta > 0 && diff.losers.length > 0 && diff.discardDelta === 0) { + diff.losers.forEach(loser => { + for (let i = 0; i < Math.abs(loser.delta); i++) { + const isSelf = loser.playerId === selfPlayerId; + const visibility = resolveVisibility(loser.playerId, null, false); + const card = resolveCard(visibility, selfPlayerId, [isSelf ? lostBySelf[i] : undefined]); + movements.push({ + card, staggerIndex: stagger++, + from: {kind: 'player', id: loser.playerId}, + to: {kind: 'pile', id: 'draw-pile'}, + visibility, + }); + } + }); + } + + // ── Discard pile → draw pile (reshuffle) ────────────────────────────────── + if (diff.discardDelta < 0 && diff.drawDelta > 0 && diff.gainers.length === 0 && diff.losers.length === 0) { + movements.push({ + card: stable.discardTop, + staggerIndex: stagger++, + from: {kind: 'pile', id: 'discard-pile'}, + to: {kind: 'pile', id: 'draw-pile'}, + visibility: {type: 'public'}, + }); + } + + // ── Player hand → player hand (favor, steal, combo) ─────────────────────── + if (diff.drawDelta === 0 && diff.discardDelta === 0 && diff.gainers.length > 0 && diff.losers.length > 0) { + const allLosers = diff.losers.flatMap(l => Array(Math.abs(l.delta)).fill(l.playerId)); + const allGainers = diff.gainers.flatMap(g => Array(g.delta).fill(g.playerId)); + const numMoves = Math.min(allLosers.length, allGainers.length); + + for (let i = 0; i < numMoves; i++) { + const loserId = allLosers[i]; + const gainerId = allGainers[i]; + const isSelfReceiver = gainerId === selfPlayerId; + const isSelfSender = loserId === selfPlayerId; + const visibility = resolveVisibility(loserId, gainerId, false); + const card = resolveCard(visibility, selfPlayerId, [ + isSelfReceiver ? gainedBySelf[i] : undefined, + isSelfSender ? lostBySelf[i] : undefined, + ]); + movements.push({ + card, + staggerIndex: stagger++, + from: {kind: 'player', id: loserId}, + to: {kind: 'player', id: gainerId}, + visibility, + }); + } + } + + return movements; +}; diff --git a/src/client/animations/useCardAnimations.tsx b/src/client/animations/useCardAnimations.tsx new file mode 100644 index 0000000..a5379ef --- /dev/null +++ b/src/client/animations/useCardAnimations.tsx @@ -0,0 +1,106 @@ +import {useState, useCallback, useRef, useEffect} from 'react'; +import CardAnimation, {CardAnimationData} from '../components/board/card-animation/CardAnimation'; +import {ICard} from '../../common'; +import {TheGameClient} from '../entities/game-client.ts'; +import {StateSnapshot} from './cardAnimationTypes'; +import {detectMovements, diffSnapshots} from './cardMovementDetector'; + +const STAGGER_MS = 120; +const DEBOUNCE_MS = 50; + +const takeSnapshot = (game: TheGameClient, selfPlayerId: string | null): StateSnapshot => ({ + drawSize: game.piles.drawPile.size, + discardSize: game.piles.discardPile.size, + discardTop: game.piles.discardPile.topCard, + handCounts: Object.fromEntries(game.players.allPlayers.map(p => [p.id, p.cardCount])), + selfHand: [...(game.players.allPlayers.find(p => p.id === selfPlayerId)?.hand ?? [])], +}); + +const endpointId = (e: {kind: string; id: string}) => + e.kind === 'pile' ? e.id : `player-${e.id}`; + +export const useCardAnimations = (game: TheGameClient) => { + const selfPlayerId = game.selfPlayerId; + + const [animations, setAnimations] = useState([]); + const animationIdCounter = useRef(0); + + const stableSnapshot = useRef(takeSnapshot(game, selfPlayerId)); + const debounceTimer = useRef | null>(null); + const pendingSnapshot = useRef(null); + + const getElementCenter = useCallback((id: string) => { + const el = document.querySelector(`[data-animation-id="${id}"]`) as HTMLElement; + if (!el) { console.warn(`Animation target not found: ${id}`); return null; } + const r = el.getBoundingClientRect(); + return {x: r.left + r.width / 2, y: r.top + r.height / 2}; + }, []); + + const triggerCardMovement = useCallback(( + card: ICard | null, fromId: string, toId: string, delay = 0 + ) => { + const from = getElementCenter(fromId); + const to = getElementCenter(toId); + if (!from || !to) return; + setAnimations(prev => [...prev, { + id: `card-anim-${animationIdCounter.current++}`, + card, from, to, duration: 600, delay, + }]); + }, [getElementCenter]); + + const handleAnimationComplete = useCallback((id: string) => { + setAnimations(prev => prev.filter(a => a.id !== id)); + }, []); + + // Fine-grained dependency keys so the effect fires even when + // game.players is the same object reference but hand contents changed + const handCountKey = game.players.allPlayers.map(p => `${p.id}:${p.cardCount}`).join(','); + const selfHandKey = game.players.allPlayers + .find(p => p.id === selfPlayerId)?.hand + .map(c => `${c.name}:${c.index}`).join(',') ?? ''; + + useEffect(() => { + const current = takeSnapshot(game, selfPlayerId); + + if (!debounceTimer.current) { + // New debounce window — lock in the stable baseline + stableSnapshot.current = pendingSnapshot.current ?? stableSnapshot.current; + } + + if (debounceTimer.current) clearTimeout(debounceTimer.current); + pendingSnapshot.current = current; + + debounceTimer.current = setTimeout(() => { + debounceTimer.current = null; + const snap = pendingSnapshot.current!; + const diff = diffSnapshots(snap, stableSnapshot.current); + const movements = detectMovements(diff, stableSnapshot.current, snap, selfPlayerId); + + movements.forEach(m => + triggerCardMovement(m.card, endpointId(m.from), endpointId(m.to), m.staggerIndex * STAGGER_MS) + ); + + stableSnapshot.current = snap; + pendingSnapshot.current = null; + }, DEBOUNCE_MS); + + return () => { if (debounceTimer.current) clearTimeout(debounceTimer.current); }; + }, [ + game.piles.drawPile.size, + game.piles.discardPile.size, + handCountKey, // replaces game.players — fires when any count changes + selfHandKey, // fires when self's actual card contents change + triggerCardMovement, + selfPlayerId, + ]); + + const AnimationLayer = useCallback(() => ( + <> + {animations.map(anim => ( + + ))} + + ), [animations, handleAnimationComplete]); + + return {animations, AnimationLayer, triggerCardMovement}; +}; diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index 5ffc219..0249ff1 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -1,5 +1,5 @@ import './Board.css'; -import {useCardAnimations} from '../../hooks/useCardAnimations'; +import {useCardAnimations} from '../../animations/useCardAnimations'; import Table from './table/Table'; import PlayerList from './player/player-list/PlayerList'; import BoardOverlays from './overlay/BoardOverlays.tsx'; diff --git a/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx b/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx index 3b3fd83..eb14b0b 100644 --- a/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx +++ b/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx @@ -39,7 +39,7 @@ export default function SeeTheFutureOverlay() {
diff --git a/src/client/hooks/useCardAnimations.tsx b/src/client/hooks/useCardAnimations.tsx deleted file mode 100644 index d5277e0..0000000 --- a/src/client/hooks/useCardAnimations.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import React, {useState, useCallback, useRef, useEffect} from 'react'; -import CardAnimation, {CardAnimationData} from '../components/board/card-animation/CardAnimation'; -import {ICard} from '../../common'; -import {TheGameClient} from "../entities/game-client.ts"; - -interface UseCardAnimationsReturn { - animations: CardAnimationData[]; - AnimationLayer: () => React.JSX.Element; - triggerCardMovement: (card: ICard | null, fromId: string, toId: string) => void; -} - -type PlayerHandCounts = Record; - -interface HandChange { - playerId: string; - delta: number; -} - -export const useCardAnimations = (game: TheGameClient): UseCardAnimationsReturn => { - const players = game.players.players - const selfPlayerId = game.selfPlayerId; - - const [animations, setAnimations] = useState([]); - const animationIdCounter = useRef(0); - const previousDrawPileSize = useRef(game.piles.drawPile.size); - const previousDiscardPileSize = useRef(game.piles.discardPile.size); - const previousPlayerHands = useRef({}); - const previousLocalHand = useRef([]); - - const getElementCenter = useCallback((id: string): { x: number; y: number } | null => { - const element = document.querySelector(`[data-animation-id="${id}"]`) as HTMLElement; - if (!element) { - console.warn(`Element not found for animation id: ${id}`); - return null; - } - - const rect = element.getBoundingClientRect(); - return { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - }; - }, []); - - const triggerCardMovement = useCallback(( - card: ICard | null, - fromId: string, - toId: string, - delay = 0 - ) => { - const fromPos = getElementCenter(fromId); - const toPos = getElementCenter(toId); - - if (!fromPos || !toPos) return; - - const newAnimation: CardAnimationData = { - id: `card-anim-${animationIdCounter.current++}`, - card, - from: fromPos, - to: toPos, - duration: 600, - delay, - }; - - setAnimations(prev => [...prev, newAnimation]); - }, [getElementCenter]); - - const handleAnimationComplete = useCallback((id: string) => { - setAnimations(prev => prev.filter(anim => anim.id !== id)); - }, []); - - const getPlayerHandCounts = (): PlayerHandCounts => { - const counts: PlayerHandCounts = {}; - document.querySelectorAll('[data-player-id]').forEach(el => { - const playerId = el.getAttribute('data-player-id'); - const handCount = el.getAttribute('data-hand-count'); - if (playerId && handCount) { - counts[playerId] = parseInt(handCount); - } - }); - return counts; - }; - - const getHandChanges = (currentCounts: PlayerHandCounts, previousCounts: PlayerHandCounts): HandChange[] => { - return Object.entries(currentCounts) - .map(([playerId, currentCount]) => ({ - playerId, - delta: currentCount - (previousCounts[playerId] || 0) - })) - .filter(change => change.delta !== 0); - }; - - useEffect(() => { - const currentHandCounts = getPlayerHandCounts(); - const handChanges = getHandChanges(currentHandCounts, previousPlayerHands.current); - - const drawPileDecreased = game.piles.drawPile.size < previousDrawPileSize.current; - const discardPileIncreased = game.piles.discardPile.size > previousDiscardPileSize.current; - const pilesUnchanged = game.piles.drawPile.size === previousDrawPileSize.current && - game.piles.discardPile.size === previousDiscardPileSize.current; - - if (drawPileDecreased) { - handChanges - .filter(change => change.delta > 0) - .forEach(change => { - let card: ICard | null = null; - // If local player gained a card, check their hand for the new card - if (change.playerId === selfPlayerId && players[selfPlayerId]) { - const hand = players[selfPlayerId].hand; - if (hand.length > 0) { - card = hand[hand.length - 1]; - } - } - triggerCardMovement(card, 'draw-pile', `player-${change.playerId}`); - }); - } - - if (discardPileIncreased) { - const lastCard = game.piles.discardPile.topCard; - handChanges - .filter(change => change.delta < 0) - .forEach(change => { - triggerCardMovement(lastCard, `player-${change.playerId}`, 'discard-pile') - }); - } - - if (pilesUnchanged && handChanges.length > 0) { - const playerGained = handChanges.find(change => change.delta > 0); - const playerLost = handChanges.find(change => change.delta < 0); - - if (playerGained && playerLost) { - let card: ICard | null = null; - // If local player gained the transferred card, look it up - if (playerGained.playerId === selfPlayerId && players[selfPlayerId]) { - const hand = players[selfPlayerId].hand; - if (hand.length > 0) { - card = hand[hand.length - 1]; - } - } else if (playerLost.playerId === selfPlayerId && players[selfPlayerId]) { - // If local player lost the card, diff with previous hand to find which one - const currentHand = players[selfPlayerId].hand; - const previousHand = previousLocalHand.current; - - const lostCard = previousHand.find(prevCard => - !currentHand.some(currCard => - currCard.name === prevCard.name && currCard.index === prevCard.index - ) - ); - - if (lostCard) { - card = lostCard; - } - } - triggerCardMovement(card, `player-${playerLost.playerId}`, `player-${playerGained.playerId}`); - } - } - - previousDrawPileSize.current = game.piles.drawPile.size; - previousDiscardPileSize.current = game.piles.discardPile.size; - previousPlayerHands.current = currentHandCounts; - - if (selfPlayerId && players[selfPlayerId]) { - previousLocalHand.current = players[selfPlayerId].hand; - } else { - previousLocalHand.current = []; - } - }, [game.piles.drawPile.size, game.piles.discardPile.size, game.piles.drawPile, triggerCardMovement, players, selfPlayerId]); - - const AnimationLayer = useCallback(() => ( - <> - {animations.map(animation => ( - - ))} - - ), [animations, handleAnimationComplete]); - - return { - animations, - AnimationLayer, - triggerCardMovement, - }; -}; diff --git a/src/common/game.ts b/src/common/game.ts index 0792c73..fd5a36f 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -25,24 +25,21 @@ export const ExplodingKittens: Game = { disableUndo: true, - playerView: ({G, ctx, playerID}) => { - // Cannot use the game api because not the whole context is passed through. - - // The player plugin's playerView will handle filtering the player data - // We need to pass G through so it's available - - let viewableDrawPile: ICard[] = []; - - if (ctx.activePlayers?.[playerID!] === VIEWING_FUTURE) { - viewableDrawPile = G.piles.drawPile.cards.slice(0, 3); - } + playerView: ({ G, ctx, playerID }) => { + const isViewingFuture = playerID != null && ctx.activePlayers?.[playerID] === VIEWING_FUTURE; + const drawPileCards = G.piles?.drawPile?.cards ?? []; return { ...G, - drawPile: viewableDrawPile + piles: { + ...G.piles, + drawPile: { + ...(G.piles?.drawPile ?? {}), + cards: isViewingFuture ? drawPileCards.slice(0, 3) : [], + }, + }, }; }, - moves: {}, phases: { From ac01e08b94038b4c4069e78c04e4a90e27d5b8e1 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:40:59 +0100 Subject: [PATCH 15/55] fix: reimplement defuses simple --- .../entities/deck-types/original-deck.ts | 17 +-- src/common/entities/player.ts | 102 +++++++++--------- 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/src/common/entities/deck-types/original-deck.ts b/src/common/entities/deck-types/original-deck.ts index badb6ba..b6c6884 100644 --- a/src/common/entities/deck-types/original-deck.ts +++ b/src/common/entities/deck-types/original-deck.ts @@ -3,7 +3,7 @@ import type {ICard} from '../../models'; import { ATTACK, - CAT_CARD, + CAT_CARD, DEFUSE, EXPLODING_KITTEN, FAVOR, NOPE, SEE_THE_FUTURE, @@ -14,7 +14,6 @@ import { const STARTING_HAND_SIZE = 7; const TOTAL_DEFUSE_CARDS = 6; const MAX_DECK_DEFUSE_CARDS = 2; -const EXPLODING_KITTENS = 4; export class OriginalDeck extends DeckType { constructor() { @@ -25,8 +24,8 @@ export class OriginalDeck extends DeckType { return STARTING_HAND_SIZE; } - startingHandForcedCards(_index: number): ICard[] { - return []; + startingHandForcedCards(index: number): ICard[] { + return [DEFUSE.createCard(index)]; } buildBaseDeck(): ICard[] { @@ -54,11 +53,15 @@ export class OriginalDeck extends DeckType { const remaining = Math.min(TOTAL_DEFUSE_CARDS - playerCount, MAX_DECK_DEFUSE_CARDS); for (let i = 0; i < remaining; i++) { - // pile.push(DEFUSE.createCard(playerCount - 1 + i)); + pile.push(DEFUSE.createCard(playerCount - 1 + i)); } - for (let i = 0; i < EXPLODING_KITTENS; i++) { - pile.push(EXPLODING_KITTEN.createCard(i)); + // add amount of players minus one exploding kitten + + for (let i = 0; i < playerCount - 1; i++) { + // after index 3 restart at 0, since there are only 4 unique exploding kitten cards + const cardIndex = (playerCount - 1 + i) % 4; + pile.push(EXPLODING_KITTEN.createCard(cardIndex)); } } } diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 8dba281..a566c14 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -2,7 +2,7 @@ import {ICard, IPlayer} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; import {EXPLODING_KITTEN, DEFUSE} from "../registries/card-registry"; -import {CHOOSE_PLAYER_TO_REQUEST_FROM, DEFUSE_EXPLODING_KITTEN} from "../constants/stages"; +import {CHOOSE_PLAYER_TO_REQUEST_FROM} from "../constants/stages"; import {PlayerID} from "boardgame.io"; import {NAME_NOPE} from "../constants/cards"; @@ -196,7 +196,7 @@ export class Player { giveCard(cardIndex: number, recipient: Player): Card { const card = this.removeCardAt(cardIndex); if (!card) { - throw new Error("Card not found or invalid index"); + throw new Error("Card not found or invalid index"); } recipient.addCard(card); return card; @@ -204,7 +204,7 @@ export class Player { playCard(cardIndex: number): void { if (cardIndex < 0 || cardIndex >= this.hand.length) { - throw new Error(`Invalid card index: ${cardIndex}`); + throw new Error(`Invalid card index: ${cardIndex}`); } const card = this.hand[cardIndex]; @@ -221,78 +221,80 @@ export class Player { card.afterPlay(); if (card.type.isNowCard()) { - card.play(); - return; + card.play(); + return; } // Setup pending state const startedAtMs = Date.now(); this.game.piles.pendingCard = { - card: {...playedCard.data}, - playedBy: this.id, - startedAtMs, - expiresAtMs: startedAtMs + (this.game.gameRules.pendingTimerMs || 5000), - lastNopeBy: null, - nopeCount: 0, - isNoped: false + card: {...playedCard.data}, + playedBy: this.id, + startedAtMs, + expiresAtMs: startedAtMs + (this.game.gameRules.pendingTimerMs || 5000), + lastNopeBy: null, + nopeCount: 0, + isNoped: false }; card.type.setupPendingState(this.game); } draw(): void { - if (!this.isAlive) throw new Error("Dead player cannot draw"); + if (!this.isAlive) throw new Error("Dead player cannot draw"); - const cardData = this.game.piles.drawCard(); - if (!cardData) { - console.error("Draw pile is empty, cannot draw"); - this.eliminate(); - return; - } + const cardData = this.game.piles.drawCard(); + if (!cardData) { + console.error("Draw pile is empty, cannot draw"); + this.eliminate(); + return; + } - this.addCard(cardData); + this.addCard(cardData); - if (cardData.name === EXPLODING_KITTEN.name) { - const hasDefuse = this.hasCard(DEFUSE.name); - if (hasDefuse) { - this.game.turnManager.setStage(DEFUSE_EXPLODING_KITTEN); - } else { - this.eliminate(); - } + if (cardData.name === EXPLODING_KITTEN.name) { + const hasDefuse = this.hasCard(DEFUSE.name); + if (hasDefuse) { + const insertIndex = Math.floor(this.game.random.Number() * (this.game.piles.drawPile.size)); + this.game.players.actingPlayer.defuseExplodingKitten(insertIndex); // for now put at random location + // this.game.turnManager.setStage(DEFUSE_EXPLODING_KITTEN); // TODO: implement stage clientside } else { - this.game.turnManager.endTurn(); + this.eliminate(); } + } else { + this.game.turnManager.endTurn(); + } } defuseExplodingKitten(insertIndex: number): void { - if (insertIndex < 0 || insertIndex > this.game.piles.drawPile.size) { - throw new Error('Invalid insert index'); - } + if (insertIndex < 0 || insertIndex > this.game.piles.drawPile.size) { + throw new Error('Invalid insert index'); + } - const defuseCard = this.removeCard(DEFUSE.name); - const kittenCard = this.removeCard(EXPLODING_KITTEN.name); + const defuseCard = this.removeCard(DEFUSE.name); + const kittenCard = this.removeCard(EXPLODING_KITTEN.name); - if (!defuseCard || !kittenCard) { - // Should not happen if UI is correct, but safer to eliminate - this.eliminate(); - return; - } + if (!defuseCard || !kittenCard) { + // Should not happen if UI is correct, but safer to eliminate + this.eliminate(); + return; + } - this.game.piles.discardCard(defuseCard); - this.game.piles.drawPile.insertCard(kittenCard, insertIndex); - - this.game.turnManager.endStage(); - this.game.turnManager.endTurn(); + this.game.piles.discardCard(defuseCard); + this.game.piles.drawPile.insertCard(kittenCard, insertIndex); + + this.game.turnManager.endStage(); + this.game.turnManager.endTurn(); } stealRandomCardFrom(target: Player): Card { - const count = target.cardCount; - if (count === 0) throw new Error("Target has no cards"); - - // Use game context random - const index = Math.floor(this.game.random.Number() * count); - // Give card from target to this player - return target.giveCard(index, this); + const count = target.cardCount; + if (count === 0) throw new Error("Target has no cards"); + + // Use game context random + const index = Math.floor(this.game.random.Number() * count); + // Give card from target to this player + return target.giveCard(index, this); } private updateHandSize() { From 9513cc0e6d016d0ba284d467845c0c213933ea97 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:41:14 +0100 Subject: [PATCH 16/55] refactor: remove unnecessary whitespace in original-deck.ts --- src/common/entities/deck-types/original-deck.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/common/entities/deck-types/original-deck.ts b/src/common/entities/deck-types/original-deck.ts index b6c6884..d248b0e 100644 --- a/src/common/entities/deck-types/original-deck.ts +++ b/src/common/entities/deck-types/original-deck.ts @@ -57,7 +57,6 @@ export class OriginalDeck extends DeckType { } // add amount of players minus one exploding kitten - for (let i = 0; i < playerCount - 1; i++) { // after index 3 restart at 0, since there are only 4 unique exploding kitten cards const cardIndex = (playerCount - 1 + i) % 4; From 9d2abb95b6adb140f9192137c793791273aa0547 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:46:44 +0100 Subject: [PATCH 17/55] refactor: add numMoves getter to turn manager --- src/common/entities/turn-manager.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/common/entities/turn-manager.ts b/src/common/entities/turn-manager.ts index b3550a4..0266e07 100644 --- a/src/common/entities/turn-manager.ts +++ b/src/common/entities/turn-manager.ts @@ -21,6 +21,10 @@ export class TurnManager { return this.context.ctx.playOrderPos; } + get numMoves(): number | undefined { + return this.context.ctx.numMoves || 0; + } + get activePlayers(): Record | null { return this.context.ctx.activePlayers as Record | null; } From 6bac658608d5e7dd9fd60a832d13aee637aa4328 Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 23 Mar 2026 19:20:55 +0100 Subject: [PATCH 18/55] refactor: reorganize overlay imports and enhance card access method --- src/client/components/board/overlay/BoardOverlays.tsx | 10 +++++----- .../overlay/{dead-overlay => dead}/DeadOverlay.css | 0 .../overlay/{dead-overlay => dead}/DeadOverlay.tsx | 0 .../overlay/{lobby-overlay => lobby}/LobbyOverlay.css | 0 .../overlay/{lobby-overlay => lobby}/LobbyOverlay.tsx | 0 .../SeeTheFutureOverlay.css | 0 .../SeeTheFutureOverlay.tsx | 4 ++-- .../SpecialActionOverlay.css | 0 .../SpecialActionOverlay.tsx | 0 .../{winner-overlay => winner}/WinnerOverlay.css | 0 .../{winner-overlay => winner}/WinnerOverlay.tsx | 0 src/client/components/board/player/card/Card.tsx | 2 +- src/common/entities/pile.ts | 4 ++++ 13 files changed, 12 insertions(+), 8 deletions(-) rename src/client/components/board/overlay/{dead-overlay => dead}/DeadOverlay.css (100%) rename src/client/components/board/overlay/{dead-overlay => dead}/DeadOverlay.tsx (100%) rename src/client/components/board/overlay/{lobby-overlay => lobby}/LobbyOverlay.css (100%) rename src/client/components/board/overlay/{lobby-overlay => lobby}/LobbyOverlay.tsx (100%) rename src/client/components/board/overlay/{see-future-overlay => see-future}/SeeTheFutureOverlay.css (100%) rename src/client/components/board/overlay/{see-future-overlay => see-future}/SeeTheFutureOverlay.tsx (91%) rename src/client/components/board/overlay/{special-action-overlay => special-action}/SpecialActionOverlay.css (100%) rename src/client/components/board/overlay/{special-action-overlay => special-action}/SpecialActionOverlay.tsx (100%) rename src/client/components/board/overlay/{winner-overlay => winner}/WinnerOverlay.css (100%) rename src/client/components/board/overlay/{winner-overlay => winner}/WinnerOverlay.tsx (100%) diff --git a/src/client/components/board/overlay/BoardOverlays.tsx b/src/client/components/board/overlay/BoardOverlays.tsx index 362bc36..f8c4b54 100644 --- a/src/client/components/board/overlay/BoardOverlays.tsx +++ b/src/client/components/board/overlay/BoardOverlays.tsx @@ -1,14 +1,14 @@ -import WinnerOverlay from './winner-overlay/WinnerOverlay.tsx'; -import DeadOverlay from './dead-overlay/DeadOverlay.tsx'; -import SpecialActionOverlay from './special-action-overlay/SpecialActionOverlay.tsx'; -import SeeTheFutureOverlay from './see-future-overlay/SeeTheFutureOverlay.tsx'; +import WinnerOverlay from './winner/WinnerOverlay.tsx'; +import DeadOverlay from './dead/DeadOverlay.tsx'; +import SpecialActionOverlay from './special-action/SpecialActionOverlay.tsx'; +import SeeTheFutureOverlay from './see-future/SeeTheFutureOverlay.tsx'; import {useGame} from "../../../context/GameContext.tsx"; import { CHOOSE_CARD_TO_GIVE, CHOOSE_PLAYER_TO_REQUEST_FROM, CHOOSE_PLAYER_TO_STEAL_FROM } from "../../../../common/constants/stages.ts"; -import LobbyOverlay from "./lobby-overlay/LobbyOverlay.tsx"; +import LobbyOverlay from "./lobby/LobbyOverlay.tsx"; /** * Manages and renders all game overlays diff --git a/src/client/components/board/overlay/dead-overlay/DeadOverlay.css b/src/client/components/board/overlay/dead/DeadOverlay.css similarity index 100% rename from src/client/components/board/overlay/dead-overlay/DeadOverlay.css rename to src/client/components/board/overlay/dead/DeadOverlay.css diff --git a/src/client/components/board/overlay/dead-overlay/DeadOverlay.tsx b/src/client/components/board/overlay/dead/DeadOverlay.tsx similarity index 100% rename from src/client/components/board/overlay/dead-overlay/DeadOverlay.tsx rename to src/client/components/board/overlay/dead/DeadOverlay.tsx diff --git a/src/client/components/board/overlay/lobby-overlay/LobbyOverlay.css b/src/client/components/board/overlay/lobby/LobbyOverlay.css similarity index 100% rename from src/client/components/board/overlay/lobby-overlay/LobbyOverlay.css rename to src/client/components/board/overlay/lobby/LobbyOverlay.css diff --git a/src/client/components/board/overlay/lobby-overlay/LobbyOverlay.tsx b/src/client/components/board/overlay/lobby/LobbyOverlay.tsx similarity index 100% rename from src/client/components/board/overlay/lobby-overlay/LobbyOverlay.tsx rename to src/client/components/board/overlay/lobby/LobbyOverlay.tsx diff --git a/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.css b/src/client/components/board/overlay/see-future/SeeTheFutureOverlay.css similarity index 100% rename from src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.css rename to src/client/components/board/overlay/see-future/SeeTheFutureOverlay.css diff --git a/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx b/src/client/components/board/overlay/see-future/SeeTheFutureOverlay.tsx similarity index 91% rename from src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx rename to src/client/components/board/overlay/see-future/SeeTheFutureOverlay.tsx index eb14b0b..f809652 100644 --- a/src/client/components/board/overlay/see-future-overlay/SeeTheFutureOverlay.tsx +++ b/src/client/components/board/overlay/see-future/SeeTheFutureOverlay.tsx @@ -11,8 +11,8 @@ export default function SeeTheFutureOverlay() { return null; } - // Get the top 3 card-types from the draw pile for the see the future overlay - const cards: Card[] = game.piles.drawPile.peek(3); + // Get the top ard-types from the draw pile for the see the future overlay that are visible + const cards: Card[] = game.piles.drawPile.allCards; if (!cards || cards.length === 0) { console.error("No cards available to see in the future! This shouldn't happen."); diff --git a/src/client/components/board/overlay/special-action-overlay/SpecialActionOverlay.css b/src/client/components/board/overlay/special-action/SpecialActionOverlay.css similarity index 100% rename from src/client/components/board/overlay/special-action-overlay/SpecialActionOverlay.css rename to src/client/components/board/overlay/special-action/SpecialActionOverlay.css diff --git a/src/client/components/board/overlay/special-action-overlay/SpecialActionOverlay.tsx b/src/client/components/board/overlay/special-action/SpecialActionOverlay.tsx similarity index 100% rename from src/client/components/board/overlay/special-action-overlay/SpecialActionOverlay.tsx rename to src/client/components/board/overlay/special-action/SpecialActionOverlay.tsx diff --git a/src/client/components/board/overlay/winner-overlay/WinnerOverlay.css b/src/client/components/board/overlay/winner/WinnerOverlay.css similarity index 100% rename from src/client/components/board/overlay/winner-overlay/WinnerOverlay.css rename to src/client/components/board/overlay/winner/WinnerOverlay.css diff --git a/src/client/components/board/overlay/winner-overlay/WinnerOverlay.tsx b/src/client/components/board/overlay/winner/WinnerOverlay.tsx similarity index 100% rename from src/client/components/board/overlay/winner-overlay/WinnerOverlay.tsx rename to src/client/components/board/overlay/winner/WinnerOverlay.tsx diff --git a/src/client/components/board/player/card/Card.tsx b/src/client/components/board/player/card/Card.tsx index f8a1064..b64b553 100644 --- a/src/client/components/board/player/card/Card.tsx +++ b/src/client/components/board/player/card/Card.tsx @@ -34,7 +34,7 @@ export default function Card({ const cardImage = TheGameClient.getCardTexture(card); - const couldBePlayed = game.isSelf(owner) && ((card?.serverIndex && game.canPlayCard(card?.serverIndex)) || game.canGiveCard()); + const couldBePlayed = game.isSelf(owner) && ((card && game.canPlayCard(card.serverIndex)) || game.canGiveCard()); const handleAction = () => { if (!card) { diff --git a/src/common/entities/pile.ts b/src/common/entities/pile.ts index 576f3a7..ee0a568 100644 --- a/src/common/entities/pile.ts +++ b/src/common/entities/pile.ts @@ -9,6 +9,10 @@ export class Pile { this.cards = state.cards; } + get allCards(): Card[] { + return this.cards.map(iCard => new Card(this.game, iCard)); + } + addCard(card: Card | ICard): void { const cardData: ICard = {name: card.name, index: card.index}; From 84df595e8cba111e42c8e58baf7f4b8cd23d04db Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 24 Mar 2026 08:10:45 +0100 Subject: [PATCH 19/55] refactor: implement defuse overlay --- src/client/animations/useCardAnimations.tsx | 4 +- .../board/overlay/BoardOverlays.tsx | 6 +- .../board/overlay/dead/DeadOverlay.tsx | 2 +- .../board/overlay/defuse/DefuseOverlay.css | 230 ++++++++++++++++++ .../board/overlay/defuse/DefuseOverlay.tsx | 169 +++++++++++++ .../see-future/SeeTheFutureOverlay.tsx | 2 +- .../components/board/player/card/Card.tsx | 2 +- .../board/player/player-area/Player.tsx | 4 +- .../board/player/player-cards/PlayerCards.tsx | 2 +- src/client/entities/game-client.ts | 6 + src/common/entities/card-types/cat-card.ts | 2 +- src/common/entities/card-types/defuse-card.ts | 2 +- .../card-types/exploding-kitten-card.ts | 4 + src/common/entities/card-types/favor-card.ts | 2 +- src/common/entities/card-types/nope-card.ts | 2 +- .../entities/deck-types/original-deck.ts | 3 +- src/common/entities/player.ts | 31 +-- src/common/entities/players.ts | 2 +- src/common/game.ts | 6 +- src/common/setup/player-setup.ts | 7 +- 20 files changed, 451 insertions(+), 37 deletions(-) create mode 100644 src/client/components/board/overlay/defuse/DefuseOverlay.css create mode 100644 src/client/components/board/overlay/defuse/DefuseOverlay.tsx diff --git a/src/client/animations/useCardAnimations.tsx b/src/client/animations/useCardAnimations.tsx index a5379ef..f90f1b9 100644 --- a/src/client/animations/useCardAnimations.tsx +++ b/src/client/animations/useCardAnimations.tsx @@ -12,7 +12,7 @@ const takeSnapshot = (game: TheGameClient, selfPlayerId: string | null): StateSn drawSize: game.piles.drawPile.size, discardSize: game.piles.discardPile.size, discardTop: game.piles.discardPile.topCard, - handCounts: Object.fromEntries(game.players.allPlayers.map(p => [p.id, p.cardCount])), + handCounts: Object.fromEntries(game.players.allPlayers.map(p => [p.id, p.handSize])), selfHand: [...(game.players.allPlayers.find(p => p.id === selfPlayerId)?.hand ?? [])], }); @@ -54,7 +54,7 @@ export const useCardAnimations = (game: TheGameClient) => { // Fine-grained dependency keys so the effect fires even when // game.players is the same object reference but hand contents changed - const handCountKey = game.players.allPlayers.map(p => `${p.id}:${p.cardCount}`).join(','); + const handCountKey = game.players.allPlayers.map(p => `${p.id}:${p.handSize}`).join(','); const selfHandKey = game.players.allPlayers .find(p => p.id === selfPlayerId)?.hand .map(c => `${c.name}:${c.index}`).join(',') ?? ''; diff --git a/src/client/components/board/overlay/BoardOverlays.tsx b/src/client/components/board/overlay/BoardOverlays.tsx index f8c4b54..4cdb1b9 100644 --- a/src/client/components/board/overlay/BoardOverlays.tsx +++ b/src/client/components/board/overlay/BoardOverlays.tsx @@ -2,6 +2,7 @@ import WinnerOverlay from './winner/WinnerOverlay.tsx'; import DeadOverlay from './dead/DeadOverlay.tsx'; import SpecialActionOverlay from './special-action/SpecialActionOverlay.tsx'; import SeeTheFutureOverlay from './see-future/SeeTheFutureOverlay.tsx'; +import DefuseOverlay from './defuse/DefuseOverlay.tsx'; import {useGame} from "../../../context/GameContext.tsx"; import { CHOOSE_CARD_TO_GIVE, @@ -26,6 +27,8 @@ export default function BoardOverlays() { return ( <> + + {selectionMessage && ( ) @@ -34,8 +37,7 @@ export default function BoardOverlays() { )} - - + ); } diff --git a/src/client/components/board/overlay/dead/DeadOverlay.tsx b/src/client/components/board/overlay/dead/DeadOverlay.tsx index 4d97d62..881d75a 100644 --- a/src/client/components/board/overlay/dead/DeadOverlay.tsx +++ b/src/client/components/board/overlay/dead/DeadOverlay.tsx @@ -4,7 +4,7 @@ import {useGame} from "../../../../context/GameContext.tsx"; export default function DeadOverlay() { const game = useGame(); - if (game.isSelfAlive) { + if (game.isSelfAlive || !game.selfPlayer) { return null; } diff --git a/src/client/components/board/overlay/defuse/DefuseOverlay.css b/src/client/components/board/overlay/defuse/DefuseOverlay.css new file mode 100644 index 0000000..ac82141 --- /dev/null +++ b/src/client/components/board/overlay/defuse/DefuseOverlay.css @@ -0,0 +1,230 @@ +.defuse-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: var(--color-overlay-dark); + backdrop-filter: blur(10px); + display: flex; + justify-content: center; + align-items: center; + z-index: 2000; + animation: fadeIn 0.3s ease-in-out; +} + +.defuse-content { + background: var(--color-bg-dark); + border: 3px solid var(--color-accent); + border-radius: 20px; + padding: 2.5rem; + box-shadow: 0 10px 60px var(--color-shadow-dark), + 0 0 30px var(--color-accent-glow-medium); + display: flex; + flex-direction: column; + align-items: center; + color: var(--color-text-light); + min-width: 450px; + max-width: 90vw; /* Responsive width */ + animation: slideUp 0.3s ease-out; +} + +.defuse-content h2 { + margin: 0; + color: var(--color-accent); + font-size: 2rem; + text-transform: uppercase; + letter-spacing: 2px; + text-shadow: 0 0 10px var(--color-accent-glow-medium); +} + +.defuse-content p { + color: var(--color-text-light-dim); + margin: 0.5rem 0 2rem 0; +} + +.defuse-interface { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 20px; + margin-bottom: 2rem; + height: 350px; /* Fixed height for the interaction area */ + position: relative; + width: 100%; +} + +/* Depth Markers (Left) */ +.depth-markers { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + color: var(--color-text-light-dim); + font-size: 0.8rem; + padding-right: 10px; + border-right: 1px dashed var(--color-border-dim); +} + +.marker-label { + text-transform: uppercase; + letter-spacing: 1px; + font-weight: bold; +} + +.marker-label.mid { + flex: 1; + display: flex; + align-items: center; +} +.marker-line { + width: 10px; + height: 1px; + background: var(--color-border-dim); +} + +/* The Track (Center) */ +.depth-track-container { + height: 100%; /* Matches interface height */ + width: 60px; /* Width of the hittable area */ + position: relative; + cursor: pointer; + background: rgba(0,0,0,0.2); + border-radius: 30px; + box-shadow: inset 0 0 10px rgba(0,0,0,0.3); +} + +.depth-track-rail { + position: absolute; + top: 10px; + bottom: 10px; + left: 50%; + width: 4px; + transform: translateX(-50%); + background: linear-gradient(to bottom, + var(--color-accent) 0%, + var(--color-bg-medium) 100%); + border-radius: 2px; + box-shadow: 0 0 10px var(--color-accent-glow-light); +} + +/* The Draggable Thumb */ +.card-thumb { + position: absolute; + left: 50%; + transform: translate(-50%, -50%); /* Center on the point */ + width: 80px; + pointer-events: auto; /* Changed from none to auto */ + cursor: grab; /* Add grab cursor */ + display: flex; + align-items: center; + transition: transform 0.1s ease; + z-index: 10; +} + +.card-thumb.dragging { + transform: translate(-50%, -50%) scale(1.1); + cursor: grabbing; /* Add grabbing cursor while dragging */ +} + +.thumb-graphic { + width: 60px; + height: 84px; /* Card Ratio */ + background: var(--color-bg-dark); + border: 2px solid var(--color-accent); + border-radius: 6px; + overflow: hidden; + box-shadow: 0 5px 15px rgba(0,0,0,0.5); + position: relative; + z-index: 2; +} + +.thumb-graphic img { + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; /* Prevent native image drag */ + user-select: none; /* Prevent text selection on drag */ +} + +/* Arrow Design */ +.thumb-arrow { + position: absolute; + left: -15px; /* Stick out left pointing to track */ + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + border-right: 15px solid var(--color-accent); + display: none; /* Actually, let's hide this and focus on the card being on the rail */ +} + +/* Tooltip floating next to thumb */ +.thumb-tooltip { + position: absolute; + left: 100%; + top: 50%; + transform: translateY(-50%); + margin-left: 15px; /* Gap */ + background: var(--color-accent); + color: var(--color-bg-dark); + padding: 0.5rem 1rem; + border-radius: 6px; + white-space: nowrap; + font-weight: bold; + font-size: 1rem; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + + /* Little arrow for tooltip */ + /* ... pseudo element ... */ +} + +.thumb-tooltip::before { + content: ''; + position: absolute; + left: -6px; + top: 50%; + transform: translateY(-50%); + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 6px solid var(--color-accent); +} + +.info-panel { + width: 80px; /* Space for badges on right? Or integrated? */ + height: 100%; + display: flex; + justify-content: center; + align-items: center; + /* Actually we used tooltip for main info, so this is extra decor */ + opacity: 0.8; +} + +.risk-badge { + padding: 0.5rem; + border-radius: 4px; + font-weight: bold; + text-transform: uppercase; + font-size: 0.8rem; + letter-spacing: 1px; + writing-mode: vertical-rl; + text-orientation: mixed; + height: 100px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid currentColor; +} + +.risk-badge.high { color: #ff4757; text-shadow: 0 0 5px #ff4757; } +.risk-badge.medium { color: #ffa502; } +.risk-badge.low { color: #2ed573; } + +.defuse-confirm-btn { + width: 100%; + margin-top: 1rem; +} diff --git a/src/client/components/board/overlay/defuse/DefuseOverlay.tsx b/src/client/components/board/overlay/defuse/DefuseOverlay.tsx new file mode 100644 index 0000000..1788644 --- /dev/null +++ b/src/client/components/board/overlay/defuse/DefuseOverlay.tsx @@ -0,0 +1,169 @@ +import { useState, useRef, useEffect } from 'react'; +import './DefuseOverlay.css'; +import { useGame } from '../../../../context/GameContext'; +import { DEFUSE_EXPLODING_KITTEN } from '../../../../../common/constants/stages'; + +export default function DefuseOverlay() { + const game = useGame(); + + const [insertIndex, setInsertIndex] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const trackRef = useRef(null); + + const cardAmount = game.piles.drawPile.size; + + // Set initial random position once + useEffect(() => { + if (insertIndex === null) { + setInsertIndex(Math.floor(Math.random() * (cardAmount + 1))); + } + }, [cardAmount, insertIndex]); + + const handleDefuse = () => { + if (insertIndex !== null) { + game.playDefuse(insertIndex); + } + }; + + const updatePosition = (clientY: number) => { + if (!trackRef.current) return; + const rect = trackRef.current.getBoundingClientRect(); + + // Calculate percentage based on Y position (Top = 0, Bottom = 1) + const relativeY = clientY - rect.top; + let percentage = relativeY / rect.height; + + // Clamp between 0 and 1 + percentage = Math.max(0, Math.min(1, percentage)); + + // Map to card index + const newIndex = Math.round(percentage * cardAmount); + setInsertIndex(newIndex); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + updatePosition(e.clientY); + }; + + const handleTouchStart = (e: React.TouchEvent) => { + setIsDragging(true); + updatePosition(e.touches[0].clientY); + }; + + // Add a handler for the thumb itself to start dragging + const handleThumbMouseDown = (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent the track's handler from firing + setIsDragging(true); + updatePosition(e.clientY); + }; + + const handleThumbTouchStart = (e: React.TouchEvent) => { + e.stopPropagation(); + setIsDragging(true); + updatePosition(e.touches[0].clientY); + }; + + // Global event listeners for dragging outside the element + useEffect(() => { + const handleMove = (e: MouseEvent) => { + if (isDragging) { + e.preventDefault(); + updatePosition(e.clientY); + } + }; + + const handleUp = () => { + setIsDragging(false); + }; + + const handleTouchMove = (e: TouchEvent) => { + if (isDragging) { + e.preventDefault(); // Prevent scrolling while dragging + updatePosition(e.touches[0].clientY); + } + }; + + if (isDragging) { + window.addEventListener('mousemove', handleMove, { passive: false }); + window.addEventListener('mouseup', handleUp); + window.addEventListener('touchmove', handleTouchMove, { passive: false }); + window.addEventListener('touchend', handleUp); + } + + return () => { + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleUp); + window.removeEventListener('touchmove', handleTouchMove); + window.removeEventListener('touchend', handleUp); + }; + }, [isDragging, cardAmount]); + + if (!game.selfPlayer?.isInStage(DEFUSE_EXPLODING_KITTEN)) { + return null; + } + + const currentIdx = insertIndex ?? 0; + const percentage = cardAmount > 0 ? (currentIdx / cardAmount) * 100 : 0; + + return ( +
+
+

Defuse Protocol

+

Select insertion depth for the Exploding Kitten.

+ +
+
+
Top Deck
+
+
+
+
Bottom
+
+ +
+
+ + {/* The "Thumb" / Card Indicator */} +
+
+ Kitten +
+
+ + {/* Tooltip moves with the thumb */} +
+ + {currentIdx === 0 ? "Top Card" : + currentIdx === cardAmount ? "Bottom" : + `${currentIdx} Deep`} + +
+
+
+ +
+ {/* Could add risk visualizer here later */} + {currentIdx === 0 &&
EXPLODE!
} + {currentIdx > 0 && currentIdx < cardAmount &&
Hidden
} + {currentIdx === cardAmount &&
Lame
} +
+
+ + +
+
+ ); +} diff --git a/src/client/components/board/overlay/see-future/SeeTheFutureOverlay.tsx b/src/client/components/board/overlay/see-future/SeeTheFutureOverlay.tsx index f809652..75f8fda 100644 --- a/src/client/components/board/overlay/see-future/SeeTheFutureOverlay.tsx +++ b/src/client/components/board/overlay/see-future/SeeTheFutureOverlay.tsx @@ -11,7 +11,7 @@ export default function SeeTheFutureOverlay() { return null; } - // Get the top ard-types from the draw pile for the see the future overlay that are visible + // Get the top cards from the draw pile that are visible const cards: Card[] = game.piles.drawPile.allCards; if (!cards || cards.length === 0) { diff --git a/src/client/components/board/player/card/Card.tsx b/src/client/components/board/player/card/Card.tsx index b64b553..12af23e 100644 --- a/src/client/components/board/player/card/Card.tsx +++ b/src/client/components/board/player/card/Card.tsx @@ -62,7 +62,7 @@ export default function Card({ position: 'absolute', '--base-transform': `translate(${offsetX}%, ${offsetY}%) rotate(${angle}deg)`, transformOrigin: 'center 200%', - zIndex: owner.cardCount - index, + zIndex: owner.handSize - index, } as CSSProperties} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} diff --git a/src/client/components/board/player/player-area/Player.tsx b/src/client/components/board/player/player-area/Player.tsx index d777ac8..a846e86 100644 --- a/src/client/components/board/player/player-area/Player.tsx +++ b/src/client/components/board/player/player-area/Player.tsx @@ -74,7 +74,7 @@ export default function Player({ }} onClick={handleInteract} data-player-id={playerId} - data-hand-count={player.cardCount} + data-hand-count={player.handSize} data-animation-id={`player-${playerId}`} >
@@ -83,7 +83,7 @@ export default function Player({ {isSelf && ' (You)'}
- Cards: {player.cardCount} + Cards: {player.handSize}
diff --git a/src/client/components/board/player/player-cards/PlayerCards.tsx b/src/client/components/board/player/player-cards/PlayerCards.tsx index 68e8e3c..908b6fb 100644 --- a/src/client/components/board/player/player-cards/PlayerCards.tsx +++ b/src/client/components/board/player/player-cards/PlayerCards.tsx @@ -16,7 +16,7 @@ export default function PlayerCards({ }: PlayerCardsProps) { const game = useGame(); - const cardCount = player.cardCount; + const cardCount = player.handSize; const fanSpread = game.isSpectator || game.isSelf(player) ? Math.min(cardCount * 6, 60) : Math.min(cardCount * 4, 40); const angleStep = cardCount > 1 ? fanSpread / (cardCount - 1) : 0; const baseOffset = cardCount > 1 ? -fanSpread / 2 : 0; diff --git a/src/client/entities/game-client.ts b/src/client/entities/game-client.ts index d58d481..f33f88e 100644 --- a/src/client/entities/game-client.ts +++ b/src/client/entities/game-client.ts @@ -76,6 +76,12 @@ export class TheGameClient extends TheGame { } } + playDefuse(index: number) { + if (this.selfPlayer && this.moves.defuseExplodingKitten) { + this.moves.defuseExplodingKitten(index); + } + } + selectCard(cardIndex: number) { if (!this.selfPlayer) { console.error("No self player found"); diff --git a/src/common/entities/card-types/cat-card.ts b/src/common/entities/card-types/cat-card.ts index 324bfa6..829b922 100644 --- a/src/common/entities/card-types/cat-card.ts +++ b/src/common/entities/card-types/cat-card.ts @@ -55,6 +55,6 @@ export class CatCard extends CardType { } sortOrder(): number { - return 98; + return 6; } } diff --git a/src/common/entities/card-types/defuse-card.ts b/src/common/entities/card-types/defuse-card.ts index 96e816f..69bae75 100644 --- a/src/common/entities/card-types/defuse-card.ts +++ b/src/common/entities/card-types/defuse-card.ts @@ -14,6 +14,6 @@ export class DefuseCard extends CardType { sortOrder(): number { - return 99; + return 100; } } diff --git a/src/common/entities/card-types/exploding-kitten-card.ts b/src/common/entities/card-types/exploding-kitten-card.ts index 42625e2..b24f8aa 100644 --- a/src/common/entities/card-types/exploding-kitten-card.ts +++ b/src/common/entities/card-types/exploding-kitten-card.ts @@ -5,4 +5,8 @@ export class ExplodingKittenCard extends CardType { constructor(name: string) { super(name); } + + sortOrder(): number { + return 0; + } } diff --git a/src/common/entities/card-types/favor-card.ts b/src/common/entities/card-types/favor-card.ts index 0b49117..a928184 100644 --- a/src/common/entities/card-types/favor-card.ts +++ b/src/common/entities/card-types/favor-card.ts @@ -18,7 +18,7 @@ export class FavorCard extends CardType { const { ctx } = game.context; const candidates = game.players.allPlayers.filter((target) => { - return target.id !== ctx.currentPlayer && target.isAlive && target.cardCount > 0; + return target.id !== ctx.currentPlayer && target.isAlive && target.handSize > 0; }); if (candidates.length === 1) { diff --git a/src/common/entities/card-types/nope-card.ts b/src/common/entities/card-types/nope-card.ts index 4c11133..568b4f1 100644 --- a/src/common/entities/card-types/nope-card.ts +++ b/src/common/entities/card-types/nope-card.ts @@ -37,6 +37,6 @@ export class NopeCard extends CardType { } sortOrder(): number { - return 6; + return 99; } } diff --git a/src/common/entities/deck-types/original-deck.ts b/src/common/entities/deck-types/original-deck.ts index d248b0e..200fdb1 100644 --- a/src/common/entities/deck-types/original-deck.ts +++ b/src/common/entities/deck-types/original-deck.ts @@ -53,7 +53,8 @@ export class OriginalDeck extends DeckType { const remaining = Math.min(TOTAL_DEFUSE_CARDS - playerCount, MAX_DECK_DEFUSE_CARDS); for (let i = 0; i < remaining; i++) { - pile.push(DEFUSE.createCard(playerCount - 1 + i)); + const cardIndex = (playerCount - 1 + i) % 5; + pile.push(DEFUSE.createCard(cardIndex)); } // add amount of players minus one exploding kitten diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index a566c14..19076bf 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -2,7 +2,7 @@ import {ICard, IPlayer} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; import {EXPLODING_KITTEN, DEFUSE} from "../registries/card-registry"; -import {CHOOSE_PLAYER_TO_REQUEST_FROM} from "../constants/stages"; +import {CHOOSE_PLAYER_TO_REQUEST_FROM, DEFUSE_EXPLODING_KITTEN} from "../constants/stages"; import {PlayerID} from "boardgame.io"; import {NAME_NOPE} from "../constants/cards"; @@ -30,7 +30,7 @@ export class Player { /** * Get the count of card-types in hand */ - get cardCount(): number { + get handSize(): number { return this._state.handSize; } @@ -43,7 +43,7 @@ export class Player { } get isValidCardTarget(): boolean { - return this.isAlive && this.cardCount > 0; + return this.isAlive && this.handSize > 0; } get canNope(): boolean { @@ -252,18 +252,21 @@ export class Player { this.addCard(cardData); - if (cardData.name === EXPLODING_KITTEN.name) { - const hasDefuse = this.hasCard(DEFUSE.name); - if (hasDefuse) { - const insertIndex = Math.floor(this.game.random.Number() * (this.game.piles.drawPile.size)); - this.game.players.actingPlayer.defuseExplodingKitten(insertIndex); // for now put at random location - // this.game.turnManager.setStage(DEFUSE_EXPLODING_KITTEN); // TODO: implement stage clientside - } else { - this.eliminate(); - } - } else { + if (cardData.name !== EXPLODING_KITTEN.name) { this.game.turnManager.endTurn(); + return; + } + + if (!this.hasCard(DEFUSE.name)) { + this.eliminate(); + return; + } + + if (this.game.piles.drawPile.size <= 0) { + this.defuseExplodingKitten(0); // if no cards left, just put it back on top since it doesn't matter + return; } + this.game.turnManager.setStage(DEFUSE_EXPLODING_KITTEN); } defuseExplodingKitten(insertIndex: number): void { @@ -288,7 +291,7 @@ export class Player { } stealRandomCardFrom(target: Player): Card { - const count = target.cardCount; + const count = target.handSize; if (count === 0) throw new Error("Target has no cards"); // Use game context random diff --git a/src/common/entities/players.ts b/src/common/entities/players.ts index 9c38e1f..a5cf474 100644 --- a/src/common/entities/players.ts +++ b/src/common/entities/players.ts @@ -93,7 +93,7 @@ export class Players { * Get all alive players who have at least one card in hand */ get playersWithCards(): Player[] { - return this.alivePlayers.filter(player => player.cardCount > 0); + return this.alivePlayers.filter(player => player.handSize > 0); } /** diff --git a/src/common/game.ts b/src/common/game.ts index fd5a36f..bc8bbd3 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -53,8 +53,8 @@ export const ExplodingKittens: Game = { const deck = new OriginalDeck(); const pile: ICard[] = deck.buildBaseDeck().sort(() => Math.random() - 0.5); - dealHands(pile, game.context.player.state, deck); // TODO: use api wrapper - deck.addPostDealCards(pile, Object.keys(game.context.ctx.playOrder).length); // TODO: use api wrapper + dealHands(pile, game.context.player.state, deck); + deck.addPostDealCards(pile, Object.keys(game.players.playerCount).length); game.piles.drawPile = pile; game.piles.drawPile.shuffle(); @@ -100,7 +100,6 @@ export const ExplodingKittens: Game = { moves: { defuseExplodingKitten: { move: inGame(defuseExplodingKitten), - client: false }, } }, @@ -116,7 +115,6 @@ export const ExplodingKittens: Game = { moves: { requestCard: { move: inGame(requestCard), - client: false }, }, }, diff --git a/src/common/setup/player-setup.ts b/src/common/setup/player-setup.ts index 7b85ec2..a518530 100644 --- a/src/common/setup/player-setup.ts +++ b/src/common/setup/player-setup.ts @@ -3,6 +3,7 @@ import type {DeckType} from '../entities/deck-type'; import {cardTypeRegistry} from "../registries/card-registry"; import {CardType} from "../entities/card-type"; import {TheGame} from "../entities/game"; +import {NAME_EXPLODING_KITTEN} from "../constants/cards"; export const createPlayerState = (): IPlayer => ({ hand: [], @@ -17,11 +18,11 @@ const createFullPlayerView = (player: IPlayer): IPlayer => player; /** * Create a limited view of a player (used for opponent views) + * Show exploding kittens but remove the other cards */ const createLimitedPlayerView = (player: IPlayer): IPlayer => ({ - hand: [], - handSize: player.hand.length, - isAlive: player.isAlive + ...player, + hand: player.hand.filter(c => c.name === NAME_EXPLODING_KITTEN) }); /** From effaa029a522a19363dca48018ceb2a239c13deb Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 24 Mar 2026 08:30:51 +0100 Subject: [PATCH 20/55] refactor: streamline player name retrieval and improve match data handling --- .../board/game-status/GameStatusList.tsx | 4 +-- .../board/player/player-area/Player.tsx | 6 ++-- .../board/player/player-list/PlayerList.tsx | 1 - src/client/components/lobby/MatchCard.tsx | 6 ++-- src/client/context/MatchDetailsContext.tsx | 5 ++- src/client/utils/matchData.ts | 33 ++++++++----------- 6 files changed, 22 insertions(+), 33 deletions(-) diff --git a/src/client/components/board/game-status/GameStatusList.tsx b/src/client/components/board/game-status/GameStatusList.tsx index 11774da..370fcd8 100644 --- a/src/client/components/board/game-status/GameStatusList.tsx +++ b/src/client/components/board/game-status/GameStatusList.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import './GameStatusList.css'; import { useResponsive } from '../../../context/ResponsiveContext'; import {useGame} from "../../../context/GameContext.tsx"; +import {getPlayerName} from "../../../utils/matchData.ts"; export default function GameStatusList() { const game = useGame(); @@ -49,7 +50,6 @@ export default function GameStatusList() { const matchInfo = matchPlayers.find(p => p.id === player.id); const isEmpty = !(matchInfo && matchInfo.name) const isConnected = matchInfo ? (matchInfo.isConnected ?? true) : false; - const name = isEmpty ? 'Empty Seat' : matchInfo.name; return (
- {name} {game.isSelf(player) ? '(You)' : ''} + {getPlayerName(matchInfo)} {game.isSelf(player) ? '(You)' : ''} {!player.isAlive && ( diff --git a/src/client/components/board/player/player-area/Player.tsx b/src/client/components/board/player/player-area/Player.tsx index a846e86..9ac99d0 100644 --- a/src/client/components/board/player/player-area/Player.tsx +++ b/src/client/components/board/player/player-area/Player.tsx @@ -1,6 +1,6 @@ import './Player.css'; import PlayerCards from '../player-cards/PlayerCards.tsx'; -import {MatchPlayer, getPlayerName} from "../../../../utils/matchData.ts"; +import {getPlayerName} from "../../../../utils/matchData.ts"; import {PlayerPosition} from "../../../../types/component-props.ts"; import {useGame} from "../../../../context/GameContext.tsx"; import {Player as PlayerModel} from "../../../../../common"; @@ -13,13 +13,11 @@ import { interface PlayerAreaProps { player: PlayerModel; position: PlayerPosition; - matchData?: MatchPlayer[]; } export default function Player({ player, position, - matchData, }: PlayerAreaProps) { const game = useGame(); const selfPlayer = game.selfPlayer; @@ -36,7 +34,7 @@ export default function Player({ const { cardPosition, infoPosition } = position; const cardRotation = cardPosition.angle - 90; - const playerName = getPlayerName(playerId, matchData); + const playerName = getPlayerName(playerId); const extraClasses = `${isSelf ? 'hand-interactable self' : ''} ${isTurn ? 'turn' : ''} ${isSelectable ? 'selectable' : ''} ${isWaitingOn ? 'waiting-on' : ''}` diff --git a/src/client/components/board/player/player-list/PlayerList.tsx b/src/client/components/board/player/player-list/PlayerList.tsx index 1ce4560..68efabd 100644 --- a/src/client/components/board/player/player-list/PlayerList.tsx +++ b/src/client/components/board/player/player-list/PlayerList.tsx @@ -27,7 +27,6 @@ export default function PlayerList() { player={player} key={player.id} position={{ cardPosition, infoPosition }} - matchData={game.matchData} /> ); })} diff --git a/src/client/components/lobby/MatchCard.tsx b/src/client/components/lobby/MatchCard.tsx index 430b43c..3ee7fe4 100644 --- a/src/client/components/lobby/MatchCard.tsx +++ b/src/client/components/lobby/MatchCard.tsx @@ -1,5 +1,5 @@ import './MatchCard.css'; -import {MatchPlayer} from '../../utils/matchData'; +import {getPlayerName, MatchPlayer} from '../../utils/matchData'; interface MatchSetupData { matchName: string; @@ -41,10 +41,10 @@ export function MatchCard({matchID, matchName, players, setupData, onJoin}: Matc
- {players[i]?.isConnected ? players[i]?.name || "Unknown" : `Empty Seat`} + {getPlayerName(players[i]?.id || null)}
))} diff --git a/src/client/context/MatchDetailsContext.tsx b/src/client/context/MatchDetailsContext.tsx index b78d031..9dc55eb 100644 --- a/src/client/context/MatchDetailsContext.tsx +++ b/src/client/context/MatchDetailsContext.tsx @@ -7,7 +7,6 @@ export interface MatchDetails { players: MatchPlayer[]; numPlayers: number; matchName: string; - gameOver?: any; } interface MatchDetailsContextType { @@ -41,8 +40,9 @@ export function MatchDetailsProvider({ matchID, children }: MatchDetailsProvider const lobbyClient = new LobbyClient({ server: SERVER_URL }); const match = await lobbyClient.getMatch(GAME_NAME, matchID); + const matchPlayers: MatchPlayer[] = match.players.map((p: any) => ({ - id: p.id, + id: String(p.id), name: p.name, isConnected: p.isConnected, })); @@ -52,7 +52,6 @@ export function MatchDetailsProvider({ matchID, children }: MatchDetailsProvider matchName: match.setupData?.matchName || 'Match', numPlayers: match.setupData?.maxPlayers || match.players.length, players: matchPlayers, - gameOver: match.gameover, }); setError(null); } catch (err: any) { diff --git a/src/client/utils/matchData.ts b/src/client/utils/matchData.ts index b6e0a9a..65c6d48 100644 --- a/src/client/utils/matchData.ts +++ b/src/client/utils/matchData.ts @@ -1,9 +1,13 @@ /** * Utility functions for working with matchData */ +import {useMatchDetails} from "../context/MatchDetailsContext.tsx"; +import {PlayerID} from "boardgame.io"; + +const EMPTY_NAME = "Empty Seat"; export interface MatchPlayer { - id: string; + id: PlayerID; name?: string; isConnected?: boolean; } @@ -13,26 +17,15 @@ export interface MatchPlayer { * Returns "Player X" if matchData is not available * Returns "Empty Seat" if matchData is available but player name is missing */ -export function getPlayerName(playerId: string | null, matchData?: MatchPlayer[]): string { - if (!playerId) return 'Unknown Player'; - - if (!matchData || matchData.length === 0) { - return `Player`; - } +export function getPlayerName(player: MatchPlayer | string | null | undefined): string { + if (!player) return EMPTY_NAME; - const player = matchData.find(p => p.id === playerId); - - // If player object exists but has no name, it's an empty seat - if (player && !player.name) { - return "Empty Seat"; - } - - // If player object doesn't exist in matchData (but matchData exists), it's likely an empty seat too - if (!player) { - // Check if the ID is within the bounds of matchData (if matchData represents all seats) - // Assuming matchData is the full list including empty seats - return "Empty Seat"; + if (typeof player === 'string') { + const { matchDetails } = useMatchDetails(); + const players = matchDetails?.players; + const playerObj = players?.find(p => p.id === player); + return playerObj?.name || EMPTY_NAME; } - return player.name!; + return player?.name || EMPTY_NAME; } From 2ca2db4214735cd801b24c5f26ed54b3de245efd Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 24 Mar 2026 08:48:49 +0100 Subject: [PATCH 21/55] refactor: rename boardgame game file --- AGENTS.md | 6 +++--- src/common/{game.ts => exploding-kittens.ts} | 0 src/common/index.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/common/{game.ts => exploding-kittens.ts} (100%) diff --git a/AGENTS.md b/AGENTS.md index ca3b274..a6a14c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,9 +11,9 @@ This project implements Exploding Kittens using **React** (frontend) and **board ### Game State Management The project uses a custom wrapper pattern over standard `boardgame.io` state (`G` and `ctx`): -- **`TheGame` Class** (`src/common/entities/game.ts`): Wraps the raw context. Always instantiate this to interact with game state. +- **`TheGame` Class** (`src/common/entities/exploding-kittens.ts`): Wraps the raw context. Always instantiate this to interact with game state. - **`IGameState`** (`src/common/models/game-state.model.ts`): Defines the shape of `G` (piles, deck type, etc.). -- **Moves with `inGame` HOC**: All game moves are wrapped with the `inGame` higher-order function (`src/common/moves/in-game.ts`). +- **Moves with `inGame` HOC**: All game moves are wrapped with the `inGame` higher-order function (`src/common/moves/in-exploding-kittens.ts`). - *Pattern*: Define moves as `(game: TheGame, ...args) => void`. The HOC handles context injection. - *Example*: See `src/common/moves/draw-move.ts`. @@ -43,7 +43,7 @@ When implementing game moves (actions): 1. Create a function in `src/common/moves/`. 2. Function signature must be `(game: TheGame, ...args)`. 3. Mutate state via `game.piles`, `game.players`, or `game.gameState`. -4. Register the move in `src/common/game.ts` using `inGame(yourMove)`. +4. Register the move in `src/common/exploding-kittens.ts` using `inGame(yourMove)`. **Example Move:** ```typescript diff --git a/src/common/game.ts b/src/common/exploding-kittens.ts similarity index 100% rename from src/common/game.ts rename to src/common/exploding-kittens.ts diff --git a/src/common/index.ts b/src/common/index.ts index 471996e..075c2fa 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,5 +1,5 @@ // Main game export -export {ExplodingKittens} from './game'; +export {ExplodingKittens} from './exploding-kittens.ts'; // Models export * from './models'; From fa8abe2105261c3ba20ec9a0f6ac52d6f5e165f1 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 24 Mar 2026 23:07:46 +0100 Subject: [PATCH 22/55] refactor: update card dealing logic and clean up imports in WinnerOverlay --- .../board/overlay/winner/WinnerOverlay.tsx | 22 +++++++++---------- .../entities/deck-types/original-deck.ts | 4 ++-- src/common/exploding-kittens.ts | 2 +- src/common/index.ts | 2 +- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/client/components/board/overlay/winner/WinnerOverlay.tsx b/src/client/components/board/overlay/winner/WinnerOverlay.tsx index 332c3d3..cbc83ae 100644 --- a/src/client/components/board/overlay/winner/WinnerOverlay.tsx +++ b/src/client/components/board/overlay/winner/WinnerOverlay.tsx @@ -1,17 +1,14 @@ -import './WinnerOverlay.css'; -import {getPlayerName} from '../../../../utils/matchData.ts'; -import {useGame} from "../../../../context/GameContext.tsx"; +import React from 'react'; +import {useGame} from '../../../../context/GameContext'; +import {getPlayerName} from '../../../../utils/matchData'; -export default function WinnerOverlay() { +const WinnerOverlay: React.FC = () => { const game = useGame(); - const winner = game.players.winner; - if (!game.isGameOver() || !winner) { - return null; - } - const isSelfWinner = game.selfPlayerId === winner.id; - const winnerName = getPlayerName(winner.id, game.matchData); + if (!game.isGameOver() || !winner) return null; + + const winnerName = getPlayerName(winner.id); return (
@@ -19,10 +16,10 @@ export default function WinnerOverlay() {
🏆
Game Over!
- {isSelfWinner ? 'You Win!' : `${winnerName} Wins!`} + {game.selfPlayerId === winner.id ? 'You Win!' : `${winnerName} Wins!`}
- {isSelfWinner + {game.selfPlayerId === winner.id ? 'Congratulations!' : 'Better luck next time!'}
@@ -31,3 +28,4 @@ export default function WinnerOverlay() { ); } +export default WinnerOverlay; diff --git a/src/common/entities/deck-types/original-deck.ts b/src/common/entities/deck-types/original-deck.ts index 200fdb1..e2772be 100644 --- a/src/common/entities/deck-types/original-deck.ts +++ b/src/common/entities/deck-types/original-deck.ts @@ -53,14 +53,14 @@ export class OriginalDeck extends DeckType { const remaining = Math.min(TOTAL_DEFUSE_CARDS - playerCount, MAX_DECK_DEFUSE_CARDS); for (let i = 0; i < remaining; i++) { - const cardIndex = (playerCount - 1 + i) % 5; + const cardIndex = (playerCount + i) % TOTAL_DEFUSE_CARDS; pile.push(DEFUSE.createCard(cardIndex)); } // add amount of players minus one exploding kitten for (let i = 0; i < playerCount - 1; i++) { // after index 3 restart at 0, since there are only 4 unique exploding kitten cards - const cardIndex = (playerCount - 1 + i) % 4; + const cardIndex = i % 4; pile.push(EXPLODING_KITTEN.createCard(cardIndex)); } } diff --git a/src/common/exploding-kittens.ts b/src/common/exploding-kittens.ts index bc8bbd3..504750c 100644 --- a/src/common/exploding-kittens.ts +++ b/src/common/exploding-kittens.ts @@ -54,7 +54,7 @@ export const ExplodingKittens: Game = { const pile: ICard[] = deck.buildBaseDeck().sort(() => Math.random() - 0.5); dealHands(pile, game.context.player.state, deck); - deck.addPostDealCards(pile, Object.keys(game.players.playerCount).length); + deck.addPostDealCards(pile, game.players.playerCount); game.piles.drawPile = pile; game.piles.drawPile.shuffle(); diff --git a/src/common/index.ts b/src/common/index.ts index 075c2fa..9b0d221 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,5 +1,5 @@ // Main game export -export {ExplodingKittens} from './exploding-kittens.ts'; +export {ExplodingKittens} from './exploding-kittens'; // Models export * from './models'; From 296e49adc8f59650259ee3e75af746984160111f Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 24 Mar 2026 23:09:15 +0100 Subject: [PATCH 23/55] refactor: remove unused card animation logic from Board component --- src/client/animations/cardAnimationTypes.ts | 38 ---- src/client/animations/cardMovementDetector.ts | 162 ------------------ src/client/animations/useCardAnimations.tsx | 106 ------------ src/client/components/board/Board.tsx | 6 - .../board/card-animation/CardAnimation.css | 67 -------- .../board/card-animation/CardAnimation.tsx | 54 ------ 6 files changed, 433 deletions(-) delete mode 100644 src/client/animations/cardAnimationTypes.ts delete mode 100644 src/client/animations/cardMovementDetector.ts delete mode 100644 src/client/animations/useCardAnimations.tsx delete mode 100644 src/client/components/board/card-animation/CardAnimation.css delete mode 100644 src/client/components/board/card-animation/CardAnimation.tsx diff --git a/src/client/animations/cardAnimationTypes.ts b/src/client/animations/cardAnimationTypes.ts deleted file mode 100644 index d3770a2..0000000 --- a/src/client/animations/cardAnimationTypes.ts +++ /dev/null @@ -1,38 +0,0 @@ -import {ICard} from "../../common"; - -export type AnimationEndpoint = - | { kind: 'pile'; id: 'draw-pile' | 'discard-pile' } - | { kind: 'player'; id: string }; - -export type CardVisibility = - | { type: 'public' } // everyone sees face - | { type: 'participants'; ids: string[] } // only these players see face - | { type: 'hidden' }; // everyone sees backside - -export interface CardMovement { - card: ICard | null; - from: AnimationEndpoint; - to: AnimationEndpoint; - visibility: CardVisibility; - staggerIndex: number; -} - -export interface StateSnapshot { - drawSize: number; - discardSize: number; - discardTop: ICard | null; - handCounts: Record; - selfHand: ICard[]; -} - -export interface HandChange { - playerId: string; - delta: number; -} - -export interface StateDiff { - drawDelta: number; - discardDelta: number; - gainers: HandChange[]; - losers: HandChange[]; -} diff --git a/src/client/animations/cardMovementDetector.ts b/src/client/animations/cardMovementDetector.ts deleted file mode 100644 index 8e4d981..0000000 --- a/src/client/animations/cardMovementDetector.ts +++ /dev/null @@ -1,162 +0,0 @@ -import {CardMovement, CardVisibility, HandChange, StateSnapshot, StateDiff} from './cardAnimationTypes'; -import {ICard} from "../../common"; - -const diffHands = ( - current: Record, - previous: Record -): HandChange[] => - Object.entries(current) - .map(([playerId, count]) => ({playerId, delta: count - (previous[playerId] ?? 0)})) - .filter(c => c.delta !== 0); - -export const diffSnapshots = (current: StateSnapshot, stable: StateSnapshot): StateDiff => ({ - drawDelta: current.drawSize - stable.drawSize, - discardDelta: current.discardSize - stable.discardSize, - gainers: diffHands(current.handCounts, stable.handCounts).filter(c => c.delta > 0), - losers: diffHands(current.handCounts, stable.handCounts).filter(c => c.delta < 0), -}); - -const diffCards = (from: ICard[], to: ICard[]): ICard[] => - from.filter(a => !to.some(b => b.name === a.name && b.index === a.index)); - -const resolveVisibility = ( - senderId: string | null, - receiverId: string | null, - isPublic: boolean, -): CardVisibility => { - if (isPublic) return {type: 'public'}; - const ids = [senderId, receiverId].filter(Boolean) as string[]; - return ids.length > 0 ? {type: 'participants', ids} : {type: 'hidden'}; -}; - -const resolveCard = ( - visibility: CardVisibility, - selfPlayerId: string | null, - candidates: (ICard | null | undefined)[] -): ICard | null => { - const card = candidates.find(c => c != null) ?? null; - if (visibility.type === 'hidden') return null; - if (visibility.type === 'public') return card; - if (visibility.type === 'participants') { - return selfPlayerId && visibility.ids.includes(selfPlayerId) ? card : null; - } - return null; -}; - -export const detectMovements = ( - diff: StateDiff, - stable: StateSnapshot, - current: StateSnapshot, - selfPlayerId: string | null, -): CardMovement[] => { - const movements: CardMovement[] = []; - let stagger = 0; - - const gainedBySelf = diffCards(current.selfHand, stable.selfHand); - const lostBySelf = diffCards(stable.selfHand, current.selfHand); - - // ── Draw pile → player hand ─────────────────────────────────────────────── - if (diff.drawDelta < 0 && diff.gainers.length > 0) { - diff.gainers.forEach(gainer => { - for (let i = 0; i < gainer.delta; i++) { - const isSelf = gainer.playerId === selfPlayerId; - const visibility = resolveVisibility(null, gainer.playerId, false); - const card = resolveCard(visibility, selfPlayerId, [isSelf ? gainedBySelf[i] : undefined]); - movements.push({ - card, staggerIndex: stagger++, - from: {kind: 'pile', id: 'draw-pile'}, - to: {kind: 'player', id: gainer.playerId}, - visibility, - }); - } - }); - } - - // ── Player hand → discard pile ──────────────────────────────────────────── - // Always use topCard — discard is public and it's the authoritative card identity. - // This also fixes the "wrong index" bug with duplicate card types. - if (diff.discardDelta > 0 && diff.losers.length > 0) { - diff.losers.forEach(loser => { - for (let i = 0; i < Math.abs(loser.delta); i++) { - const isSelf = loser.playerId === selfPlayerId; - const card = isSelf ? lostBySelf[i] : current.discardTop; - movements.push({ - card, - staggerIndex: stagger++, - from: {kind: 'player', id: loser.playerId}, - to: {kind: 'pile', id: 'discard-pile'}, - visibility: {type: 'public'}, - }); - } - }); - } - - // ── Discard pile → player hand ──────────────────────────────────────────── - if (diff.discardDelta < 0 && diff.gainers.length > 0 && diff.drawDelta === 0) { - diff.gainers.forEach(gainer => { - movements.push({ - card: stable.discardTop, // what was on top before the move - staggerIndex: stagger++, - from: {kind: 'pile', id: 'discard-pile'}, - to: {kind: 'player', id: gainer.playerId}, - visibility: {type: 'public'}, - }); - }); - } - - // ── Player hand → draw pile (defuse, insert) ────────────────────────────── - if (diff.drawDelta > 0 && diff.losers.length > 0 && diff.discardDelta === 0) { - diff.losers.forEach(loser => { - for (let i = 0; i < Math.abs(loser.delta); i++) { - const isSelf = loser.playerId === selfPlayerId; - const visibility = resolveVisibility(loser.playerId, null, false); - const card = resolveCard(visibility, selfPlayerId, [isSelf ? lostBySelf[i] : undefined]); - movements.push({ - card, staggerIndex: stagger++, - from: {kind: 'player', id: loser.playerId}, - to: {kind: 'pile', id: 'draw-pile'}, - visibility, - }); - } - }); - } - - // ── Discard pile → draw pile (reshuffle) ────────────────────────────────── - if (diff.discardDelta < 0 && diff.drawDelta > 0 && diff.gainers.length === 0 && diff.losers.length === 0) { - movements.push({ - card: stable.discardTop, - staggerIndex: stagger++, - from: {kind: 'pile', id: 'discard-pile'}, - to: {kind: 'pile', id: 'draw-pile'}, - visibility: {type: 'public'}, - }); - } - - // ── Player hand → player hand (favor, steal, combo) ─────────────────────── - if (diff.drawDelta === 0 && diff.discardDelta === 0 && diff.gainers.length > 0 && diff.losers.length > 0) { - const allLosers = diff.losers.flatMap(l => Array(Math.abs(l.delta)).fill(l.playerId)); - const allGainers = diff.gainers.flatMap(g => Array(g.delta).fill(g.playerId)); - const numMoves = Math.min(allLosers.length, allGainers.length); - - for (let i = 0; i < numMoves; i++) { - const loserId = allLosers[i]; - const gainerId = allGainers[i]; - const isSelfReceiver = gainerId === selfPlayerId; - const isSelfSender = loserId === selfPlayerId; - const visibility = resolveVisibility(loserId, gainerId, false); - const card = resolveCard(visibility, selfPlayerId, [ - isSelfReceiver ? gainedBySelf[i] : undefined, - isSelfSender ? lostBySelf[i] : undefined, - ]); - movements.push({ - card, - staggerIndex: stagger++, - from: {kind: 'player', id: loserId}, - to: {kind: 'player', id: gainerId}, - visibility, - }); - } - } - - return movements; -}; diff --git a/src/client/animations/useCardAnimations.tsx b/src/client/animations/useCardAnimations.tsx deleted file mode 100644 index f90f1b9..0000000 --- a/src/client/animations/useCardAnimations.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import {useState, useCallback, useRef, useEffect} from 'react'; -import CardAnimation, {CardAnimationData} from '../components/board/card-animation/CardAnimation'; -import {ICard} from '../../common'; -import {TheGameClient} from '../entities/game-client.ts'; -import {StateSnapshot} from './cardAnimationTypes'; -import {detectMovements, diffSnapshots} from './cardMovementDetector'; - -const STAGGER_MS = 120; -const DEBOUNCE_MS = 50; - -const takeSnapshot = (game: TheGameClient, selfPlayerId: string | null): StateSnapshot => ({ - drawSize: game.piles.drawPile.size, - discardSize: game.piles.discardPile.size, - discardTop: game.piles.discardPile.topCard, - handCounts: Object.fromEntries(game.players.allPlayers.map(p => [p.id, p.handSize])), - selfHand: [...(game.players.allPlayers.find(p => p.id === selfPlayerId)?.hand ?? [])], -}); - -const endpointId = (e: {kind: string; id: string}) => - e.kind === 'pile' ? e.id : `player-${e.id}`; - -export const useCardAnimations = (game: TheGameClient) => { - const selfPlayerId = game.selfPlayerId; - - const [animations, setAnimations] = useState([]); - const animationIdCounter = useRef(0); - - const stableSnapshot = useRef(takeSnapshot(game, selfPlayerId)); - const debounceTimer = useRef | null>(null); - const pendingSnapshot = useRef(null); - - const getElementCenter = useCallback((id: string) => { - const el = document.querySelector(`[data-animation-id="${id}"]`) as HTMLElement; - if (!el) { console.warn(`Animation target not found: ${id}`); return null; } - const r = el.getBoundingClientRect(); - return {x: r.left + r.width / 2, y: r.top + r.height / 2}; - }, []); - - const triggerCardMovement = useCallback(( - card: ICard | null, fromId: string, toId: string, delay = 0 - ) => { - const from = getElementCenter(fromId); - const to = getElementCenter(toId); - if (!from || !to) return; - setAnimations(prev => [...prev, { - id: `card-anim-${animationIdCounter.current++}`, - card, from, to, duration: 600, delay, - }]); - }, [getElementCenter]); - - const handleAnimationComplete = useCallback((id: string) => { - setAnimations(prev => prev.filter(a => a.id !== id)); - }, []); - - // Fine-grained dependency keys so the effect fires even when - // game.players is the same object reference but hand contents changed - const handCountKey = game.players.allPlayers.map(p => `${p.id}:${p.handSize}`).join(','); - const selfHandKey = game.players.allPlayers - .find(p => p.id === selfPlayerId)?.hand - .map(c => `${c.name}:${c.index}`).join(',') ?? ''; - - useEffect(() => { - const current = takeSnapshot(game, selfPlayerId); - - if (!debounceTimer.current) { - // New debounce window — lock in the stable baseline - stableSnapshot.current = pendingSnapshot.current ?? stableSnapshot.current; - } - - if (debounceTimer.current) clearTimeout(debounceTimer.current); - pendingSnapshot.current = current; - - debounceTimer.current = setTimeout(() => { - debounceTimer.current = null; - const snap = pendingSnapshot.current!; - const diff = diffSnapshots(snap, stableSnapshot.current); - const movements = detectMovements(diff, stableSnapshot.current, snap, selfPlayerId); - - movements.forEach(m => - triggerCardMovement(m.card, endpointId(m.from), endpointId(m.to), m.staggerIndex * STAGGER_MS) - ); - - stableSnapshot.current = snap; - pendingSnapshot.current = null; - }, DEBOUNCE_MS); - - return () => { if (debounceTimer.current) clearTimeout(debounceTimer.current); }; - }, [ - game.piles.drawPile.size, - game.piles.discardPile.size, - handCountKey, // replaces game.players — fires when any count changes - selfHandKey, // fires when self's actual card contents change - triggerCardMovement, - selfPlayerId, - ]); - - const AnimationLayer = useCallback(() => ( - <> - {animations.map(anim => ( - - ))} - - ), [animations, handleAnimationComplete]); - - return {animations, AnimationLayer, triggerCardMovement}; -}; diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index 0249ff1..421e19c 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -1,5 +1,4 @@ import './Board.css'; -import {useCardAnimations} from '../../animations/useCardAnimations'; import Table from './table/Table'; import PlayerList from './player/player-list/PlayerList'; import BoardOverlays from './overlay/BoardOverlays.tsx'; @@ -70,14 +69,9 @@ export default function ExplodingKittensBoard(props: BoardProps & { game.moves, ]); - // Handle card animations - const {AnimationLayer} = useCardAnimations(game); - return ( <> - -
diff --git a/src/client/components/board/card-animation/CardAnimation.css b/src/client/components/board/card-animation/CardAnimation.css deleted file mode 100644 index f52e8ab..0000000 --- a/src/client/components/board/card-animation/CardAnimation.css +++ /dev/null @@ -1,67 +0,0 @@ -body { - --duration: 1s; - --from-x: 0; - --from-y: 0; - --to-x: 0; - --to-y: 0; -} - -.flying-card { - position: fixed; - width: calc(var(--scale) * 9); - aspect-ratio: 6 / 8.4; - background-size: cover; - background-position: center; - border: calc(var(--scale) * 0.4) solid var(--color-accent); - border-radius: calc(var(--scale) * 0.8); - box-shadow: 0 calc(var(--scale) * 1) calc(var(--scale) * 3) var(--color-shadow-dark), - 0 0 calc(var(--scale) * 2) var(--color-accent-glow-medium); - pointer-events: none; - z-index: 9; - /* Start at origin, will be positioned via left/top in component */ - transform: translate(-50%, -50%) scale(0.8); - opacity: 0; -} - -.flying-card.flying { - animation: flyCard var(--duration) cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; -} - -@keyframes flyCard { - 0% { - transform: translate( - calc(-50% + var(--from-x)), - calc(-50% + var(--from-y)) - ) scale(0.8) rotate(0deg); - opacity: 1; - } - 20% { - transform: translate( - calc(-50% + var(--from-x) * 0.8 + var(--to-x) * 0.2), - calc(-50% + var(--from-y) * 0.8 + var(--to-y) * 0.2 - 50px) - ) scale(1.1) rotate(5deg); - opacity: 1; - } - 50% { - transform: translate( - calc(-50% + var(--from-x) * 0.5 + var(--to-x) * 0.5), - calc(-50% + var(--from-y) * 0.5 + var(--to-y) * 0.5 - 80px) - ) scale(1.2) rotate(-3deg); - opacity: 1; - } - 80% { - transform: translate( - calc(-50% + var(--from-x) * 0.2 + var(--to-x) * 0.8), - calc(-50% + var(--from-y) * 0.2 + var(--to-y) * 0.8 - 30px) - ) scale(1) rotate(2deg); - opacity: 1; - } - 100% { - transform: translate( - calc(-50% + var(--to-x)), - calc(-50% + var(--to-y)) - ) scale(0.8) rotate(0deg); - opacity: 0; - } -} - diff --git a/src/client/components/board/card-animation/CardAnimation.tsx b/src/client/components/board/card-animation/CardAnimation.tsx deleted file mode 100644 index e569569..0000000 --- a/src/client/components/board/card-animation/CardAnimation.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import './CardAnimation.css'; -import React, {useEffect, useState} from 'react'; -import {ICard} from '../../../../common'; -import {TheGameClient} from "../../../entities/game-client.ts"; - -export interface CardAnimationData { - id: string; - card: ICard | null; // null means face-down - from: { x: number; y: number }; - to: { x: number; y: number }; - duration: number; - delay?: number; -} - -interface CardAnimationProps { - animation: CardAnimationData; - onComplete: (id: string) => void; -} - -export default function CardAnimation({animation, onComplete}: CardAnimationProps) { - const [isVisible, setIsVisible] = useState(false); - const cardImage = TheGameClient.getCardTexture(animation.card); - - useEffect(() => { - // Start animation immediately - setIsVisible(true); - - // Complete animation after duration - const completeTimer = setTimeout(() => { - onComplete(animation.id); - }, animation.duration + (animation.delay || 0)); - - return () => { - clearTimeout(completeTimer); - }; - }, [animation, onComplete]); - - return ( -
- ); -} - From ccf8966382e4ca69cb71a5b139db60fc0e237f3d Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 24 Mar 2026 23:10:18 +0100 Subject: [PATCH 24/55] refactor: fix conditional check for pending card resolution in Board component --- src/client/components/board/Board.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index 421e19c..a610349 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -41,7 +41,7 @@ export default function ExplodingKittensBoard(props: BoardProps & { }, [game.isLobbyPhase(), setPollingInterval]); useEffect(() => { - if (!game.piles.pendingCard || !game.piles.pendingCard || !game.moves.resolvePendingCard) { + if (!game.piles.pendingCard || !game.moves.resolvePendingCard) { return; } From 0156df958ee9573fd7f957df5cb0798dd256d3a3 Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 26 Mar 2026 20:31:22 +0100 Subject: [PATCH 25/55] feat: simple animation system --- .../components/animations/AnimatedCard.tsx | 55 ++++++++++++++++ .../components/animations/AnimationManager.ts | 62 +++++++++++++++++++ .../animations/AnimationOverlay.css | 13 ++++ .../animations/AnimationOverlay.tsx | 25 ++++++++ src/client/components/board/Board.tsx | 6 +- 5 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 src/client/components/animations/AnimatedCard.tsx create mode 100644 src/client/components/animations/AnimationManager.ts create mode 100644 src/client/components/animations/AnimationOverlay.css create mode 100644 src/client/components/animations/AnimationOverlay.tsx diff --git a/src/client/components/animations/AnimatedCard.tsx b/src/client/components/animations/AnimatedCard.tsx new file mode 100644 index 0000000..fdd0b1c --- /dev/null +++ b/src/client/components/animations/AnimatedCard.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { CardAnimation } from './AnimationManager'; +import { TheGameClient } from '../../entities/game-client'; + +export function AnimatedCard({ animation }: { animation: CardAnimation }) { + const [style, setStyle] = useState({}); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const fromEl = document.querySelector(`[data-animation-id="${animation.fromId}"]`); + const toEl = document.querySelector(`[data-animation-id="${animation.toId}"]`); + + if (!fromEl || !toEl) { + console.warn(`Card Animation Failed: Could not find elements [${animation.fromId}] -> [${animation.toId}]`); + return; + } + + const fromRect = fromEl.getBoundingClientRect(); + const toRect = toEl.getBoundingClientRect(); + + // Initialize Card Style Position Based on 'From' Rect Position + const initialStyle: React.CSSProperties = { + position: 'fixed', + left: `${fromRect.left + fromRect.width / 2}px`, + top: `${fromRect.top + fromRect.height / 2}px`, + backgroundImage: `url(${TheGameClient.getCardTexture(animation.card as any)})`, + transform: 'translate(-50%, -50%) scale(1)', + transition: 'none', + zIndex: 9999, + pointerEvents: 'none', // Cards moving shouldn't receive pointer events + }; + + setStyle(initialStyle); + setIsVisible(true); + + // Apply specific CSS transition smoothly to target coordinate 'to' rect + const frame1 = requestAnimationFrame(() => { + const frame2 = requestAnimationFrame(() => { + setStyle(prev => ({ + ...prev, + left: `${toRect.left + toRect.width / 2}px`, + top: `${toRect.top + toRect.height / 2}px`, + transition: `all ${animation.durationMs}ms cubic-bezier(0.25, 0.8, 0.25, 1)`, // Smooth Ease out cubic bezier + })); + }); + return () => cancelAnimationFrame(frame2); + }); + + return () => cancelAnimationFrame(frame1); + }, [animation]); + + if (!isVisible) return null; + + return
; +} diff --git a/src/client/components/animations/AnimationManager.ts b/src/client/components/animations/AnimationManager.ts new file mode 100644 index 0000000..289668e --- /dev/null +++ b/src/client/components/animations/AnimationManager.ts @@ -0,0 +1,62 @@ +export interface CardAnimation { + id: string; + fromId: string; + toId: string; + card: { name: string; index: number } | null; + durationMs: number; +} + +type AnimationListener = (animations: CardAnimation[]) => void; + +class AnimationManager { + private animations: CardAnimation[] = []; + private listeners: Set = new Set(); + private animationCounter = 0; + + public playAnimation( + fromId: string, + toId: string, + card: { name: string; index: number } | null = null, + durationMs = 500 + ) { + const id = `anim_${this.animationCounter++}`; + const newAnim: CardAnimation = { id, fromId, toId, card, durationMs }; + + this.animations = [...this.animations, newAnim]; + this.notifyListeners(); + + setTimeout(() => { + this.animations = this.animations.filter(a => a.id !== id); + this.notifyListeners(); + }, durationMs + 50); // slight buffer to ensure transition finishes + } + + public getAnimations() { + return this.animations; + } + + public subscribe(listener: AnimationListener): () => void { + this.listeners.add(listener); + // Send initial state + listener(this.animations); + return () => { this.listeners.delete(listener); }; + } + + private notifyListeners() { + this.listeners.forEach(l => l(this.animations)); + } +} + +export const animationManager = new AnimationManager(); + +// Dev tool testing command accessible from console +(window as any).playAnimation = ( + fromId: string, + toId: string, + cardName?: string, + cardIndex?: number, + durationMs = 500 +) => { + const card = cardName ? { name: cardName, index: cardIndex ?? 0 } : null; + animationManager.playAnimation(fromId, toId, card, durationMs); +}; diff --git a/src/client/components/animations/AnimationOverlay.css b/src/client/components/animations/AnimationOverlay.css new file mode 100644 index 0000000..d3d053c --- /dev/null +++ b/src/client/components/animations/AnimationOverlay.css @@ -0,0 +1,13 @@ +.animation-interaction-blocker { + position: fixed; + inset: 0; + z-index: 9998; /* Below moving cards but above the board */ + cursor: not-allowed; + pointer-events: all; /* Explicitly block all pointer events passing through to elements underneath */ +} + +.animated-card { + position: absolute !important; /* Using JS to apply left/top, so use explicit positioning */ + transition-property: all; + will-change: transform, left, top, width, height; +} diff --git a/src/client/components/animations/AnimationOverlay.tsx b/src/client/components/animations/AnimationOverlay.tsx new file mode 100644 index 0000000..73f0ec0 --- /dev/null +++ b/src/client/components/animations/AnimationOverlay.tsx @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; +import { CardAnimation, animationManager } from './AnimationManager'; +import { AnimatedCard } from './AnimatedCard'; +import './AnimationOverlay.css'; +import '../board/player/card/Card.css'; + +export function AnimationOverlay() { + const [animations, setAnimations] = useState([]); + + useEffect(() => { + const unsubscribe = animationManager.subscribe(newAnimations => { + setAnimations(newAnimations); + }); + return () => unsubscribe(); + }, []); + + if (animations.length === 0) return null; + + return ( + <> +
+ {animations.map(anim => )} + + ); +} diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index a610349..e063539 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -6,10 +6,11 @@ import GameStatusList from './game-status/GameStatusList'; import {useEffect} from 'react'; import {Chat} from '../chat/Chat'; import {useMatchDetails} from "../../context/MatchDetailsContext.tsx"; -import type { BoardProps } from 'boardgame.io/react'; -import {IContext, IGameState} from "../../../common"; +import {AnimationOverlay} from '../animations/AnimationOverlay.tsx'; import {TheGameClient} from "../../entities/game-client.ts"; import {GameProvider} from "../../context/GameContext.tsx"; +import type { BoardProps } from 'boardgame.io/react'; +import {IContext, IGameState} from "../../../common"; export default function ExplodingKittensBoard(props: BoardProps & { plugins: any }) { @@ -82,6 +83,7 @@ export default function ExplodingKittensBoard(props: BoardProps & { + ); From 3b60dd135ab0cb75eb662377cb101f89382e88d7 Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 26 Mar 2026 20:35:09 +0100 Subject: [PATCH 26/55] refactor: enhance width handling for animated card transitions --- .../components/animations/AnimatedCard.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/client/components/animations/AnimatedCard.tsx b/src/client/components/animations/AnimatedCard.tsx index fdd0b1c..7ab9ca6 100644 --- a/src/client/components/animations/AnimatedCard.tsx +++ b/src/client/components/animations/AnimatedCard.tsx @@ -18,11 +18,26 @@ export function AnimatedCard({ animation }: { animation: CardAnimation }) { const fromRect = fromEl.getBoundingClientRect(); const toRect = toEl.getBoundingClientRect(); + // Determine target width to use for size transitioning + const getTargetWidth = (el: Element, rect: DOMRect) => { + // If it's explicitly a card or a pile, it has valid dimensions + if (el.classList.contains('pile') || el.classList.contains('card')) { + return rect.width; + } + // If targeting a badge/generic element, assume standard player card size + const sampleCard = document.querySelector('.card:not(.pile):not(.animated-card)'); + return sampleCard ? sampleCard.getBoundingClientRect().width : 80; + }; + + const fromWidth = getTargetWidth(fromEl, fromRect); + const toWidth = getTargetWidth(toEl, toRect); + // Initialize Card Style Position Based on 'From' Rect Position const initialStyle: React.CSSProperties = { position: 'fixed', left: `${fromRect.left + fromRect.width / 2}px`, top: `${fromRect.top + fromRect.height / 2}px`, + width: `${fromWidth}px`, // Explicit initial width backgroundImage: `url(${TheGameClient.getCardTexture(animation.card as any)})`, transform: 'translate(-50%, -50%) scale(1)', transition: 'none', @@ -40,6 +55,7 @@ export function AnimatedCard({ animation }: { animation: CardAnimation }) { ...prev, left: `${toRect.left + toRect.width / 2}px`, top: `${toRect.top + toRect.height / 2}px`, + width: `${toWidth}px`, // Explicit target width. 'aspect-ratio' handles height natively! transition: `all ${animation.durationMs}ms cubic-bezier(0.25, 0.8, 0.25, 1)`, // Smooth Ease out cubic bezier })); }); From 734203c52df076670256f75334139c1c7ab06265 Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 26 Mar 2026 20:37:31 +0100 Subject: [PATCH 27/55] feat: use player hand instead of name as animation target --- src/client/components/board/player/player-area/Player.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/components/board/player/player-area/Player.tsx b/src/client/components/board/player/player-area/Player.tsx index 9ac99d0..791ed78 100644 --- a/src/client/components/board/player/player-area/Player.tsx +++ b/src/client/components/board/player/player-area/Player.tsx @@ -55,6 +55,7 @@ export default function Player({ transform: `translate(-50%, -50%) rotate(${cardRotation}deg)`, zIndex: isSelf ? 2 : 1, }} + data-animation-id={`player-${playerId}`} >
From 01ff8b6da8007e0fe40270fb85163c23557dea80 Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 26 Mar 2026 20:40:58 +0100 Subject: [PATCH 28/55] fix: player cards too big --- src/client/components/animations/AnimatedCard.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client/components/animations/AnimatedCard.tsx b/src/client/components/animations/AnimatedCard.tsx index 7ab9ca6..cad2e84 100644 --- a/src/client/components/animations/AnimatedCard.tsx +++ b/src/client/components/animations/AnimatedCard.tsx @@ -22,11 +22,11 @@ export function AnimatedCard({ animation }: { animation: CardAnimation }) { const getTargetWidth = (el: Element, rect: DOMRect) => { // If it's explicitly a card or a pile, it has valid dimensions if (el.classList.contains('pile') || el.classList.contains('card')) { - return rect.width; + return (el as HTMLElement).offsetWidth || rect.width; } // If targeting a badge/generic element, assume standard player card size - const sampleCard = document.querySelector('.card:not(.pile):not(.animated-card)'); - return sampleCard ? sampleCard.getBoundingClientRect().width : 80; + const sampleCard = document.querySelector('.card:not(.pile):not(.animated-card)') as HTMLElement; + return sampleCard ? sampleCard.offsetWidth : 80; }; const fromWidth = getTargetWidth(fromEl, fromRect); From ff69a623691e4363f7cb5cf90135fba1518f7355 Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 26 Mar 2026 21:14:17 +0100 Subject: [PATCH 29/55] feat: implement animation context for managing card animations --- .../components/animations/AnimatedCard.tsx | 75 +++++++------ .../animations/AnimationOverlay.css | 2 +- .../animations/AnimationOverlay.tsx | 14 +-- src/client/components/board/Board.tsx | 21 ++-- .../components/board/player/card/Card.css | 8 +- .../board/player/player-area/Player.tsx | 5 +- src/client/components/board/table/Table.css | 8 +- src/client/components/board/table/Table.tsx | 20 ++-- .../board/table/pending/PendingPlayStack.tsx | 14 ++- src/client/components/chat/Chat.css | 2 +- src/client/components/common/Modal.css | 2 +- src/client/components/game-view/GameView.css | 6 +- src/client/components/lobby/Lobby.css | 2 +- src/client/components/rulebook/Rulebook.css | 4 +- src/client/context/AnimationContext.tsx | 101 ++++++++++++++++++ 15 files changed, 204 insertions(+), 80 deletions(-) create mode 100644 src/client/context/AnimationContext.tsx diff --git a/src/client/components/animations/AnimatedCard.tsx b/src/client/components/animations/AnimatedCard.tsx index cad2e84..61c75e9 100644 --- a/src/client/components/animations/AnimatedCard.tsx +++ b/src/client/components/animations/AnimatedCard.tsx @@ -1,17 +1,22 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { CardAnimation } from './AnimationManager'; import { TheGameClient } from '../../entities/game-client'; +import { useAnimationState } from '../../context/AnimationContext'; export function AnimatedCard({ animation }: { animation: CardAnimation }) { - const [style, setStyle] = useState({}); + const { getNode } = useAnimationState(); + const cardRef = useRef(null); const [isVisible, setIsVisible] = useState(false); useEffect(() => { - const fromEl = document.querySelector(`[data-animation-id="${animation.fromId}"]`); - const toEl = document.querySelector(`[data-animation-id="${animation.toId}"]`); + const fromEl = getNode(animation.fromId); + const toEl = getNode(animation.toId); - if (!fromEl || !toEl) { - console.warn(`Card Animation Failed: Could not find elements [${animation.fromId}] -> [${animation.toId}]`); + if (!fromEl || !toEl || !cardRef.current) { + console.warn(`Card Animation Failed! +fromId (${animation.fromId}):`, fromEl, ` +toId (${animation.toId}):`, toEl, ` +cardRef:`, cardRef.current); return; } @@ -19,10 +24,10 @@ export function AnimatedCard({ animation }: { animation: CardAnimation }) { const toRect = toEl.getBoundingClientRect(); // Determine target width to use for size transitioning - const getTargetWidth = (el: Element, rect: DOMRect) => { + const getTargetWidth = (el: HTMLElement, rect: DOMRect) => { // If it's explicitly a card or a pile, it has valid dimensions if (el.classList.contains('pile') || el.classList.contains('card')) { - return (el as HTMLElement).offsetWidth || rect.width; + return el.offsetWidth || rect.width; } // If targeting a badge/generic element, assume standard player card size const sampleCard = document.querySelector('.card:not(.pile):not(.animated-card)') as HTMLElement; @@ -32,40 +37,44 @@ export function AnimatedCard({ animation }: { animation: CardAnimation }) { const fromWidth = getTargetWidth(fromEl, fromRect); const toWidth = getTargetWidth(toEl, toRect); - // Initialize Card Style Position Based on 'From' Rect Position - const initialStyle: React.CSSProperties = { - position: 'fixed', - left: `${fromRect.left + fromRect.width / 2}px`, - top: `${fromRect.top + fromRect.height / 2}px`, - width: `${fromWidth}px`, // Explicit initial width - backgroundImage: `url(${TheGameClient.getCardTexture(animation.card as any)})`, - transform: 'translate(-50%, -50%) scale(1)', - transition: 'none', - zIndex: 9999, - pointerEvents: 'none', // Cards moving shouldn't receive pointer events - }; + // Apply strictly to the DOM Ref instead of triggering standard React renders mid-animation - setStyle(initialStyle); + // 1. Set initial geometry + cardRef.current.style.transition = 'none'; + cardRef.current.style.left = `${fromRect.left + fromRect.width / 2}px`; + cardRef.current.style.top = `${fromRect.top + fromRect.height / 2}px`; + cardRef.current.style.width = `${fromWidth}px`; + cardRef.current.style.backgroundImage = `url(${TheGameClient.getCardTexture(animation.card as any)})`; + + // Unhide securely setIsVisible(true); - // Apply specific CSS transition smoothly to target coordinate 'to' rect + // 2. Play transition explicitly targeting 'to' rect in next animation frames const frame1 = requestAnimationFrame(() => { const frame2 = requestAnimationFrame(() => { - setStyle(prev => ({ - ...prev, - left: `${toRect.left + toRect.width / 2}px`, - top: `${toRect.top + toRect.height / 2}px`, - width: `${toWidth}px`, // Explicit target width. 'aspect-ratio' handles height natively! - transition: `all ${animation.durationMs}ms cubic-bezier(0.25, 0.8, 0.25, 1)`, // Smooth Ease out cubic bezier - })); + if (!cardRef.current) return; + cardRef.current.style.transition = `all ${animation.durationMs}ms cubic-bezier(0.25, 0.8, 0.25, 1)`; + cardRef.current.style.left = `${toRect.left + toRect.width / 2}px`; + cardRef.current.style.top = `${toRect.top + toRect.height / 2}px`; + cardRef.current.style.width = `${toWidth}px`; }); return () => cancelAnimationFrame(frame2); }); return () => cancelAnimationFrame(frame1); - }, [animation]); - - if (!isVisible) return null; + }, [animation, getNode]); - return
; + return ( +
+ ); } diff --git a/src/client/components/animations/AnimationOverlay.css b/src/client/components/animations/AnimationOverlay.css index d3d053c..9fc680b 100644 --- a/src/client/components/animations/AnimationOverlay.css +++ b/src/client/components/animations/AnimationOverlay.css @@ -1,7 +1,7 @@ .animation-interaction-blocker { position: fixed; inset: 0; - z-index: 9998; /* Below moving cards but above the board */ + z-index: 60; /* Below moving cards but above the board */ cursor: not-allowed; pointer-events: all; /* Explicitly block all pointer events passing through to elements underneath */ } diff --git a/src/client/components/animations/AnimationOverlay.tsx b/src/client/components/animations/AnimationOverlay.tsx index 73f0ec0..cf2053e 100644 --- a/src/client/components/animations/AnimationOverlay.tsx +++ b/src/client/components/animations/AnimationOverlay.tsx @@ -1,25 +1,17 @@ -import { useEffect, useState } from 'react'; -import { CardAnimation, animationManager } from './AnimationManager'; +import { useAnimationState } from '../../context/AnimationContext'; import { AnimatedCard } from './AnimatedCard'; import './AnimationOverlay.css'; import '../board/player/card/Card.css'; export function AnimationOverlay() { - const [animations, setAnimations] = useState([]); - - useEffect(() => { - const unsubscribe = animationManager.subscribe(newAnimations => { - setAnimations(newAnimations); - }); - return () => unsubscribe(); - }, []); + const { animations } = useAnimationState(); if (animations.length === 0) return null; return ( <>
- {animations.map(anim => )} + {animations.map((anim: any) => )} ); } diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index e063539..0915ac6 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -9,6 +9,7 @@ import {useMatchDetails} from "../../context/MatchDetailsContext.tsx"; import {AnimationOverlay} from '../animations/AnimationOverlay.tsx'; import {TheGameClient} from "../../entities/game-client.ts"; import {GameProvider} from "../../context/GameContext.tsx"; +import {AnimationProvider} from "../../context/AnimationContext.tsx"; import type { BoardProps } from 'boardgame.io/react'; import {IContext, IGameState} from "../../../common"; @@ -73,17 +74,19 @@ export default function ExplodingKittensBoard(props: BoardProps & { return ( <> -
-
-
- + +
+
+
+ + - - - - - + + + + + ); diff --git a/src/client/components/board/player/card/Card.css b/src/client/components/board/player/card/Card.css index 002661d..323d393 100644 --- a/src/client/components/board/player/card/Card.css +++ b/src/client/components/board/player/card/Card.css @@ -148,7 +148,7 @@ .hand-interactable .card:hover, .hand-interactable .card.selected { - z-index: 35; + z-index: 25; transform: var(--base-transform) translateY(calc(var(--scale) * -2)) scale(1.2) rotate(0deg); } @@ -156,7 +156,7 @@ .hand-interactable .card.selected::after { content: ''; position: absolute; - z-index: 35; + z-index: 25; top: 0; left: 0; right: 0; @@ -185,7 +185,7 @@ left: 0; right: 0; bottom: 0; - z-index: 50; + z-index: 70; display: flex; align-items: center; justify-content: flex-start; @@ -246,7 +246,7 @@ body.mobile-view .card-preview-overlay { height: 100%; background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(5px); - z-index: 50; + z-index: 70; display: flex; flex-direction: column; align-items: center; diff --git a/src/client/components/board/player/player-area/Player.tsx b/src/client/components/board/player/player-area/Player.tsx index 791ed78..38bda50 100644 --- a/src/client/components/board/player/player-area/Player.tsx +++ b/src/client/components/board/player/player-area/Player.tsx @@ -4,6 +4,7 @@ import {getPlayerName} from "../../../../utils/matchData.ts"; import {PlayerPosition} from "../../../../types/component-props.ts"; import {useGame} from "../../../../context/GameContext.tsx"; import {Player as PlayerModel} from "../../../../../common"; +import {useAnimationNode} from "../../../../context/AnimationContext.tsx"; import { CHOOSE_CARD_TO_GIVE, CHOOSE_PLAYER_TO_REQUEST_FROM, @@ -23,6 +24,8 @@ export default function Player({ const selfPlayer = game.selfPlayer; const playerId = player.id; + const playerAnimRef = useAnimationNode(`player-${playerId}`); + const isSelf = game.isSelf(playerId); const isTurn = player.isCurrentPlayer; const isSelectable = @@ -47,6 +50,7 @@ export default function Player({ return ( <>
(null); + const setDiscardRef = useCallback((node: HTMLDivElement | null) => { + if (discardPileRef) { + (discardPileRef as any).current = node; + } + discardPileAnimRef(node); + }, [discardPileAnimRef]); + const discardCard = game.piles.discardPile.topCard; // Detect when a card is drawn @@ -114,10 +124,9 @@ export default function Table() { {!game.piles.pendingCard && ( <>
{ if (!isMobile) setIsDiscardPileSelected(true); }} @@ -138,17 +147,16 @@ export default function Table() { )} {game.piles.pendingCard && ( - /* Replaces discard pile during pending card play */ )}
setIsHoveringDrawPile(true)} onMouseLeave={() => setIsHoveringDrawPile(false)} - data-animation-id="draw-pile" > {isHoveringDrawPile && game.piles.drawPile.size > 0 && (
diff --git a/src/client/components/board/table/pending/PendingPlayStack.tsx b/src/client/components/board/table/pending/PendingPlayStack.tsx index 753e1ae..a130478 100644 --- a/src/client/components/board/table/pending/PendingPlayStack.tsx +++ b/src/client/components/board/table/pending/PendingPlayStack.tsx @@ -1,9 +1,10 @@ import '../../player/card/Card.css'; import './PendingPlayStack.css'; -import {useRef, useState} from 'react'; +import {useRef, useState, useCallback} from 'react'; import CardPreview from '../../CardPreview.tsx'; import {useGame} from "../../../../context/GameContext.tsx"; import {TheGameClient} from "../../../../entities/game-client.ts"; +import {useAnimationNode} from "../../../../context/AnimationContext.tsx"; export default function PendingPlayStack() { const game = useGame(); @@ -15,18 +16,25 @@ export default function PendingPlayStack() { const isNoped = pendingCard.isNoped; const [isHovered, setIsHovered] = useState(false); const pileRef = useRef(null); + const discardPileAnimRef = useAnimationNode('discard-pile'); + + const setDiscardRef = useCallback((node: HTMLDivElement | null) => { + if (pileRef) { + (pileRef as any).current = node; + } + discardPileAnimRef(node); + }, [discardPileAnimRef]); const cardImage = TheGameClient.getCardTexture(targetCard); return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} /> diff --git a/src/client/components/chat/Chat.css b/src/client/components/chat/Chat.css index e4deffc..82d4d03 100644 --- a/src/client/components/chat/Chat.css +++ b/src/client/components/chat/Chat.css @@ -2,7 +2,7 @@ position: fixed; bottom: 2rem; right: 2rem; - z-index: 1000; + z-index: 50; display: flex; flex-direction: column; align-items: flex-end; diff --git a/src/client/components/common/Modal.css b/src/client/components/common/Modal.css index 12ca552..bdaf196 100644 --- a/src/client/components/common/Modal.css +++ b/src/client/components/common/Modal.css @@ -9,7 +9,7 @@ display: flex; align-items: center; justify-content: center; - z-index: 90; + z-index: 80; backdrop-filter: blur(8px); } diff --git a/src/client/components/game-view/GameView.css b/src/client/components/game-view/GameView.css index 62d05b7..07839bb 100644 --- a/src/client/components/game-view/GameView.css +++ b/src/client/components/game-view/GameView.css @@ -10,7 +10,7 @@ position: fixed; top: 1rem; left: 1rem; - z-index: 3000; + z-index: 50; padding: 0.75rem 1.5rem; background: rgba(255, 255, 255, 0.95); border: 2px solid #e2e8f0; @@ -43,7 +43,7 @@ position: fixed; top: 1rem; right: 1rem; - z-index: 3000; + z-index: 50; padding: 0.75rem 1.25rem; background: rgba(255, 255, 255, 0.95); border: 2px solid #e2e8f0; @@ -129,7 +129,7 @@ display: flex; align-items: center; justify-content: center; - z-index: 90; + z-index: 80; animation: fadeIn 0.2s; } diff --git a/src/client/components/lobby/Lobby.css b/src/client/components/lobby/Lobby.css index 9b91299..f7f8838 100644 --- a/src/client/components/lobby/Lobby.css +++ b/src/client/components/lobby/Lobby.css @@ -722,7 +722,7 @@ display: flex; align-items: center; justify-content: center; - z-index: 10; + z-index: 80; backdrop-filter: blur(8px); animation: fadeIn 0.2s ease; } diff --git a/src/client/components/rulebook/Rulebook.css b/src/client/components/rulebook/Rulebook.css index e21a646..ed1054c 100644 --- a/src/client/components/rulebook/Rulebook.css +++ b/src/client/components/rulebook/Rulebook.css @@ -6,7 +6,7 @@ left: 0; width: 100vw; height: 100vh; - z-index: 4000; + z-index: 90; display: flex; justify-content: center; align-items: center; @@ -143,7 +143,7 @@ justify-content: center; align-items: center; cursor: pointer; - z-index: 900; + z-index: 50; transition: all 0.2s; } diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx new file mode 100644 index 0000000..f5f5d5c --- /dev/null +++ b/src/client/context/AnimationContext.tsx @@ -0,0 +1,101 @@ +import { createContext, useContext, useRef, useState, ReactNode, useCallback } from 'react'; +import { CardAnimation } from '../components/animations/AnimationManager'; + +interface AnimationContextValue { + animations: CardAnimation[]; + playAnimation: (fromId: string, toId: string, card: { name: string; index: number } | null, durationMs?: number) => void; + // Node Ref Registry + registerNode: (id: string, ref: HTMLElement | null) => void; + getNode: (id: string) => HTMLElement | null; +} + +const AnimationContext = createContext(null); + +export function AnimationProvider({ children }: { children: ReactNode }) { + const [animations, setAnimations] = useState([]); + const animationCounter = useRef(0); + + // Ref map resolving 'string ids' -> DOM Elements + const nodeRefs = useRef>(new Map()); + + const registerNode = useCallback((id: string, ref: HTMLElement | null, previousRef?: HTMLElement | null) => { + if (ref) { + // console.log(`[AnimationContext] Registered node:`, id, ref); + nodeRefs.current.set(id, ref); + } else { + // Only delete if the node trying to unregister is the one currently registered + // Prevents race conditions where a replacing component mounts before the old one unmounts + if (previousRef && nodeRefs.current.get(id) !== previousRef) { + return; + } + // console.log(`[AnimationContext] Deleted node:`, id); + nodeRefs.current.delete(id); + } + }, []); + + const getNode = useCallback((id: string) => { + return nodeRefs.current.get(id) || null; + }, []); + + const playAnimation = useCallback(( + fromId: string, + toId: string, + card: { name: string; index: number } | null = null, + durationMs = 500 + ) => { + const id = `anim_${animationCounter.current++}`; + const newAnim: CardAnimation = { id, fromId, toId, card, durationMs }; + + setAnimations(prev => [...prev, newAnim]); + + setTimeout(() => { + setAnimations(prev => prev.filter(a => a.id !== id)); + }, durationMs + 50); + }, []); + + // Expose global window command for dev testing + if (typeof window !== 'undefined') { + (window as any).playAnimation = (fromId: string, toId: string, cardName?: string, cardIndex?: number, durationMs = 500) => { + const card = cardName ? { name: cardName, index: cardIndex ?? 0 } : null; + playAnimation(fromId, toId, card, durationMs); + }; + } + + const value = { + animations, + playAnimation, + registerNode, + getNode + }; + + return ( + + {children} + + ); +} + +export function useAnimationState() { + const context = useContext(AnimationContext); + if (!context) { + throw new Error('useAnimationState must be used within an AnimationProvider'); + } + return context; +} + +export function useAnimationNode(id: string) { + const { registerNode } = useAnimationState(); + const ref = useRef(null); + + const setRef = useCallback((node: HTMLElement | null) => { + if (node) { + ref.current = node; + registerNode(id, node); + } else { + registerNode(id, null, ref.current); + ref.current = null; + } + }, [id, registerNode]); + + return setRef; +} From c141efb029404f7b38cd05b62aa2260e040ad9fa Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 26 Mar 2026 21:17:34 +0100 Subject: [PATCH 30/55] feat: simplify animation node references for discard and draw piles --- src/client/components/board/table/Table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/components/board/table/Table.tsx b/src/client/components/board/table/Table.tsx index 59f89c6..a50b81d 100644 --- a/src/client/components/board/table/Table.tsx +++ b/src/client/components/board/table/Table.tsx @@ -14,8 +14,8 @@ export default function Table() { const game = useGame() const { isMobile } = useResponsive(); - const discardPileAnimRef = useAnimationNode('discard-pile'); - const drawPileAnimRef = useAnimationNode('draw-pile'); + const discardPileAnimRef = useAnimationNode('discard'); + const drawPileAnimRef = useAnimationNode('draw'); const [isDrawing, setIsDrawing] = useState(false); const [isShuffling, setIsShuffling] = useState(false); From 258abdf64dc1ec60459986e3b56f35e3261fe5c8 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 27 Mar 2026 13:11:26 +0100 Subject: [PATCH 31/55] fix: remove unnecessary reference from registerNode call --- src/client/context/AnimationContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx index f5f5d5c..b7aa9cc 100644 --- a/src/client/context/AnimationContext.tsx +++ b/src/client/context/AnimationContext.tsx @@ -92,7 +92,7 @@ export function useAnimationNode(id: string) { ref.current = node; registerNode(id, node); } else { - registerNode(id, null, ref.current); + registerNode(id, null); ref.current = null; } }, [id, registerNode]); From eaace854fb9e61d92ec5e3c1ba2af615a55bb751 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 27 Mar 2026 14:03:30 +0100 Subject: [PATCH 32/55] feat: add AnimationQueue for managing card animations --- .../board/player/player-area/Player.tsx | 2 +- src/client/context/AnimationContext.tsx | 8 ++--- src/common/constants/piles.ts | 3 ++ src/common/entities/animation-queue.ts | 35 +++++++++++++++++++ src/common/entities/game.ts | 3 ++ src/common/entities/pile.ts | 2 +- src/common/entities/piles.ts | 5 +-- src/common/models/game-state.model.ts | 20 ++++++++--- src/common/models/players.model.ts | 3 +- 9 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 src/common/constants/piles.ts create mode 100644 src/common/entities/animation-queue.ts diff --git a/src/client/components/board/player/player-area/Player.tsx b/src/client/components/board/player/player-area/Player.tsx index 38bda50..00c9952 100644 --- a/src/client/components/board/player/player-area/Player.tsx +++ b/src/client/components/board/player/player-area/Player.tsx @@ -24,7 +24,7 @@ export default function Player({ const selfPlayer = game.selfPlayer; const playerId = player.id; - const playerAnimRef = useAnimationNode(`player-${playerId}`); + const playerAnimRef = useAnimationNode(`${playerId}`); const isSelf = game.isSelf(playerId); const isTurn = player.isCurrentPlayer; diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx index b7aa9cc..1c018e1 100644 --- a/src/client/context/AnimationContext.tsx +++ b/src/client/context/AnimationContext.tsx @@ -1,5 +1,5 @@ -import { createContext, useContext, useRef, useState, ReactNode, useCallback } from 'react'; -import { CardAnimation } from '../components/animations/AnimationManager'; +import {createContext, ReactNode, useCallback, useContext, useRef, useState} from 'react'; +import {CardAnimation} from '../components/animations/AnimationManager'; interface AnimationContextValue { animations: CardAnimation[]; @@ -87,7 +87,7 @@ export function useAnimationNode(id: string) { const { registerNode } = useAnimationState(); const ref = useRef(null); - const setRef = useCallback((node: HTMLElement | null) => { + return useCallback((node: HTMLElement | null) => { if (node) { ref.current = node; registerNode(id, node); @@ -96,6 +96,4 @@ export function useAnimationNode(id: string) { ref.current = null; } }, [id, registerNode]); - - return setRef; } diff --git a/src/common/constants/piles.ts b/src/common/constants/piles.ts new file mode 100644 index 0000000..857396d --- /dev/null +++ b/src/common/constants/piles.ts @@ -0,0 +1,3 @@ +export const + DRAW = "draw", + DISCARD = "discard"; \ No newline at end of file diff --git a/src/common/entities/animation-queue.ts b/src/common/entities/animation-queue.ts new file mode 100644 index 0000000..821f11f --- /dev/null +++ b/src/common/entities/animation-queue.ts @@ -0,0 +1,35 @@ +import {IAnimation, IAnimationQueue, ICard} from "../models"; +import {Player} from "./player.ts"; +import {Pile} from "./pile.ts"; +import {Card} from "./card.ts"; + +export class AnimationQueue { + + constructor(public readonly queue: IAnimationQueue) { + } + + enqueue(card: Card | ICard, from: Player | Pile, to: Player | Pile, durationMs: number = 500) { + const animation: IAnimation = { + from: from instanceof Player ? `${from.id}` : `${from.name}`, + to: to instanceof Player ? `${to.id}` : `${to.name}`, + card: {name: card.name, index: card.index}, + durationMs + }; + this.enqueueAnimation(animation); + } + + enqueueAnimation(animation: IAnimation) { + // generate a number unique id + const id = Date.now().toString() + Math.random().toString(36).substring(2); + this.queue[id] = animation; + } + + clear(animationId: string) { + delete this.queue[animationId]; + } + + getAnimations(): IAnimation[] { + return Object.values(this.queue); + } + +} \ No newline at end of file diff --git a/src/common/entities/game.ts b/src/common/entities/game.ts index ce0ae95..1da8cff 100644 --- a/src/common/entities/game.ts +++ b/src/common/entities/game.ts @@ -7,6 +7,7 @@ import {RandomAPI} from "boardgame.io/dist/types/src/plugins/random/random"; import {EventsAPI} from "boardgame.io/dist/types/src/plugins/events/events"; import {Ctx} from "boardgame.io"; import {GAME_OVER, LOBBY, PLAY} from "../constants/phases"; +import {AnimationQueue} from "./animation-queue.ts"; export class TheGame { @@ -19,6 +20,7 @@ export class TheGame { public readonly piles: Piles; public readonly turnManager: TurnManager; public players: Players; + public animationsQueue: AnimationQueue; constructor(context: IContext) { this.context = context; @@ -29,6 +31,7 @@ export class TheGame { this.piles = new Piles(this, this.gameState.piles); this.turnManager = new TurnManager(this.context); + this.animationsQueue = new AnimationQueue(this.gameState.animationsQueue); if (this.context?.player?.state) { this.players = new Players(this, this.gameState, this.context.player.state); diff --git a/src/common/entities/pile.ts b/src/common/entities/pile.ts index ee0a568..9e83e6b 100644 --- a/src/common/entities/pile.ts +++ b/src/common/entities/pile.ts @@ -5,7 +5,7 @@ import {Card} from "./card"; export class Pile { public cards: ICard[]; - constructor(private game: TheGame, public state: IPile) { + constructor(public readonly name: string, private game: TheGame, public state: IPile) { this.cards = state.cards; } diff --git a/src/common/entities/piles.ts b/src/common/entities/piles.ts index 6c621c5..2931d7b 100644 --- a/src/common/entities/piles.ts +++ b/src/common/entities/piles.ts @@ -2,13 +2,14 @@ import {ICard, IPendingCardPlay, IPiles} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; import {Pile} from "./pile"; +import {DISCARD, DRAW} from "../constants/piles.ts"; export class Piles { constructor(private game: TheGame, private piles: IPiles) { } get drawPile(): Pile { - return new Pile(this.game, this.piles.drawPile); + return new Pile(DRAW, this.game, this.piles.drawPile); } set drawPile(pile: ICard[]) { @@ -16,7 +17,7 @@ export class Piles { } get discardPile(): Pile { - return new Pile(this.game, this.piles.discardPile); + return new Pile(DISCARD, this.game, this.piles.discardPile); } set discardPile(pile: ICard[]) { diff --git a/src/common/models/game-state.model.ts b/src/common/models/game-state.model.ts index 7499e3c..89800c9 100644 --- a/src/common/models/game-state.model.ts +++ b/src/common/models/game-state.model.ts @@ -17,15 +17,26 @@ export interface IPendingCardPlay { isNoped: boolean; } +export interface IPiles { + drawPile: IPile; + discardPile: IPile; + pendingCardPlay: IPendingCardPlay | null; +} + export interface IPile { cards: ICard[]; size: number; } -export interface IPiles { - drawPile: IPile; - discardPile: IPile; - pendingCardPlay: IPendingCardPlay | null; +export interface IAnimationQueue { + [animationId: string]: IAnimation +} + +export interface IAnimation { + from: number | string; + to: number | string; + card: ICard | null; + durationMs: number; } export interface IGameState { @@ -34,4 +45,5 @@ export interface IGameState { turnsRemaining: number; gameRules: IGameRules; deckType: string; + animationsQueue: IAnimationQueue; } diff --git a/src/common/models/players.model.ts b/src/common/models/players.model.ts index f2e8f9c..beab497 100644 --- a/src/common/models/players.model.ts +++ b/src/common/models/players.model.ts @@ -1,5 +1,6 @@ import type {IPlayer} from './player.model'; +import type {PlayerID} from 'boardgame.io'; export interface IPlayers { - [playerID: string]: IPlayer; + [playerID: PlayerID]: IPlayer; } From 85b456b7caae3b257f3a44b6df2622443b548cc6 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 27 Mar 2026 14:41:48 +0100 Subject: [PATCH 33/55] feat: refactor animation handling to use metadata for card animations --- .../components/animations/AnimatedCard.tsx | 15 +++-- .../components/animations/AnimationManager.ts | 62 ------------------- src/client/context/AnimationContext.tsx | 53 +++++++++++----- src/common/entities/animation-queue.ts | 8 +-- src/common/entities/game.ts | 2 +- src/common/entities/piles.ts | 2 +- src/common/setup/game-setup.ts | 3 +- 7 files changed, 53 insertions(+), 92 deletions(-) delete mode 100644 src/client/components/animations/AnimationManager.ts diff --git a/src/client/components/animations/AnimatedCard.tsx b/src/client/components/animations/AnimatedCard.tsx index 61c75e9..3526d8d 100644 --- a/src/client/components/animations/AnimatedCard.tsx +++ b/src/client/components/animations/AnimatedCard.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import { CardAnimation } from './AnimationManager'; import { TheGameClient } from '../../entities/game-client'; -import { useAnimationState } from '../../context/AnimationContext'; +import {CardAnimation, useAnimationState} from '../../context/AnimationContext'; export function AnimatedCard({ animation }: { animation: CardAnimation }) { const { getNode } = useAnimationState(); @@ -9,13 +8,13 @@ export function AnimatedCard({ animation }: { animation: CardAnimation }) { const [isVisible, setIsVisible] = useState(false); useEffect(() => { - const fromEl = getNode(animation.fromId); - const toEl = getNode(animation.toId); + const fromEl = getNode(String(animation.metadata.from)); + const toEl = getNode(String(animation.metadata.to)); if (!fromEl || !toEl || !cardRef.current) { console.warn(`Card Animation Failed! -fromId (${animation.fromId}):`, fromEl, ` -toId (${animation.toId}):`, toEl, ` +fromId (${animation.metadata.from}):`, fromEl, ` +toId (${animation.metadata.to}):`, toEl, ` cardRef:`, cardRef.current); return; } @@ -44,7 +43,7 @@ cardRef:`, cardRef.current); cardRef.current.style.left = `${fromRect.left + fromRect.width / 2}px`; cardRef.current.style.top = `${fromRect.top + fromRect.height / 2}px`; cardRef.current.style.width = `${fromWidth}px`; - cardRef.current.style.backgroundImage = `url(${TheGameClient.getCardTexture(animation.card as any)})`; + cardRef.current.style.backgroundImage = `url(${TheGameClient.getCardTexture(animation.metadata.card as any)})`; // Unhide securely setIsVisible(true); @@ -53,7 +52,7 @@ cardRef:`, cardRef.current); const frame1 = requestAnimationFrame(() => { const frame2 = requestAnimationFrame(() => { if (!cardRef.current) return; - cardRef.current.style.transition = `all ${animation.durationMs}ms cubic-bezier(0.25, 0.8, 0.25, 1)`; + cardRef.current.style.transition = `all ${animation.metadata.durationMs}ms cubic-bezier(0.25, 0.8, 0.25, 1)`; cardRef.current.style.left = `${toRect.left + toRect.width / 2}px`; cardRef.current.style.top = `${toRect.top + toRect.height / 2}px`; cardRef.current.style.width = `${toWidth}px`; diff --git a/src/client/components/animations/AnimationManager.ts b/src/client/components/animations/AnimationManager.ts deleted file mode 100644 index 289668e..0000000 --- a/src/client/components/animations/AnimationManager.ts +++ /dev/null @@ -1,62 +0,0 @@ -export interface CardAnimation { - id: string; - fromId: string; - toId: string; - card: { name: string; index: number } | null; - durationMs: number; -} - -type AnimationListener = (animations: CardAnimation[]) => void; - -class AnimationManager { - private animations: CardAnimation[] = []; - private listeners: Set = new Set(); - private animationCounter = 0; - - public playAnimation( - fromId: string, - toId: string, - card: { name: string; index: number } | null = null, - durationMs = 500 - ) { - const id = `anim_${this.animationCounter++}`; - const newAnim: CardAnimation = { id, fromId, toId, card, durationMs }; - - this.animations = [...this.animations, newAnim]; - this.notifyListeners(); - - setTimeout(() => { - this.animations = this.animations.filter(a => a.id !== id); - this.notifyListeners(); - }, durationMs + 50); // slight buffer to ensure transition finishes - } - - public getAnimations() { - return this.animations; - } - - public subscribe(listener: AnimationListener): () => void { - this.listeners.add(listener); - // Send initial state - listener(this.animations); - return () => { this.listeners.delete(listener); }; - } - - private notifyListeners() { - this.listeners.forEach(l => l(this.animations)); - } -} - -export const animationManager = new AnimationManager(); - -// Dev tool testing command accessible from console -(window as any).playAnimation = ( - fromId: string, - toId: string, - cardName?: string, - cardIndex?: number, - durationMs = 500 -) => { - const card = cardName ? { name: cardName, index: cardIndex ?? 0 } : null; - animationManager.playAnimation(fromId, toId, card, durationMs); -}; diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx index 1c018e1..71407f8 100644 --- a/src/client/context/AnimationContext.tsx +++ b/src/client/context/AnimationContext.tsx @@ -1,14 +1,20 @@ -import {createContext, ReactNode, useCallback, useContext, useRef, useState} from 'react'; -import {CardAnimation} from '../components/animations/AnimationManager'; +import { createContext, useContext, useRef, useState, ReactNode, useCallback } from 'react'; +import {IAnimation, ICard} from "../../common"; interface AnimationContextValue { animations: CardAnimation[]; - playAnimation: (fromId: string, toId: string, card: { name: string; index: number } | null, durationMs?: number) => void; + playAnimation: (id: number, animation: IAnimation) => void; + playManualAnimation: (fromId: string, toId: string, card: ICard | null, durationMs?: number) => void; // Node Ref Registry registerNode: (id: string, ref: HTMLElement | null) => void; getNode: (id: string) => HTMLElement | null; } +export interface CardAnimation { + id: number; + metadata: IAnimation +} + const AnimationContext = createContext(null); export function AnimationProvider({ children }: { children: ReactNode }) { @@ -38,32 +44,47 @@ export function AnimationProvider({ children }: { children: ReactNode }) { }, []); const playAnimation = useCallback(( - fromId: string, - toId: string, - card: { name: string; index: number } | null = null, - durationMs = 500 + id: number, + animation: IAnimation ) => { - const id = `anim_${animationCounter.current++}`; - const newAnim: CardAnimation = { id, fromId, toId, card, durationMs }; - - setAnimations(prev => [...prev, newAnim]); + const newAnimation = { id, metadata: animation }; + + setAnimations(prev => [...prev, newAnimation]); setTimeout(() => { setAnimations(prev => prev.filter(a => a.id !== id)); - }, durationMs + 50); + }, animation.durationMs + 50); + + }, []) + + const playManualAnimation = useCallback(( + fromId: string, + toId: string, + card: ICard | null = null, + durationMs = 500 + ) => { + animationCounter.current++; + const id = animationCounter.current; + playAnimation(id, { + from: fromId, + to: toId, + card: card, + durationMs: durationMs + }); }, []); // Expose global window command for dev testing if (typeof window !== 'undefined') { (window as any).playAnimation = (fromId: string, toId: string, cardName?: string, cardIndex?: number, durationMs = 500) => { const card = cardName ? { name: cardName, index: cardIndex ?? 0 } : null; - playAnimation(fromId, toId, card, durationMs); + playManualAnimation(fromId, toId, card, durationMs); }; } const value = { animations, - playAnimation, + playManualAnimation: playManualAnimation, + playAnimation: playAnimation, registerNode, getNode }; @@ -87,7 +108,7 @@ export function useAnimationNode(id: string) { const { registerNode } = useAnimationState(); const ref = useRef(null); - return useCallback((node: HTMLElement | null) => { + const setRef = useCallback((node: HTMLElement | null) => { if (node) { ref.current = node; registerNode(id, node); @@ -96,4 +117,6 @@ export function useAnimationNode(id: string) { ref.current = null; } }, [id, registerNode]); + + return setRef; } diff --git a/src/common/entities/animation-queue.ts b/src/common/entities/animation-queue.ts index 821f11f..3542bdf 100644 --- a/src/common/entities/animation-queue.ts +++ b/src/common/entities/animation-queue.ts @@ -1,7 +1,7 @@ import {IAnimation, IAnimationQueue, ICard} from "../models"; -import {Player} from "./player.ts"; -import {Pile} from "./pile.ts"; -import {Card} from "./card.ts"; +import {Player} from "./player"; +import {Pile} from "./pile"; +import {Card} from "./card"; export class AnimationQueue { @@ -31,5 +31,5 @@ export class AnimationQueue { getAnimations(): IAnimation[] { return Object.values(this.queue); } - + } \ No newline at end of file diff --git a/src/common/entities/game.ts b/src/common/entities/game.ts index 1da8cff..c04f22e 100644 --- a/src/common/entities/game.ts +++ b/src/common/entities/game.ts @@ -7,7 +7,7 @@ import {RandomAPI} from "boardgame.io/dist/types/src/plugins/random/random"; import {EventsAPI} from "boardgame.io/dist/types/src/plugins/events/events"; import {Ctx} from "boardgame.io"; import {GAME_OVER, LOBBY, PLAY} from "../constants/phases"; -import {AnimationQueue} from "./animation-queue.ts"; +import {AnimationQueue} from "./animation-queue"; export class TheGame { diff --git a/src/common/entities/piles.ts b/src/common/entities/piles.ts index 2931d7b..7aa320d 100644 --- a/src/common/entities/piles.ts +++ b/src/common/entities/piles.ts @@ -2,7 +2,7 @@ import {ICard, IPendingCardPlay, IPiles} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; import {Pile} from "./pile"; -import {DISCARD, DRAW} from "../constants/piles.ts"; +import {DISCARD, DRAW} from "../constants/piles"; export class Piles { constructor(private game: TheGame, private piles: IPiles) { diff --git a/src/common/setup/game-setup.ts b/src/common/setup/game-setup.ts index be34afb..b3bdcc4 100644 --- a/src/common/setup/game-setup.ts +++ b/src/common/setup/game-setup.ts @@ -29,6 +29,7 @@ export const setupGame = (_context: any, setupData?: SetupData): IGameState => { openCards: setupData?.openCards ?? false, pendingTimerMs: 3000, }, - deckType: setupData?.deckType ?? 'original' + deckType: setupData?.deckType ?? 'original', + animationsQueue: {} }; }; From c3ebc61c91a0c71334112acf53a75d2f54a474c3 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 27 Mar 2026 14:42:09 +0100 Subject: [PATCH 34/55] feat: streamline AnimationContext by simplifying setRef implementation --- src/client/context/AnimationContext.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx index 71407f8..2c8baab 100644 --- a/src/client/context/AnimationContext.tsx +++ b/src/client/context/AnimationContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useRef, useState, ReactNode, useCallback } from 'react'; +import {createContext, ReactNode, useCallback, useContext, useRef, useState} from 'react'; import {IAnimation, ICard} from "../../common"; interface AnimationContextValue { @@ -108,7 +108,7 @@ export function useAnimationNode(id: string) { const { registerNode } = useAnimationState(); const ref = useRef(null); - const setRef = useCallback((node: HTMLElement | null) => { + return useCallback((node: HTMLElement | null) => { if (node) { ref.current = node; registerNode(id, node); @@ -117,6 +117,4 @@ export function useAnimationNode(id: string) { ref.current = null; } }, [id, registerNode]); - - return setRef; } From e88d6e77f528ee57a2f3d4f9c0ac2985b4418fc8 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 27 Mar 2026 14:49:30 +0100 Subject: [PATCH 35/55] feat: integrate game animations queue into AnimationContext for improved animation handling --- src/client/context/AnimationContext.tsx | 20 +++++++++++++++++++- src/common/entities/player.ts | 1 + 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx index 2c8baab..b7dbef6 100644 --- a/src/client/context/AnimationContext.tsx +++ b/src/client/context/AnimationContext.tsx @@ -1,5 +1,6 @@ -import {createContext, ReactNode, useCallback, useContext, useRef, useState} from 'react'; +import {createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'; import {IAnimation, ICard} from "../../common"; +import {useGame} from "./GameContext.tsx"; interface AnimationContextValue { animations: CardAnimation[]; @@ -18,6 +19,8 @@ export interface CardAnimation { const AnimationContext = createContext(null); export function AnimationProvider({ children }: { children: ReactNode }) { + const game = useGame(); + const [animations, setAnimations] = useState([]); const animationCounter = useRef(0); @@ -43,10 +46,25 @@ export function AnimationProvider({ children }: { children: ReactNode }) { return nodeRefs.current.get(id) || null; }, []); + useEffect(() => { + if (!game.animationsQueue?.queue) { + return; + } + + for (let id in game.animationsQueue.queue) { + const animation = game.animationsQueue.queue[id]; + playAnimation(Number(id), animation); + } + }, [game.animationsQueue]); + const playAnimation = useCallback(( id: number, animation: IAnimation ) => { + if (animations.some(value1 => value1.id === id)) { + return + } + const newAnimation = { id, metadata: animation }; setAnimations(prev => [...prev, newAnimation]); diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 19076bf..841c59d 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -251,6 +251,7 @@ export class Player { } this.addCard(cardData); + this.game.animationsQueue.enqueue(cardData, this.game.piles.drawPile, this); if (cardData.name !== EXPLODING_KITTEN.name) { this.game.turnManager.endTurn(); From 4933e19682489b8978fab01a724a11bec0702144 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 27 Mar 2026 16:29:27 +0100 Subject: [PATCH 36/55] feat: defuse card animations --- src/client/context/AnimationContext.tsx | 39 +++++++++++++++---------- src/common/entities/animation-queue.ts | 10 ++++--- src/common/entities/player.ts | 11 +++++-- src/common/moves/in-game.ts | 1 + 4 files changed, 38 insertions(+), 23 deletions(-) diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx index b7dbef6..43ee3ab 100644 --- a/src/client/context/AnimationContext.tsx +++ b/src/client/context/AnimationContext.tsx @@ -22,7 +22,7 @@ export function AnimationProvider({ children }: { children: ReactNode }) { const game = useGame(); const [animations, setAnimations] = useState([]); - const animationCounter = useRef(0); + const lastAnimationTime = useRef(Date.now()); // Ref map resolving 'string ids' -> DOM Elements const nodeRefs = useRef>(new Map()); @@ -46,17 +46,6 @@ export function AnimationProvider({ children }: { children: ReactNode }) { return nodeRefs.current.get(id) || null; }, []); - useEffect(() => { - if (!game.animationsQueue?.queue) { - return; - } - - for (let id in game.animationsQueue.queue) { - const animation = game.animationsQueue.queue[id]; - playAnimation(Number(id), animation); - } - }, [game.animationsQueue]); - const playAnimation = useCallback(( id: number, animation: IAnimation @@ -65,6 +54,10 @@ export function AnimationProvider({ children }: { children: ReactNode }) { return } + if (id > lastAnimationTime.current) { + lastAnimationTime.current = id; + } + const newAnimation = { id, metadata: animation }; setAnimations(prev => [...prev, newAnimation]); @@ -73,7 +66,7 @@ export function AnimationProvider({ children }: { children: ReactNode }) { setAnimations(prev => prev.filter(a => a.id !== id)); }, animation.durationMs + 50); - }, []) + }, [animations]) const playManualAnimation = useCallback(( fromId: string, @@ -81,15 +74,29 @@ export function AnimationProvider({ children }: { children: ReactNode }) { card: ICard | null = null, durationMs = 500 ) => { - animationCounter.current++; - const id = animationCounter.current; + const id = Date.now() + Math.random(); playAnimation(id, { from: fromId, to: toId, card: card, durationMs: durationMs }); - }, []); + }, [playAnimation]); + + useEffect(() => { + if (!game.animationsQueue?.queue) { + return; + } + + const currentAnimations = game.animationsQueue.queue; + for (let idStr in currentAnimations) { + const id = Number(idStr); + if (id > lastAnimationTime.current) { + const animation = currentAnimations[idStr]; + playAnimation(id, animation); + } + } + }, [game.animationsQueue, playAnimation]); // Expose global window command for dev testing if (typeof window !== 'undefined') { diff --git a/src/common/entities/animation-queue.ts b/src/common/entities/animation-queue.ts index 3542bdf..d7288d0 100644 --- a/src/common/entities/animation-queue.ts +++ b/src/common/entities/animation-queue.ts @@ -20,12 +20,14 @@ export class AnimationQueue { enqueueAnimation(animation: IAnimation) { // generate a number unique id - const id = Date.now().toString() + Math.random().toString(36).substring(2); - this.queue[id] = animation; + const id = Date.now() + Math.random(); + this.queue[id.toString()] = animation; } - clear(animationId: string) { - delete this.queue[animationId]; + clear() { + for (let id in this.queue) { + delete this.queue[id]; + } } getAnimations(): IAnimation[] { diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 841c59d..072cdba 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -271,7 +271,9 @@ export class Player { } defuseExplodingKitten(insertIndex: number): void { - if (insertIndex < 0 || insertIndex > this.game.piles.drawPile.size) { + const drawPile = this.game.piles.drawPile; + + if (insertIndex < 0 || insertIndex > drawPile.size) { throw new Error('Invalid insert index'); } @@ -284,8 +286,11 @@ export class Player { return; } - this.game.piles.discardCard(defuseCard); - this.game.piles.drawPile.insertCard(kittenCard, insertIndex); + const discardPile = this.game.piles.discardPile; + discardPile.addCard(defuseCard); + this.game.animationsQueue.enqueue(defuseCard, this, discardPile) + drawPile.insertCard(kittenCard, insertIndex); + this.game.animationsQueue.enqueue(kittenCard, this, drawPile) this.game.turnManager.endStage(); this.game.turnManager.endTurn(); diff --git a/src/common/moves/in-game.ts b/src/common/moves/in-game.ts index c7e0e86..c8d5915 100644 --- a/src/common/moves/in-game.ts +++ b/src/common/moves/in-game.ts @@ -7,6 +7,7 @@ export function inGame(move: GameMove) { return (context: IContext, ...args: Args): void => { try { const game = new TheGame(context); + game.animationsQueue.clear() move(game, ...args); } catch (error) { console.error(`Move failed: ${move.name || 'anonymous move'}`, error, {args}); From 18ed7a4509ab68f626eb2e94ea91d5ecd5d61207 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 27 Mar 2026 16:54:05 +0100 Subject: [PATCH 37/55] fix: winner overlay --- src/client/components/board/overlay/winner/WinnerOverlay.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/components/board/overlay/winner/WinnerOverlay.tsx b/src/client/components/board/overlay/winner/WinnerOverlay.tsx index cbc83ae..5088b65 100644 --- a/src/client/components/board/overlay/winner/WinnerOverlay.tsx +++ b/src/client/components/board/overlay/winner/WinnerOverlay.tsx @@ -1,6 +1,7 @@ import React from 'react'; import {useGame} from '../../../../context/GameContext'; import {getPlayerName} from '../../../../utils/matchData'; +import "./WinnerOverlay.css" const WinnerOverlay: React.FC = () => { const game = useGame(); From 30d620f47017e8c79bda9ccb945c0bafe954c716 Mon Sep 17 00:00:00 2001 From: Dominik Date: Sat, 28 Mar 2026 17:41:56 +0100 Subject: [PATCH 38/55] feat: enhance animation queue management and simplify animation retrieval --- src/client/context/AnimationContext.tsx | 21 ++++++++--------- src/common/entities/animation-queue.ts | 31 ++++++++++++++++++------- src/common/exploding-kittens.ts | 3 +++ src/common/models/game-state.model.ts | 4 ++-- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx index 43ee3ab..6daa8f7 100644 --- a/src/client/context/AnimationContext.tsx +++ b/src/client/context/AnimationContext.tsx @@ -74,7 +74,7 @@ export function AnimationProvider({ children }: { children: ReactNode }) { card: ICard | null = null, durationMs = 500 ) => { - const id = Date.now() + Math.random(); + const id = Date.now(); playAnimation(id, { from: fromId, to: toId, @@ -89,22 +89,21 @@ export function AnimationProvider({ children }: { children: ReactNode }) { } const currentAnimations = game.animationsQueue.queue; - for (let idStr in currentAnimations) { - const id = Number(idStr); + for (let id of currentAnimations.keys()) { if (id > lastAnimationTime.current) { - const animation = currentAnimations[idStr]; - playAnimation(id, animation); + const animations = currentAnimations.get(id); + if (animations) { + animations.forEach(anim => playAnimation(id, anim)) + } } } }, [game.animationsQueue, playAnimation]); // Expose global window command for dev testing - if (typeof window !== 'undefined') { - (window as any).playAnimation = (fromId: string, toId: string, cardName?: string, cardIndex?: number, durationMs = 500) => { - const card = cardName ? { name: cardName, index: cardIndex ?? 0 } : null; - playManualAnimation(fromId, toId, card, durationMs); - }; - } + (window as any).playAnimation = (fromId: string, toId: string, cardName?: string, cardIndex?: number, durationMs = 500) => { + const card = cardName ? { name: cardName, index: cardIndex ?? 0 } : null; + playManualAnimation(fromId, toId, card, durationMs); + }; const value = { animations, diff --git a/src/common/entities/animation-queue.ts b/src/common/entities/animation-queue.ts index d7288d0..5d42472 100644 --- a/src/common/entities/animation-queue.ts +++ b/src/common/entities/animation-queue.ts @@ -20,18 +20,33 @@ export class AnimationQueue { enqueueAnimation(animation: IAnimation) { // generate a number unique id - const id = Date.now() + Math.random(); - this.queue[id.toString()] = animation; + const id = Date.now(); + let currentQueue = this.queue.get(id); + if (!currentQueue) { + currentQueue = [] + this.queue.set(id, []) + } + currentQueue.push(animation) } - clear() { - for (let id in this.queue) { - delete this.queue[id]; + getAnimationsToPlay(lastTimePlayed: number): [number, IAnimation[]] { + const animationsToPlay: IAnimation[] = [] + let highestTime: number = lastTimePlayed + for (let id of this.queue.keys()) { + if (id > lastTimePlayed) { + const animations = this.queue.get(id); + if (animations) { + if (id > highestTime) { + highestTime = id; + } + animations.forEach(anim => animationsToPlay.push(anim)); + } + } } + return [highestTime, animationsToPlay] } - getAnimations(): IAnimation[] { - return Object.values(this.queue); + clear() { + this.queue.clear(); } - } \ No newline at end of file diff --git a/src/common/exploding-kittens.ts b/src/common/exploding-kittens.ts index 504750c..71644e3 100644 --- a/src/common/exploding-kittens.ts +++ b/src/common/exploding-kittens.ts @@ -15,6 +15,7 @@ import {TheGame} from "./entities/game"; import {stealCard} from "./moves/steal-card-move"; import {GAME_OVER, PLAY} from "./constants/phases"; import {VIEWING_FUTURE, WAITING_FOR_START} from "./constants/stages"; +import {EXPLODING_KITTEN} from "./registries/card-registry"; export const ExplodingKittens: Game = { name: "Exploding-Kittens", @@ -58,6 +59,8 @@ export const ExplodingKittens: Game = { game.piles.drawPile = pile; game.piles.drawPile.shuffle(); + game.piles.drawPile.insertCard(EXPLODING_KITTEN.createCard(0), 0) + game.piles.drawPile.insertCard(EXPLODING_KITTEN.createCard(0), -1) }, turn: { activePlayers: { diff --git a/src/common/models/game-state.model.ts b/src/common/models/game-state.model.ts index 89800c9..62e367b 100644 --- a/src/common/models/game-state.model.ts +++ b/src/common/models/game-state.model.ts @@ -28,8 +28,8 @@ export interface IPile { size: number; } -export interface IAnimationQueue { - [animationId: string]: IAnimation +export interface IAnimationQueue extends Map { + } export interface IAnimation { From 642934a403c62f2014a79fafe6c5daf72fb3d6fe Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:05:41 +0100 Subject: [PATCH 39/55] refactor: optimize animation queue implementation and simplify data structure --- src/client/context/AnimationContext.tsx | 18 +++++------------- src/common/entities/animation-queue.ts | 16 +++++++++------- src/common/models/game-state.model.ts | 4 ++-- src/common/setup/game-setup.ts | 2 +- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx index 6daa8f7..b7fbbec 100644 --- a/src/client/context/AnimationContext.tsx +++ b/src/client/context/AnimationContext.tsx @@ -54,10 +54,6 @@ export function AnimationProvider({ children }: { children: ReactNode }) { return } - if (id > lastAnimationTime.current) { - lastAnimationTime.current = id; - } - const newAnimation = { id, metadata: animation }; setAnimations(prev => [...prev, newAnimation]); @@ -88,15 +84,11 @@ export function AnimationProvider({ children }: { children: ReactNode }) { return; } - const currentAnimations = game.animationsQueue.queue; - for (let id of currentAnimations.keys()) { - if (id > lastAnimationTime.current) { - const animations = currentAnimations.get(id); - if (animations) { - animations.forEach(anim => playAnimation(id, anim)) - } - } - } + const [highestTime, animationsToPlay] = game.animationsQueue.getAnimationsToPlay(lastAnimationTime.current); + + lastAnimationTime.current = highestTime; + + animationsToPlay.forEach(anim => playAnimation(highestTime, anim)); }, [game.animationsQueue, playAnimation]); // Expose global window command for dev testing diff --git a/src/common/entities/animation-queue.ts b/src/common/entities/animation-queue.ts index 5d42472..ef2a518 100644 --- a/src/common/entities/animation-queue.ts +++ b/src/common/entities/animation-queue.ts @@ -10,8 +10,8 @@ export class AnimationQueue { enqueue(card: Card | ICard, from: Player | Pile, to: Player | Pile, durationMs: number = 500) { const animation: IAnimation = { - from: from instanceof Player ? `${from.id}` : `${from.name}`, - to: to instanceof Player ? `${to.id}` : `${to.name}`, + from: from instanceof Player ? from.id : `${from.name}`, + to: to instanceof Player ? to.id : `${to.name}`, card: {name: card.name, index: card.index}, durationMs }; @@ -21,10 +21,10 @@ export class AnimationQueue { enqueueAnimation(animation: IAnimation) { // generate a number unique id const id = Date.now(); - let currentQueue = this.queue.get(id); + let currentQueue = this.queue[id]; if (!currentQueue) { currentQueue = [] - this.queue.set(id, []) + this.queue[id] = currentQueue } currentQueue.push(animation) } @@ -32,9 +32,9 @@ export class AnimationQueue { getAnimationsToPlay(lastTimePlayed: number): [number, IAnimation[]] { const animationsToPlay: IAnimation[] = [] let highestTime: number = lastTimePlayed - for (let id of this.queue.keys()) { + for (let id of Object.keys(this.queue).map(Number)) { if (id > lastTimePlayed) { - const animations = this.queue.get(id); + const animations = this.queue[id]; if (animations) { if (id > highestTime) { highestTime = id; @@ -47,6 +47,8 @@ export class AnimationQueue { } clear() { - this.queue.clear(); + for (const key in this.queue) { + delete this.queue[key]; + } } } \ No newline at end of file diff --git a/src/common/models/game-state.model.ts b/src/common/models/game-state.model.ts index 62e367b..e042dac 100644 --- a/src/common/models/game-state.model.ts +++ b/src/common/models/game-state.model.ts @@ -28,8 +28,8 @@ export interface IPile { size: number; } -export interface IAnimationQueue extends Map { - +export interface IAnimationQueue { + [key: number]: IAnimation[]; } export interface IAnimation { diff --git a/src/common/setup/game-setup.ts b/src/common/setup/game-setup.ts index b3bdcc4..5c7103b 100644 --- a/src/common/setup/game-setup.ts +++ b/src/common/setup/game-setup.ts @@ -30,6 +30,6 @@ export const setupGame = (_context: any, setupData?: SetupData): IGameState => { pendingTimerMs: 3000, }, deckType: setupData?.deckType ?? 'original', - animationsQueue: {} + animationsQueue: {}, }; }; From acbb23034309a8afae20dc8eeb7c34c03c6a6e9d Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:08:29 +0100 Subject: [PATCH 40/55] refactor: dont put in string --- src/common/entities/animation-queue.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/entities/animation-queue.ts b/src/common/entities/animation-queue.ts index ef2a518..9fe5d88 100644 --- a/src/common/entities/animation-queue.ts +++ b/src/common/entities/animation-queue.ts @@ -10,8 +10,8 @@ export class AnimationQueue { enqueue(card: Card | ICard, from: Player | Pile, to: Player | Pile, durationMs: number = 500) { const animation: IAnimation = { - from: from instanceof Player ? from.id : `${from.name}`, - to: to instanceof Player ? to.id : `${to.name}`, + from: from instanceof Player ? from.id : from.name, + to: to instanceof Player ? to.id : to.name, card: {name: card.name, index: card.index}, durationMs }; From 281a54b9aa3e2a8f05efca39c4d0fdbe3d97fe9a Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:10:57 +0100 Subject: [PATCH 41/55] refactor: streamline animation handling and remove unnecessary state management --- .../components/animations/AnimatedCard.tsx | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/client/components/animations/AnimatedCard.tsx b/src/client/components/animations/AnimatedCard.tsx index 3526d8d..ecec1b3 100644 --- a/src/client/components/animations/AnimatedCard.tsx +++ b/src/client/components/animations/AnimatedCard.tsx @@ -1,11 +1,10 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { TheGameClient } from '../../entities/game-client'; import {CardAnimation, useAnimationState} from '../../context/AnimationContext'; export function AnimatedCard({ animation }: { animation: CardAnimation }) { const { getNode } = useAnimationState(); const cardRef = useRef(null); - const [isVisible, setIsVisible] = useState(false); useEffect(() => { const fromEl = getNode(String(animation.metadata.from)); @@ -44,20 +43,18 @@ cardRef:`, cardRef.current); cardRef.current.style.top = `${fromRect.top + fromRect.height / 2}px`; cardRef.current.style.width = `${fromWidth}px`; cardRef.current.style.backgroundImage = `url(${TheGameClient.getCardTexture(animation.metadata.card as any)})`; + cardRef.current.style.display = 'flex'; - // Unhide securely - setIsVisible(true); + // Force a synchronous DOM reflow to ensure the browser registers the start position + void cardRef.current.offsetHeight; // 2. Play transition explicitly targeting 'to' rect in next animation frames const frame1 = requestAnimationFrame(() => { - const frame2 = requestAnimationFrame(() => { - if (!cardRef.current) return; - cardRef.current.style.transition = `all ${animation.metadata.durationMs}ms cubic-bezier(0.25, 0.8, 0.25, 1)`; - cardRef.current.style.left = `${toRect.left + toRect.width / 2}px`; - cardRef.current.style.top = `${toRect.top + toRect.height / 2}px`; - cardRef.current.style.width = `${toWidth}px`; - }); - return () => cancelAnimationFrame(frame2); + if (!cardRef.current) return; + cardRef.current.style.transition = `all ${animation.metadata.durationMs}ms cubic-bezier(0.25, 0.8, 0.25, 1)`; + cardRef.current.style.left = `${toRect.left + toRect.width / 2}px`; + cardRef.current.style.top = `${toRect.top + toRect.height / 2}px`; + cardRef.current.style.width = `${toWidth}px`; }); return () => cancelAnimationFrame(frame1); @@ -66,13 +63,13 @@ cardRef:`, cardRef.current); return (
); From f5e92fada775d29c19673779055f2c40f79d9cb5 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:11:17 +0100 Subject: [PATCH 42/55] refactor: remove unnecessary type casting in background image assignment --- src/client/components/animations/AnimatedCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/components/animations/AnimatedCard.tsx b/src/client/components/animations/AnimatedCard.tsx index ecec1b3..27066be 100644 --- a/src/client/components/animations/AnimatedCard.tsx +++ b/src/client/components/animations/AnimatedCard.tsx @@ -42,7 +42,7 @@ cardRef:`, cardRef.current); cardRef.current.style.left = `${fromRect.left + fromRect.width / 2}px`; cardRef.current.style.top = `${fromRect.top + fromRect.height / 2}px`; cardRef.current.style.width = `${fromWidth}px`; - cardRef.current.style.backgroundImage = `url(${TheGameClient.getCardTexture(animation.metadata.card as any)})`; + cardRef.current.style.backgroundImage = `url(${TheGameClient.getCardTexture(animation.metadata.card)})`; cardRef.current.style.display = 'flex'; // Force a synchronous DOM reflow to ensure the browser registers the start position From 3a72ef5bb3e7702452fd8558b9d277113f2261ec Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:20:10 +0100 Subject: [PATCH 43/55] refactor: improve insert index calculation for defuse stage --- .../board/overlay/defuse/DefuseOverlay.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/client/components/board/overlay/defuse/DefuseOverlay.tsx b/src/client/components/board/overlay/defuse/DefuseOverlay.tsx index 1788644..7b003b0 100644 --- a/src/client/components/board/overlay/defuse/DefuseOverlay.tsx +++ b/src/client/components/board/overlay/defuse/DefuseOverlay.tsx @@ -11,13 +11,24 @@ export default function DefuseOverlay() { const trackRef = useRef(null); const cardAmount = game.piles.drawPile.size; + const isDefusing = game.selfPlayer?.isInStage(DEFUSE_EXPLODING_KITTEN); - // Set initial random position once + // Set initial random position when entering the defuse stage useEffect(() => { - if (insertIndex === null) { - setInsertIndex(Math.floor(Math.random() * (cardAmount + 1))); + if (isDefusing) { + const min = Math.ceil(cardAmount * 0.25); + const max = Math.floor(cardAmount * 0.75); + + if (max >= min) { + const range = max - min + 1; + setInsertIndex(min + Math.floor(Math.random() * range)); + } else { + setInsertIndex(Math.floor(Math.random() * (cardAmount + 1))); + } + } else { + setInsertIndex(null); } - }, [cardAmount, insertIndex]); + }, [isDefusing]); const handleDefuse = () => { if (insertIndex !== null) { @@ -99,7 +110,7 @@ export default function DefuseOverlay() { }; }, [isDragging, cardAmount]); - if (!game.selfPlayer?.isInStage(DEFUSE_EXPLODING_KITTEN)) { + if (!isDefusing) { return null; } From 4fc50cd5f6f85937d80c53ba018d1735df657789 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:29:38 +0100 Subject: [PATCH 44/55] feat: animation visible to --- src/client/context/AnimationContext.tsx | 3 ++- src/common/entities/animation-queue.ts | 8 +++++++- src/common/entities/player.ts | 6 ++++-- src/common/exploding-kittens.ts | 20 +++++++++++++++++++- src/common/models/game-state.model.ts | 1 + 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx index b7fbbec..42bed6a 100644 --- a/src/client/context/AnimationContext.tsx +++ b/src/client/context/AnimationContext.tsx @@ -75,7 +75,8 @@ export function AnimationProvider({ children }: { children: ReactNode }) { from: fromId, to: toId, card: card, - durationMs: durationMs + durationMs: durationMs, + visibleTo: [] }); }, [playAnimation]); diff --git a/src/common/entities/animation-queue.ts b/src/common/entities/animation-queue.ts index 9fe5d88..53519cf 100644 --- a/src/common/entities/animation-queue.ts +++ b/src/common/entities/animation-queue.ts @@ -2,17 +2,23 @@ import {IAnimation, IAnimationQueue, ICard} from "../models"; import {Player} from "./player"; import {Pile} from "./pile"; import {Card} from "./card"; +import type {PlayerID} from "boardgame.io"; export class AnimationQueue { constructor(public readonly queue: IAnimationQueue) { } - enqueue(card: Card | ICard, from: Player | Pile, to: Player | Pile, durationMs: number = 500) { + enqueue(card: Card | ICard, from: Player | Pile, to: Player | Pile, visibleTo: (Player | PlayerID)[] = [], durationMs: number = 500) { + const idVisibleTo: PlayerID[] = visibleTo.map(player => { + return player instanceof Player ? player.id : player; + }); + const animation: IAnimation = { from: from instanceof Player ? from.id : from.name, to: to instanceof Player ? to.id : to.name, card: {name: card.name, index: card.index}, + visibleTo: idVisibleTo, durationMs }; this.enqueueAnimation(animation); diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 072cdba..90010d4 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -250,10 +250,12 @@ export class Player { return; } + const explodingKittenDrawn = cardData.name === EXPLODING_KITTEN.name; + this.addCard(cardData); - this.game.animationsQueue.enqueue(cardData, this.game.piles.drawPile, this); + this.game.animationsQueue.enqueue(cardData, this.game.piles.drawPile, this, explodingKittenDrawn ? [] : [this]); - if (cardData.name !== EXPLODING_KITTEN.name) { + if (!explodingKittenDrawn) { this.game.turnManager.endTurn(); return; } diff --git a/src/common/exploding-kittens.ts b/src/common/exploding-kittens.ts index 71644e3..0bb8a90 100644 --- a/src/common/exploding-kittens.ts +++ b/src/common/exploding-kittens.ts @@ -27,9 +27,26 @@ export const ExplodingKittens: Game = { disableUndo: true, playerView: ({ G, ctx, playerID }) => { - const isViewingFuture = playerID != null && ctx.activePlayers?.[playerID] === VIEWING_FUTURE; + const isSpectator = playerID == null; + const canSeeCards = isSpectator && G.gameRules?.spectatorsSeeCards; + const isViewingFuture = !isSpectator && ctx.activePlayers?.[playerID] === VIEWING_FUTURE; const drawPileCards = G.piles?.drawPile?.cards ?? []; + const animationsQueue = { ...G.animationsQueue }; + if (!G.gameRules?.openCards) { + for (const timeKey in animationsQueue) { + animationsQueue[timeKey] = animationsQueue[timeKey].map(anim => { + const isGloballyVisible = anim.visibleTo.length == 0; + const isVisible = playerID != null && anim.visibleTo?.includes(playerID); + + if (!canSeeCards && !isGloballyVisible && !isVisible) { + return { ...anim, card: null }; + } + return anim; + }); + } + } + return { ...G, piles: { @@ -39,6 +56,7 @@ export const ExplodingKittens: Game = { cards: isViewingFuture ? drawPileCards.slice(0, 3) : [], }, }, + animationsQueue }; }, moves: {}, diff --git a/src/common/models/game-state.model.ts b/src/common/models/game-state.model.ts index e042dac..fea4d7b 100644 --- a/src/common/models/game-state.model.ts +++ b/src/common/models/game-state.model.ts @@ -36,6 +36,7 @@ export interface IAnimation { from: number | string; to: number | string; card: ICard | null; + visibleTo: PlayerID[]; durationMs: number; } From cef6ef808bef44f5b732af8afbe05e23946014d0 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:48:59 +0100 Subject: [PATCH 45/55] fix: cat card --- src/common/entities/card-types/cat-card.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/common/entities/card-types/cat-card.ts b/src/common/entities/card-types/cat-card.ts index 829b922..884d169 100644 --- a/src/common/entities/card-types/cat-card.ts +++ b/src/common/entities/card-types/cat-card.ts @@ -45,13 +45,14 @@ export class CatCard extends CardType { */ afterPlay(game: TheGame, card: Card) { const player = game.players.actingPlayer; - const secondCard = player.removeCard(card.name); + const secondCard = player.removeCard(card.name, card.index); if (!secondCard) { console.log("Error: Expected to find a second cat card to consume, but none found."); return; } - game.piles.discardCard(secondCard); + game.piles.discardPile.addCard(secondCard); + game.animationsQueue.enqueue(secondCard, player, game.piles.discardPile); } sortOrder(): number { From 69bf988a53cd3d2c93a143749cd6a087d242fd6e Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:49:11 +0100 Subject: [PATCH 46/55] fix: cat card --- src/common/entities/player.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 90010d4..076b927 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -135,15 +135,16 @@ export class Player { } /** - * Remove the first occurrence of a specific card type + * Remove the first occurrence of a specific card type and index (if not defined as -1) * @returns The removed card, or undefined if not found */ - removeCard(cardName: string): Card | undefined { - const index = this._state.hand.findIndex(c => c.name === cardName); + removeCard(cardName: string, cardIndex: number = -1): Card | undefined { + const index = this._state.hand.findIndex(c => c.name === cardName && (cardIndex === -1 || c.index === cardIndex)); if (index === -1) return undefined; return this.removeCardAt(index); } + /** * Remove all card-types of a specific type * @returns Array of removed card-types @@ -217,7 +218,8 @@ export class Player { const playedCard = this.removeCardAt(cardIndex); if (!playedCard) return; // Should not happen - this.game.piles.discardCard(playedCard); + this.game.piles.discardPile.addCard(playedCard); + this.game.animationsQueue.enqueue(playedCard, this, this.game.piles.discardPile); card.afterPlay(); if (card.type.isNowCard()) { From d164e8ff4ed70c441fef3588a0ed0455923e03b6 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:49:22 +0100 Subject: [PATCH 47/55] fix: discard ref id --- src/client/components/board/table/Table.tsx | 5 +++-- .../components/board/table/pending/PendingPlayStack.tsx | 3 ++- src/common/moves/defuse-exploding-kitten.ts | 1 - 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/client/components/board/table/Table.tsx b/src/client/components/board/table/Table.tsx index a50b81d..c263086 100644 --- a/src/client/components/board/table/Table.tsx +++ b/src/client/components/board/table/Table.tsx @@ -9,13 +9,14 @@ import {useResponsive} from "../../../context/ResponsiveContext.tsx"; import {NAME_SHUFFLE} from "../../../../common/constants/cards.ts"; import {useGame} from "../../../context/GameContext.tsx"; import {useAnimationNode} from "../../../context/AnimationContext.tsx"; +import {DISCARD, DRAW} from "../../../../common/constants/piles.ts"; export default function Table() { const game = useGame() const { isMobile } = useResponsive(); - const discardPileAnimRef = useAnimationNode('discard'); - const drawPileAnimRef = useAnimationNode('draw'); + const discardPileAnimRef = useAnimationNode(DISCARD); + const drawPileAnimRef = useAnimationNode(DRAW); const [isDrawing, setIsDrawing] = useState(false); const [isShuffling, setIsShuffling] = useState(false); diff --git a/src/client/components/board/table/pending/PendingPlayStack.tsx b/src/client/components/board/table/pending/PendingPlayStack.tsx index a130478..1155de8 100644 --- a/src/client/components/board/table/pending/PendingPlayStack.tsx +++ b/src/client/components/board/table/pending/PendingPlayStack.tsx @@ -5,6 +5,7 @@ import CardPreview from '../../CardPreview.tsx'; import {useGame} from "../../../../context/GameContext.tsx"; import {TheGameClient} from "../../../../entities/game-client.ts"; import {useAnimationNode} from "../../../../context/AnimationContext.tsx"; +import {DISCARD} from "../../../../../common/constants/piles.ts"; export default function PendingPlayStack() { const game = useGame(); @@ -16,7 +17,7 @@ export default function PendingPlayStack() { const isNoped = pendingCard.isNoped; const [isHovered, setIsHovered] = useState(false); const pileRef = useRef(null); - const discardPileAnimRef = useAnimationNode('discard-pile'); + const discardPileAnimRef = useAnimationNode(DISCARD); const setDiscardRef = useCallback((node: HTMLDivElement | null) => { if (pileRef) { diff --git a/src/common/moves/defuse-exploding-kitten.ts b/src/common/moves/defuse-exploding-kitten.ts index e9713cd..c309426 100644 --- a/src/common/moves/defuse-exploding-kitten.ts +++ b/src/common/moves/defuse-exploding-kitten.ts @@ -3,4 +3,3 @@ import {TheGame} from "../entities/game"; export const defuseExplodingKitten = (game: TheGame, insertIndex: number) => { game.players.actingPlayer.defuseExplodingKitten(insertIndex); }; - From d1ba3e2c1da3a6e057dc333aec287cda97df3f5b Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:03:06 +0100 Subject: [PATCH 48/55] feat: enhance card handling and animation logic with id support --- src/client/context/AnimationContext.tsx | 6 +- src/common/entities/animation-queue.ts | 19 ++++-- src/common/entities/card-type.ts | 8 ++- src/common/entities/card-types/cat-card.ts | 10 ++-- src/common/entities/card.ts | 60 ++++++++++++++++++- src/common/entities/deck-type.ts | 8 +-- .../entities/deck-types/original-deck.ts | 33 +++++----- src/common/entities/game.ts | 22 ++++++- src/common/entities/pile.ts | 19 ++++-- src/common/entities/player.ts | 51 +++++++++------- src/common/exploding-kittens.ts | 16 ++--- src/common/models/card.model.ts | 1 + src/common/models/game-state.model.ts | 1 + src/common/setup/game-setup.ts | 1 + src/common/setup/player-setup.ts | 23 ++++--- 15 files changed, 200 insertions(+), 78 deletions(-) diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx index 42bed6a..c949374 100644 --- a/src/client/context/AnimationContext.tsx +++ b/src/client/context/AnimationContext.tsx @@ -94,7 +94,11 @@ export function AnimationProvider({ children }: { children: ReactNode }) { // Expose global window command for dev testing (window as any).playAnimation = (fromId: string, toId: string, cardName?: string, cardIndex?: number, durationMs = 500) => { - const card = cardName ? { name: cardName, index: cardIndex ?? 0 } : null; + const card = cardName ? { + id: -1, + name: cardName, + index: cardIndex ?? 0 + } : null; playManualAnimation(fromId, toId, card, durationMs); }; diff --git a/src/common/entities/animation-queue.ts b/src/common/entities/animation-queue.ts index 53519cf..21a7c2f 100644 --- a/src/common/entities/animation-queue.ts +++ b/src/common/entities/animation-queue.ts @@ -9,7 +9,8 @@ export class AnimationQueue { constructor(public readonly queue: IAnimationQueue) { } - enqueue(card: Card | ICard, from: Player | Pile, to: Player | Pile, visibleTo: (Player | PlayerID)[] = [], durationMs: number = 500) { + enqueue(card: Card | ICard, from: Player | Pile, to: Player | Pile, options?: { visibleTo?: (Player | PlayerID)[], durationMs?: number }) { + const { visibleTo = [], durationMs = 500 } = options || {}; const idVisibleTo: PlayerID[] = visibleTo.map(player => { return player instanceof Player ? player.id : player; }); @@ -17,16 +18,20 @@ export class AnimationQueue { const animation: IAnimation = { from: from instanceof Player ? from.id : from.name, to: to instanceof Player ? to.id : to.name, - card: {name: card.name, index: card.index}, + card: { + id: 'id' in card ? card.id : -1, + name: card.name, + index: card.index + }, visibleTo: idVisibleTo, durationMs }; this.enqueueAnimation(animation); } - enqueueAnimation(animation: IAnimation) { - // generate a number unique id - const id = Date.now(); + enqueueAnimationWithDelay(animation: IAnimation, options?: { delayMs?: number }) { + const delayMs = options?.delayMs || 0; + const id = Date.now() + delayMs; let currentQueue = this.queue[id]; if (!currentQueue) { currentQueue = [] @@ -35,6 +40,10 @@ export class AnimationQueue { currentQueue.push(animation) } + enqueueAnimation(animation: IAnimation) { + this.enqueueAnimationWithDelay(animation, { delayMs: 0 }); + } + getAnimationsToPlay(lastTimePlayed: number): [number, IAnimation[]] { const animationsToPlay: IAnimation[] = [] let highestTime: number = lastTimePlayed diff --git a/src/common/entities/card-type.ts b/src/common/entities/card-type.ts index 70e3ede..ecb2334 100644 --- a/src/common/entities/card-type.ts +++ b/src/common/entities/card-type.ts @@ -14,8 +14,12 @@ export class CardType { return false; } - createCard(index: number): ICard { - return {name: this.name, index}; + createCard(index: number, game: TheGame): ICard { + return { + id: game.generateCardId(), + name: this.name, + index: index + }; } canBePlayed(_game: TheGame, _card: Card): boolean { diff --git a/src/common/entities/card-types/cat-card.ts b/src/common/entities/card-types/cat-card.ts index 884d169..ae911b7 100644 --- a/src/common/entities/card-types/cat-card.ts +++ b/src/common/entities/card-types/cat-card.ts @@ -45,14 +45,14 @@ export class CatCard extends CardType { */ afterPlay(game: TheGame, card: Card) { const player = game.players.actingPlayer; - const secondCard = player.removeCard(card.name, card.index); - - if (!secondCard) { + // Just find the card but don't remove it directly, use moveTo to ensure logic flows properly + const matchingCards = player.getMatchingCards(card); + if (matchingCards.length === 0) { console.log("Error: Expected to find a second cat card to consume, but none found."); return; } - game.piles.discardPile.addCard(secondCard); - game.animationsQueue.enqueue(secondCard, player, game.piles.discardPile); + const secondCard = matchingCards[0]; + secondCard.moveTo(game.piles.discardPile, { delayMs: 500 }); } sortOrder(): number { diff --git a/src/common/entities/card.ts b/src/common/entities/card.ts index 58e77d5..ee6d776 100644 --- a/src/common/entities/card.ts +++ b/src/common/entities/card.ts @@ -2,8 +2,12 @@ import {ICard} from '../models'; import {TheGame} from './game'; import {cardTypeRegistry} from '../registries/card-registry'; import {CardType} from './card-type'; +import {Player} from "./player"; +import {Pile} from "./pile"; +import type {PlayerID} from "boardgame.io"; export class Card { + public id: number; public name: string; public index: number; @@ -11,12 +15,13 @@ export class Card { private game: TheGame, _data: ICard ) { + this.id = _data.id; this.name = _data.name; this.index = _data.index; } get data(): ICard { - return {name: this.name, index: this.index}; + return {id: this.id, name: this.name, index: this.index}; } get type(): CardType { @@ -25,6 +30,58 @@ export class Card { return t; } + moveTo(destination: Player | Pile, options?: { visibleTo?: PlayerID[], delayMs?: number, insertIndex?: number }): void { + const { visibleTo, delayMs, insertIndex } = options || {}; + + const removal = this.game.findAndRemoveCardById(this.id); + if (!removal) { + console.error(`Could not find card ${this.name} (${this.id}) to move.`); + return; + } + const { card: cardData, source } = removal; + + // Add to destination + if (destination instanceof Player) { + destination.addCard(cardData); + } else { + if (insertIndex !== undefined && insertIndex >= 0 && insertIndex <= destination.cards.length) { + destination.insertCard(cardData, insertIndex); + } else { + destination.addCard(cardData); + } + } + + // Determine visibility + let finalVisibleTo: PlayerID[] = []; + if (visibleTo !== undefined) { + finalVisibleTo = visibleTo; + } else { + // Default inference + if (destination instanceof Player) { + finalVisibleTo = [destination.id]; + // If moving between players, make it visible to both + for (const p of this.game.players.allPlayers) { + if (p.id === source) { + finalVisibleTo.push(source); + } + } + } + } + + // Enqueue animation + // Note: since source is string and destination is Player|Pile, we adapt enqueue + const toStr = destination instanceof Player ? destination.id : destination.name; + + const animation = { + from: source, + to: toStr, + card: cardData, + visibleTo: finalVisibleTo, + durationMs: 500 + }; + this.game.animationsQueue.enqueueAnimationWithDelay(animation, { delayMs: delayMs || 0 }); + } + canPlay(): boolean { return this.type.canBePlayed(this.game, this); } @@ -37,4 +94,3 @@ export class Card { this.type.afterPlay(this.game, this); } } - diff --git a/src/common/entities/deck-type.ts b/src/common/entities/deck-type.ts index 876c8f8..2015309 100644 --- a/src/common/entities/deck-type.ts +++ b/src/common/entities/deck-type.ts @@ -1,4 +1,5 @@ import type {ICard} from '../models'; +import {TheGame} from './game'; export abstract class DeckType { name: string; @@ -8,18 +9,17 @@ export abstract class DeckType { } /** Cards that form the base deck before dealing */ - abstract buildBaseDeck(): ICard[]; + abstract buildBaseDeck(game: TheGame): void; /** How many card-types each player starts with */ abstract startingHandSize(): number; /** Cards automatically added to each player's hand */ - startingHandForcedCards(_player_index: number): ICard[] { + startingHandForcedCards(_game: TheGame, _player_index: number): ICard[] { return []; } /** Extra card-types added after the players are dealt */ - addPostDealCards(_pile: ICard[], _playerCount: number): void { + addPostDealCards(_game: TheGame): void { } } - diff --git a/src/common/entities/deck-types/original-deck.ts b/src/common/entities/deck-types/original-deck.ts index e2772be..225b0ad 100644 --- a/src/common/entities/deck-types/original-deck.ts +++ b/src/common/entities/deck-types/original-deck.ts @@ -1,5 +1,6 @@ import {DeckType} from '../deck-type'; import type {ICard} from '../../models'; +import {TheGame} from '../game'; import { ATTACK, @@ -24,44 +25,44 @@ export class OriginalDeck extends DeckType { return STARTING_HAND_SIZE; } - startingHandForcedCards(index: number): ICard[] { - return [DEFUSE.createCard(index)]; + startingHandForcedCards(game: TheGame, index: number): ICard[] { + return [DEFUSE.createCard(index, game)]; } - buildBaseDeck(): ICard[] { - const pile: ICard[] = []; + buildBaseDeck(game: TheGame): void { + const drawPile = game.piles.drawPile; for (let i = 0; i < 4; i++) { - pile.push(ATTACK.createCard(i)); - pile.push(SKIP.createCard(i)); - pile.push(SHUFFLE.createCard(i)); - pile.push(FAVOR.createCard(i)); - pile.push(NOPE.createCard(i)); + drawPile.addCard(ATTACK.createCard(i, game)); + drawPile.addCard(SKIP.createCard(i, game)); + drawPile.addCard(SHUFFLE.createCard(i, game)); + drawPile.addCard(FAVOR.createCard(i, game)); + drawPile.addCard(NOPE.createCard(i, game)); for (let j = 0; j < 5; j++) { - pile.push(CAT_CARD.createCard(i)); + drawPile.addCard(CAT_CARD.createCard(i, game)); } } for (let i = 0; i < 5; i++) { - pile.push(SEE_THE_FUTURE.createCard(i)); + drawPile.addCard(SEE_THE_FUTURE.createCard(i, game)); } - - return pile; } - addPostDealCards(pile: ICard[], playerCount: number): void { + addPostDealCards(game: TheGame): void { + const playerCount = game.players.playerCount; const remaining = Math.min(TOTAL_DEFUSE_CARDS - playerCount, MAX_DECK_DEFUSE_CARDS); + const drawPile = game.piles.drawPile; for (let i = 0; i < remaining; i++) { const cardIndex = (playerCount + i) % TOTAL_DEFUSE_CARDS; - pile.push(DEFUSE.createCard(cardIndex)); + drawPile.addCard(DEFUSE.createCard(cardIndex, game)); } // add amount of players minus one exploding kitten for (let i = 0; i < playerCount - 1; i++) { // after index 3 restart at 0, since there are only 4 unique exploding kitten cards const cardIndex = i % 4; - pile.push(EXPLODING_KITTEN.createCard(cardIndex)); + drawPile.addCard(EXPLODING_KITTEN.createCard(cardIndex, game)); } } } diff --git a/src/common/entities/game.ts b/src/common/entities/game.ts index c04f22e..9829533 100644 --- a/src/common/entities/game.ts +++ b/src/common/entities/game.ts @@ -1,4 +1,4 @@ -import {IContext, IGameState, IPlayers} from '../models'; +import {IContext, IGameState, IPlayers, ICard} from '../models'; import {Piles} from './piles'; import {Players} from './players'; import {TurnManager} from './turn-manager'; @@ -44,6 +44,26 @@ export class TheGame { this.players = new Players(this, this.gameState, players); } + findAndRemoveCardById(id: number): { card: ICard, source: string } | null { + for (const player of this.players.allPlayers) { + const removed = player.removeCardById(id); + if (removed) return { card: removed.data, source: player.id }; + } + + let removed = this.piles.drawPile.removeCardById(id); + if (removed) return { card: removed.data, source: this.piles.drawPile.name }; + + removed = this.piles.discardPile.removeCardById(id); + if (removed) return { card: removed.data, source: this.piles.discardPile.name }; + + // Assuming pending play might not be a source but keeping it clean + return null; + } + + generateCardId(): number { + return this.gameState.nextCardId++; + } + get phase(): string { return this.bgContext.phase; } diff --git a/src/common/entities/pile.ts b/src/common/entities/pile.ts index 9e83e6b..f9ea5c6 100644 --- a/src/common/entities/pile.ts +++ b/src/common/entities/pile.ts @@ -14,7 +14,11 @@ export class Pile { } addCard(card: Card | ICard): void { - const cardData: ICard = {name: card.name, index: card.index}; + const cardData: ICard = { + id: card.id, + name: card.name, + index: card.index + }; // Clone to avoid Proxy issues this.cards.push({...cardData}); @@ -38,12 +42,20 @@ export class Pile { } insertCard(card: ICard, index: number): void { - const cardData: ICard = {name: card.name, index: card.index}; + const cardData: ICard = {id: card.id, name: card.name, index: card.index}; // Clone to avoid Proxy issues this.cards.splice(index, 0, {...cardData}); this.updateSize(); } + removeCardById(id: number): Card | undefined { + const index = this.cards.findIndex(c => c.id === id); + if (index === -1) return undefined; + const [card] = this.cards.splice(index, 1); + this.updateSize(); + return new Card(this.game, card); + } + get size(): number { return this.state.size; } @@ -52,8 +64,7 @@ export class Pile { this.state.cards = this.game.random.Shuffle(this.cards); } - private updateSize(): void { + updateSize(): void { this.state.size = this.cards.length; } } - diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 076b927..5a29d48 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -116,7 +116,7 @@ export class Player { * Add a card to the player's hand */ addCard(card: Card | ICard): void { - const cardData: ICard = {name: card.name, index: card.index}; + const cardData: ICard = {id: card.id, name: card.name, index: card.index}; // Clone to avoid Proxy issues this._state.hand.push({...cardData}); @@ -144,6 +144,11 @@ export class Player { return this.removeCardAt(index); } + removeCardById(id: number): Card | undefined { + const index = this._state.hand.findIndex(c => c.id === id); + if (index === -1) return undefined; + return this.removeCardAt(index); + } /** * Remove all card-types of a specific type @@ -195,11 +200,11 @@ export class Player { * Transfers a card at specific index to another playerWrapper */ giveCard(cardIndex: number, recipient: Player): Card { - const card = this.removeCardAt(cardIndex); + const card = this.getCardAt(cardIndex); if (!card) { throw new Error("Card not found or invalid index"); } - recipient.addCard(card); + card.moveTo(recipient); return card; } @@ -214,12 +219,11 @@ export class Player { throw new Error(`Cannot play card: ${card.name}`); } - // Remove card from hand - const playedCard = this.removeCardAt(cardIndex); - if (!playedCard) return; // Should not happen + const cardData = {...card.data}; // save before moveTo - this.game.piles.discardPile.addCard(playedCard); - this.game.animationsQueue.enqueue(playedCard, this, this.game.piles.discardPile); + // Move to discard + card.moveTo(this.game.piles.discardPile); + card.afterPlay(); if (card.type.isNowCard()) { @@ -230,7 +234,7 @@ export class Player { // Setup pending state const startedAtMs = Date.now(); this.game.piles.pendingCard = { - card: {...playedCard.data}, + card: cardData, playedBy: this.id, startedAtMs, expiresAtMs: startedAtMs + (this.game.gameRules.pendingTimerMs || 5000), @@ -245,17 +249,17 @@ export class Player { draw(): void { if (!this.isAlive) throw new Error("Dead player cannot draw"); - const cardData = this.game.piles.drawCard(); - if (!cardData) { + const cards = this.game.piles.drawPile.allCards; + if (cards.length === 0) { console.error("Draw pile is empty, cannot draw"); this.eliminate(); return; } + const card = cards[0]; - const explodingKittenDrawn = cardData.name === EXPLODING_KITTEN.name; + const explodingKittenDrawn = card.name === EXPLODING_KITTEN.name; - this.addCard(cardData); - this.game.animationsQueue.enqueue(cardData, this.game.piles.drawPile, this, explodingKittenDrawn ? [] : [this]); + card.moveTo(this, { visibleTo: explodingKittenDrawn ? [] : [this.id] }); if (!explodingKittenDrawn) { this.game.turnManager.endTurn(); @@ -281,20 +285,23 @@ export class Player { throw new Error('Invalid insert index'); } - const defuseCard = this.removeCard(DEFUSE.name); - const kittenCard = this.removeCard(EXPLODING_KITTEN.name); + // We can't just removeCard anymore, we need the Card object to move it + const defuseCardIdx = this.findCardIndex(DEFUSE.name); + const kittenCardIdx = this.findCardIndex(EXPLODING_KITTEN.name); - if (!defuseCard || !kittenCard) { + if (defuseCardIdx === -1 || kittenCardIdx === -1) { // Should not happen if UI is correct, but safer to eliminate this.eliminate(); return; } + const defuseCard = this.hand[defuseCardIdx]; + const kittenCard = this.hand[kittenCardIdx]; + const discardPile = this.game.piles.discardPile; - discardPile.addCard(defuseCard); - this.game.animationsQueue.enqueue(defuseCard, this, discardPile) - drawPile.insertCard(kittenCard, insertIndex); - this.game.animationsQueue.enqueue(kittenCard, this, drawPile) + + defuseCard.moveTo(discardPile); + kittenCard.moveTo(drawPile, { insertIndex: insertIndex }); this.game.turnManager.endStage(); this.game.turnManager.endTurn(); @@ -310,7 +317,7 @@ export class Player { return target.giveCard(index, this); } - private updateHandSize() { + updateHandSize() { this._state.handSize = this._state.hand.length; } } diff --git a/src/common/exploding-kittens.ts b/src/common/exploding-kittens.ts index 0bb8a90..590adc6 100644 --- a/src/common/exploding-kittens.ts +++ b/src/common/exploding-kittens.ts @@ -1,7 +1,7 @@ import {Game} from 'boardgame.io'; import {createPlayerPlugin} from './plugins/player-plugin'; import {setupGame} from './setup/game-setup'; -import type {ICard, IContext, IGameState, IPluginAPIs} from './models'; +import type {IContext, IGameState, IPluginAPIs} from './models'; import {drawCard} from "./moves/draw-move"; import {playCard, resolvePendingCard} from "./moves/play-card-move"; import {requestCard, giveCard} from "./moves/favor-card-move"; @@ -70,15 +70,16 @@ export const ExplodingKittens: Game = { // Initialize the hands and piles const deck = new OriginalDeck(); - const pile: ICard[] = deck.buildBaseDeck().sort(() => Math.random() - 0.5); + + deck.buildBaseDeck(game); + game.piles.drawPile.shuffle(); - dealHands(pile, game.context.player.state, deck); - deck.addPostDealCards(pile, game.players.playerCount); + dealHands(game, deck); + deck.addPostDealCards(game); - game.piles.drawPile = pile; game.piles.drawPile.shuffle(); - game.piles.drawPile.insertCard(EXPLODING_KITTEN.createCard(0), 0) - game.piles.drawPile.insertCard(EXPLODING_KITTEN.createCard(0), -1) + game.piles.drawPile.insertCard(EXPLODING_KITTEN.createCard(0, game), 0) + game.piles.drawPile.insertCard(EXPLODING_KITTEN.createCard(0, game), -1) }, turn: { activePlayers: { @@ -204,4 +205,3 @@ export const ExplodingKittens: Game = { gameOver: {}, }, }; - diff --git a/src/common/models/card.model.ts b/src/common/models/card.model.ts index 42f0233..7ef7420 100644 --- a/src/common/models/card.model.ts +++ b/src/common/models/card.model.ts @@ -1,4 +1,5 @@ export interface ICard { + id: number; name: string; index: number; } diff --git a/src/common/models/game-state.model.ts b/src/common/models/game-state.model.ts index fea4d7b..1663c62 100644 --- a/src/common/models/game-state.model.ts +++ b/src/common/models/game-state.model.ts @@ -47,4 +47,5 @@ export interface IGameState { gameRules: IGameRules; deckType: string; animationsQueue: IAnimationQueue; + nextCardId: number; } diff --git a/src/common/setup/game-setup.ts b/src/common/setup/game-setup.ts index 5c7103b..c363742 100644 --- a/src/common/setup/game-setup.ts +++ b/src/common/setup/game-setup.ts @@ -11,6 +11,7 @@ interface SetupData { export const setupGame = (_context: any, setupData?: SetupData): IGameState => { // Don't deal card-types yet - will be done when lobby phase ends return { + nextCardId: 1, winner: null, piles: { drawPile: { diff --git a/src/common/setup/player-setup.ts b/src/common/setup/player-setup.ts index a518530..2923389 100644 --- a/src/common/setup/player-setup.ts +++ b/src/common/setup/player-setup.ts @@ -1,4 +1,4 @@ -import type {ICard, IPlayer, IPlayers} from '../models'; +import type {IPlayer, IPlayers} from '../models'; import type {DeckType} from '../entities/deck-type'; import {cardTypeRegistry} from "../registries/card-registry"; import {CardType} from "../entities/card-type"; @@ -57,23 +57,30 @@ export const filterPlayerView = (game: TheGame): IPlayers => { return view; }; -export function dealHands(pile: ICard[], players: IPlayers, deck: DeckType) { +export function dealHands(game: TheGame, deck: DeckType) { const handSize = deck.startingHandSize(); - Object.values(players).forEach((player, index) => { - player.hand = pile.splice(0, handSize); - const forcedCards = deck.startingHandForcedCards(index); + game.players.allPlayers.forEach((player, index) => { + // Draw base cards from the pile + for (let i = 0; i < handSize; i++) { + const card = game.piles.drawPile.drawCard(); + if (card) { + player.addCard(card); + } + } + + // Create forced initial cards outside of drawing like defuse + const forcedCards = deck.startingHandForcedCards(game, index); // Add any card-types that are in testing mode (e.g. for development or QA purposes) cardTypeRegistry.getAll().forEach((card: CardType) => { if (card.inTesting()) { for (let i = 0; i < 3; i++) { - forcedCards.push(card.createCard(0)); + forcedCards.push(card.createCard(0, game)); } } }); - player.hand.push(...forcedCards); - player.handSize = player.hand.length; + forcedCards.forEach(c => player.addCard(c)); }); } From 42a8280b49546a436514435594c8da70432c04db Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:26:12 +0100 Subject: [PATCH 49/55] feat: refactor card management by introducing CardList class for better structure --- src/common/entities/card-list.ts | 104 +++++++++++++++++ src/common/entities/card-types/cat-card.ts | 2 +- src/common/entities/card.ts | 10 +- src/common/entities/pile.ts | 66 ++--------- src/common/entities/player.ts | 124 +++------------------ 5 files changed, 135 insertions(+), 171 deletions(-) create mode 100644 src/common/entities/card-list.ts diff --git a/src/common/entities/card-list.ts b/src/common/entities/card-list.ts new file mode 100644 index 0000000..0442cd9 --- /dev/null +++ b/src/common/entities/card-list.ts @@ -0,0 +1,104 @@ +import {ICard} from '../models'; +import {TheGame} from './game'; +import {Card} from './card'; + +export class CardList { + constructor( + protected game: TheGame, + protected _getRawCards: () => ICard[], + protected setCards: (cards: ICard[]) => void, + protected onUpdate?: () => void + ) {} + + get cards(): Card[] { + return this._getRawCards().map(c => new Card(this.game, c)); + } + + get rawCards(): ICard[] { + return this._getRawCards(); + } + + get length(): number { + return this._getRawCards().length; + } + + get topCard(): Card | null { + const cards = this._getRawCards(); + if (cards.length === 0) return null; + return new Card(this.game, cards[cards.length - 1]); + } + + hasCard(cardName: string): boolean { + return this._getRawCards().some(c => c.name === cardName); + } + + findCardIndex(cardName: string): number { + return this._getRawCards().findIndex(c => c.name === cardName); + } + + getCardAt(index: number): Card | null { + const cards = this._getRawCards(); + if (index < 0 || index >= cards.length) return null; + return new Card(this.game, cards[index]); + } + + getCards(cardName: Card | string): Card[] { + const name = typeof cardName === 'string' ? cardName : cardName.name; + return this._getRawCards().filter(c => c.name === name).map(c => new Card(this.game, c)); + } + + getMatchingCards(card: Card): Card[] { + return this.getCards(card.name).filter(c => c.index === card.index); + } + + addCard(card: Card | ICard): void { + const cardData: ICard = { id: card.id, name: card.name, index: card.index }; + this._getRawCards().push({ ...cardData }); + this.onUpdate?.(); + } + + insertCard(card: Card | ICard, index: number): void { + const cardData: ICard = { id: card.id, name: card.name, index: card.index }; + this._getRawCards().splice(index, 0, { ...cardData }); + this.onUpdate?.(); + } + + removeCardAt(index: number): Card | undefined { + const cards = this._getRawCards(); + if (index < 0 || index >= cards.length) return undefined; + const [card] = cards.splice(index, 1); + this.onUpdate?.(); + return new Card(this.game, card); + } + + removeCardById(id: number): Card | undefined { + const index = this._getRawCards().findIndex(c => c.id === id); + if (index === -1) return undefined; + return this.removeCardAt(index); + } + + removeAllCardsFromList(): Card[] { + const cards = this._getRawCards(); + const removed = [...cards]; + // In-place clear + cards.splice(0, cards.length); + this.onUpdate?.(); + return removed.map(c => new Card(this.game, c)); + } + + drawCard(): Card | null { + const cards = this._getRawCards(); + const shift = cards.shift(); + if (shift) { + this.onUpdate?.(); + return new Card(this.game, shift); + } + return null; + } + + shuffle(): void { + const cards = this._getRawCards(); + this.setCards(this.game.random.Shuffle(cards)); + this.onUpdate?.(); + } +} diff --git a/src/common/entities/card-types/cat-card.ts b/src/common/entities/card-types/cat-card.ts index ae911b7..2127e73 100644 --- a/src/common/entities/card-types/cat-card.ts +++ b/src/common/entities/card-types/cat-card.ts @@ -52,7 +52,7 @@ export class CatCard extends CardType { return; } const secondCard = matchingCards[0]; - secondCard.moveTo(game.piles.discardPile, { delayMs: 500 }); + secondCard.moveTo(game.piles.discardPile, { delayMs: 150 }); } sortOrder(): number { diff --git a/src/common/entities/card.ts b/src/common/entities/card.ts index ee6d776..760c6b6 100644 --- a/src/common/entities/card.ts +++ b/src/common/entities/card.ts @@ -41,14 +41,10 @@ export class Card { const { card: cardData, source } = removal; // Add to destination - if (destination instanceof Player) { - destination.addCard(cardData); + if (insertIndex !== undefined && insertIndex >= 0 && insertIndex <= destination.length) { + destination.insertCard(cardData, insertIndex); } else { - if (insertIndex !== undefined && insertIndex >= 0 && insertIndex <= destination.cards.length) { - destination.insertCard(cardData, insertIndex); - } else { - destination.addCard(cardData); - } + destination.addCard(cardData); } // Determine visibility diff --git a/src/common/entities/pile.ts b/src/common/entities/pile.ts index f9ea5c6..388e3c3 100644 --- a/src/common/entities/pile.ts +++ b/src/common/entities/pile.ts @@ -1,70 +1,28 @@ -import {ICard, IPile} from '../models'; +import {IPile} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; +import {CardList} from "./card-list"; -export class Pile { - public cards: ICard[]; +export class Pile extends CardList { - constructor(public readonly name: string, private game: TheGame, public state: IPile) { - this.cards = state.cards; + constructor(public readonly name: string, game: TheGame, public state: IPile) { + super( + game, + () => state.cards, + (cards) => { state.cards = cards; }, + () => { state.size = state.cards.length; } + ); } get allCards(): Card[] { - return this.cards.map(iCard => new Card(this.game, iCard)); - } - - addCard(card: Card | ICard): void { - const cardData: ICard = { - id: card.id, - name: card.name, - index: card.index - }; - - // Clone to avoid Proxy issues - this.cards.push({...cardData}); - this.updateSize(); - } - - get topCard(): Card | null { - const iCard = this.cards[this.cards.length - 1]; - return this.cards.length > 0 ? new Card(this.game, iCard) : null; - } - - peek(amount: number): Card[] { - const peekedCards = this.cards.slice(0, amount); - return peekedCards.map(iCard => new Card(this.game, iCard)); - } - - drawCard(): Card | null { - const shift = this.cards.shift(); - this.updateSize(); - return shift ? new Card(this.game, shift) : null; - } - - insertCard(card: ICard, index: number): void { - const cardData: ICard = {id: card.id, name: card.name, index: card.index}; - // Clone to avoid Proxy issues - this.cards.splice(index, 0, {...cardData}); - this.updateSize(); - } - - removeCardById(id: number): Card | undefined { - const index = this.cards.findIndex(c => c.id === id); - if (index === -1) return undefined; - const [card] = this.cards.splice(index, 1); - this.updateSize(); - return new Card(this.game, card); + return this.cards; } get size(): number { return this.state.size; } - shuffle(): void { - this.state.cards = this.game.random.Shuffle(this.cards); - } - updateSize(): void { - this.state.size = this.cards.length; + this.state.size = this.length; } } diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 5a29d48..6a823aa 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -1,23 +1,31 @@ -import {ICard, IPlayer} from '../models'; +import {IPlayer} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; import {EXPLODING_KITTEN, DEFUSE} from "../registries/card-registry"; import {CHOOSE_PLAYER_TO_REQUEST_FROM, DEFUSE_EXPLODING_KITTEN} from "../constants/stages"; import {PlayerID} from "boardgame.io"; import {NAME_NOPE} from "../constants/cards"; +import {CardList} from "./card-list"; -export class Player { +export class Player extends CardList { constructor( - private game: TheGame, + game: TheGame, public _state: IPlayer, public readonly id: PlayerID - ) {} + ) { + super( + game, + () => _state.hand, + (cards) => { _state.hand = cards; }, + () => { _state.handSize = _state.hand.length; } + ); + } /** * Get the player's hand of card-types */ get hand(): Card[] { - return this._state.hand.map(c => new Card(this.game, c)); + return this.cards; } /** @@ -81,109 +89,11 @@ export class Player { || this.isInStage(CHOOSE_PLAYER_TO_REQUEST_FROM); } - /** - * Check if the player has at least one card of the given type - */ - hasCard(cardName: string): boolean { - return this._state.hand.some(c => c.name === cardName); - } - - findCardIndex(cardName: string): number { - return this._state.hand.findIndex(c => c.name === cardName); - } - - getCardAt(index: number): Card | null { - if (index < 0 || index >= this._state.hand.length) return null; - return new Card(this.game, this._state.hand[index]); - } - - /** - * Get all card-types of a specific type from hand, or all card-types if no type specified - */ - getCards(cardName: Card | string): Card[] { - const name = typeof cardName === 'string' ? cardName : cardName.name; - return this._state.hand.filter(c => c.name === name).map(c => new Card(this.game, c)); - } - - /** - * Get all card-types that match both name and index of the given card. - */ - getMatchingCards(card: Card): Card[] { - return this.getCards(card.name).filter(c => c.index === card.index); - } - - /** - * Add a card to the player's hand - */ - addCard(card: Card | ICard): void { - const cardData: ICard = {id: card.id, name: card.name, index: card.index}; - - // Clone to avoid Proxy issues - this._state.hand.push({...cardData}); - this.updateHandSize(); - } - - /** - * Remove a card at a specific index - * @returns The removed card, or undefined if index invalid - */ - removeCardAt(index: number): Card | undefined { - if (index < 0 || index >= this._state.hand.length) return undefined; - const [card] = this._state.hand.splice(index, 1); - this.updateHandSize(); - return new Card(this.game, card); - } - - /** - * Remove the first occurrence of a specific card type and index (if not defined as -1) - * @returns The removed card, or undefined if not found - */ - removeCard(cardName: string, cardIndex: number = -1): Card | undefined { - const index = this._state.hand.findIndex(c => c.name === cardName && (cardIndex === -1 || c.index === cardIndex)); - if (index === -1) return undefined; - return this.removeCardAt(index); - } - - removeCardById(id: number): Card | undefined { - const index = this._state.hand.findIndex(c => c.id === id); - if (index === -1) return undefined; - return this.removeCardAt(index); - } - - /** - * Remove all card-types of a specific type - * @returns Array of removed card-types - */ - removeAllCards(cardName: string): Card[] { - const removed: Card[] = []; - // Iterate backwards to safely remove - for (let i = this._state.hand.length - 1; i >= 0; i--) { - if (this._state.hand[i].name === cardName) { - const [card] = this._state.hand.splice(i, 1); - removed.push(new Card(this.game, card)); - } - } - if (removed.length > 0) { - this.updateHandSize(); - } - return removed; - } - - /** - * Remove all card-types from hand - * @returns Array of all removed card-types - */ - removeAllCardsFromHand(): Card[] { - const removed = [...this._state.hand]; - this._state.hand = []; - this.updateHandSize(); - return removed.map(c => new Card(this.game, c)); - } - eliminate(): void { this._state.isAlive = false; // put all hand card-types in discard pile - this._state.hand.forEach(card => this.game.piles.discardCard(card)); + const cards = this.removeAllCardsFromList(); + cards.forEach(card => this.game.piles.discardCard(card)); if (this.isActingPlayer) { this.game.turnManager.endStage(); // could also involve other players but better than being stuck } @@ -316,8 +226,4 @@ export class Player { // Give card from target to this player return target.giveCard(index, this); } - - updateHandSize() { - this._state.handSize = this._state.hand.length; - } } From c5fe9dc6ce8bcd7a030fb5c43718a633c979052f Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:26:37 +0100 Subject: [PATCH 50/55] feat: rename CardList to CardHolder --- src/common/entities/{card-list.ts => card-holder.ts} | 2 +- src/common/entities/pile.ts | 4 ++-- src/common/entities/player.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename src/common/entities/{card-list.ts => card-holder.ts} (99%) diff --git a/src/common/entities/card-list.ts b/src/common/entities/card-holder.ts similarity index 99% rename from src/common/entities/card-list.ts rename to src/common/entities/card-holder.ts index 0442cd9..760ac05 100644 --- a/src/common/entities/card-list.ts +++ b/src/common/entities/card-holder.ts @@ -2,7 +2,7 @@ import {ICard} from '../models'; import {TheGame} from './game'; import {Card} from './card'; -export class CardList { +export class CardHolder { constructor( protected game: TheGame, protected _getRawCards: () => ICard[], diff --git a/src/common/entities/pile.ts b/src/common/entities/pile.ts index 388e3c3..1a7327c 100644 --- a/src/common/entities/pile.ts +++ b/src/common/entities/pile.ts @@ -1,9 +1,9 @@ import {IPile} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; -import {CardList} from "./card-list"; +import {CardHolder} from "./card-holder.ts"; -export class Pile extends CardList { +export class Pile extends CardHolder { constructor(public readonly name: string, game: TheGame, public state: IPile) { super( diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 6a823aa..26475f9 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -5,9 +5,9 @@ import {EXPLODING_KITTEN, DEFUSE} from "../registries/card-registry"; import {CHOOSE_PLAYER_TO_REQUEST_FROM, DEFUSE_EXPLODING_KITTEN} from "../constants/stages"; import {PlayerID} from "boardgame.io"; import {NAME_NOPE} from "../constants/cards"; -import {CardList} from "./card-list"; +import {CardHolder} from "./card-holder.ts"; -export class Player extends CardList { +export class Player extends CardHolder { constructor( game: TheGame, public _state: IPlayer, From bae4bdf4a1f161fddc78ff77efde328dcbb72f92 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:35:40 +0100 Subject: [PATCH 51/55] refactor: remove animation interaction blocker and clean up imports --- src/client/components/animations/AnimationOverlay.css | 8 -------- src/client/components/animations/AnimationOverlay.tsx | 1 - src/common/entities/pile.ts | 2 +- src/common/entities/player.ts | 2 +- src/common/exploding-kittens.ts | 3 --- 5 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/client/components/animations/AnimationOverlay.css b/src/client/components/animations/AnimationOverlay.css index 9fc680b..bdb7b35 100644 --- a/src/client/components/animations/AnimationOverlay.css +++ b/src/client/components/animations/AnimationOverlay.css @@ -1,11 +1,3 @@ -.animation-interaction-blocker { - position: fixed; - inset: 0; - z-index: 60; /* Below moving cards but above the board */ - cursor: not-allowed; - pointer-events: all; /* Explicitly block all pointer events passing through to elements underneath */ -} - .animated-card { position: absolute !important; /* Using JS to apply left/top, so use explicit positioning */ transition-property: all; diff --git a/src/client/components/animations/AnimationOverlay.tsx b/src/client/components/animations/AnimationOverlay.tsx index cf2053e..16a0316 100644 --- a/src/client/components/animations/AnimationOverlay.tsx +++ b/src/client/components/animations/AnimationOverlay.tsx @@ -10,7 +10,6 @@ export function AnimationOverlay() { return ( <> -
{animations.map((anim: any) => )} ); diff --git a/src/common/entities/pile.ts b/src/common/entities/pile.ts index 1a7327c..f2909a8 100644 --- a/src/common/entities/pile.ts +++ b/src/common/entities/pile.ts @@ -1,7 +1,7 @@ import {IPile} from '../models'; import {TheGame} from "./game"; import {Card} from "./card"; -import {CardHolder} from "./card-holder.ts"; +import {CardHolder} from "./card-holder"; export class Pile extends CardHolder { diff --git a/src/common/entities/player.ts b/src/common/entities/player.ts index 26475f9..6a55bfa 100644 --- a/src/common/entities/player.ts +++ b/src/common/entities/player.ts @@ -5,7 +5,7 @@ import {EXPLODING_KITTEN, DEFUSE} from "../registries/card-registry"; import {CHOOSE_PLAYER_TO_REQUEST_FROM, DEFUSE_EXPLODING_KITTEN} from "../constants/stages"; import {PlayerID} from "boardgame.io"; import {NAME_NOPE} from "../constants/cards"; -import {CardHolder} from "./card-holder.ts"; +import {CardHolder} from "./card-holder"; export class Player extends CardHolder { constructor( diff --git a/src/common/exploding-kittens.ts b/src/common/exploding-kittens.ts index 590adc6..ac8a6dd 100644 --- a/src/common/exploding-kittens.ts +++ b/src/common/exploding-kittens.ts @@ -15,7 +15,6 @@ import {TheGame} from "./entities/game"; import {stealCard} from "./moves/steal-card-move"; import {GAME_OVER, PLAY} from "./constants/phases"; import {VIEWING_FUTURE, WAITING_FOR_START} from "./constants/stages"; -import {EXPLODING_KITTEN} from "./registries/card-registry"; export const ExplodingKittens: Game = { name: "Exploding-Kittens", @@ -78,8 +77,6 @@ export const ExplodingKittens: Game = { deck.addPostDealCards(game); game.piles.drawPile.shuffle(); - game.piles.drawPile.insertCard(EXPLODING_KITTEN.createCard(0, game), 0) - game.piles.drawPile.insertCard(EXPLODING_KITTEN.createCard(0, game), -1) }, turn: { activePlayers: { From ae5806371a0505a89b10cec248a7c05b3cb9bef4 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:02:59 +0100 Subject: [PATCH 52/55] feat: implement DiscardPile and DrawPile components for improved card management --- src/client/components/board/table/Table.css | 75 +++++++++- src/client/components/board/table/Table.tsx | 132 +----------------- .../board/table/pending/PendingPlayStack.css | 74 ---------- .../board/table/pending/PendingPlayStack.tsx | 67 --------- .../board/table/pile/DiscardPile.tsx | 79 +++++++++++ .../components/board/table/pile/DrawPile.tsx | 87 ++++++++++++ .../components/board/table/pile/Pile.tsx | 27 ++++ 7 files changed, 271 insertions(+), 270 deletions(-) delete mode 100644 src/client/components/board/table/pending/PendingPlayStack.css delete mode 100644 src/client/components/board/table/pending/PendingPlayStack.tsx create mode 100644 src/client/components/board/table/pile/DiscardPile.tsx create mode 100644 src/client/components/board/table/pile/DrawPile.tsx create mode 100644 src/client/components/board/table/pile/Pile.tsx diff --git a/src/client/components/board/table/Table.css b/src/client/components/board/table/Table.css index b831f62..8c13376 100644 --- a/src/client/components/board/table/Table.css +++ b/src/client/components/board/table/Table.css @@ -28,7 +28,7 @@ transparent var(--timer-deg) ); pointer-events: none; - z-index: 11; + z-index: 1; transition: opacity 0.3s ease; } @@ -70,7 +70,7 @@ flex-direction: column; align-items: center; justify-content: center; - z-index: 12; + z-index: 2; } .card-piles { @@ -94,7 +94,7 @@ calc(var(--scale) * 0.2) calc(var(--scale) * 0.2) calc(var(--scale) * 0.4) var(--color-shadow-dark); pointer-events: none; - z-index: 11; + z-index: 3; animation: fade 0.2s ease-in; } @@ -141,3 +141,72 @@ from { box-shadow: 0 0 10px var(--color-primary); } to { box-shadow: 0 0 20px var(--color-primary), 0 0 5px white; } } + +.status-badge { + position: absolute; + bottom: 110%; /* Show above card */ + left: 50%; + transform: translateX(-50%); + padding: calc(var(--scale) * 0.4) calc(var(--scale) * 1.5); + border-radius: calc(var(--scale) * 1); + font-weight: 800; + font-size: calc(var(--scale) * 1.5); + box-shadow: 0 2px 4px rgba(0,0,0,0.5); + white-space: nowrap; + border: calc(var(--scale) * 0.2) solid white; + z-index: 10; +} + +.nope-button-inline { + position: absolute; + top: 110%; /* Show below card */ + left: 0; + width: 100%; + + background-color: var(--color-primary); + color: white; + border: none; + border-radius: calc(var(--scale) * 0.5); + padding: calc(var(--scale) * 0.5); + font-size: calc(var(--scale) * 1.2); + font-weight: bold; + cursor: pointer; + z-index: 20; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); + animation: pulse-button-inline 1s infinite alternate; + white-space: nowrap; + + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} + +.nope-button-inline:hover { + filter: brightness(1.2); + transform: scale(1.05); +} + +.nope-button-inline:active { + transform: scale(0.95); +} + +@keyframes pulse-button-inline { + from { box-shadow: 0 0 5px var(--color-primary); } + to { box-shadow: 0 0 15px var(--color-primary), 0 0 2px white; } +} + +.status-badge.noped { + background-color: var(--color-primary); + color: white; + animation: pulse-badge 1s infinite alternate; +} + +.status-badge.active { + background-color: var(--color-accent); + color: var(--color-text-dark); +} + +@keyframes pulse-badge { + from { transform: translateX(-50%) scale(1); } + to { transform: translateX(-50%) scale(1.1); } +} diff --git a/src/client/components/board/table/Table.tsx b/src/client/components/board/table/Table.tsx index c263086..5bd85d8 100644 --- a/src/client/components/board/table/Table.tsx +++ b/src/client/components/board/table/Table.tsx @@ -1,91 +1,12 @@ -import back from '/assets/cards/back/0.jpg'; -import './Table.css'; -import {useEffect, useState, useRef, useCallback} from "react"; -import PendingPlayStack from './pending/PendingPlayStack.tsx'; +import {useEffect, useState} from "react"; import TurnBadge from './turn-badge/TurnBadge'; -import '../player/card/Card.css'; -import CardPreview from '../CardPreview.tsx'; -import {useResponsive} from "../../../context/ResponsiveContext.tsx"; -import {NAME_SHUFFLE} from "../../../../common/constants/cards.ts"; +import {DrawPile} from './pile/DrawPile'; +import {DiscardPile} from './pile/DiscardPile'; import {useGame} from "../../../context/GameContext.tsx"; -import {useAnimationNode} from "../../../context/AnimationContext.tsx"; -import {DISCARD, DRAW} from "../../../../common/constants/piles.ts"; +import './Table.css'; export default function Table() { const game = useGame() - const { isMobile } = useResponsive(); - - const discardPileAnimRef = useAnimationNode(DISCARD); - const drawPileAnimRef = useAnimationNode(DRAW); - - const [isDrawing, setIsDrawing] = useState(false); - const [isShuffling, setIsShuffling] = useState(false); - const [lastDrawPileSize, setLastDrawPileSize] = useState(game.piles.drawPile.size); - const [lastDiscardPileSize, setLastDiscardPileSize] = useState(game.piles.discardPile.size); - const [isHoveringDrawPile, setIsHoveringDrawPile] = useState(false); - const [isDiscardPileSelected, setIsDiscardPileSelected] = useState(false); - const discardPileRef = useRef(null); - - const setDiscardRef = useCallback((node: HTMLDivElement | null) => { - if (discardPileRef) { - (discardPileRef as any).current = node; - } - discardPileAnimRef(node); - }, [discardPileAnimRef]); - - const discardCard = game.piles.discardPile.topCard; - - // Detect when a card is drawn - useEffect(() => { - if (game.piles.drawPile.size < lastDrawPileSize) { - setIsDrawing(true); - setTimeout(() => setIsDrawing(false), 400); - } - setLastDrawPileSize(game.piles.drawPile.size); - }, [game.piles.drawPile.size, lastDrawPileSize]); - - // Detect when a shuffle card is played - const pendingCardRef = useRef(game.piles.pendingCard); - useEffect(() => { - // If there is an active pending play, do not trigger shuffle yet - if (game.piles.pendingCard) { - pendingCardRef.current = game.piles.pendingCard; - return; - } - - const wasPending = pendingCardRef.current; - pendingCardRef.current = null; - - // Check if discard pile changed - const discardChanged = game.piles.discardPile.size > lastDiscardPileSize; - - // Check if we just finished a pending play that was a Shuffle and NOT noped - const resolvedShuffle = wasPending && - !wasPending.isNoped && - wasPending.card.name === NAME_SHUFFLE; - - const lastCard = game.piles.discardPile.topCard; - - // Trigger if newly placed shuffle - if ((discardChanged || resolvedShuffle) && lastCard?.name === NAME_SHUFFLE) { - // Double check it wasn't noped if it came from pending - if (!(wasPending && wasPending.isNoped)) { - setIsShuffling(true); - setTimeout(() => setIsShuffling(false), 800); - } - } - - if (!game.piles.pendingCard) { - setLastDiscardPileSize(game.piles.discardPile.size); - } - }, [game.piles.discardPile.size, lastDiscardPileSize, game.piles.discardPile, game.piles.pendingCard]); - - const handleDrawClick = () => { - // wait for previous draw animation to finish before allowing another draw - if (!isDrawing) { - game.playDrawCard(); - } - }; // Timer calculation const [timeLeftMs, setTimeLeftMs] = useState(0); @@ -122,49 +43,8 @@ export default function Table() {
- {!game.piles.pendingCard && ( - <> -
{ - if (!isMobile) setIsDiscardPileSelected(true); - }} - onMouseLeave={() => { - if (!isMobile) setIsDiscardPileSelected(false); - }} - onClick={() => { - if (isMobile) setIsDiscardPileSelected(true); - }} - /> - setIsDiscardPileSelected(false)} - /> - - )} - - {game.piles.pendingCard && ( - - )} - -
setIsHoveringDrawPile(true)} - onMouseLeave={() => setIsHoveringDrawPile(false)} - > - {isHoveringDrawPile && game.piles.drawPile.size > 0 && ( -
- {game.piles.drawPile.size} -
- )} -
+ +
diff --git a/src/client/components/board/table/pending/PendingPlayStack.css b/src/client/components/board/table/pending/PendingPlayStack.css deleted file mode 100644 index f7f0adb..0000000 --- a/src/client/components/board/table/pending/PendingPlayStack.css +++ /dev/null @@ -1,74 +0,0 @@ -.pending-stack-container { - position: relative; -} - -.status-badge { - position: absolute; - bottom: 110%; /* Show above card */ - left: 50%; - transform: translateX(-50%); - padding: calc(var(--scale) * 0.4) calc(var(--scale) * 1.5); - border-radius: calc(var(--scale) * 1); - font-weight: 800; - font-size: calc(var(--scale) * 1.5); - box-shadow: 0 2px 4px rgba(0,0,0,0.5); - white-space: nowrap; - border: calc(var(--scale) * 0.2) solid white; - z-index: 10; -} - -.nope-button-inline { - position: absolute; - top: 110%; /* Show below card */ - left: 0; - width: 100%; - - background-color: var(--color-primary); - color: white; - border: none; - border-radius: calc(var(--scale) * 0.5); - padding: calc(var(--scale) * 0.5); - font-size: calc(var(--scale) * 1.2); - font-weight: bold; - cursor: pointer; - z-index: 20; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); - animation: pulse-button-inline 1s infinite alternate; - white-space: nowrap; - - /* Text overflow handling just in case */ - overflow: hidden; - text-overflow: ellipsis; - text-align: center; -} - -.nope-button-inline:hover { - filter: brightness(1.2); - transform: scale(1.05); -} - -.nope-button-inline:active { - transform: scale(0.95); -} - -@keyframes pulse-button-inline { - from { box-shadow: 0 0 5px var(--color-primary); } - to { box-shadow: 0 0 15px var(--color-primary), 0 0 2px white; } -} - -.status-badge.noped { - background-color: var(--color-primary); - color: white; - animation: pulse-badge 1s infinite alternate; -} - -.status-badge.active { - background-color: var(--color-accent); - color: var(--color-text-dark); -} - -@keyframes pulse-badge { - from { transform: translateX(-50%) scale(1); } - to { transform: translateX(-50%) scale(1.1); } -} - diff --git a/src/client/components/board/table/pending/PendingPlayStack.tsx b/src/client/components/board/table/pending/PendingPlayStack.tsx deleted file mode 100644 index 1155de8..0000000 --- a/src/client/components/board/table/pending/PendingPlayStack.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import '../../player/card/Card.css'; -import './PendingPlayStack.css'; -import {useRef, useState, useCallback} from 'react'; -import CardPreview from '../../CardPreview.tsx'; -import {useGame} from "../../../../context/GameContext.tsx"; -import {TheGameClient} from "../../../../entities/game-client.ts"; -import {useAnimationNode} from "../../../../context/AnimationContext.tsx"; -import {DISCARD} from "../../../../../common/constants/piles.ts"; - -export default function PendingPlayStack() { - const game = useGame(); - - const pendingCard = game.piles.pendingCard; - if (!pendingCard) return null; - - const targetCard = pendingCard.card; - const isNoped = pendingCard.isNoped; - const [isHovered, setIsHovered] = useState(false); - const pileRef = useRef(null); - const discardPileAnimRef = useAnimationNode(DISCARD); - - const setDiscardRef = useCallback((node: HTMLDivElement | null) => { - if (pileRef) { - (pileRef as any).current = node; - } - discardPileAnimRef(node); - }, [discardPileAnimRef]); - - const cardImage = TheGameClient.getCardTexture(targetCard); - - return ( -
-
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - /> - - - - {/*Only show when at least one nope card been played*/} - {pendingCard.nopeCount > 0 && ( -
- {isNoped ? 'Noped' : 'Un-Noped'} -
- )} - - {/* Nope Button */} - {game.selfPlayer?.canNope && ( - - )} -
- ); -} diff --git a/src/client/components/board/table/pile/DiscardPile.tsx b/src/client/components/board/table/pile/DiscardPile.tsx new file mode 100644 index 0000000..e8fa357 --- /dev/null +++ b/src/client/components/board/table/pile/DiscardPile.tsx @@ -0,0 +1,79 @@ +import { useRef, useState, useCallback } from 'react'; +import { Pile } from './Pile'; +import { useGame } from '../../../../context/GameContext'; +import { useAnimationNode } from '../../../../context/AnimationContext'; +import { useResponsive } from '../../../../context/ResponsiveContext'; +import { DISCARD } from '../../../../../common/constants/piles'; +import CardPreview from '../../CardPreview'; + +export function DiscardPile() { + const game = useGame(); + const { isMobile } = useResponsive(); + const discardPileAnimRef = useAnimationNode(DISCARD); + + const [isDiscardPileSelected, setIsDiscardPileSelected] = useState(false); + const discardPileRef = useRef(null); + + const setDiscardRef = useCallback((node: HTMLDivElement | null) => { + if (discardPileRef) { + (discardPileRef as any).current = node; + } + discardPileAnimRef(node); + }, [discardPileAnimRef]); + + const pendingCard = game.piles.pendingCard; + const isNoped = pendingCard?.isNoped ?? false; + const discardCard = pendingCard ? pendingCard.card : game.piles.discardPile.topCard; + + // Use card from pending state if present, otherwise use the top of the discard pile + const displayImage = pendingCard + ? game.getCardTexture(pendingCard.card) + : game.getDiscardCardTexture(); + + return ( +
+ { + if (!isMobile) setIsDiscardPileSelected(true); + }} + onMouseLeave={() => { + if (!isMobile) setIsDiscardPileSelected(false); + }} + onClick={() => { + if (isMobile) setIsDiscardPileSelected(true); + }} + /> + setIsDiscardPileSelected(false)} + /> + + {pendingCard && ( + <> + {/*Only show when at least one nope card been played*/} + {pendingCard.nopeCount > 0 && ( +
+ {isNoped ? 'Noped' : 'Un-Noped'} +
+ )} + + {/* Nope Button */} + {game.selfPlayer?.canNope && ( + + )} + + )} +
+ ); +} + diff --git a/src/client/components/board/table/pile/DrawPile.tsx b/src/client/components/board/table/pile/DrawPile.tsx new file mode 100644 index 0000000..e1c61b9 --- /dev/null +++ b/src/client/components/board/table/pile/DrawPile.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState, useRef } from 'react'; +import { Pile } from './Pile'; +import back from '/assets/cards/back/0.jpg'; +import { useGame } from '../../../../context/GameContext'; +import { useAnimationNode } from '../../../../context/AnimationContext'; +import { DRAW } from '../../../../../common/constants/piles'; +import { NAME_SHUFFLE } from '../../../../../common/constants/cards'; + +export function DrawPile() { + const game = useGame(); + const drawPileAnimRef = useAnimationNode(DRAW); + + const [isDrawing, setIsDrawing] = useState(false); + const [isShuffling, setIsShuffling] = useState(false); + const [lastDrawPileSize, setLastDrawPileSize] = useState(game.piles.drawPile.size); + const [lastDiscardPileSize, setLastDiscardPileSize] = useState(game.piles.discardPile.size); + const [isHoveringDrawPile, setIsHoveringDrawPile] = useState(false); + const pendingCardRef = useRef(game.piles.pendingCard); + + // Detect when a card is drawn + useEffect(() => { + if (game.piles.drawPile.size < lastDrawPileSize) { + setIsDrawing(true); + setTimeout(() => setIsDrawing(false), 400); + } + setLastDrawPileSize(game.piles.drawPile.size); + }, [game.piles.drawPile.size, lastDrawPileSize]); + + // Handle shuffling animation detection + useEffect(() => { + // If there is an active pending play, do not trigger shuffle yet + if (game.piles.pendingCard) { + pendingCardRef.current = game.piles.pendingCard; + return; + } + + const wasPending = pendingCardRef.current; + pendingCardRef.current = null; + + const discardChanged = game.piles.discardPile.size > lastDiscardPileSize; + + // Check if we just finished a pending play that was a Shuffle and NOT noped + const resolvedShuffle = wasPending && + !wasPending.isNoped && + wasPending.card.name === NAME_SHUFFLE; + + const lastCard = game.piles.discardPile.topCard; + + // Trigger if newly placed shuffle + if ((discardChanged || resolvedShuffle) && lastCard?.name === NAME_SHUFFLE) { + // Double check it wasn't noped if it came from pending + if (!(wasPending && wasPending.isNoped)) { + setIsShuffling(true); + setTimeout(() => setIsShuffling(false), 800); + } + } + + if (!game.piles.pendingCard) { + setLastDiscardPileSize(game.piles.discardPile.size); + } + }, [game.piles.discardPile.size, lastDiscardPileSize, game.piles.discardPile, game.piles.pendingCard]); + + const handleDrawClick = () => { + // wait for previous draw animation to finish before allowing another draw + if (!isDrawing) { + game.playDrawCard(); + } + }; + + return ( + setIsHoveringDrawPile(true)} + onMouseLeave={() => setIsHoveringDrawPile(false)} + > + {isHoveringDrawPile && game.piles.drawPile.size > 0 && ( +
+ {game.piles.drawPile.size} +
+ )} +
+ ); +} + diff --git a/src/client/components/board/table/pile/Pile.tsx b/src/client/components/board/table/pile/Pile.tsx new file mode 100644 index 0000000..b1978f2 --- /dev/null +++ b/src/client/components/board/table/pile/Pile.tsx @@ -0,0 +1,27 @@ +import React, { forwardRef } from 'react'; +import '../../player/card/Card.css'; + +interface PileProps extends React.HTMLAttributes { + className?: string; + image?: string; + children?: React.ReactNode; +} + +export const Pile = forwardRef( + ({ className = '', image, children, style, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); + From 151d5846610d3bcbd00361a3839f65fb42a897c1 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:41:06 +0100 Subject: [PATCH 53/55] refactor: enhance animation queue to support unique animation IDs and improve playback timing --- src/client/context/AnimationContext.tsx | 16 +++++++++++++++- src/common/entities/animation-queue.ts | 12 +++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/client/context/AnimationContext.tsx b/src/client/context/AnimationContext.tsx index c949374..25aa4c5 100644 --- a/src/client/context/AnimationContext.tsx +++ b/src/client/context/AnimationContext.tsx @@ -89,7 +89,21 @@ export function AnimationProvider({ children }: { children: ReactNode }) { lastAnimationTime.current = highestTime; - animationsToPlay.forEach(anim => playAnimation(highestTime, anim)); + const now = Date.now(); + animationsToPlay.forEach(({id, animation}) => { + // The base timestamp of id (floor) is the target execution time for delayMs. + // E.g. Date.now() + 150 + const targetTime = Math.floor(id); + const delay = Math.max(0, targetTime - now); + + if (delay > 0) { + setTimeout(() => { + playAnimation(id, animation); + }, delay); + } else { + playAnimation(id, animation); + } + }); }, [game.animationsQueue, playAnimation]); // Expose global window command for dev testing diff --git a/src/common/entities/animation-queue.ts b/src/common/entities/animation-queue.ts index 21a7c2f..babb771 100644 --- a/src/common/entities/animation-queue.ts +++ b/src/common/entities/animation-queue.ts @@ -44,9 +44,10 @@ export class AnimationQueue { this.enqueueAnimationWithDelay(animation, { delayMs: 0 }); } - getAnimationsToPlay(lastTimePlayed: number): [number, IAnimation[]] { - const animationsToPlay: IAnimation[] = [] + getAnimationsToPlay(lastTimePlayed: number): [number, {id: number, animation: IAnimation}[]] { + const animationsToPlay: {id: number, animation: IAnimation}[] = [] let highestTime: number = lastTimePlayed + let uniqueCounter = 0; for (let id of Object.keys(this.queue).map(Number)) { if (id > lastTimePlayed) { const animations = this.queue[id]; @@ -54,10 +55,15 @@ export class AnimationQueue { if (id > highestTime) { highestTime = id; } - animations.forEach(anim => animationsToPlay.push(anim)); + animations.forEach(anim => { + animationsToPlay.push({id: id + uniqueCounter, animation: anim}); + uniqueCounter += 0.001; // ensure unique ids even if same timestamp + }); } } } + // Sort animations by time + animationsToPlay.sort((a, b) => a.id - b.id); return [highestTime, animationsToPlay] } From fbde51a3b18782f9df69229ae3d4a256832d7115 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:08:52 +0100 Subject: [PATCH 54/55] fix: attack using next player --- src/common/entities/card-types/attack-card.ts | 13 +++++++++---- src/common/utils/turn-order.ts | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/common/entities/card-types/attack-card.ts b/src/common/entities/card-types/attack-card.ts index 1662632..4285bba 100644 --- a/src/common/entities/card-types/attack-card.ts +++ b/src/common/entities/card-types/attack-card.ts @@ -1,6 +1,7 @@ import {CardType} from '../card-type'; import {TheGame} from '../game'; import {Card} from '../card'; +import {findNextAlivePlayer} from "../../utils/turn-order"; export class AttackCard extends CardType { @@ -19,10 +20,14 @@ export class AttackCard extends CardType { const remaining = turnManager.turnsRemaining; turnManager.turnsRemaining = remaining + 3; - // End turn and force move to next player - const nextPlayer = ctx.playOrderPos + 1; - const nextPlayerIndex = nextPlayer % ctx.numPlayers; - turnManager.endTurn({ next: nextPlayerIndex + "" }); + // End turn and force move to next alive player + const nextPlayerIndex = findNextAlivePlayer(game, ctx.playOrderPos + 1); + + if (nextPlayerIndex !== undefined) { + turnManager.endTurn({ next: turnManager.playOrder[nextPlayerIndex] }); + } else { + turnManager.endTurn(); + } } diff --git a/src/common/utils/turn-order.ts b/src/common/utils/turn-order.ts index 4ad012c..6bcbb89 100644 --- a/src/common/utils/turn-order.ts +++ b/src/common/utils/turn-order.ts @@ -1,7 +1,7 @@ import {IContext} from '../models'; import {TheGame} from "../entities/game"; -const findNextAlivePlayer = (game: TheGame, startPos: number): number | undefined => { +export const findNextAlivePlayer = (game: TheGame, startPos: number): number | undefined => { const numPlayers = game.players.playerCount; let currentPos = startPos % numPlayers; From aef65214ed7bf78f625c7d90673e5f22cb9d11b0 Mon Sep 17 00:00:00 2001 From: DomiIRL <67184131+DomiIRL@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:23:10 +0100 Subject: [PATCH 55/55] refactor: update AGENTS.md for improved clarity on game state management and OOP patterns --- AGENTS.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a6a14c9..7ac14e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,9 +11,9 @@ This project implements Exploding Kittens using **React** (frontend) and **board ### Game State Management The project uses a custom wrapper pattern over standard `boardgame.io` state (`G` and `ctx`): -- **`TheGame` Class** (`src/common/entities/exploding-kittens.ts`): Wraps the raw context. Always instantiate this to interact with game state. +- **`TheGame` Class** (`src/common/entities/game.ts`): Wraps the raw context. Always instantiate this to interact with game state. - **`IGameState`** (`src/common/models/game-state.model.ts`): Defines the shape of `G` (piles, deck type, etc.). -- **Moves with `inGame` HOC**: All game moves are wrapped with the `inGame` higher-order function (`src/common/moves/in-exploding-kittens.ts`). +- **Moves with `inGame` HOC**: All game moves are wrapped with the `inGame` higher-order function (`src/common/moves/in-game.ts`). - *Pattern*: Define moves as `(game: TheGame, ...args) => void`. The HOC handles context injection. - *Example*: See `src/common/moves/draw-move.ts`. @@ -26,7 +26,7 @@ The React app accesses game state via `GameContext` (`src/client/context/GameCon ### Running the Project The project requires two concurrent processes: -1. **Game Server**: `npm run server:watch` (Runs on `http://localhost:51399`) +1. **Game Server**: `npm run server:dev` (Runs on `http://localhost:51399` usually, configurable) 2. **Client**: `npm run dev` (Runs on `http://localhost:5173`) ### Building @@ -38,12 +38,19 @@ The project requires two concurrent processes: ## 🧩 Patterns & Conventions +### Models & Entities (OOP Pattern) +The codebase strictly separates raw state from game logic using an OOP wrapper pattern: +- **Models (`src/common/models/`)**: Pure TypeScript `interface`s prefix with `I` (e.g., `ICard`, `IGameState`, `IPlayer`). These represent the plain JSON-serializable state strictly managed by `boardgame.io`. **Never add methods here.** +- **Entities (`src/common/entities/`)**: Object-Oriented wrapper classes (e.g., `Card`, `TheGame`, `Player`). They take the raw state interface in their constructor and provide getter/setter and mutation logic. + - *Rule*: When mutating state in moves, always instantiate or access the Entity wrapper, execute methods on it, and let it safely update the underlying raw Model state. Do not mutate the `IPlayer` or `ICard` interfaces directly. + ### Move Mechanics When implementing game moves (actions): 1. Create a function in `src/common/moves/`. 2. Function signature must be `(game: TheGame, ...args)`. 3. Mutate state via `game.piles`, `game.players`, or `game.gameState`. 4. Register the move in `src/common/exploding-kittens.ts` using `inGame(yourMove)`. +5. If the move transitions the game state, use constants from `src/common/constants/phases.ts` or `src/common/constants/stages.ts`. **Example Move:** ```typescript @@ -64,4 +71,3 @@ export const myMove = (game: TheGame, argument: string) => { ### Docker - `docker-compose.yml` orchestrates both client and server containers. - Environment variables are managed in `stack.env` and passed to containers. -