diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7ac14e0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# 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:dev` (Runs on `http://localhost:51399` usually, configurable) +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 + +### 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 +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. diff --git a/src/client/components/animations/AnimatedCard.tsx b/src/client/components/animations/AnimatedCard.tsx new file mode 100644 index 0000000..27066be --- /dev/null +++ b/src/client/components/animations/AnimatedCard.tsx @@ -0,0 +1,76 @@ +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); + + useEffect(() => { + 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.metadata.from}):`, fromEl, ` +toId (${animation.metadata.to}):`, toEl, ` +cardRef:`, cardRef.current); + return; + } + + const fromRect = fromEl.getBoundingClientRect(); + const toRect = toEl.getBoundingClientRect(); + + // Determine target width to use for size transitioning + 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.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; + return sampleCard ? sampleCard.offsetWidth : 80; + }; + + const fromWidth = getTargetWidth(fromEl, fromRect); + const toWidth = getTargetWidth(toEl, toRect); + + // Apply strictly to the DOM Ref instead of triggering standard React renders mid-animation + + // 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.metadata.card)})`; + cardRef.current.style.display = 'flex'; + + // 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(() => { + 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); + }, [animation, getNode]); + + return ( +
+ ); +} diff --git a/src/client/components/animations/AnimationOverlay.css b/src/client/components/animations/AnimationOverlay.css new file mode 100644 index 0000000..bdb7b35 --- /dev/null +++ b/src/client/components/animations/AnimationOverlay.css @@ -0,0 +1,5 @@ +.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..16a0316 --- /dev/null +++ b/src/client/components/animations/AnimationOverlay.tsx @@ -0,0 +1,16 @@ +import { useAnimationState } from '../../context/AnimationContext'; +import { AnimatedCard } from './AnimatedCard'; +import './AnimationOverlay.css'; +import '../board/player/card/Card.css'; + +export function AnimationOverlay() { + const { animations } = useAnimationState(); + + if (animations.length === 0) return null; + + return ( + <> + {animations.map((anim: any) => )} + + ); +} diff --git a/src/client/components/board/Board.tsx b/src/client/components/board/Board.tsx index 2ad3740..0915ac6 100644 --- a/src/client/components/board/Board.tsx +++ b/src/client/components/board/Board.tsx @@ -1,73 +1,60 @@ 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'; -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 GameStatusList from './game-status/GameStatusList'; import {useEffect} from 'react'; import {Chat} from '../chat/Chat'; 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"; + +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, + }; -type BoardPropsWithPlugins = Omit, 'plugins'> & { - plugins: BoardPlugins; -} - -/** - * Main game board component - */ -export default function ExplodingKittensBoard({ - ctx, - G, - moves, - plugins, - playerID, - chatMessages, - sendChatMessage -}: BoardPropsWithPlugins) { - const { matchDetails, setPollingInterval } = useMatchDetails(); + const game = new TheGameClient( + context, + props.moves, + props.matchID, + props.playerID || null, + props.matchData, + props.sendChatMessage, + props.chatMessages, + props.isMultiplayer + ); - const isInLobby = ctx.phase === 'lobby'; + const { setPollingInterval } = useMatchDetails(); useEffect(() => { - setPollingInterval(isInLobby ? 500 : 3000); - }, [isInLobby, setPollingInterval]); - - const allPlayers = plugins.player.data.players; - - // Bundle game context - const gameContext: GameContext = { - ctx, - G, - moves, - playerID, - matchData: matchDetails?.players - }; - - // Derive game state properties - const gameState = useGameState(ctx, G, allPlayers, playerID); - - const selfPlayer = gameState.selfPlayerId !== null && allPlayers[gameState.selfPlayerId] ? allPlayers[gameState.selfPlayerId] : null; - const selfHand = selfPlayer ? selfPlayer.hand : []; + setPollingInterval(game.isLobbyPhase() ? 500 : 3000); + }, [game.isLobbyPhase(), setPollingInterval]); useEffect(() => { - if (!gameState.isInNowCardStage || !G.pendingCardPlay || !moves.resolvePendingCard) { + if (!game.piles.pendingCard || !game.moves.resolvePendingCard) { return; } const checkAndResolve = () => { - if (G.pendingCardPlay && Date.now() >= G.pendingCardPlay.expiresAtMs) { - moves.resolvePendingCard(); + if (game.piles.pendingCard && Date.now() >= game.piles.pendingCard.expiresAtMs) { + game.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); @@ -79,122 +66,28 @@ export default function ExplodingKittensBoard({ window.clearInterval(intervalId); }; }, [ - gameState.isInNowCardStage, - G.pendingCardPlay?.expiresAtMs, - moves, + game.piles.pendingCard, + game.piles.pendingCard?.expiresAtMs, + game.moves, ]); - // Bundle player state - const playerState: PlayerStateBundle = { - allPlayers, - selfPlayerId: gameState.selfPlayerId, - currentPlayer: gameState.currentPlayer, - isSelfDead: gameState.isSelfDead, - isSelfSpectator: gameState.isSelfSpectator, - 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, allPlayers, playerID); - - /** - * 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(); - } - }; - - /** - * Handle starting the game from lobby - */ - const handleStartGame = () => { - if (moves.startGame) { - moves.startGame(); - } - }; - return ( <> - - -
-
- - - - - - - - - {isInLobby && ( - - )} - - - - + + +
+
+
+ + + + + + + + + + ); } diff --git a/src/client/components/board/card/HoverCardPreview.tsx b/src/client/components/board/CardPreview.tsx similarity index 96% rename from src/client/components/board/card/HoverCardPreview.tsx rename to src/client/components/board/CardPreview.tsx index 3ee8650..53d184c 100644 --- a/src/client/components/board/card/HoverCardPreview.tsx +++ b/src/client/components/board/CardPreview.tsx @@ -1,7 +1,7 @@ import {createPortal} from 'react-dom'; import {useEffect, useState, CSSProperties, RefObject} from 'react'; -import './Card.css'; -import {useResponsive} from "../../../context/ResponsiveContext.tsx"; +import './player/card/Card.css'; +import {useResponsive} from "../../context/ResponsiveContext.tsx"; interface HoverCardPreviewProps { // Common props @@ -19,7 +19,7 @@ interface HoverCardPreviewProps { onClose?: () => void; } -export default function HoverCardPreview({ +export default function CardPreview({ cardImage, anchorRef, isVisible, 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 326aed4..0000000 --- a/src/client/components/board/card-animation/CardAnimation.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import './CardAnimation.css'; -import React, {useEffect, useState} from 'react'; -import {Card} from '../../../../common'; - -export interface CardAnimationData { - id: string; - card: Card | 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 = animation.card - ? `/assets/cards/${animation.card.name}/${animation.card.index}.png` - : '/assets/cards/back/0.jpg'; - - 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 ( -
- ); -} - diff --git a/src/client/components/board/card/Card.tsx b/src/client/components/board/card/Card.tsx deleted file mode 100644 index c712106..0000000 --- a/src/client/components/board/card/Card.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import './Card.css'; -import back from '/assets/cards/back/0.jpg'; -import {CSSProperties, useRef, useState} from 'react'; -import {CardWithServerIndex} from "../../../model/PlayerState"; -import HoverCardPreview from './HoverCardPreview'; -import {useResponsive} from "../../../context/ResponsiveContext.tsx"; - -interface CardProps { - card: CardWithServerIndex | null; - index: number; - count: number; - angle: number; - offsetX: number; - offsetY: number; - moves?: any; - isClickable?: boolean; - isChoosingCardToGive?: boolean; - isInNowCardStage?: boolean; - onCardGive?: (cardIndex: number) => void; -} - -export default function Card({ - card, - index, - count, - angle, - offsetX, - offsetY, - moves, - isClickable, - isChoosingCardToGive = false, - isInNowCardStage = false, - onCardGive, - }: CardProps) { - - const { isMobile } = useResponsive(); - - const [isHovered, setIsHovered] = useState(false); - const [isSelected, setIsSelected] = useState(false); - const cardRef = useRef(null); - - const cardImage = card ? `/assets/cards/${card.name}/${card.index}.png` : back; - - const handleAction = () => { - if (!card) return; - - const serverIndex = card.serverIndex ?? index; - - // If choosing a card to give (favor card flow) - if (isChoosingCardToGive && onCardGive) { - onCardGive(serverIndex); - 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); - } - } - } - - const handleClick = () => { - if (isMobile) { - setIsSelected(true) - return; - } - - handleAction(); - }; - - return ( - <> -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - onClick={handleClick} - /> - { - setIsSelected(false); - handleAction(); - }} - onClose={() => setIsSelected(false)} - /> - - ); -} diff --git a/src/client/components/board/game-status/GameStatusList.tsx b/src/client/components/board/game-status/GameStatusList.tsx index 86b40a1..370fcd8 100644 --- a/src/client/components/board/game-status/GameStatusList.tsx +++ b/src/client/components/board/game-status/GameStatusList.tsx @@ -1,55 +1,21 @@ -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"; +import {getPlayerName} from "../../../utils/matchData.ts"; -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 +33,61 @@ 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; + + return ( +
+ - - - {p.name} {p.isSelf ? '(You)' : ''} + + + {getPlayerName(matchInfo)} {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-manager/OverlayManager.tsx b/src/client/components/board/overlay-manager/OverlayManager.tsx deleted file mode 100644 index 935a86b..0000000 --- a/src/client/components/board/overlay-manager/OverlayManager.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import WinnerOverlay from '../winner-overlay/WinnerOverlay'; -import DeadOverlay from '../dead-overlay/DeadOverlay'; -import PlayerSelectionOverlay from '../player-selection-overlay/PlayerSelectionOverlay'; -import SeeTheFutureOverlay from '../see-future-overlay/SeeTheFutureOverlay'; -import { - GameContext, - PlayerStateBundle, - OverlayStateBundle, -} from '../../../types/component-props'; - -interface OverlayManagerProps { - gameContext: GameContext; - playerState: PlayerStateBundle; - overlayState: OverlayStateBundle; - winnerID: string | null; - onCloseFutureView: () => void; -} - -/** - * 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; - // 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"; - } - } - - // Get the top 3 cards from the draw pile for the see the future overlay - const futureCards = isViewingFuture ? G.drawPile.slice(0, 3) : []; - - return ( - <> - {isSelectingPlayer && } - {isChoosingCardToGive && } - {isViewingFuture && ( - - )} - {isSelfDead && !isGameOver && } - {isGameOver && winnerID && ( - - )} - - ); -} diff --git a/src/client/components/board/overlay/BoardOverlays.tsx b/src/client/components/board/overlay/BoardOverlays.tsx new file mode 100644 index 0000000..4cdb1b9 --- /dev/null +++ b/src/client/components/board/overlay/BoardOverlays.tsx @@ -0,0 +1,43 @@ +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, + CHOOSE_PLAYER_TO_REQUEST_FROM, + CHOOSE_PLAYER_TO_STEAL_FROM +} from "../../../../common/constants/stages.ts"; +import LobbyOverlay from "./lobby/LobbyOverlay.tsx"; + +/** + * Manages and renders all game overlays + */ +export default function BoardOverlays() { + const game = useGame(); + + // Determine the overlay message based on the current stage + 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"; + } + + return ( + <> + + + + {selectionMessage && ( + ) + } + {game.selfPlayer?.isInStage(CHOOSE_CARD_TO_GIVE) && ( + + )} + + + + ); +} diff --git a/src/client/components/board/dead-overlay/DeadOverlay.css b/src/client/components/board/overlay/dead/DeadOverlay.css similarity index 100% rename from src/client/components/board/dead-overlay/DeadOverlay.css rename to src/client/components/board/overlay/dead/DeadOverlay.css diff --git a/src/client/components/board/dead-overlay/DeadOverlay.tsx b/src/client/components/board/overlay/dead/DeadOverlay.tsx similarity index 61% rename from src/client/components/board/dead-overlay/DeadOverlay.tsx rename to src/client/components/board/overlay/dead/DeadOverlay.tsx index 805e1a1..881d75a 100644 --- a/src/client/components/board/dead-overlay/DeadOverlay.tsx +++ b/src/client/components/board/overlay/dead/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 || !game.selfPlayer) { + return null; + } + + if (!game.isPlayingPhase()) { + return null; + } + return (
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..7b003b0 --- /dev/null +++ b/src/client/components/board/overlay/defuse/DefuseOverlay.tsx @@ -0,0 +1,180 @@ +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; + const isDefusing = game.selfPlayer?.isInStage(DEFUSE_EXPLODING_KITTEN); + + // Set initial random position when entering the defuse stage + useEffect(() => { + 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); + } + }, [isDefusing]); + + 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 (!isDefusing) { + 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/lobby-overlay/LobbyOverlay.css b/src/client/components/board/overlay/lobby/LobbyOverlay.css similarity index 100% rename from src/client/components/board/lobby-overlay/LobbyOverlay.css rename to src/client/components/board/overlay/lobby/LobbyOverlay.css diff --git a/src/client/components/board/lobby-overlay/LobbyOverlay.tsx b/src/client/components/board/overlay/lobby/LobbyOverlay.tsx similarity index 82% rename from src/client/components/board/lobby-overlay/LobbyOverlay.tsx rename to src/client/components/board/overlay/lobby/LobbyOverlay.tsx index 360fcb8..5cf199f 100644 --- a/src/client/components/board/lobby-overlay/LobbyOverlay.tsx +++ b/src/client/components/board/overlay/lobby/LobbyOverlay.tsx @@ -1,12 +1,15 @@ import './LobbyOverlay.css'; -import {useMatchDetails} from "../../../context/MatchDetailsContext.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() { + const game = useGame(); + + if (!game.isLobbyPhase()) { + return null; + } + const { matchDetails } = useMatchDetails(); const { players, numPlayers } = matchDetails || {players: [], numPlayers: 1}; @@ -17,6 +20,12 @@ export default function LobbyOverlay({playerID, onStartGame}: LobbyOverlayProps) navigator.clipboard.writeText(window.location.href); }; + const handStartButton = () => { + if (game.moves.startGame) { + game.moves.startGame(); + } + }; + return (
@@ -49,7 +58,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 (
- {allPlayersFilled && onStartGame && ( - )} diff --git a/src/client/components/board/see-future-overlay/SeeTheFutureOverlay.css b/src/client/components/board/overlay/see-future/SeeTheFutureOverlay.css similarity index 100% rename from src/client/components/board/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/SeeTheFutureOverlay.tsx b/src/client/components/board/overlay/see-future/SeeTheFutureOverlay.tsx new file mode 100644 index 0000000..75f8fda --- /dev/null +++ b/src/client/components/board/overlay/see-future/SeeTheFutureOverlay.tsx @@ -0,0 +1,55 @@ +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(); + + if (!game.selfPlayer?.isInStage(VIEWING_FUTURE)) { + return null; + } + + // Get the top cards from the draw pile 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."); + return null; + } + + const handleFutureClose = () => { + if (game.moves.closeFutureView) { + game.moves.closeFutureView(); + } + } + + return ( +
+
+

The Future

+
+ These are the next cards to be drawn from left to right: +
+
+ {cards.map((card, index) => ( +
+
#{index + 1}
+
+
+ ))} +
+ +
+
+ ); +} + diff --git a/src/client/components/board/player-selection-overlay/PlayerSelectionOverlay.css b/src/client/components/board/overlay/special-action/SpecialActionOverlay.css similarity index 100% rename from src/client/components/board/player-selection-overlay/PlayerSelectionOverlay.css rename to src/client/components/board/overlay/special-action/SpecialActionOverlay.css diff --git a/src/client/components/board/player-selection-overlay/PlayerSelectionOverlay.tsx b/src/client/components/board/overlay/special-action/SpecialActionOverlay.tsx similarity index 55% rename from src/client/components/board/player-selection-overlay/PlayerSelectionOverlay.tsx rename to src/client/components/board/overlay/special-action/SpecialActionOverlay.tsx index d1015ae..27ab253 100644 --- a/src/client/components/board/player-selection-overlay/PlayerSelectionOverlay.tsx +++ b/src/client/components/board/overlay/special-action/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/board/overlay/winner/WinnerOverlay.css similarity index 100% rename from src/client/components/board/winner-overlay/WinnerOverlay.css rename to src/client/components/board/overlay/winner/WinnerOverlay.css diff --git a/src/client/components/board/overlay/winner/WinnerOverlay.tsx b/src/client/components/board/overlay/winner/WinnerOverlay.tsx new file mode 100644 index 0000000..5088b65 --- /dev/null +++ b/src/client/components/board/overlay/winner/WinnerOverlay.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import {useGame} from '../../../../context/GameContext'; +import {getPlayerName} from '../../../../utils/matchData'; +import "./WinnerOverlay.css" + +const WinnerOverlay: React.FC = () => { + const game = useGame(); + const winner = game.players.winner; + + if (!game.isGameOver() || !winner) return null; + + const winnerName = getPlayerName(winner.id); + + return ( +
+
+
🏆
+
Game Over!
+
+ {game.selfPlayerId === winner.id ? 'You Win!' : `${winnerName} Wins!`} +
+
+ {game.selfPlayerId === winner.id + ? 'Congratulations!' + : 'Better luck next time!'} +
+
+
+ ); +} + +export default WinnerOverlay; diff --git a/src/client/components/board/player-area/Player.tsx b/src/client/components/board/player-area/Player.tsx deleted file mode 100644 index 8903b6b..0000000 --- a/src/client/components/board/player-area/Player.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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"; - -interface PlayerAreaProps { - playerID: string; - playerState: PlayerState; - position: PlayerPosition; - moves: any; - isSelectable: boolean; - isChoosingCardToGive: boolean; - isInNowCardStage: boolean; - interactionHandlers: PlayerInteractionHandlers; - animationCallbacks: AnimationCallbacks; - matchData?: MatchPlayer[]; - isWaitingOn?: boolean; -} - -export default function Player({ - playerID, - playerState, - position, - moves, - isSelectable = false, - isChoosingCardToGive = false, - isInNowCardStage = false, - interactionHandlers, - animationCallbacks, - matchData, - isWaitingOn = false -}: PlayerAreaProps) { - const {cardPosition, infoPosition} = position; - const {onPlayerSelect} = interactionHandlers; - const cardRotation = cardPosition.angle - 90; - const playerName = getPlayerName(playerID, matchData); - - const extraClasses = `${playerState.isSelf ? 'hand-interactable self' : ''} ${playerState.isTurn ? 'turn' : ''} ${isSelectable ? 'selectable' : ''} ${isWaitingOn ? 'waiting-on' : ''}` - - const handleClick = () => { - if (isSelectable && onPlayerSelect) { - onPlayerSelect(playerID); - } - }; - - return ( - <> -
- -
- -
-
-
- {playerName} - {playerState.isSelf && ' (You)'} -
-
- Cards: {playerState.handCount} -
-
-
- - ); -} diff --git a/src/client/components/board/player-cards/PlayerCards.tsx b/src/client/components/board/player-cards/PlayerCards.tsx deleted file mode 100644 index 83c6de3..0000000 --- a/src/client/components/board/player-cards/PlayerCards.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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 - -interface PlayerCardsProps { - playerState: PlayerRenderState; - moves?: any; - playerID: string; - isChoosingCardToGive: boolean; - isInNowCardStage: boolean; - animationCallbacks: AnimationCallbacks; - interactionHandlers: PlayerInteractionHandlers; -} - -export default function PlayerCards({ - playerState, - moves, - isChoosingCardToGive, - isInNowCardStage, - interactionHandlers -}: PlayerCardsProps) { - - 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; - - return ( -
- {Array(handCount).fill(null).map((_, i) => { - const angle = baseOffset + (angleStep * i); - const offsetX = (i - (handCount - 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 deleted file mode 100644 index 209ece3..0000000 --- a/src/client/components/board/player-list/PlayerList.tsx +++ /dev/null @@ -1,93 +0,0 @@ -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 './PlayerList.css'; - -interface PlayerListProps { - alivePlayersSorted: string[]; - playerState: PlayerStateBundle; - overlayState: OverlayStateBundle; - isInNowCardStage: boolean; - animationCallbacks: AnimationCallbacks; - interactionHandlers: PlayerInteractionHandlers; - gameContext: GameContext; -} - -/** - * 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; - - 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 - ); - - const playerRenderState = new PlayerState( - isSelfSpectator, - isSelf, - playerInfo.isAlive, - playerNumber === currentPlayer, - playerInfo.client.handCount, - playerInfo.hand - ); - - 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/card/Card.css b/src/client/components/board/player/card/Card.css similarity index 99% rename from src/client/components/board/card/Card.css rename to src/client/components/board/player/card/Card.css index 002661d..323d393 100644 --- a/src/client/components/board/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/card/Card.tsx b/src/client/components/board/player/card/Card.tsx new file mode 100644 index 0000000..12af23e --- /dev/null +++ b/src/client/components/board/player/card/Card.tsx @@ -0,0 +1,85 @@ +import './Card.css'; +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, + card: CardWithServerIndex | null; + index: number; + angle: number; + offsetX: number; + offsetY: number; +} + +export default function Card({ + owner, + card, + index, + angle, + offsetX, + offsetY, + }: CardProps) { + + const game = useGame(); + const { isMobile } = useResponsive(); + + const [isHovered, setIsHovered] = useState(false); + const [isSelected, setIsSelected] = useState(false); + const cardRef = useRef(null); + + const cardImage = TheGameClient.getCardTexture(card); + + const couldBePlayed = game.isSelf(owner) && ((card && game.canPlayCard(card.serverIndex)) || game.canGiveCard()); + + const handleAction = () => { + if (!card) { + return; + } + game.selectCard(card.serverIndex); + } + + const handleClick = () => { + if (isMobile) { + setIsSelected(true) + return; + } + handleAction(); + }; + + + return ( + <> +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={handleClick} + /> + { + setIsSelected(false); + handleAction(); + }} + onClose={() => setIsSelected(false)} + /> + + ); +} diff --git a/src/client/components/board/player-area/Player.css b/src/client/components/board/player/player-area/Player.css similarity index 100% rename from src/client/components/board/player-area/Player.css rename to src/client/components/board/player/player-area/Player.css diff --git a/src/client/components/board/player/player-area/Player.tsx b/src/client/components/board/player/player-area/Player.tsx new file mode 100644 index 0000000..00c9952 --- /dev/null +++ b/src/client/components/board/player/player-area/Player.tsx @@ -0,0 +1,93 @@ +import './Player.css'; +import PlayerCards from '../player-cards/PlayerCards.tsx'; +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, + CHOOSE_PLAYER_TO_STEAL_FROM +} from "../../../../../common/constants/stages.ts"; + +interface PlayerAreaProps { + player: PlayerModel; + position: PlayerPosition; +} + +export default function Player({ + player, + position, +}: PlayerAreaProps) { + const game = useGame(); + const selfPlayer = game.selfPlayer; + + const playerId = player.id; + const playerAnimRef = useAnimationNode(`${playerId}`); + + 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); + + const extraClasses = `${isSelf ? 'hand-interactable self' : ''} ${isTurn ? 'turn' : ''} ${isSelectable ? 'selectable' : ''} ${isWaitingOn ? 'waiting-on' : ''}` + + const handleInteract = () => { + if (isSelectable) { + game.selectPlayer(player); + } + }; + + return ( + <> +
+ +
+ +
+
+
+ {playerName} + {isSelf && ' (You)'} +
+
+ 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 new file mode 100644 index 0000000..908b6fb --- /dev/null +++ b/src/client/components/board/player/player-cards/PlayerCards.tsx @@ -0,0 +1,54 @@ +import Card from '../card/Card.tsx'; +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 { + player: Player; +} + +export default function PlayerCards({ + player +}: PlayerCardsProps) { + const game = useGame(); + + 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; + const spreadDistance = game.isSelf(player) ? 25 : game.isSpectator ? 10 : 5; + + const cardsWithIndices: CardWithServerIndex[] = player.hand.map((card, index) => ({ + ...card, + serverIndex: index + })); + const hand = sortCards(cardsWithIndices) as CardWithServerIndex[]; + + return ( +
+ {Array(cardCount).fill(null).map((_, i) => { + const angle = baseOffset + (angleStep * i); + 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.css b/src/client/components/board/player/player-list/PlayerList.css similarity index 100% rename from src/client/components/board/player-list/PlayerList.css rename to src/client/components/board/player/player-list/PlayerList.css diff --git a/src/client/components/board/player/player-list/PlayerList.tsx b/src/client/components/board/player/player-list/PlayerList.tsx new file mode 100644 index 0000000..68efabd --- /dev/null +++ b/src/client/components/board/player/player-list/PlayerList.tsx @@ -0,0 +1,35 @@ +import Player from '../player-area/Player.tsx'; +import { calculatePlayerPositions } from '../../../../utils/playerPositioning.ts'; +import './PlayerList.css'; +import { useGame } from '../../../../context/GameContext.tsx'; + +export default function PlayerList() { + const game = useGame(); + const alivePlayers = game.players.alivePlayers; + + const selfIndex = !game.isSelfAlive + ? null + : alivePlayers.findIndex(p => p.id === game.selfPlayerId); + + return ( +
+ {alivePlayers.map((player, playerIndex) => { + + const { cardPosition, infoPosition } = calculatePlayerPositions( + playerIndex, + alivePlayers.length, + selfIndex === -1 ? null : selfIndex, + !game.selfPlayer?.isAlive + ); + + return ( + + ); + })} +
+ ); +} diff --git a/src/client/components/board/see-future-overlay/SeeTheFutureOverlay.tsx b/src/client/components/board/see-future-overlay/SeeTheFutureOverlay.tsx deleted file mode 100644 index 1bdda1f..0000000 --- a/src/client/components/board/see-future-overlay/SeeTheFutureOverlay.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import './SeeTheFutureOverlay.css'; -import {Card} from '../../../../common'; - -interface SeeTheFutureOverlayProps { - cards: Card[]; - onClose: () => void; -} - -export default function SeeTheFutureOverlay({ cards, onClose }: SeeTheFutureOverlayProps) { - return ( -
-
-

The Future

-
- These are the next cards to be drawn from left to right: -
-
- {cards.map((card, index) => ( -
-
#{index + 1}
-
-
- ))} -
- -
-
- ); -} - diff --git a/src/client/components/board/table/PendingPlayStack.css b/src/client/components/board/table/PendingPlayStack.css deleted file mode 100644 index f7f0adb..0000000 --- a/src/client/components/board/table/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/PendingPlayStack.tsx b/src/client/components/board/table/PendingPlayStack.tsx deleted file mode 100644 index d75c086..0000000 --- a/src/client/components/board/table/PendingPlayStack.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import {PendingCardPlay} 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; - canNope: boolean; - onNope: () => void; -} - -export default function PendingPlayStack({pendingPlay, canNope, onNope}: PendingPlayStackProps) { - const targetCard = pendingPlay.card; - const isNoped = pendingPlay.isNoped; - const [isHovered, setIsHovered] = useState(false); - const pileRef = useRef(null); - - const cardImage = `/assets/cards/${targetCard.name}/${targetCard.index}.png`; - - return ( -
-
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - /> - - - - {/*Only show when at least one nope card been played*/} - {pendingPlay.nopeCount > 0 && ( -
- {isNoped ? 'Noped' : 'Un-Noped'} -
- )} - - {/* Nope Button */} - {canNope && ( - - )} -
- ); -} diff --git a/src/client/components/board/table/Table.css b/src/client/components/board/table/Table.css index d0dc150..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: 5; + z-index: 1; transition: opacity 0.3s ease; } @@ -70,7 +70,7 @@ flex-direction: column; align-items: center; justify-content: center; - z-index: 10; + 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: 1; + z-index: 3; animation: fade 0.2s ease-in; } @@ -122,7 +122,7 @@ font-size: calc(var(--scale) * 1.5); font-weight: bold; cursor: pointer; - z-index: 65; + z-index: 50; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); animation: pulse-button 1s infinite alternate; white-space: nowrap; @@ -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 0f97097..5bd85d8 100644 --- a/src/client/components/board/table/Table.tsx +++ b/src/client/components/board/table/Table.tsx @@ -1,189 +1,50 @@ -import back from '/assets/cards/back/0.jpg'; +import {useEffect, useState} from "react"; +import TurnBadge from './turn-badge/TurnBadge'; +import {DrawPile} from './pile/DrawPile'; +import {DiscardPile} from './pile/DiscardPile'; +import {useGame} from "../../../context/GameContext.tsx"; import './Table.css'; -import {useEffect, useState, useRef} from "react"; -import {GameContext} from "../../../types/component-props"; -import {Card, canPlayerNope} from '../../../../common'; -import PendingPlayStack from './PendingPlayStack'; -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"; - -interface TableProps { - gameContext: GameContext; - playerHand?: Card[]; -} - -export default function Table({gameContext, playerHand = []}: TableProps) { - - 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.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 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 handlePlayNope = () => { - if (nopeCardIndex !== -1 && moves.playNowCard) { - moves.playNowCard(nopeCardIndex); - } - }; - - // Detect when a card is drawn - useEffect(() => { - if (G.client.drawPileLength< lastDrawPileLength) { - setIsDrawing(true); - setTimeout(() => setIsDrawing(false), 400); - } - setLastDrawPileLength(G.client.drawPileLength); - }, [G.client.drawPileLength, lastDrawPileLength]); - - // Detect when a shuffle card is played - const pendingCardRef = useRef(G.pendingCardPlay); - useEffect(() => { - // If there is an active pending play, do not trigger shuffle yet - if (G.pendingCardPlay) { - pendingCardRef.current = G.pendingCardPlay; - return; - } - - const wasPending = pendingCardRef.current; - pendingCardRef.current = null; - - // Check if discard pile changed - const discardChanged = G.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]; - - // Trigger if newly placed shuffle - // OR if delayed resolution happened - if ((discardChanged || resolvedShuffle) && lastCard?.name === 'shuffle') { - // Double check it wasn't noped if it came from pending - if (!(wasPending && wasPending.isNoped)) { - setIsShuffling(true); - setTimeout(() => setIsShuffling(false), 800); - } - } - - if (!G.pendingCardPlay) { - setLastDiscardPileLength(G.discardPile.length); - } - }, [G.discardPile.length, lastDiscardPileLength, G.discardPile, G.pendingCardPlay]); - - const handleDrawClick = () => { - if (!isDrawing) { - moves.drawCard(); - } - }; +export default function Table() { + const game = useGame() // 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 && ( - <> -
{ - if (!isMobile) setIsDiscardPileSelected(true); - }} - onMouseLeave={() => { - if (!isMobile) setIsDiscardPileSelected(false); - }} - onClick={() => { - if (isMobile) setIsDiscardPileSelected(true); - }} - /> - setIsDiscardPileSelected(false)} - /> - - )} - - {G.pendingCardPlay && ( - /* Replaces discard pile */ - - )} - -
setIsHoveringDrawPile(true)} - onMouseLeave={() => setIsHoveringDrawPile(false)} - data-animation-id="draw-pile" - > - {isHoveringDrawPile && G.client.drawPileLength > 0 && ( -
- {G.client.drawPileLength} -
- )} -
+ +
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} +
+ ); + } +); + diff --git a/src/client/components/board/turn-badge/TurnBadge.css b/src/client/components/board/table/turn-badge/TurnBadge.css similarity index 100% rename from src/client/components/board/turn-badge/TurnBadge.css rename to src/client/components/board/table/turn-badge/TurnBadge.css diff --git a/src/client/components/board/turn-badge/TurnBadge.tsx b/src/client/components/board/table/turn-badge/TurnBadge.tsx similarity index 70% rename from src/client/components/board/turn-badge/TurnBadge.tsx rename to src/client/components/board/table/turn-badge/TurnBadge.tsx index ee7801c..4d2e17a 100644 --- a/src/client/components/board/turn-badge/TurnBadge.tsx +++ b/src/client/components/board/table/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/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/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/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/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/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/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/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/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/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/AnimationContext.tsx b/src/client/context/AnimationContext.tsx new file mode 100644 index 0000000..25aa4c5 --- /dev/null +++ b/src/client/context/AnimationContext.tsx @@ -0,0 +1,155 @@ +import {createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'; +import {IAnimation, ICard} from "../../common"; +import {useGame} from "./GameContext.tsx"; + +interface AnimationContextValue { + animations: CardAnimation[]; + 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 }) { + const game = useGame(); + + const [animations, setAnimations] = useState([]); + const lastAnimationTime = useRef(Date.now()); + + // 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(( + id: number, + animation: IAnimation + ) => { + if (animations.some(value1 => value1.id === id)) { + return + } + + const newAnimation = { id, metadata: animation }; + + setAnimations(prev => [...prev, newAnimation]); + + setTimeout(() => { + setAnimations(prev => prev.filter(a => a.id !== id)); + }, animation.durationMs + 50); + + }, [animations]) + + const playManualAnimation = useCallback(( + fromId: string, + toId: string, + card: ICard | null = null, + durationMs = 500 + ) => { + const id = Date.now(); + playAnimation(id, { + from: fromId, + to: toId, + card: card, + durationMs: durationMs, + visibleTo: [] + }); + }, [playAnimation]); + + useEffect(() => { + if (!game.animationsQueue?.queue) { + return; + } + + const [highestTime, animationsToPlay] = game.animationsQueue.getAnimationsToPlay(lastAnimationTime.current); + + lastAnimationTime.current = highestTime; + + 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 + (window as any).playAnimation = (fromId: string, toId: string, cardName?: string, cardIndex?: number, durationMs = 500) => { + const card = cardName ? { + id: -1, + name: cardName, + index: cardIndex ?? 0 + } : null; + playManualAnimation(fromId, toId, card, durationMs); + }; + + const value = { + animations, + playManualAnimation: playManualAnimation, + playAnimation: 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); + + return useCallback((node: HTMLElement | null) => { + if (node) { + ref.current = node; + registerNode(id, node); + } else { + registerNode(id, null); + ref.current = null; + } + }, [id, registerNode]); +} 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/context/MatchDetailsContext.tsx b/src/client/context/MatchDetailsContext.tsx index 14914e6..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 { @@ -40,13 +39,19 @@ 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: String(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, - gameover: match.gameover, + players: matchPlayers, }); setError(null); } catch (err: any) { diff --git a/src/client/entities/game-client.ts b/src/client/entities/game-client.ts new file mode 100644 index 0000000..f33f88e --- /dev/null +++ b/src/client/entities/game-client.ts @@ -0,0 +1,187 @@ +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"; +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"; +import back from "/assets/cards/back/0.jpg"; + +export class TheGameClient extends TheGame { + public readonly moves: Record void>; + public readonly matchID: string; + public readonly selfPlayerId: string | null; + public readonly matchData: MatchPlayer[]; + 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.setPlayers((this.context.player as IPlayerAPI & { data: { players: IPlayers } }).data.players); + + this.moves = moves; + this.matchID = matchID; + this.selfPlayerId = playerID; + this.matchData = matchData; + this.sendChatMessage = sendChatMessage; + this.chatMessages = chatMessages; + this.isMultiplayer = isMultiplayer; + } + + get isSpectator(): boolean { + return this.selfPlayerId === null; + } + + get selfPlayer(): Player | null { + if (!this.selfPlayerId) { + 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.currentPlayerId; + } + + isSelf(player: Player | PlayerID | MatchPlayer | null) { + if (!this.selfPlayerId || !player) { + return false; + } + const playerId = typeof player === 'string' ? player : player.id; + return this.selfPlayerId === playerId; + } + + playDrawCard() { + if (this.selfPlayer && this.moves.drawCard) { + this.moves.drawCard(); + } + } + + 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"); + 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); + if (!cardAt) { + console.error(`No card at index ${cardIndex} in player's hand`); + return; + } + if (!this.canPlayCard(cardIndex)) { + console.error(`Card ${cardAt.name} at index ${cardIndex} cannot be played right now`); + return; + } + this.moves.playCard(cardIndex); + } + } + + playNope() { + 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); + } + } + + 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 back; + } + return `/assets/cards/${card.name}/${card.index}.png`; + } +} + diff --git a/src/client/hooks/useCardAnimations.tsx b/src/client/hooks/useCardAnimations.tsx deleted file mode 100644 index 3c3811d..0000000 --- a/src/client/hooks/useCardAnimations.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import React, {useState, useCallback, useRef, useEffect} from 'react'; -import CardAnimation, {CardAnimationData} from '../components/board/card-animation/CardAnimation'; -import {Card, GameState, Players} from '../../common'; - -interface UseCardAnimationsReturn { - animations: CardAnimationData[]; - AnimationLayer: () => React.JSX.Element; - triggerCardMovement: (card: Card | null, fromId: string, toId: string) => void; -} - -type PlayerHandCounts = Record; - -interface HandChange { - playerId: string; - delta: number; -} - -export const useCardAnimations = (G: GameState, players: Players, 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 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: Card | 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 = G.client.drawPileLength < previousDrawPileLength.current; - const discardPileIncreased = G.discardPile.length > previousDiscardPileLength.current; - const pilesUnchanged = G.client.drawPileLength === previousDrawPileLength.current && - G.discardPile.length === previousDiscardPileLength.current; - - if (drawPileDecreased) { - handChanges - .filter(change => change.delta > 0) - .forEach(change => { - let card: Card | 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 = G.discardPile[G.discardPile.length - 1]; - 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: Card | 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}`); - } - } - - previousDrawPileLength.current = G.client.drawPileLength; - previousDiscardPileLength.current = G.discardPile.length; - previousPlayerHands.current = currentHandCounts; - - if (selfPlayerId && players[selfPlayerId]) { - previousLocalHand.current = players[selfPlayerId].hand; - } else { - previousLocalHand.current = []; - } - }, [G.client.drawPileLength, G.discardPile.length, G.discardPile, triggerCardMovement, players, selfPlayerId]); - - const AnimationLayer = useCallback(() => ( - <> - {animations.map(animation => ( - - ))} - - ), [animations, handleAnimationComplete]); - - return { - animations, - AnimationLayer, - triggerCardMovement, - }; -}; 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/useGameState.tsx b/src/client/hooks/useGameState.tsx deleted file mode 100644 index aa6697c..0000000 --- a/src/client/hooks/useGameState.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import {useMemo} from 'react'; -import {Ctx} from 'boardgame.io'; -import {GameState, Players} 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: GameState, - allPlayers: Players, - 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/model/PlayerState.ts b/src/client/model/PlayerState.ts deleted file mode 100644 index 3f7d601..0000000 --- a/src/client/model/PlayerState.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {Card, sortCards} from "../../common"; - -export interface CardWithServerIndex extends Card { - 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: Card[]) { - this.isSelfSpectator = isSelfSpectator; - this.isSelf = isSelf; - this.isAlive = isAlive; - this.isTurn = isTurn; - this.handCount = handCount; - - // Create cards 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/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..57338ec --- /dev/null +++ b/src/client/types/client-context.ts @@ -0,0 +1,8 @@ +import type { BoardProps } from 'boardgame.io/react'; +import type { IBoardPlugins } from '../models/client.model'; +import { IGameState, IPlayerAPI } from "../../common"; + +export type IClientContext = BoardProps & { + plugins: IBoardPlugins; + player: IPlayerAPI; +}; diff --git a/src/client/types/component-props.ts b/src/client/types/component-props.ts index 7667364..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 {GameState, Players, Card} from '../../common'; +import {IGameState, IPlayers} from '../../common'; import {MatchPlayer} from '../utils/matchData'; /** @@ -12,7 +7,7 @@ import {MatchPlayer} from '../utils/matchData'; */ export interface GameContext { ctx: Ctx; - G: GameState; + G: IGameState; moves: any; playerID: string | null; matchData?: MatchPlayer[]; @@ -22,7 +17,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; @@ -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: Card | 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/matchData.ts b/src/client/utils/matchData.ts index 528a841..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: number; + id: PlayerID; name?: string; isConnected?: boolean; } @@ -13,28 +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'; - - const playerId = parseInt(playerID); +export function getPlayerName(player: MatchPlayer | string | null | undefined): string { + if (!player) return EMPTY_NAME; - if (!matchData || matchData.length === 0) { - return `Player ${playerId + 1}`; - } - - 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; } 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/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/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/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/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/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/animation-queue.ts b/src/common/entities/animation-queue.ts new file mode 100644 index 0000000..babb771 --- /dev/null +++ b/src/common/entities/animation-queue.ts @@ -0,0 +1,75 @@ +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, options?: { visibleTo?: (Player | PlayerID)[], durationMs?: number }) { + const { visibleTo = [], durationMs = 500 } = options || {}; + 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: { + id: 'id' in card ? card.id : -1, + name: card.name, + index: card.index + }, + visibleTo: idVisibleTo, + durationMs + }; + this.enqueueAnimation(animation); + } + + enqueueAnimationWithDelay(animation: IAnimation, options?: { delayMs?: number }) { + const delayMs = options?.delayMs || 0; + const id = Date.now() + delayMs; + let currentQueue = this.queue[id]; + if (!currentQueue) { + currentQueue = [] + this.queue[id] = currentQueue + } + currentQueue.push(animation) + } + + enqueueAnimation(animation: IAnimation) { + this.enqueueAnimationWithDelay(animation, { delayMs: 0 }); + } + + 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]; + if (animations) { + if (id > highestTime) { + highestTime = id; + } + 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] + } + + clear() { + for (const key in this.queue) { + delete this.queue[key]; + } + } +} \ No newline at end of file diff --git a/src/common/entities/card-holder.ts b/src/common/entities/card-holder.ts new file mode 100644 index 0000000..760ac05 --- /dev/null +++ b/src/common/entities/card-holder.ts @@ -0,0 +1,104 @@ +import {ICard} from '../models'; +import {TheGame} from './game'; +import {Card} from './card'; + +export class CardHolder { + 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-type.ts b/src/common/entities/card-type.ts index 26f5365..ecb2334 100644 --- a/src/common/entities/card-type.ts +++ b/src/common/entities/card-type.ts @@ -1,4 +1,7 @@ -import type {Card, FnContext} from '../models'; +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; @@ -11,35 +14,41 @@ export class CardType { return false; } - createCard(index: number): Card { - return {name: this.name, index}; + createCard(index: number, game: TheGame): ICard { + return { + id: game.generateCardId(), + name: this.name, + index: index + }; } - canBePlayed(_context: FnContext, _card: Card): boolean { + canBePlayed(_game: TheGame, _card: Card): boolean { return true; } - isNowCard(_context: FnContext, _card: Card): boolean { + isNowCard(): boolean { return false; } - setupPendingState(context: FnContext) { - context.events.setActivePlayers({ - currentPlayer: 'awaitingNowCards', + setupPendingState(game: TheGame) { + game.turnManager.setActivePlayers({ + currentPlayer: AWAITING_NOW_CARDS, others: { - stage: 'respondWithNowCard', + stage: RESPOND_WITH_NOW_CARD, }, }); } - cleanupPendingState(context: FnContext) { - context.events.setActivePlayers({value: {}}); + cleanupPendingState(game: TheGame) { + game.turnManager.endStage(); + game.turnManager.setActivePlayers({value: {}}); } - afterPlay(_context: FnContext, _card: Card): void {} + afterPlay(_game: TheGame, _card: Card): void {} + + onPlayed(_game: TheGame, _card: Card): void {} - onPlayed(_context: FnContext, _card: Card): 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 new file mode 100644 index 0000000..4285bba --- /dev/null +++ b/src/common/entities/card-types/attack-card.ts @@ -0,0 +1,37 @@ +import {CardType} from '../card-type'; +import {TheGame} from '../game'; +import {Card} from '../card'; +import {findNextAlivePlayer} from "../../utils/turn-order"; + +export class AttackCard extends CardType { + + constructor(name: string) { + super(name); + } + + 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 = turnManager.turnsRemaining; + turnManager.turnsRemaining = remaining + 3; + + // 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(); + } + } + + + sortOrder(): number { + return 1; + } +} diff --git a/src/common/entities/card-types/cat-card.ts b/src/common/entities/card-types/cat-card.ts new file mode 100644 index 0000000..2127e73 --- /dev/null +++ b/src/common/entities/card-types/cat-card.ts @@ -0,0 +1,61 @@ +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 { + + constructor(name: string) { + super(name); + } + + /** + * Cat card-types can only be played in pairs + */ + 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; + } + + 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(game: TheGame, _card: Card) { + const candidates = game.players.getValidCardActionTargets(game.players.actingPlayer); + + if (candidates.length === 1) { + // Automatically choose the only valid opponent + stealCard(game, candidates[0].id); + } else if (candidates.length > 1) { + game.turnManager.setStage(CHOOSE_PLAYER_TO_STEAL_FROM); + } + } + + /** + * Immediately consume the second matching cat card after the first is played. + */ + afterPlay(game: TheGame, card: Card) { + const player = game.players.actingPlayer; + // 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; + } + const secondCard = matchingCards[0]; + secondCard.moveTo(game.piles.discardPile, { delayMs: 150 }); + } + + sortOrder(): number { + return 6; + } +} diff --git a/src/common/entities/cards/defuse-card.ts b/src/common/entities/card-types/defuse-card.ts similarity index 58% rename from src/common/entities/cards/defuse-card.ts rename to src/common/entities/card-types/defuse-card.ts index e1737a6..69bae75 100644 --- a/src/common/entities/cards/defuse-card.ts +++ b/src/common/entities/card-types/defuse-card.ts @@ -1,5 +1,6 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; +import {TheGame} from '../game'; +import {Card} from '../card'; export class DefuseCard extends CardType { @@ -7,11 +8,12 @@ export class DefuseCard extends CardType { super(name); } - canBePlayed(_context: FnContext, _card: Card): boolean { + canBePlayed(_game: TheGame, _card: Card): boolean { return false; } + sortOrder(): number { - return 99; + return 100; } } diff --git a/src/common/entities/cards/exploding-kitten-card.ts b/src/common/entities/card-types/exploding-kitten-card.ts similarity index 77% rename from src/common/entities/cards/exploding-kitten-card.ts rename to src/common/entities/card-types/exploding-kitten-card.ts index 42625e2..b24f8aa 100644 --- a/src/common/entities/cards/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 new file mode 100644 index 0000000..a928184 --- /dev/null +++ b/src/common/entities/card-types/favor-card.ts @@ -0,0 +1,37 @@ +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 { + + constructor(name: string) { + super(name); + } + + canBePlayed(game: TheGame, _card: Card): boolean { + return game.players.getValidCardActionTargets(game.players.actingPlayer).length > 0; + } + + onPlayed(game: TheGame, _card: Card) { + const { ctx } = game.context; + + const candidates = game.players.allPlayers.filter((target) => { + return target.id !== ctx.currentPlayer && target.isAlive && target.handSize > 0; + }); + + if (candidates.length === 1) { + // Automatically choose the only valid opponent + 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) + } + + } + + sortOrder(): number { + return 3; + } +} diff --git a/src/common/entities/cards/nope-card.ts b/src/common/entities/card-types/nope-card.ts similarity index 56% rename from src/common/entities/cards/nope-card.ts rename to src/common/entities/card-types/nope-card.ts index 015ebfe..568b4f1 100644 --- a/src/common/entities/cards/nope-card.ts +++ b/src/common/entities/card-types/nope-card.ts @@ -1,7 +1,6 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; -import {validateNope} from '../../utils/action-validation'; -import {GameLogic} from '../../wrappers/game-logic'; +import {TheGame} from '../game'; +import {Card} from '../card'; export class NopeCard extends CardType { @@ -9,25 +8,24 @@ export class NopeCard extends CardType { super(name); } - isNowCard(_context: FnContext, _card: Card): boolean { + isNowCard(): boolean { return true; } - canBePlayed(context: FnContext, _card: Card): boolean { - const {G, playerID} = context; - return validateNope(G, playerID); + canBePlayed(game: TheGame, _card: Card): boolean { + return game.players.actingPlayer.canNope } - onPlayed(context: FnContext, _card: Card): void { - const game = new GameLogic(context); - const pendingCardPlay = game.pendingCardPlay; - const player = game.actingPlayer; + onPlayed(game: TheGame, _card: Card) { + const pendingCardPlay = game.piles.pendingCard; + 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); @@ -39,6 +37,6 @@ export class NopeCard extends CardType { } sortOrder(): number { - return 6; + return 99; } } 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 52% 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..ed301f3 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,7 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; +import {TheGame} from '../game'; +import {Card} from '../card'; +import {VIEWING_FUTURE} from "../../constants/stages"; export class SeeTheFutureCard extends CardType { @@ -7,15 +9,12 @@ export class SeeTheFutureCard extends CardType { super(name); } - onPlayed(context: FnContext, _card: Card) { - const { events } = context; - + onPlayed(game: TheGame, _card: Card) { // Set stage to view the future - events.setActivePlayers({ - currentPlayer: 'viewingFuture', - }); + game.turnManager.setStage(VIEWING_FUTURE) } + sortOrder(): number { return 5; } diff --git a/src/common/entities/cards/shuffle-card.ts b/src/common/entities/card-types/shuffle-card.ts similarity index 52% rename from src/common/entities/cards/shuffle-card.ts rename to src/common/entities/card-types/shuffle-card.ts index 9047d88..670fb60 100644 --- a/src/common/entities/cards/shuffle-card.ts +++ b/src/common/entities/card-types/shuffle-card.ts @@ -1,5 +1,6 @@ import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; +import {TheGame} from '../game'; +import {Card} from '../card'; export class ShuffleCard extends CardType { @@ -7,9 +8,8 @@ export class ShuffleCard extends CardType { super(name); } - onPlayed(context: FnContext, _card: Card) { - const { G, random } = context; - G.drawPile = random.Shuffle(G.drawPile) + onPlayed(game: TheGame, _card: Card) { + game.piles.drawPile.shuffle(); } sortOrder(): number { diff --git a/src/common/entities/cards/skip-card.ts b/src/common/entities/card-types/skip-card.ts similarity index 55% rename from src/common/entities/cards/skip-card.ts rename to src/common/entities/card-types/skip-card.ts index 10535eb..df129d4 100644 --- a/src/common/entities/cards/skip-card.ts +++ b/src/common/entities/card-types/skip-card.ts @@ -1,5 +1,6 @@ import {CardType} from '../card-type'; -import {Card, FnContext} 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: FnContext, _card: Card) { - 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..760c6b6 --- /dev/null +++ b/src/common/entities/card.ts @@ -0,0 +1,92 @@ +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; + + constructor( + private game: TheGame, + _data: ICard + ) { + this.id = _data.id; + this.name = _data.name; + this.index = _data.index; + } + + get data(): ICard { + return {id: this.id, 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}`); + 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 (insertIndex !== undefined && insertIndex >= 0 && insertIndex <= destination.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); + } + + play(): void { + this.type.onPlayed(this.game, this); + } + + afterPlay(): void { + this.type.afterPlay(this.game, this); + } +} diff --git a/src/common/entities/cards/attack-card.ts b/src/common/entities/cards/attack-card.ts deleted file mode 100644 index 4f71346..0000000 --- a/src/common/entities/cards/attack-card.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; - -export class AttackCard extends CardType { - - constructor(name: string) { - super(name); - } - - onPlayed(context: FnContext, _card: Card) { - const { G, ctx, events } = 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; - - // End turn and force move to next player - const nextPlayer = ctx.playOrderPos + 1; - const nextPlayerIndex = nextPlayer % ctx.numPlayers; - events.endTurn({ next: nextPlayerIndex + "" }); - } - - sortOrder(): number { - return 1; - } -} diff --git a/src/common/entities/cards/cat-card.ts b/src/common/entities/cards/cat-card.ts deleted file mode 100644 index a83d5e6..0000000 --- a/src/common/entities/cards/cat-card.ts +++ /dev/null @@ -1,90 +0,0 @@ -import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; -import {stealCard} from '../../moves/steal-card-move'; - -export class CatCard extends CardType { - - constructor(name: string) { - super(name); - } - - /** - * Cat cards can only be played in pairs - */ - canBePlayed(context: FnContext, card: Card): boolean { - const { player, ctx } = context; - - const playerData = player.get(); - - // Count how many cat cards with the same index the player has - const matchingCards = playerData.hand.filter( - (c: Card) => c.name === card.name && c.index === card.index - ); - - // Need at least 2 matching cat cards to play - if (matchingCards.length < 2) { - return false; - } - - // Check if there is at least one other player with cards - 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; - }); - } - - /** - * Prompt player to choose a target after pair cost is already consumed. - */ - onPlayed(context: FnContext, _card: Card) { - 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; - }); - - if (candidates.length === 1) { - // Automatically choose the only valid opponent - stealCard(context, candidates[0]); - } else if (candidates.length > 1) { - // Set stage to choose a player to steal from - events.setActivePlayers({ - currentPlayer: 'choosePlayerToStealFrom', - }); - } - } - - /** - * Immediately consume the second matching cat card after the first is played. - */ - afterPlay(context: FnContext, card: Card) { - const {G, player} = context; - const playerData = player.get(); - - const secondCardIndex = playerData.hand.findIndex( - (c: Card) => c.name === card.name && c.index === card.index - ); - - if (secondCardIndex === -1) { - return; - } - - const secondCard = playerData.hand[secondCardIndex]; - const newHand = playerData.hand.filter((_: Card, index: number) => index !== secondCardIndex); - - player.set({ - ...playerData, - hand: newHand, - }); - - G.discardPile.push(secondCard); - } - - sortOrder(): number { - return 98; - } -} diff --git a/src/common/entities/cards/favor-card.ts b/src/common/entities/cards/favor-card.ts deleted file mode 100644 index 7afcded..0000000 --- a/src/common/entities/cards/favor-card.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {CardType} from '../card-type'; -import {Card, FnContext} from "../../models"; -import {requestCard} from '../../moves/favor-card-move'; - -export class FavorCard extends CardType { - - constructor(name: string) { - super(name); - } - - canBePlayed(context: FnContext, _card: Card): boolean { - const { player, ctx } = context; - - // Check if there is at least one other player with cards - 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; - }); - } - - onPlayed(context: FnContext, _card: Card) { - 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; - }); - - if (candidates.length === 1) { - // Automatically choose the only valid opponent - requestCard(context, candidates[0]); - } else if (candidates.length > 1) { - // Set stage to choose a player to request a card from - events.setActivePlayers({ - currentPlayer: 'choosePlayerToRequestFrom', - }); - } - } - - sortOrder(): number { - return 3; - } -} diff --git a/src/common/entities/deck-type.ts b/src/common/entities/deck-type.ts new file mode 100644 index 0000000..2015309 --- /dev/null +++ b/src/common/entities/deck-type.ts @@ -0,0 +1,25 @@ +import type {ICard} from '../models'; +import {TheGame} from './game'; + +export abstract class DeckType { + name: string; + + protected constructor(name: string) { + this.name = name; + } + + /** Cards that form the base deck before dealing */ + 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(_game: TheGame, _player_index: number): ICard[] { + return []; + } + + /** Extra card-types added after the players are dealt */ + addPostDealCards(_game: TheGame): void { + } +} diff --git a/src/common/entities/deck-types/original-deck.ts b/src/common/entities/deck-types/original-deck.ts new file mode 100644 index 0000000..225b0ad --- /dev/null +++ b/src/common/entities/deck-types/original-deck.ts @@ -0,0 +1,68 @@ +import {DeckType} from '../deck-type'; +import type {ICard} from '../../models'; +import {TheGame} from '../game'; + +import { + ATTACK, + CAT_CARD, DEFUSE, + EXPLODING_KITTEN, + FAVOR, NOPE, + SEE_THE_FUTURE, + SHUFFLE, + SKIP, +} from '../../registries/card-registry'; + +const STARTING_HAND_SIZE = 7; +const TOTAL_DEFUSE_CARDS = 6; +const MAX_DECK_DEFUSE_CARDS = 2; + +export class OriginalDeck extends DeckType { + constructor() { + super('original'); + } + + startingHandSize(): number { + return STARTING_HAND_SIZE; + } + + startingHandForcedCards(game: TheGame, index: number): ICard[] { + return [DEFUSE.createCard(index, game)]; + } + + buildBaseDeck(game: TheGame): void { + const drawPile = game.piles.drawPile; + + for (let i = 0; i < 4; 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++) { + drawPile.addCard(CAT_CARD.createCard(i, game)); + } + } + + for (let i = 0; i < 5; i++) { + drawPile.addCard(SEE_THE_FUTURE.createCard(i, game)); + } + } + + 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; + 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; + drawPile.addCard(EXPLODING_KITTEN.createCard(cardIndex, game)); + } + } +} 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/decks/original-deck.ts b/src/common/entities/decks/original-deck.ts deleted file mode 100644 index ff12fd5..0000000 --- a/src/common/entities/decks/original-deck.ts +++ /dev/null @@ -1,65 +0,0 @@ -import {Deck} from '../deck'; -import type {Card} from '../../models'; - -import { - ATTACK, - CAT_CARD, - DEFUSE, - EXPLODING_KITTEN, - FAVOR, NOPE, - SEE_THE_FUTURE, - SHUFFLE, - SKIP, -} from '../../constants/card-types'; - -const STARTING_HAND_SIZE = 7; -const TOTAL_DEFUSE_CARDS = 6; -const MAX_DECK_DEFUSE_CARDS = 2; -const EXPLODING_KITTENS = 4; - -export class OriginalDeck extends Deck { - constructor() { - super('original'); - } - - startingHandSize(): number { - return STARTING_HAND_SIZE; - } - - startingHandForcedCards(index: number): Card[] { - return [DEFUSE.createCard(index)]; - } - - buildBaseDeck(): Card[] { - const pile: Card[] = []; - - 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)); - for (let j = 0; j < 5; j++) { - pile.push(CAT_CARD.createCard(i)); - } - } - - for (let i = 0; i < 5; i++) { - pile.push(SEE_THE_FUTURE.createCard(i)); - } - - return pile; - } - - addPostDealCards(pile: Card[], playerCount: number): void { - 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)); - } - - for (let i = 0; i < EXPLODING_KITTENS; i++) { - pile.push(EXPLODING_KITTEN.createCard(i)); - } - } -} diff --git a/src/common/entities/game.ts b/src/common/entities/game.ts new file mode 100644 index 0000000..9829533 --- /dev/null +++ b/src/common/entities/game.ts @@ -0,0 +1,86 @@ +import {IContext, IGameState, IPlayers, ICard} from '../models'; +import {Piles} from './piles'; +import {Players} from './players'; +import {TurnManager} from './turn-manager'; +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 {GAME_OVER, LOBBY, PLAY} from "../constants/phases"; +import {AnimationQueue} from "./animation-queue"; + + +export class TheGame { + public readonly context: IContext; + protected readonly gameState: IGameState; + protected readonly bgContext: Ctx; + public readonly events: EventsAPI; + public readonly random: RandomAPI; + + public readonly piles: Piles; + public readonly turnManager: TurnManager; + public players: Players; + public animationsQueue: AnimationQueue; + + 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.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); + } else { + this.players = new Players(this, this.gameState, {}); + } + } + + setPlayers(players: IPlayers) { + 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; + } + + isLobbyPhase(): boolean { + 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 new file mode 100644 index 0000000..f2909a8 --- /dev/null +++ b/src/common/entities/pile.ts @@ -0,0 +1,28 @@ +import {IPile} from '../models'; +import {TheGame} from "./game"; +import {Card} from "./card"; +import {CardHolder} from "./card-holder"; + +export class Pile extends CardHolder { + + 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; + } + + get size(): number { + return this.state.size; + } + + updateSize(): void { + this.state.size = this.length; + } +} diff --git a/src/common/entities/piles.ts b/src/common/entities/piles.ts new file mode 100644 index 0000000..7aa320d --- /dev/null +++ b/src/common/entities/piles.ts @@ -0,0 +1,86 @@ +import {ICard, IPendingCardPlay, IPiles} from '../models'; +import {TheGame} from "./game"; +import {Card} from "./card"; +import {Pile} from "./pile"; +import {DISCARD, DRAW} from "../constants/piles"; + +export class Piles { + constructor(private game: TheGame, private piles: IPiles) { + } + + get drawPile(): Pile { + return new Pile(DRAW, this.game, this.piles.drawPile); + } + + set drawPile(pile: ICard[]) { + this.piles.drawPile = { ...this.piles.drawPile, cards: pile, size: pile.length }; + } + + get discardPile(): Pile { + return new Pile(DISCARD, this.game, this.piles.discardPile); + } + + set discardPile(pile: ICard[]) { + this.piles.discardPile = { ...this.piles.discardPile, cards: pile, size: pile.length }; + } + + /** + * Add a card to the discard pile + */ + discardCard(card: Card | ICard): void { + this.discardPile.addCard(card); + } + + /** + * Draw a card from the top of the draw pile + */ + drawCard(): Card | null { + return this.drawPile.drawCard(); + } + + canCardBePlayed(card: Card): boolean { + if (this.game.piles.pendingCard && !card.type.isNowCard()) { + return false; + } + + return card.canPlay(); + } + + /** + * 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 new file mode 100644 index 0000000..6a55bfa --- /dev/null +++ b/src/common/entities/player.ts @@ -0,0 +1,229 @@ +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 {CardHolder} from "./card-holder"; + +export class Player extends CardHolder { + constructor( + 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.cards; + } + + /** + * Check if player is alive in game terms (not eliminated) + */ + get isAlive(): boolean { + return this._state.isAlive; + } + + /** + * Get the count of card-types in hand + */ + get handSize(): number { + return this._state.handSize; + } + + get isCurrentPlayer(): boolean { + return this.game.players.currentPlayerId === this.id; + } + + get isActingPlayer(): boolean { + return this.game.players.actingPlayer.id === this.id; + } + + get isValidCardTarget(): boolean { + return this.isAlive && this.handSize > 0; + } + + get canNope(): boolean { + if (!this.hand.some(c => c.name === NAME_NOPE)) { + return false; + } + if (!this.game.piles.pendingCard) { + return false; + } + + 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 + 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 + 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); + } + + eliminate(): void { + this._state.isAlive = false; + // put all hand card-types in discard pile + 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 + } + if (this.isCurrentPlayer) { + this.game.turnManager.endTurn(); + } + } + + isInStage(stage: string): boolean { + return this.game.turnManager.isInStage(this, stage); + } + + /** + * Transfers a card at specific index to another playerWrapper + */ + giveCard(cardIndex: number, recipient: Player): Card { + const card = this.getCardAt(cardIndex); + if (!card) { + throw new Error("Card not found or invalid index"); + } + card.moveTo(recipient); + return card; + } + + playCard(cardIndex: number): void { + if (cardIndex < 0 || cardIndex >= this.hand.length) { + throw new Error(`Invalid card index: ${cardIndex}`); + } + + const card = this.hand[cardIndex]; + + if (!this.game.piles.canCardBePlayed(card)) { + throw new Error(`Cannot play card: ${card.name}`); + } + + const cardData = {...card.data}; // save before moveTo + + // Move to discard + card.moveTo(this.game.piles.discardPile); + + card.afterPlay(); + + if (card.type.isNowCard()) { + card.play(); + return; + } + + // Setup pending state + const startedAtMs = Date.now(); + this.game.piles.pendingCard = { + card: cardData, + 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"); + + 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 = card.name === EXPLODING_KITTEN.name; + + card.moveTo(this, { visibleTo: explodingKittenDrawn ? [] : [this.id] }); + + if (!explodingKittenDrawn) { + 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 { + const drawPile = this.game.piles.drawPile; + + if (insertIndex < 0 || insertIndex > drawPile.size) { + throw new Error('Invalid insert index'); + } + + // 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 (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; + + defuseCard.moveTo(discardPile); + kittenCard.moveTo(drawPile, { insertIndex: insertIndex }); + + this.game.turnManager.endStage(); + this.game.turnManager.endTurn(); + } + + stealRandomCardFrom(target: Player): Card { + const count = target.handSize; + 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); + } +} diff --git a/src/common/entities/players.ts b/src/common/entities/players.ts new file mode 100644 index 0000000..a5cf474 --- /dev/null +++ b/src/common/entities/players.ts @@ -0,0 +1,121 @@ +import {Player} from './player'; +import {TheGame} from "./game"; +import {PlayerID} from "boardgame.io"; +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. + */ + getPlayer(id: PlayerID): Player { + // boardgame.io player plugin structure + const playerData = this.players?.[id]; + if (!playerData) { + throw new Error(`Player data not found for ID: ${id}`); + } + 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 + */ + get currentPlayer(): Player { + 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; + if (!id) { + throw new Error('No playerID found in context; cannot determine acting player'); + } + 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; + } + + 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 + */ + get allPlayers(): Player[] { + return this.game.turnManager.playOrder + .map(id => this.getPlayer(id)) + .filter(player => player !== null) as Player[]; + } + + /** + * Get all alive players + */ + get alivePlayers(): Player[] { + return this.allPlayers.filter(player => player.isAlive); + } + + /** + * Get all alive players who have at least one card in hand + */ + get playersWithCards(): Player[] { + return this.alivePlayers.filter(player => player.handSize > 0); + } + + /** + * 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))); + } + + /** + * 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 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 player; + } +} + diff --git a/src/common/entities/turn-manager.ts b/src/common/entities/turn-manager.ts new file mode 100644 index 0000000..0266e07 --- /dev/null +++ b/src/common/entities/turn-manager.ts @@ -0,0 +1,53 @@ +import {IContext} from "../models"; +import {Player} from "./player"; +import {PlayerID} from "boardgame.io"; + +export class TurnManager { + constructor(private context: IContext) {} + + get turnsRemaining(): number { + return this.context.G.turnsRemaining; + } + + set turnsRemaining(value: number) { + this.context.G.turnsRemaining = value; + } + + get playOrder(): PlayerID[] { + return this.context.ctx.playOrder; + } + + get playOrderPos(): number { + 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; + } + + 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); + } + + setStage(stage: string): void { + this.context.events.setStage(stage); + } + + endStage(): void { + this.context.events.endStage(); + } + + setActivePlayers(arg: any): void { + this.context.events.setActivePlayers(arg); + } +} + diff --git a/src/common/exploding-kittens.ts b/src/common/exploding-kittens.ts new file mode 100644 index 0000000..ac8a6dd --- /dev/null +++ b/src/common/exploding-kittens.ts @@ -0,0 +1,204 @@ +import {Game} from 'boardgame.io'; +import {createPlayerPlugin} from './plugins/player-plugin'; +import {setupGame} from './setup/game-setup'; +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"; +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'; +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", + + plugins: [createPlayerPlugin()], + + setup: setupGame, + + disableUndo: true, + + playerView: ({ G, ctx, playerID }) => { + 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: { + ...G.piles, + drawPile: { + ...(G.piles?.drawPile ?? {}), + cards: isViewingFuture ? drawPileCards.slice(0, 3) : [], + }, + }, + animationsQueue + }; + }, + moves: {}, + + phases: { + lobby: { + start: true, + next: 'play', + onEnd: (context: IContext) => { + const game = new TheGame(context) + + // Initialize the hands and piles + const deck = new OriginalDeck(); + + deck.buildBaseDeck(game); + game.piles.drawPile.shuffle(); + + dealHands(game, deck); + deck.addPostDealCards(game); + + game.piles.drawPile.shuffle(); + }, + turn: { + activePlayers: { + all: WAITING_FOR_START, + }, + stages: { + waitingForStart: { + moves: { + startGame: { + move: (context: IContext) => { + context.events.setPhase(PLAY); + }, + client: false, + }, + } + } + }, + order: { + first: () => 0, + next: () => undefined, + }, + }, + }, + play: { + turn: { + order: turnOrder, + onEnd: (context: IContext) => { + const game = new TheGame(context); + + // Decrement the turns remaining counter + game.turnManager.turnsRemaining = game.turnManager.turnsRemaining - 1; + + // If we're moving to the next player, reset the counter + if (game.turnManager.turnsRemaining <= 0) { + game.turnManager.turnsRemaining = 1; + } + }, + stages: { + defuseExplodingKitten: { + moves: { + defuseExplodingKitten: { + move: inGame(defuseExplodingKitten), + }, + } + }, + choosePlayerToStealFrom: { + moves: { + stealCard: { + move: inGame(stealCard), + client: false + }, + }, + }, + choosePlayerToRequestFrom: { + moves: { + requestCard: { + move: inGame(requestCard), + }, + }, + }, + chooseCardToGive: { + moves: { + giveCard: inGame(giveCard), + }, + }, + viewingFuture: { + moves: { + closeFutureView: inGame(closeFutureView), + }, + }, + respondWithNowCard: { + moves: { + playCard: { + move: inGame(playCard), + client: false, + }, + }, + }, + awaitingNowCards: { + moves: { + playCard: { + move: inGame(playCard), + client: false, + }, + resolvePendingCard: { + move: inGame(resolvePendingCard), + client: false, + }, + }, + }, + }, + }, + moves: { + drawCard: { + move: inGame(drawCard), + client: false + }, + playCard: { + move: inGame(playCard), + client: false + } + }, + endIf: ({player}) => { + const alivePlayers = Object.entries(player.state).filter( + ([_, p]) => p.isAlive + ); + + // End phase when only one player is alive + if (alivePlayers.length === 1) { + return {next: GAME_OVER}; + } + }, + onEnd: (context: IContext) => { + const game = new TheGame(context); + + // Find the last alive player + const players = game.players.alivePlayers; + if (players.length === 1) { + game.players.winner = players[0]; + } + }, + }, + gameOver: {}, + }, +}; diff --git a/src/common/game.ts b/src/common/game.ts deleted file mode 100644 index c72739f..0000000 --- a/src/common/game.ts +++ /dev/null @@ -1,187 +0,0 @@ -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 {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 {dealHands} from './setup/player-setup'; - -export const ExplodingKittens: Game = { - name: "Exploding-Kittens", - - plugins: [createPlayerPlugin()], - - setup: setupGame, - - disableUndo: true, - - playerView: ({G, ctx, playerID}: {G: GameState; 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[] = []; - - if (ctx.activePlayers?.[playerID!] === 'viewingFuture') { - viewableDrawPile = G.drawPile.slice(0, 3); - } - - return { - ...G, - drawPile: viewableDrawPile, - client: { - drawPileLength: G.drawPile.length - } - }; - }, - - moves: {}, - - phases: { - lobby: { - start: true, - next: 'play', - onBegin: ({G}: FnContext) => { - // Reset game state for lobby - G.lobbyReady = false; - }, - onEnd: ({G, ctx, player}) => { - // Deal cards when leaving lobby phase - const deck = new OriginalDeck(); - const pile: Card[] = deck.buildBaseDeck().sort(() => Math.random() - 0.5); - - dealHands(pile, player.state, deck); - deck.addPostDealCards(pile, Object.keys(ctx.playOrder).length); - - G.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', - }, - stages: { - 'waitingForStart': { - moves: { - startGame: { - move: ({G}: FnContext) => { - // 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; - }, - client: false, - }, - } - } - }, - order: { - first: () => 0, - next: () => undefined, - }, - }, - }, - play: { - turn: { - order: turnOrder, - onEnd: ({G}: any) => { - // Decrement the turns remaining counter - G.turnsRemaining = G.turnsRemaining - 1; - - // If we're moving to the next player, reset the counter - if (G.turnsRemaining <= 0) { - G.turnsRemaining = 1; - } - }, - stages: { - choosePlayerToStealFrom: { - moves: { - stealCard: { - move: stealCard, - client: false - }, - }, - }, - choosePlayerToRequestFrom: { - moves: { - requestCard: { - move: requestCard, - client: false - }, - }, - }, - chooseCardToGive: { - moves: { - giveCard: giveCard, - }, - }, - viewingFuture: { - moves: { - closeFutureView: closeFutureView, - }, - }, - respondWithNowCard: { - moves: { - playNowCard: { - move: playNowCard, - client: false, - }, - }, - }, - awaitingNowCards: { - moves: { - playNowCard: { - move: playNowCard, - client: false, - }, - resolvePendingCard: { - move: resolvePendingCard, - client: false, - }, - }, - }, - }, - }, - moves: { - drawCard: { - move: drawCard, - client: false - }, - playCard: { - move: playCard, - client: false - } - }, - endIf: ({player}) => { - const alivePlayers = Object.entries(player.state).filter( - ([_, p]) => p.isAlive - ); - - // End phase when only one player is alive - if (alivePlayers.length === 1) { - return {next: 'gameover'}; - } - }, - onEnd: ({G, player}) => { - // Find the last alive player - const alivePlayers = Object.entries(player.state).filter( - ([_, p]) => p.isAlive - ); - - if (alivePlayers.length === 1) { - G.winner = alivePlayers[0][0]; - } - }, - }, - gameover: {}, - }, -}; - diff --git a/src/common/index.ts b/src/common/index.ts index 0a77a8e..9b0d221 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,16 +1,16 @@ // Main game export -export {ExplodingKittens} from './game'; +export {ExplodingKittens} from './exploding-kittens'; // Models 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 { @@ -24,10 +24,10 @@ export { DEFUSE, EXPLODING_KITTEN, cardTypeRegistry -} from './constants/card-types'; +} from './registries/card-registry'; // Constants - Decks -export {ORIGINAL} from './constants/decks'; +export {ORIGINAL} from './registries/deck-registry'; // Setup functions export {setupGame} from './setup/game-setup'; @@ -38,8 +38,7 @@ export {createPlayerPlugin} from './plugins/player-plugin'; // Utilities 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 {TheGame} from './entities/game'; diff --git a/src/common/models/card.model.ts b/src/common/models/card.model.ts index e4f83c2..7ef7420 100644 --- a/src/common/models/card.model.ts +++ b/src/common/models/card.model.ts @@ -1,4 +1,5 @@ -export interface Card { +export interface ICard { + id: number; 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..1663c62 100644 --- a/src/common/models/game-state.model.ts +++ b/src/common/models/game-state.model.ts @@ -1,18 +1,14 @@ -import type {Card} from './card.model'; +import type {ICard} from './card.model'; import type {PlayerID} from 'boardgame.io'; -export interface ClientGameState { - 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 +17,35 @@ export interface PendingCardPlay { isNoped: boolean; } -export interface GameState { +export interface IPiles { + drawPile: IPile; + discardPile: IPile; + pendingCardPlay: IPendingCardPlay | null; +} + +export interface IPile { + cards: ICard[]; + size: number; +} + +export interface IAnimationQueue { + [key: number]: IAnimation[]; +} + +export interface IAnimation { + from: number | string; + to: number | string; + card: ICard | null; + visibleTo: PlayerID[]; + durationMs: number; +} + +export interface IGameState { winner: PlayerID | null; - drawPile: Card[]; - discardPile: Card[]; - pendingCardPlay: PendingCardPlay | null; + piles: IPiles; turnsRemaining: number; - gameRules: GameRules; + gameRules: IGameRules; deckType: string; - client: ClientGameState; - lobbyReady: boolean; + animationsQueue: IAnimationQueue; + nextCardId: number; } 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..d22530a 100644 --- a/src/common/models/player.model.ts +++ b/src/common/models/player.model.ts @@ -1,12 +1,7 @@ -import type {Card} from './card.model'; +import type {ICard} from './card.model'; -export interface ClientPlayer { - handCount: number; -} - -export interface Player { - hand: Card[]; - // On server this is always 0! Do not use anywhere else than on the client frontend for when player . +export interface IPlayer { + hand: ICard[]; + handSize: number; isAlive: boolean; - client: ClientPlayer } diff --git a/src/common/models/players.model.ts b/src/common/models/players.model.ts index 6d7c1eb..beab497 100644 --- a/src/common/models/players.model.ts +++ b/src/common/models/players.model.ts @@ -1,5 +1,6 @@ -import type {Player} from './player.model'; +import type {IPlayer} from './player.model'; +import type {PlayerID} from 'boardgame.io'; -export interface Players { - [playerID: string]: Player; +export interface IPlayers { + [playerID: PlayerID]: 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..c309426 --- /dev/null +++ b/src/common/moves/defuse-exploding-kitten.ts @@ -0,0 +1,5 @@ +import {TheGame} from "../entities/game"; + +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 24e3eb0..c45d424 100644 --- a/src/common/moves/draw-move.ts +++ b/src/common/moves/draw-move.ts @@ -1,53 +1,6 @@ -import {FnContext} from "../models"; -import {EXPLODING_KITTEN, DEFUSE} from "../constants/card-types"; -import {GameLogic} from "../wrappers/game-logic"; +import {TheGame} from "../entities/game"; -export const drawCard = (context: FnContext) => { - const { events, random } = context; - const game = new GameLogic(context); - const player = game.actingPlayer; - - if (!player.isAlive) { - throw new Error('Dead player cannot draw cards'); - } - - const cardToDraw = game.drawCardFromPile(); - if (!cardToDraw) { - throw new Error('No card to draw'); - } - - // 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(); - 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(); +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 c3ba991..d8ad0b0 100644 --- a/src/common/moves/favor-card-move.ts +++ b/src/common/moves/favor-card-move.ts @@ -1,24 +1,19 @@ -import {FnContext} from "../models"; +import {TheGame} from "../entities/game"; import {PlayerID} from "boardgame.io"; -import {GameLogic} from "../wrappers/game-logic"; +import {CHOOSE_CARD_TO_GIVE} from "../constants/stages"; /** * Request a card from a target player (favor card - first stage) */ -export const requestCard = (context: FnContext, targetPlayerId: PlayerID) => { - const {events} = context; - const game = new GameLogic(context); - +export const requestCard = (game: TheGame, targetPlayerId: PlayerID) => { // 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', + [targetPlayerId]: CHOOSE_CARD_TO_GIVE, }, }); }; @@ -26,26 +21,27 @@ 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) => { - const {ctx, events} = context; - const game = new GameLogic(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( - playerId => ctx.activePlayers?.[playerId] === 'chooseCardToGive' + playerId => ctx.activePlayers?.[playerId] === CHOOSE_CARD_TO_GIVE ); if (!givingPlayerId) { 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/in-game.ts b/src/common/moves/in-game.ts new file mode 100644 index 0000000..c8d5915 --- /dev/null +++ b/src/common/moves/in-game.ts @@ -0,0 +1,17 @@ +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); + game.animationsQueue.clear() + 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 7cd3355..da168db 100644 --- a/src/common/moves/play-card-move.ts +++ b/src/common/moves/play-card-move.ts @@ -1,144 +1,14 @@ -import {FnContext} from "../models"; -import {cardTypeRegistry, NOPE} from "../constants/card-types"; -import {GameLogic} from "../wrappers/game-logic"; +import {TheGame} from "../entities/game"; - -export const playCard = (context: FnContext, cardIndex: number) => { - const game = new GameLogic(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); - } - - 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 cards (like Nope) - cardType.onPlayed(context, cardToPlay); - return; - } - - // For normal action cards, 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 cards 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 playCard = (game: TheGame, cardIndex: number) => { + game.players.actingPlayer.playCard(cardIndex); }; -export const playNowCard = (context: FnContext, cardIndex: number) => { - const game = new GameLogic(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 cards 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(); - } - } - } +export const playNowCard = (game: TheGame, cardIndex: number) => { + playCard(game, cardIndex); }; -export const resolvePendingCard = (context: FnContext) => { - const game = new GameLogic(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); - } +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 2a4d282..64c9fc3 100644 --- a/src/common/moves/see-future-move.ts +++ b/src/common/moves/see-future-move.ts @@ -1,12 +1,9 @@ -import type {FnContext} from "../models"; +import {TheGame} from "../entities/game"; /** * Close the see the future overlay */ -export const closeFutureView = (context: FnContext) => { - const {events} = context; - - // End the viewing stage - events.endStage(); +export const closeFutureView = (game: TheGame) => { + game.turnManager.endStage(); }; diff --git a/src/common/moves/steal-card-move.ts b/src/common/moves/steal-card-move.ts index c26a314..51e66c2 100644 --- a/src/common/moves/steal-card-move.ts +++ b/src/common/moves/steal-card-move.ts @@ -1,20 +1,11 @@ -import {FnContext} from "../models"; import {PlayerID} from "boardgame.io"; -import {GameLogic} from "../wrappers/game-logic"; - -export const stealCard = (context: FnContext, targetPlayerId: PlayerID) => { - const {events, random} = context; - const game = new GameLogic(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 deleted file mode 100644 index d587cab..0000000 --- a/src/common/moves/system-moves.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {FnContext} from '../models'; -import {GameLogic} from '../wrappers/game-logic'; - -/** - * 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); - game.lobbyReady = true; -}; - diff --git a/src/common/plugins/player-plugin.ts b/src/common/plugins/player-plugin.ts index b21bb9c..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 {GameState} 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: GameState, 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/constants/card-types.ts b/src/common/registries/card-registry.ts similarity index 56% rename from src/common/constants/card-types.ts rename to src/common/registries/card-registry.ts index 259a09d..ee69daf 100644 --- a/src/common/constants/card-types.ts +++ b/src/common/registries/card-registry.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 {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 {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"; +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/registries/deck-registry.ts b/src/common/registries/deck-registry.ts new file mode 100644 index 0000000..87cf1a6 --- /dev/null +++ b/src/common/registries/deck-registry.ts @@ -0,0 +1,4 @@ +import {OriginalDeck} from '../entities/deck-types/original-deck'; + +export const ORIGINAL = new OriginalDeck(); + 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 3f2b904..c363742 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,13 +8,22 @@ 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 { + nextCardId: 1, winner: null, - drawPile: [], - discardPile: [], - pendingCardPlay: null, + piles: { + drawPile: { + cards: [], + size: 0, + }, + discardPile: { + cards: [], + size: 0, + }, + pendingCardPlay: null, + }, turnsRemaining: 1, gameRules: { spectatorsSeeCards: setupData?.spectatorsSeeCards ?? false, @@ -22,9 +31,6 @@ export const setupGame = (_context: any, setupData?: SetupData): GameState => { pendingTimerMs: 3000, }, deckType: setupData?.deckType ?? 'original', - client: { - drawPileLength: 0 - }, - lobbyReady: false, + animationsQueue: {}, }; }; diff --git a/src/common/setup/player-setup.ts b/src/common/setup/player-setup.ts index 5102247..2923389 100644 --- a/src/common/setup/player-setup.ts +++ b/src/common/setup/player-setup.ts @@ -1,90 +1,86 @@ -import type {Card, GameState, Player, Players} from '../models'; -import type {Deck} from '../entities/deck'; -import {cardTypeRegistry} from "../constants/card-types"; +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"; +import {TheGame} from "../entities/game"; +import {NAME_EXPLODING_KITTEN} from "../constants/cards"; -export const createPlayerState = (): Player => ({ +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: Player): Player => ({ - ...player, - client: { - handCount: player.hand.length - } -}); +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: Player): Player => ({ - hand: [], - isAlive: player.isAlive, - client: { - handCount: player.hand.length - } +const createLimitedPlayerView = (player: IPlayer): IPlayer => ({ + ...player, + hand: player.hand.filter(c => c.name === NAME_EXPLODING_KITTEN) }); /** - * 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, - playerID?: string | null, -): boolean => { - // If openCards rule is enabled, everyone sees all cards - if (G.gameRules.openCards) return true; +const shouldSeeAllCards = (game: TheGame): boolean => { + // If openCards rule is enabled, everyone sees all card-types + if (game.gameRules.openCards) return true; - // Spectators (no playerID) see all cards 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 - return isCurrentPlayerDead && spectatorsCanSeeAll; + // Spectators (no playerID) see all card-types ONLY if rule allows + const player = game.players.actingPlayerOptional; + if (!player || !player.isAlive) { + return game.gameRules.spectatorsSeeCards; + } + return false; }; -export const filterPlayerView = (G: GameState, players: Players, playerID?: string | null): Players => { - const canSeeAllCards = shouldSeeAllCards(G, players, playerID); +export const filterPlayerView = (game: TheGame): IPlayers => { + const canSeeAllCards = shouldSeeAllCards(game); - const view: Players = {}; - Object.entries(players).forEach(([id, pdata]) => { - if (canSeeAllCards || id === playerID) { + const view: IPlayers = {}; + 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; }; -export function dealHands(pile: Card[], players: Players, deck: Deck) { +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 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++) { - forcedCards.push(card.createCard(0)); + forcedCards.push(card.createCard(0, game)); } } }); - player.hand.push(...forcedCards); + forcedCards.forEach(c => player.addCard(c)); }); } diff --git a/src/common/utils/action-validation.ts b/src/common/utils/action-validation.ts deleted file mode 100644 index 1de8686..0000000 --- a/src/common/utils/action-validation.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {GameState} from '../models/game-state.model'; -import {Card} 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 { - 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 - if (Date.now() > pending.expiresAtMs) { - return false; - } - - return true; -} - -export function canPlayerNope( - G: GameState, - playerID: string | null | undefined, - playerHand: Card[] -): boolean { - const nopeCardIndex = playerHand.findIndex(c => c.name === 'nope'); - if (nopeCardIndex === -1) return false; - - return validateNope(G, playerID); -} diff --git a/src/common/utils/card-sorting.ts b/src/common/utils/card-sorting.ts index cdc2fb6..b447bc9 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 {cardTypeRegistry} from '../constants/card-types'; +import type {ICard} from '../models'; +import {cardTypeRegistry} from '../registries/card-registry'; /** - * 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..6bcbb89 100644 --- a/src/common/utils/turn-order.ts +++ b/src/common/utils/turn-order.ts @@ -1,18 +1,15 @@ -import {FnContext} from '../models'; - -const findNextAlivePlayer = ( - ctx: FnContext['ctx'], - players: Record, - startPos: number -): number | undefined => { - const numPlayers = ctx.numPlayers; +import {IContext} from '../models'; +import {TheGame} from "../entities/game"; + +export 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,31 +21,37 @@ const findNextAlivePlayer = ( }; export const turnOrder = { - first: ({ctx, player}: FnContext): 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}: FnContext): 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); }, /** * 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-logic.ts b/src/common/wrappers/game-logic.ts deleted file mode 100644 index db62882..0000000 --- a/src/common/wrappers/game-logic.ts +++ /dev/null @@ -1,111 +0,0 @@ -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); - } - - /** - * Get a player wrapper instance for a specific player ID. - * Throws if player data not found. - */ - getPlayer(id: string): PlayerWrapper { - return this.players.getPlayer(id); - } - - /** - * Get a wrapper for the current player based on context.currentPlayer - */ - get currentPlayer(): PlayerWrapper { - 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(): PlayerWrapper { - return this.players.actingPlayer; - } - - /** - * Get all players as wrappers - */ - get allPlayers(): PlayerWrapper[] { - return this.players.allPlayers; - } - - /** - * Get pending card play - */ - get pendingCardPlay(): PendingCardPlay | null { - return this.state.pendingCardPlay; - } - - /** - * Add a card to the discard pile - */ - discardCard(card: Card): void { - this.deck.discardCard(card); - } - - /** - * Get the last discarded card - */ - get lastDiscardedCard(): Card | null { - return this.deck.lastDiscardedCard; - } - - /** - * Draw a card from the top of the draw pile - */ - drawCardFromPile(): Card | undefined { - return this.deck.drawCardFromPile(); - } - - /** - * Insert a card into the draw pile at a specific index - */ - insertCardIntoDrawPile(card: Card, index: number): void { - this.deck.insertCardIntoDrawPile(card, index); - } - - /** - * Get draw pile size - */ - get drawPileSize(): number { - return this.deck.drawPileSize; - } - - set pendingCardPlay(pending: PendingCardPlay | null) { - this.state.pendingCardPlay = pending; - } - - set lobbyReady(ready: boolean) { - this.state.lobbyReady = ready; - } - - get gameRules(): GameRules { - 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. - */ - validateTarget(targetPlayerId: string): PlayerWrapper { - return this.players.validateTarget(targetPlayerId); - } -} 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; - } -} - diff --git a/src/common/wrappers/player-logic.ts b/src/common/wrappers/player-logic.ts deleted file mode 100644 index 1ae2473..0000000 --- a/src/common/wrappers/player-logic.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {FnContext} from '../models'; -import {PlayerWrapper} from './player-wrapper'; - -export class PlayerLogic { - constructor(private context: FnContext) {} - - /** - * Get a player wrapper instance for a specific player ID. - * Throws if player data not found. - */ - getPlayer(id: string): PlayerWrapper { - // boardgame.io player plugin structure - const playerData = this.context.player.state?.[id]; - if (!playerData) { - throw new Error(`Player data not found for ID: ${id}`); - } - return new PlayerWrapper(playerData, id); - } - - /** - * Get a wrapper for the current player based on context.currentPlayer - */ - get currentPlayer(): PlayerWrapper { - return this.getPlayer(this.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; - return this.getPlayer(id); - } - - /** - * Get all players as wrappers - */ - get allPlayers(): PlayerWrapper[] { - const playerIDs = Object.keys(this.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. - */ - validateTarget(targetPlayerId: string): PlayerWrapper { - const target = this.getPlayer(targetPlayerId); - - let current; - try { - current = this.currentPlayer; - } catch(e) { - // Current player might be undefined in some contexts - } - - if (current && target.id === current.id) { - throw new Error('Cannot target yourself'); - } - - if (!target.isAlive) { - throw new Error('Target player is dead'); - } - - if (target.getCardCount() === 0) { - throw new Error('Target player has no cards'); - } - - return target; - } -} - diff --git a/src/common/wrappers/player-wrapper.ts b/src/common/wrappers/player-wrapper.ts deleted file mode 100644 index afa9c2b..0000000 --- a/src/common/wrappers/player-wrapper.ts +++ /dev/null @@ -1,135 +0,0 @@ -import {Player} from '../models'; -import {Card} from '../models'; - -export class PlayerWrapper { - constructor( - private _state: Player, - public readonly id: string - ) {} - - /** - * Get the underlying raw state object. - * Useful if direct property access or modification is needed. - */ - get state(): Player { - return this._state; - } - - /** - * Get the player's hand of cards - */ - get hand(): Card[] { - return this._state.hand; - } - - /** - * Check if player is alive in game terms (not eliminated) - */ - get isAlive(): boolean { - return this._state.isAlive; - } - - /** - * 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); - } - - /** - * Get all cards of a specific type from hand, or all cards if no type specified - */ - getCards(cardName?: string): Card[] { - if (!cardName) return this._state.hand; - return this._state.hand.filter(c => c.name === cardName); - } - - /** - * Get the count of cards in hand - */ - getCardCount(): number { - return this._state.hand.length; - } - - /** - * Add a card to the player's hand - */ - addCard(card: Card): void { - // Clone to avoid Proxy issues - this._state.hand.push({...card}); - this._updateClientState(); - } - - /** - * 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._updateClientState(); - return card; - } - - /** - * Remove the first occurrence of a specific card type - * @returns The removed card, or undefined if not found - */ - removeCard(cardName: string): Card | 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 - */ - 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); - } - } - if (removed.length > 0) { - this._updateClientState(); - } - return removed; - } - - /** - * Remove all cards from hand - * @returns Array of all removed cards - */ - removeAllCardsFromHand(): Card[] { - const removed = [...this._state.hand]; - this._state.hand = []; - this._updateClientState(); - return removed; - } - - eliminate(): void { - this._state.isAlive = false; - } - - /** - * Transfers a card at specific index to another playerWrapper - */ - giveCard(cardIndex: number, recipient: PlayerWrapper): Card { - const card = this.removeCardAt(cardIndex); - if (!card) { - throw new Error("Card not found or invalid index"); - } - recipient.addCard(card); - return card; - } - - private _updateClientState() { - if (this._state.client) { - this._state.client.handCount = this._state.hand.length; - } - } -}