diff --git a/backend/app/core/visibility.py b/backend/app/core/visibility.py index a21c6c6..0195a70 100644 --- a/backend/app/core/visibility.py +++ b/backend/app/core/visibility.py @@ -37,6 +37,7 @@ from typing import TYPE_CHECKING from pydantic import BaseModel, Field +from app.core.config import RulesConfig from app.core.enums import GameEndReason, TurnPhase from app.core.models.card import CardDefinition, CardInstance @@ -124,6 +125,7 @@ class VisibleGameState(BaseModel): stadium_owner_id: Player who played the current stadium (public). forced_action: Current forced action, if any. card_registry: Card definitions (needed to display cards). + rules_config: Rules configuration for UI rendering decisions (e.g., prize cards vs points). """ game_id: str @@ -154,6 +156,9 @@ class VisibleGameState(BaseModel): # Card definitions for display card_registry: dict[str, CardDefinition] = Field(default_factory=dict) + # Rules configuration for UI rendering decisions + rules_config: RulesConfig = Field(default_factory=RulesConfig) + def _create_visible_zone( cards: list[CardInstance], @@ -298,6 +303,7 @@ def get_visible_state(game: GameState, viewer_id: str) -> VisibleGameState: forced_action_type=forced_action_type, forced_action_reason=forced_action_reason, card_registry=game.card_registry, + rules_config=game.rules, ) @@ -393,4 +399,5 @@ def get_spectator_state(game: GameState) -> VisibleGameState: forced_action_type=forced_action_type, forced_action_reason=forced_action_reason, card_registry=game.card_registry, + rules_config=game.rules, ) diff --git a/frontend/src/game/layout.spec.ts b/frontend/src/game/layout.spec.ts index ee22904..5af82a1 100644 --- a/frontend/src/game/layout.spec.ts +++ b/frontend/src/game/layout.spec.ts @@ -582,3 +582,377 @@ describe('getAllZones', () => { expect(allZones.find(z => z.label === 'oppBench[0]')).toBeDefined() }) }) + +// ============================================================================= +// Conditional Prize Zone Tests (RulesConfig support) +// ============================================================================= + +describe('calculateLayout - prize zone options', () => { + it('returns empty prize arrays when usePrizeCards is false', () => { + /** + * Test that prize zones are not generated in points-based mode. + * + * When using the Mantimon TCG points system instead of classic prize cards, + * the prize zones should be omitted from the layout to save screen space. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + usePrizeCards: false, + }) + + expect(layout.myPrizes).toHaveLength(0) + expect(layout.oppPrizes).toHaveLength(0) + }) + + it('returns correct prize count when usePrizeCards is true with custom count', () => { + /** + * Test that prize zones can have a custom count. + * + * Different rule variants may use 4, 6, or other prize counts. + * The layout should generate exactly the requested number of slots. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + usePrizeCards: true, + prizeCount: 4, + }) + + expect(layout.myPrizes).toHaveLength(4) + expect(layout.oppPrizes).toHaveLength(4) + }) + + it('defaults to 6 prizes when usePrizeCards is true without count', () => { + /** + * Test that classic mode defaults to 6 prize cards. + * + * When using prize cards without specifying a count, the standard + * Pokemon TCG count of 6 should be used. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + usePrizeCards: true, + }) + + expect(layout.myPrizes).toHaveLength(PRIZE_SIZE) + expect(layout.oppPrizes).toHaveLength(PRIZE_SIZE) + }) + + it('returns 6 prizes when no options provided (backwards compatibility)', () => { + /** + * Test backwards compatibility with existing code. + * + * When called without options, the layout should behave as before, + * generating 6 prize slots for each player. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT) + + // Default behavior assumes classic prize card mode with 6 prizes + expect(layout.myPrizes).toHaveLength(PRIZE_SIZE) + expect(layout.oppPrizes).toHaveLength(PRIZE_SIZE) + }) + + it('works correctly in portrait mode with usePrizeCards false', () => { + /** + * Test that portrait mode also respects prize card settings. + * + * The portrait layout should also omit prize zones when using + * the points system. + */ + const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, { + usePrizeCards: false, + }) + + expect(layout.myPrizes).toHaveLength(0) + expect(layout.oppPrizes).toHaveLength(0) + }) + + it('works correctly in portrait mode with custom prize count', () => { + /** + * Test that portrait mode generates correct prize count. + * + * The portrait layout should create the correct number of + * prize positions when using prize cards with a custom count. + */ + const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, { + usePrizeCards: true, + prizeCount: 4, + }) + + expect(layout.myPrizes).toHaveLength(4) + expect(layout.oppPrizes).toHaveLength(4) + }) + + it('prize positions are valid when generated', () => { + /** + * Test that generated prize positions are all valid. + * + * Each prize slot should have positive dimensions and valid coordinates. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + usePrizeCards: true, + prizeCount: 4, + }) + + for (const prize of layout.myPrizes) { + expect(isValidZone(prize), 'Player prize zone should be valid').toBe(true) + } + for (const prize of layout.oppPrizes) { + expect(isValidZone(prize), 'Opponent prize zone should be valid').toBe(true) + } + }) +}) + +// ============================================================================= +// Conditional Bench Size Tests (RulesConfig support) +// ============================================================================= + +describe('calculateLayout - bench size options', () => { + it('returns default 5 bench slots when no options provided', () => { + /** + * Test backwards compatibility with existing code. + * + * When called without options, the layout should generate 5 bench slots + * for each player (standard Pokemon TCG bench size). + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT) + + expect(layout.myBench).toHaveLength(BENCH_SIZE) + expect(layout.oppBench).toHaveLength(BENCH_SIZE) + }) + + it('returns correct bench count with custom benchSize option', () => { + /** + * Test that bench zones can have a custom count. + * + * Some rule variants may use fewer bench slots (e.g., 3 for a faster game). + * The layout should generate exactly the requested number of slots. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + benchSize: 3, + }) + + expect(layout.myBench).toHaveLength(3) + expect(layout.oppBench).toHaveLength(3) + }) + + it('works with larger bench sizes', () => { + /** + * Test that larger bench sizes are supported. + * + * Some custom variants might use more than 5 bench slots. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + benchSize: 8, + }) + + expect(layout.myBench).toHaveLength(8) + expect(layout.oppBench).toHaveLength(8) + }) + + it('works correctly in portrait mode with custom bench size', () => { + /** + * Test that portrait mode also respects bench size settings. + * + * The portrait layout should also generate the correct number + * of bench positions based on the benchSize option. + */ + const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, { + benchSize: 3, + }) + + expect(layout.myBench).toHaveLength(3) + expect(layout.oppBench).toHaveLength(3) + }) + + it('bench positions are valid when using custom size', () => { + /** + * Test that generated bench positions are all valid. + * + * Each bench slot should have positive dimensions and valid coordinates. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + benchSize: 4, + }) + + for (const bench of layout.myBench) { + expect(isValidZone(bench), 'Player bench zone should be valid').toBe(true) + } + for (const bench of layout.oppBench) { + expect(isValidZone(bench), 'Opponent bench zone should be valid').toBe(true) + } + }) + + it('bench slots are centered properly with different sizes', () => { + /** + * Test that bench zones remain centered regardless of count. + * + * Whether using 3, 5, or 8 bench slots, they should all be + * horizontally centered on the screen. + */ + const layout3 = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { benchSize: 3 }) + const layout5 = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { benchSize: 5 }) + const layout7 = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { benchSize: 7 }) + + const centerX = LANDSCAPE_WIDTH / 2 + + // Calculate average X position of each player's bench + const avg3 = layout3.myBench.reduce((sum, b) => sum + b.x, 0) / 3 + const avg5 = layout5.myBench.reduce((sum, b) => sum + b.x, 0) / 5 + const avg7 = layout7.myBench.reduce((sum, b) => sum + b.x, 0) / 7 + + // All should be centered + expect(avg3).toBeCloseTo(centerX, 0) + expect(avg5).toBeCloseTo(centerX, 0) + expect(avg7).toBeCloseTo(centerX, 0) + }) + + it('can combine benchSize with prize options', () => { + /** + * Test that bench and prize options work together. + * + * Custom rules might have both custom bench size and prize count, + * and both should be applied correctly. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + benchSize: 3, + usePrizeCards: true, + prizeCount: 4, + }) + + expect(layout.myBench).toHaveLength(3) + expect(layout.oppBench).toHaveLength(3) + expect(layout.myPrizes).toHaveLength(4) + expect(layout.oppPrizes).toHaveLength(4) + }) +}) + +// ============================================================================= +// Conditional Energy Deck Zone Tests (RulesConfig support) +// ============================================================================= + +describe('calculateLayout - energy deck options', () => { + it('returns energy zones when energyDeckEnabled is true (default)', () => { + /** + * Test that energy deck zones are generated by default. + * + * The Pokemon Pocket-style energy deck zone should be included + * when the energy deck feature is enabled (the default behavior). + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT) + + expect(layout.myEnergyZone).not.toBeNull() + expect(layout.oppEnergyZone).not.toBeNull() + }) + + it('returns null energy zones when energyDeckEnabled is false', () => { + /** + * Test that energy zones are omitted in classic mode. + * + * When using classic Pokemon TCG rules without an energy deck, + * the energy zones should be null to save screen space. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + energyDeckEnabled: false, + }) + + expect(layout.myEnergyZone).toBeNull() + expect(layout.oppEnergyZone).toBeNull() + }) + + it('energy zones have valid positions when enabled', () => { + /** + * Test that generated energy zones have valid positions. + * + * Each energy zone should have positive dimensions and valid coordinates. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + energyDeckEnabled: true, + }) + + expect(layout.myEnergyZone).not.toBeNull() + expect(layout.oppEnergyZone).not.toBeNull() + + if (layout.myEnergyZone) { + expect(isValidZone(layout.myEnergyZone), 'Player energy zone should be valid').toBe(true) + } + if (layout.oppEnergyZone) { + expect(isValidZone(layout.oppEnergyZone), 'Opponent energy zone should be valid').toBe(true) + } + }) + + it('works correctly in portrait mode with energyDeckEnabled false', () => { + /** + * Test that portrait mode also respects energy deck settings. + * + * The portrait layout should also omit energy zones when using + * classic mode without an energy deck. + */ + const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, { + energyDeckEnabled: false, + }) + + expect(layout.myEnergyZone).toBeNull() + expect(layout.oppEnergyZone).toBeNull() + }) + + it('works correctly in portrait mode with energyDeckEnabled true', () => { + /** + * Test that portrait mode generates energy zones when enabled. + * + * The portrait layout should include energy zones when the + * energy deck feature is enabled. + */ + const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, { + energyDeckEnabled: true, + }) + + expect(layout.myEnergyZone).not.toBeNull() + expect(layout.oppEnergyZone).not.toBeNull() + }) + + it('can combine energyDeckEnabled with other options', () => { + /** + * Test that energy deck option works with other layout options. + * + * Custom rules might combine energy deck settings with bench size + * and prize options, and all should be applied correctly. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + energyDeckEnabled: false, + benchSize: 3, + usePrizeCards: true, + prizeCount: 4, + }) + + expect(layout.myEnergyZone).toBeNull() + expect(layout.oppEnergyZone).toBeNull() + expect(layout.myBench).toHaveLength(3) + expect(layout.oppBench).toHaveLength(3) + expect(layout.myPrizes).toHaveLength(4) + expect(layout.oppPrizes).toHaveLength(4) + }) + + it('getAllZones excludes null energy zones', () => { + /** + * Test that getAllZones handles null energy zones correctly. + * + * When energy deck is disabled, getAllZones should not include + * energy zone entries in its output. + */ + const layoutWithEnergy = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + energyDeckEnabled: true, + }) + const layoutWithoutEnergy = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + energyDeckEnabled: false, + }) + + const zonesWithEnergy = getAllZones(layoutWithEnergy) + const zonesWithoutEnergy = getAllZones(layoutWithoutEnergy) + + // Should have 2 fewer zones when energy deck is disabled + expect(zonesWithoutEnergy.length).toBe(zonesWithEnergy.length - 2) + + // Verify energy zone labels exist/don't exist + expect(zonesWithEnergy.find(z => z.label === 'myEnergyZone')).toBeDefined() + expect(zonesWithEnergy.find(z => z.label === 'oppEnergyZone')).toBeDefined() + expect(zonesWithoutEnergy.find(z => z.label === 'myEnergyZone')).toBeUndefined() + expect(zonesWithoutEnergy.find(z => z.label === 'oppEnergyZone')).toBeUndefined() + }) +}) diff --git a/frontend/src/game/layout.ts b/frontend/src/game/layout.ts index 391614b..49bc869 100644 --- a/frontend/src/game/layout.ts +++ b/frontend/src/game/layout.ts @@ -53,12 +53,30 @@ export const EDGE_PADDING = 16 /** Number of bench slots */ export const BENCH_SIZE = 5 -/** Number of prize slots */ +/** Default number of prize slots (for classic Pokemon TCG mode) */ export const PRIZE_SIZE = 6 /** Portrait mode threshold - below this aspect ratio we use portrait layout */ export const PORTRAIT_THRESHOLD = 0.9 +// ============================================================================= +// Layout Options +// ============================================================================= + +/** + * Options for customizing board layout based on rules config. + */ +export interface LayoutOptions { + /** Whether to use prize cards (classic mode). If false, no prize zones are rendered. */ + usePrizeCards?: boolean + /** Number of prize cards when using classic mode. Defaults to 6. */ + prizeCount?: number + /** Number of bench slots. Defaults to 5. */ + benchSize?: number + /** Whether to show energy deck zone (Pokemon Pocket style). Defaults to true. */ + energyDeckEnabled?: boolean +} + // ============================================================================= // Orientation Detection // ============================================================================= @@ -103,19 +121,28 @@ export function getScaleFactor(width: number, height: number): number { * * @param width - Canvas width in pixels * @param height - Canvas height in pixels + * @param options - Optional layout customization based on rules config * @returns Complete board layout with all zone positions * * @example * ```ts * const layout = calculateLayout(1920, 1080) * console.log(layout.myActive) // { x: 960, y: 700, width: 120, height: 168 } + * + * // For points-based mode (no prize cards) + * const layoutNoCards = calculateLayout(1920, 1080, { usePrizeCards: false }) + * console.log(layoutNoCards.myPrizes) // [] (empty array) * ``` */ -export function calculateLayout(width: number, height: number): BoardLayout { +export function calculateLayout( + width: number, + height: number, + options?: LayoutOptions +): BoardLayout { if (isPortrait(width, height)) { - return calculatePortraitLayout(width, height) + return calculatePortraitLayout(width, height, options) } - return calculateLandscapeLayout(width, height) + return calculateLandscapeLayout(width, height, options) } /** @@ -129,11 +156,20 @@ export function calculateLayout(width: number, height: number): BoardLayout { * * @param width - Canvas width * @param height - Canvas height + * @param options - Optional layout customization * @returns Board layout for landscape orientation */ -function calculateLandscapeLayout(width: number, height: number): BoardLayout { +function calculateLandscapeLayout( + width: number, + height: number, + options?: LayoutOptions +): BoardLayout { + const usePrizeCards = options?.usePrizeCards ?? true + const prizeCount = options?.prizeCount ?? PRIZE_SIZE + const benchSize = options?.benchSize ?? BENCH_SIZE + const energyDeckEnabled = options?.energyDeckEnabled ?? true const scale = getScaleFactor(width, height) - + // Scale card dimensions const cardW = Math.round(CARD_WIDTH_MEDIUM * scale) const cardH = Math.round(CARD_HEIGHT_MEDIUM * scale) @@ -141,7 +177,7 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout { const smallCardH = Math.round(CARD_HEIGHT_SMALL * scale) const padding = Math.round(ZONE_PADDING * scale) const edgePad = Math.round(EDGE_PADDING * scale) - + // Vertical layout divisions const centerY = height / 2 const playerY = centerY + (height * 0.18) // Player active zone @@ -149,11 +185,11 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout { const benchOffsetY = cardH + padding // Bench below/above active (full card height + padding) const handY = height - edgePad - cardH / 2 // Hand at bottom const oppHandY = edgePad + cardH / 2 // Opponent hand at top - + // Horizontal layout - center of screen const centerX = width / 2 const benchSpacing = cardW + padding - const totalBenchWidth = (BENCH_SIZE - 1) * benchSpacing + const totalBenchWidth = (benchSize - 1) * benchSpacing // Deck/discard positions (right side) const deckX = width - edgePad - cardW / 2 - padding @@ -178,7 +214,7 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout { rotation: 0, }, - myBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({ + myBench: Array.from({ length: benchSize }, (_, i) => ({ x: centerX - totalBenchWidth / 2 + i * benchSpacing, y: playerY + benchOffsetY, width: cardW, @@ -210,24 +246,29 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout { rotation: 0, }, - myPrizes: calculatePrizePositions( - prizesStartX, - playerY - cardH / 2, - smallCardW, - smallCardH, - prizesColSpacing, - prizesRowSpacing, - false // Not flipped for player - ), - - myEnergyZone: { - x: centerX + energyOffsetX, - y: playerY, - width: cardW, - height: cardH, - rotation: 0, - }, + myPrizes: usePrizeCards + ? calculatePrizePositions( + prizesStartX, + playerY - cardH / 2, + smallCardW, + smallCardH, + prizesColSpacing, + prizesRowSpacing, + false, // Not flipped for player + prizeCount + ) + : [], + myEnergyZone: energyDeckEnabled + ? { + x: centerX + energyOffsetX, + y: playerY, + width: cardW, + height: cardH, + rotation: 0, + } + : null, + // Opponent zones (top of screen, mirrored) oppActive: { x: centerX, @@ -237,7 +278,7 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout { rotation: Math.PI, // Upside down }, - oppBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({ + oppBench: Array.from({ length: benchSize }, (_, i) => ({ x: centerX + totalBenchWidth / 2 - i * benchSpacing, // Reversed order y: oppY - benchOffsetY, // Above active for opponent width: cardW, @@ -269,25 +310,30 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout { rotation: Math.PI, }, - oppPrizes: calculatePrizePositions( - width - prizesStartX - prizesColSpacing, // Right side for opponent - oppY + cardH / 2, - smallCardW, - smallCardH, - -prizesColSpacing, // Reversed column spacing - -prizesRowSpacing, // Reversed row spacing - true // Flipped for opponent - ), + oppPrizes: usePrizeCards + ? calculatePrizePositions( + width - prizesStartX - prizesColSpacing, // Right side for opponent + oppY + cardH / 2, + smallCardW, + smallCardH, + -prizesColSpacing, // Reversed column spacing + -prizesRowSpacing, // Reversed row spacing + true, // Flipped for opponent + prizeCount + ) + : [], - oppEnergyZone: { - x: centerX - energyOffsetX, - y: oppY, - width: cardW, - height: cardH, - rotation: Math.PI, - }, + oppEnergyZone: energyDeckEnabled + ? { + x: centerX - energyOffsetX, + y: oppY, + width: cardW, + height: cardH, + rotation: Math.PI, + } + : null, } - + return layout } @@ -302,11 +348,20 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout { * * @param width - Canvas width * @param height - Canvas height + * @param options - Optional layout customization * @returns Board layout for portrait orientation */ -function calculatePortraitLayout(width: number, height: number): BoardLayout { +function calculatePortraitLayout( + width: number, + height: number, + options?: LayoutOptions +): BoardLayout { + const usePrizeCards = options?.usePrizeCards ?? true + const prizeCount = options?.prizeCount ?? PRIZE_SIZE + const benchSize = options?.benchSize ?? BENCH_SIZE + const energyDeckEnabled = options?.energyDeckEnabled ?? true const scale = getScaleFactor(width, height) * 0.85 // Slightly smaller for portrait - + // Scale card dimensions - use smaller cards in portrait const cardW = Math.round(CARD_WIDTH_SMALL * scale * 1.2) const cardH = Math.round(CARD_HEIGHT_SMALL * scale * 1.2) @@ -314,7 +369,7 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout { const smallCardH = Math.round(CARD_HEIGHT_SMALL * scale * 0.9) const padding = Math.round(ZONE_PADDING * scale * 0.8) const edgePad = Math.round(EDGE_PADDING * scale * 0.8) - + // Vertical layout divisions const centerY = height / 2 const playerY = centerY + (height * 0.15) @@ -322,11 +377,11 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout { const benchOffsetY = cardH + padding // Full card height + padding const handY = height - edgePad - cardH / 2 const oppHandY = edgePad + cardH / 2 - + // Horizontal layout - tighter spacing for portrait const centerX = width / 2 - const benchSpacing = Math.min(cardW + padding, (width - edgePad * 2) / BENCH_SIZE) - const totalBenchWidth = (BENCH_SIZE - 1) * benchSpacing + const benchSpacing = Math.min(cardW + padding, (width - edgePad * 2) / benchSize) + const totalBenchWidth = (benchSize - 1) * benchSpacing // Deck/discard positions (right side, more compact) // Use reduced size (0.8) for these zones @@ -352,7 +407,7 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout { rotation: 0, }, - myBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({ + myBench: Array.from({ length: benchSize }, (_, i) => ({ x: centerX - totalBenchWidth / 2 + i * benchSpacing, y: playerY + benchOffsetY, width: cardW * 0.9, // Slightly smaller bench cards @@ -384,23 +439,28 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout { rotation: 0, }, - myPrizes: calculatePortraitPrizePositions( - prizesX, - playerY, - smallCardW, - smallCardH, - prizesRowSpacing, - false - ), - - myEnergyZone: { - x: centerX + energyOffsetX, - y: playerY, - width: cardW * 0.8, - height: cardH * 0.8, - rotation: 0, - }, + myPrizes: usePrizeCards + ? calculatePortraitPrizePositions( + prizesX, + playerY, + smallCardW, + smallCardH, + prizesRowSpacing, + false, + prizeCount + ) + : [], + myEnergyZone: energyDeckEnabled + ? { + x: centerX + energyOffsetX, + y: playerY, + width: cardW * 0.8, + height: cardH * 0.8, + rotation: 0, + } + : null, + // Opponent zones (top, mirrored) oppActive: { x: centerX, @@ -410,7 +470,7 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout { rotation: Math.PI, }, - oppBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({ + oppBench: Array.from({ length: benchSize }, (_, i) => ({ x: centerX + totalBenchWidth / 2 - i * benchSpacing, y: oppY - benchOffsetY, width: cardW * 0.9, @@ -442,29 +502,34 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout { rotation: Math.PI, }, - oppPrizes: calculatePortraitPrizePositions( - width - prizesX, - oppY, - smallCardW, - smallCardH, - -prizesRowSpacing, - true - ), + oppPrizes: usePrizeCards + ? calculatePortraitPrizePositions( + width - prizesX, + oppY, + smallCardW, + smallCardH, + -prizesRowSpacing, + true, + prizeCount + ) + : [], - oppEnergyZone: { - x: centerX - energyOffsetX, - y: oppY, - width: cardW * 0.8, - height: cardH * 0.8, - rotation: Math.PI, - }, + oppEnergyZone: energyDeckEnabled + ? { + x: centerX - energyOffsetX, + y: oppY, + width: cardW * 0.8, + height: cardH * 0.8, + rotation: Math.PI, + } + : null, } - + return layout } /** - * Calculate prize card positions in a 2x3 grid. + * Calculate prize card positions in a 2xN grid. * * @param startX - X position of first column * @param startY - Y position of first row center @@ -473,7 +538,8 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout { * @param colSpacing - Horizontal spacing between columns * @param rowSpacing - Vertical spacing between rows * @param flipped - Whether cards are upside down (opponent) - * @returns Array of 6 prize zone positions + * @param count - Number of prize cards (defaults to PRIZE_SIZE) + * @returns Array of prize zone positions */ function calculatePrizePositions( startX: number, @@ -482,13 +548,17 @@ function calculatePrizePositions( cardH: number, colSpacing: number, rowSpacing: number, - flipped: boolean + flipped: boolean, + count: number = PRIZE_SIZE ): ZonePosition[] { const prizes: ZonePosition[] = [] - - for (let row = 0; row < 3; row++) { - for (let col = 0; col < 2; col++) { - const index = row * 2 + col + const cols = 2 + const rows = Math.ceil(count / cols) + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const index = row * cols + col + if (index >= count) break prizes.push({ x: startX + col * colSpacing, y: startY + row * rowSpacing, @@ -498,7 +568,7 @@ function calculatePrizePositions( }) } } - + return prizes } @@ -514,7 +584,8 @@ function calculatePrizePositions( * @param cardH - Card height * @param rowSpacing - Vertical spacing (can be negative for overlap) * @param flipped - Whether cards are upside down - * @returns Array of 6 prize zone positions + * @param count - Number of prize cards (defaults to PRIZE_SIZE) + * @returns Array of prize zone positions */ function calculatePortraitPrizePositions( x: number, @@ -522,12 +593,14 @@ function calculatePortraitPrizePositions( cardW: number, cardH: number, rowSpacing: number, - flipped: boolean + flipped: boolean, + count: number = PRIZE_SIZE ): ZonePosition[] { const prizes: ZonePosition[] = [] - const startY = centerY - (2.5 * Math.abs(rowSpacing)) // Center the stack - - for (let i = 0; i < PRIZE_SIZE; i++) { + // Center the stack based on count + const startY = centerY - ((count - 1) / 2 * Math.abs(rowSpacing)) + + for (let i = 0; i < count; i++) { prizes.push({ x, y: startY + i * Math.abs(rowSpacing), @@ -536,7 +609,7 @@ function calculatePortraitPrizePositions( rotation: flipped ? Math.PI : 0, }) } - + return prizes } @@ -632,7 +705,7 @@ export function getAllZones( layout: BoardLayout ): Array<{ label: string; zone: ZonePosition }> { const zones: Array<{ label: string; zone: ZonePosition }> = [] - + // My zones zones.push({ label: 'myActive', zone: layout.myActive }) layout.myBench.forEach((z, i) => zones.push({ label: `myBench[${i}]`, zone: z })) @@ -640,8 +713,10 @@ export function getAllZones( zones.push({ label: 'myDeck', zone: layout.myDeck }) zones.push({ label: 'myDiscard', zone: layout.myDiscard }) layout.myPrizes.forEach((z, i) => zones.push({ label: `myPrizes[${i}]`, zone: z })) - zones.push({ label: 'myEnergyZone', zone: layout.myEnergyZone }) - + if (layout.myEnergyZone) { + zones.push({ label: 'myEnergyZone', zone: layout.myEnergyZone }) + } + // Opponent zones zones.push({ label: 'oppActive', zone: layout.oppActive }) layout.oppBench.forEach((z, i) => zones.push({ label: `oppBench[${i}]`, zone: z })) @@ -649,7 +724,9 @@ export function getAllZones( zones.push({ label: 'oppDeck', zone: layout.oppDeck }) zones.push({ label: 'oppDiscard', zone: layout.oppDiscard }) layout.oppPrizes.forEach((z, i) => zones.push({ label: `oppPrizes[${i}]`, zone: z })) - zones.push({ label: 'oppEnergyZone', zone: layout.oppEnergyZone }) - + if (layout.oppEnergyZone) { + zones.push({ label: 'oppEnergyZone', zone: layout.oppEnergyZone }) + } + return zones } diff --git a/frontend/src/game/sync/StateRenderer.ts b/frontend/src/game/sync/StateRenderer.ts index 39b96cd..edc4d88 100644 --- a/frontend/src/game/sync/StateRenderer.ts +++ b/frontend/src/game/sync/StateRenderer.ts @@ -29,7 +29,7 @@ import type { } from '@/types/game' import type { BoardLayout } from '@/types/phaser' import { getMyPlayerState, getOpponentState } from '@/types/game' -import { calculateLayout } from '../layout' +import { calculateLayout, type LayoutOptions } from '../layout' import { Card } from '../objects/Card' import { ActiveZone } from '../objects/ActiveZone' import { BenchZone } from '../objects/BenchZone' @@ -51,8 +51,8 @@ interface PlayerZones { hand: HandZone deck: PileZone discard: PileZone - prizes: PrizeZone - energyZone: PileZone + prizes: PrizeZone | null // null when using points system instead of prize cards + energyZone: PileZone | null // null when energy deck is disabled (classic mode) } /** @@ -128,8 +128,16 @@ export class StateRenderer { // Get canvas dimensions const { width, height } = this.scene.cameras.main + // Extract layout options from rules config + const layoutOptions: LayoutOptions = { + usePrizeCards: state.rules_config?.prizes.use_prize_cards ?? false, + prizeCount: state.rules_config?.prizes.count ?? 4, + benchSize: state.rules_config?.bench.max_size ?? 5, + energyDeckEnabled: state.rules_config?.deck.energy_deck_enabled ?? true, + } + // Calculate layout - this.layout = calculateLayout(width, height) + this.layout = calculateLayout(width, height, layoutOptions) // Create zones if needed if (!this.zones) { @@ -258,13 +266,17 @@ export class StateRenderer { return } + // Check if we should create optional zones + const usePrizeCards = state.rules_config?.prizes.use_prize_cards ?? false + const energyDeckEnabled = state.rules_config?.deck.energy_deck_enabled ?? true + // Create main container this.container = this.scene.add.container(0, 0) // Create player zones this.zones = { - my: this.createPlayerZones(myState.player_id, false), - opp: this.createPlayerZones(oppState.player_id, true), + my: this.createPlayerZones(myState.player_id, false, usePrizeCards, energyDeckEnabled), + opp: this.createPlayerZones(oppState.player_id, true, usePrizeCards, energyDeckEnabled), } } @@ -273,9 +285,16 @@ export class StateRenderer { * * @param playerId - The player's ID * @param isOpponent - Whether this is the opponent (affects layout orientation) + * @param usePrizeCards - Whether to create prize zones (false for points system) + * @param energyDeckEnabled - Whether to create energy deck zone (false for classic mode) * @returns PlayerZones object */ - private createPlayerZones(playerId: string, isOpponent: boolean): PlayerZones { + private createPlayerZones( + playerId: string, + isOpponent: boolean, + usePrizeCards: boolean, + energyDeckEnabled: boolean + ): PlayerZones { // Create zones at origin - positions updated in updateZonePositions() const zones: PlayerZones = { active: new ActiveZone(this.scene, 0, 0, playerId), @@ -283,8 +302,8 @@ export class StateRenderer { hand: new HandZone(this.scene, 0, 0, playerId), deck: new PileZone(this.scene, 0, 0, playerId, 'deck'), discard: new PileZone(this.scene, 0, 0, playerId, 'discard'), - prizes: new PrizeZone(this.scene, 0, 0, playerId), - energyZone: new PileZone(this.scene, 0, 0, playerId, 'deck'), // Energy deck uses pile zone + prizes: usePrizeCards ? new PrizeZone(this.scene, 0, 0, playerId) : null, + energyZone: energyDeckEnabled ? new PileZone(this.scene, 0, 0, playerId, 'deck') : null, } // Add all zones to container @@ -294,8 +313,12 @@ export class StateRenderer { this.container.add(zones.hand) this.container.add(zones.deck) this.container.add(zones.discard) - this.container.add(zones.prizes) - this.container.add(zones.energyZone) + if (zones.prizes) { + this.container.add(zones.prizes) + } + if (zones.energyZone) { + this.container.add(zones.energyZone) + } } return zones @@ -312,8 +335,8 @@ export class StateRenderer { zones.hand.destroy() zones.deck.destroy() zones.discard.destroy() - zones.prizes.destroy() - zones.energyZone.destroy() + zones.prizes?.destroy() + zones.energyZone?.destroy() } // =========================================================================== @@ -331,7 +354,9 @@ export class StateRenderer { this.zones.my.hand.applyLayout(this.layout.myHand) this.zones.my.deck.applyLayout(this.layout.myDeck) this.zones.my.discard.applyLayout(this.layout.myDiscard) - this.zones.my.energyZone.applyLayout(this.layout.myEnergyZone) + if (this.zones.my.energyZone && this.layout.myEnergyZone) { + this.zones.my.energyZone.applyLayout(this.layout.myEnergyZone) + } // Bench zone - use the center position and full width const benchLayout = this.layout.myBench @@ -344,8 +369,8 @@ export class StateRenderer { this.zones.my.bench.setZoneDimensions(totalWidth, benchLayout[0].height) } - // Prizes - use the first prize position for the zone - if (this.layout.myPrizes.length > 0) { + // Prizes - use the first prize position for the zone (only if using prize cards) + if (this.zones.my.prizes && this.layout.myPrizes.length > 0) { const prizePositions = this.layout.myPrizes const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2)) const maxX = Math.max(...prizePositions.map(p => p.x + p.width / 2)) @@ -362,7 +387,9 @@ export class StateRenderer { this.zones.opp.hand.applyLayout(this.layout.oppHand) this.zones.opp.deck.applyLayout(this.layout.oppDeck) this.zones.opp.discard.applyLayout(this.layout.oppDiscard) - this.zones.opp.energyZone.applyLayout(this.layout.oppEnergyZone) + if (this.zones.opp.energyZone && this.layout.oppEnergyZone) { + this.zones.opp.energyZone.applyLayout(this.layout.oppEnergyZone) + } // Opponent bench const oppBenchLayout = this.layout.oppBench @@ -375,8 +402,8 @@ export class StateRenderer { this.zones.opp.bench.setZoneDimensions(totalWidth, oppBenchLayout[0].height) } - // Opponent prizes - if (this.layout.oppPrizes.length > 0) { + // Opponent prizes (only if using prize cards) + if (this.zones.opp.prizes && this.layout.oppPrizes.length > 0) { const prizePositions = this.layout.oppPrizes const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2)) const maxX = Math.max(...prizePositions.map(p => p.x + p.width / 2)) @@ -449,20 +476,22 @@ export class StateRenderer { true // always visible ) - // Update prizes + // Update prizes (only if using prize cards) // Viewer sees their own prize count (cards still hidden) // Opponent just sees count - zones.prizes.setRemainingCount(playerState.prizes_count) + zones.prizes?.setRemainingCount(playerState.prizes_count) - // Update energy zone - this.updateZone( - zones.energyZone, - playerState.energy_zone.cards, - registry, - playerId, - 'energy_zone', - true // always visible - ) + // Update energy zone (only if energy deck is enabled) + if (zones.energyZone) { + this.updateZone( + zones.energyZone, + playerState.energy_zone.cards, + registry, + playerId, + 'energy_zone', + true // always visible + ) + } } /** diff --git a/frontend/src/stores/game.ts b/frontend/src/stores/game.ts index b109de8..e7e96d0 100644 --- a/frontend/src/stores/game.ts +++ b/frontend/src/stores/game.ts @@ -14,6 +14,7 @@ import type { CardDefinition, TurnPhase, Action, + RulesConfig, } from '@/types' import { getMyPlayerState, getOpponentState, getCardDefinition, ConnectionStatus } from '@/types' @@ -216,6 +217,35 @@ export const useGameStore = defineStore('game', () => { forcedAction.value?.player === gameState.value?.viewer_id ) + // --------------------------------------------------------------------------- + // Computed - Rules Config + // --------------------------------------------------------------------------- + + /** Current game rules configuration */ + const rulesConfig = computed(() => + gameState.value?.rules_config ?? null + ) + + /** Whether this game uses classic prize cards (vs points system) */ + const usePrizeCards = computed(() => + rulesConfig.value?.prizes.use_prize_cards ?? false + ) + + /** Number of prizes/points needed to win */ + const prizeCount = computed(() => + rulesConfig.value?.prizes.count ?? 4 + ) + + /** Maximum number of Pokemon on the bench */ + const benchSize = computed(() => + rulesConfig.value?.bench.max_size ?? 5 + ) + + /** Whether this game uses an energy deck zone (Pokemon Pocket style) */ + const energyDeckEnabled = computed(() => + rulesConfig.value?.deck.energy_deck_enabled ?? true + ) + // --------------------------------------------------------------------------- // Computed - Card Lookup // --------------------------------------------------------------------------- @@ -337,6 +367,13 @@ export const useGameStore = defineStore('game', () => { hasForcedAction, isConnected, + // Rules config + rulesConfig, + usePrizeCards, + prizeCount, + benchSize, + energyDeckEnabled, + // Card lookup lookupCard, diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index f59f7c6..ee924d2 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -6,7 +6,7 @@ * and sends this structure via WebSocket. */ -import type { ModifierMode } from './rules' +import type { ModifierMode, RulesConfig } from './rules' // ============================================================================= // Enums and Constants @@ -407,6 +407,9 @@ export interface VisibleGameState { /** Card definitions for display (definition_id -> CardDefinition) */ card_registry: Record + + /** Rules configuration for UI rendering decisions (e.g., prize cards vs points) */ + rules_config?: RulesConfig } // ============================================================================= diff --git a/frontend/src/types/phaser.ts b/frontend/src/types/phaser.ts index 722eb6a..5778052 100644 --- a/frontend/src/types/phaser.ts +++ b/frontend/src/types/phaser.ts @@ -174,21 +174,21 @@ export interface ZonePosition { export interface BoardLayout { // My zones (bottom of screen) myActive: ZonePosition - myBench: ZonePosition[] // 5 slots + myBench: ZonePosition[] // configurable slots (default 5) myHand: ZonePosition myDeck: ZonePosition myDiscard: ZonePosition - myPrizes: ZonePosition[] // 6 slots - myEnergyZone: ZonePosition + myPrizes: ZonePosition[] // configurable slots (default 6), empty when using points + myEnergyZone: ZonePosition | null // null when energy deck is disabled // Opponent zones (top of screen, mirrored) oppActive: ZonePosition - oppBench: ZonePosition[] // 5 slots + oppBench: ZonePosition[] // configurable slots (default 5) oppHand: ZonePosition oppDeck: ZonePosition oppDiscard: ZonePosition - oppPrizes: ZonePosition[] // 6 slots - oppEnergyZone: ZonePosition + oppPrizes: ZonePosition[] // configurable slots (default 6), empty when using points + oppEnergyZone: ZonePosition | null // null when energy deck is disabled } /**