Merge pull request #1 from calcorum/claude/honor-rules-config-3ZRDG
Honor RulesConfig for prize cards vs points in frontend game board
This commit is contained in:
commit
8a416c8ace
@ -37,6 +37,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.core.config import RulesConfig
|
||||||
from app.core.enums import GameEndReason, TurnPhase
|
from app.core.enums import GameEndReason, TurnPhase
|
||||||
from app.core.models.card import CardDefinition, CardInstance
|
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).
|
stadium_owner_id: Player who played the current stadium (public).
|
||||||
forced_action: Current forced action, if any.
|
forced_action: Current forced action, if any.
|
||||||
card_registry: Card definitions (needed to display cards).
|
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
|
game_id: str
|
||||||
@ -154,6 +156,9 @@ class VisibleGameState(BaseModel):
|
|||||||
# Card definitions for display
|
# Card definitions for display
|
||||||
card_registry: dict[str, CardDefinition] = Field(default_factory=dict)
|
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(
|
def _create_visible_zone(
|
||||||
cards: list[CardInstance],
|
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_type=forced_action_type,
|
||||||
forced_action_reason=forced_action_reason,
|
forced_action_reason=forced_action_reason,
|
||||||
card_registry=game.card_registry,
|
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_type=forced_action_type,
|
||||||
forced_action_reason=forced_action_reason,
|
forced_action_reason=forced_action_reason,
|
||||||
card_registry=game.card_registry,
|
card_registry=game.card_registry,
|
||||||
|
rules_config=game.rules,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -582,3 +582,377 @@ describe('getAllZones', () => {
|
|||||||
expect(allZones.find(z => z.label === 'oppBench[0]')).toBeDefined()
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -53,12 +53,30 @@ export const EDGE_PADDING = 16
|
|||||||
/** Number of bench slots */
|
/** Number of bench slots */
|
||||||
export const BENCH_SIZE = 5
|
export const BENCH_SIZE = 5
|
||||||
|
|
||||||
/** Number of prize slots */
|
/** Default number of prize slots (for classic Pokemon TCG mode) */
|
||||||
export const PRIZE_SIZE = 6
|
export const PRIZE_SIZE = 6
|
||||||
|
|
||||||
/** Portrait mode threshold - below this aspect ratio we use portrait layout */
|
/** Portrait mode threshold - below this aspect ratio we use portrait layout */
|
||||||
export const PORTRAIT_THRESHOLD = 0.9
|
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
|
// Orientation Detection
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@ -103,19 +121,28 @@ export function getScaleFactor(width: number, height: number): number {
|
|||||||
*
|
*
|
||||||
* @param width - Canvas width in pixels
|
* @param width - Canvas width in pixels
|
||||||
* @param height - Canvas height 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
|
* @returns Complete board layout with all zone positions
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const layout = calculateLayout(1920, 1080)
|
* const layout = calculateLayout(1920, 1080)
|
||||||
* console.log(layout.myActive) // { x: 960, y: 700, width: 120, height: 168 }
|
* 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)) {
|
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 width - Canvas width
|
||||||
* @param height - Canvas height
|
* @param height - Canvas height
|
||||||
|
* @param options - Optional layout customization
|
||||||
* @returns Board layout for landscape orientation
|
* @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)
|
const scale = getScaleFactor(width, height)
|
||||||
|
|
||||||
// Scale card dimensions
|
// Scale card dimensions
|
||||||
const cardW = Math.round(CARD_WIDTH_MEDIUM * scale)
|
const cardW = Math.round(CARD_WIDTH_MEDIUM * scale)
|
||||||
const cardH = Math.round(CARD_HEIGHT_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 smallCardH = Math.round(CARD_HEIGHT_SMALL * scale)
|
||||||
const padding = Math.round(ZONE_PADDING * scale)
|
const padding = Math.round(ZONE_PADDING * scale)
|
||||||
const edgePad = Math.round(EDGE_PADDING * scale)
|
const edgePad = Math.round(EDGE_PADDING * scale)
|
||||||
|
|
||||||
// Vertical layout divisions
|
// Vertical layout divisions
|
||||||
const centerY = height / 2
|
const centerY = height / 2
|
||||||
const playerY = centerY + (height * 0.18) // Player active zone
|
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 benchOffsetY = cardH + padding // Bench below/above active (full card height + padding)
|
||||||
const handY = height - edgePad - cardH / 2 // Hand at bottom
|
const handY = height - edgePad - cardH / 2 // Hand at bottom
|
||||||
const oppHandY = edgePad + cardH / 2 // Opponent hand at top
|
const oppHandY = edgePad + cardH / 2 // Opponent hand at top
|
||||||
|
|
||||||
// Horizontal layout - center of screen
|
// Horizontal layout - center of screen
|
||||||
const centerX = width / 2
|
const centerX = width / 2
|
||||||
const benchSpacing = cardW + padding
|
const benchSpacing = cardW + padding
|
||||||
const totalBenchWidth = (BENCH_SIZE - 1) * benchSpacing
|
const totalBenchWidth = (benchSize - 1) * benchSpacing
|
||||||
|
|
||||||
// Deck/discard positions (right side)
|
// Deck/discard positions (right side)
|
||||||
const deckX = width - edgePad - cardW / 2 - padding
|
const deckX = width - edgePad - cardW / 2 - padding
|
||||||
@ -178,7 +214,7 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
|
|||||||
rotation: 0,
|
rotation: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
myBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({
|
myBench: Array.from({ length: benchSize }, (_, i) => ({
|
||||||
x: centerX - totalBenchWidth / 2 + i * benchSpacing,
|
x: centerX - totalBenchWidth / 2 + i * benchSpacing,
|
||||||
y: playerY + benchOffsetY,
|
y: playerY + benchOffsetY,
|
||||||
width: cardW,
|
width: cardW,
|
||||||
@ -210,24 +246,29 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
|
|||||||
rotation: 0,
|
rotation: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
myPrizes: calculatePrizePositions(
|
myPrizes: usePrizeCards
|
||||||
prizesStartX,
|
? calculatePrizePositions(
|
||||||
playerY - cardH / 2,
|
prizesStartX,
|
||||||
smallCardW,
|
playerY - cardH / 2,
|
||||||
smallCardH,
|
smallCardW,
|
||||||
prizesColSpacing,
|
smallCardH,
|
||||||
prizesRowSpacing,
|
prizesColSpacing,
|
||||||
false // Not flipped for player
|
prizesRowSpacing,
|
||||||
),
|
false, // Not flipped for player
|
||||||
|
prizeCount
|
||||||
myEnergyZone: {
|
)
|
||||||
x: centerX + energyOffsetX,
|
: [],
|
||||||
y: playerY,
|
|
||||||
width: cardW,
|
|
||||||
height: cardH,
|
|
||||||
rotation: 0,
|
|
||||||
},
|
|
||||||
|
|
||||||
|
myEnergyZone: energyDeckEnabled
|
||||||
|
? {
|
||||||
|
x: centerX + energyOffsetX,
|
||||||
|
y: playerY,
|
||||||
|
width: cardW,
|
||||||
|
height: cardH,
|
||||||
|
rotation: 0,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
|
||||||
// Opponent zones (top of screen, mirrored)
|
// Opponent zones (top of screen, mirrored)
|
||||||
oppActive: {
|
oppActive: {
|
||||||
x: centerX,
|
x: centerX,
|
||||||
@ -237,7 +278,7 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
|
|||||||
rotation: Math.PI, // Upside down
|
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
|
x: centerX + totalBenchWidth / 2 - i * benchSpacing, // Reversed order
|
||||||
y: oppY - benchOffsetY, // Above active for opponent
|
y: oppY - benchOffsetY, // Above active for opponent
|
||||||
width: cardW,
|
width: cardW,
|
||||||
@ -269,25 +310,30 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
|
|||||||
rotation: Math.PI,
|
rotation: Math.PI,
|
||||||
},
|
},
|
||||||
|
|
||||||
oppPrizes: calculatePrizePositions(
|
oppPrizes: usePrizeCards
|
||||||
width - prizesStartX - prizesColSpacing, // Right side for opponent
|
? calculatePrizePositions(
|
||||||
oppY + cardH / 2,
|
width - prizesStartX - prizesColSpacing, // Right side for opponent
|
||||||
smallCardW,
|
oppY + cardH / 2,
|
||||||
smallCardH,
|
smallCardW,
|
||||||
-prizesColSpacing, // Reversed column spacing
|
smallCardH,
|
||||||
-prizesRowSpacing, // Reversed row spacing
|
-prizesColSpacing, // Reversed column spacing
|
||||||
true // Flipped for opponent
|
-prizesRowSpacing, // Reversed row spacing
|
||||||
),
|
true, // Flipped for opponent
|
||||||
|
prizeCount
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
|
||||||
oppEnergyZone: {
|
oppEnergyZone: energyDeckEnabled
|
||||||
x: centerX - energyOffsetX,
|
? {
|
||||||
y: oppY,
|
x: centerX - energyOffsetX,
|
||||||
width: cardW,
|
y: oppY,
|
||||||
height: cardH,
|
width: cardW,
|
||||||
rotation: Math.PI,
|
height: cardH,
|
||||||
},
|
rotation: Math.PI,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
return layout
|
return layout
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -302,11 +348,20 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
|
|||||||
*
|
*
|
||||||
* @param width - Canvas width
|
* @param width - Canvas width
|
||||||
* @param height - Canvas height
|
* @param height - Canvas height
|
||||||
|
* @param options - Optional layout customization
|
||||||
* @returns Board layout for portrait orientation
|
* @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
|
const scale = getScaleFactor(width, height) * 0.85 // Slightly smaller for portrait
|
||||||
|
|
||||||
// Scale card dimensions - use smaller cards in portrait
|
// Scale card dimensions - use smaller cards in portrait
|
||||||
const cardW = Math.round(CARD_WIDTH_SMALL * scale * 1.2)
|
const cardW = Math.round(CARD_WIDTH_SMALL * scale * 1.2)
|
||||||
const cardH = Math.round(CARD_HEIGHT_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 smallCardH = Math.round(CARD_HEIGHT_SMALL * scale * 0.9)
|
||||||
const padding = Math.round(ZONE_PADDING * scale * 0.8)
|
const padding = Math.round(ZONE_PADDING * scale * 0.8)
|
||||||
const edgePad = Math.round(EDGE_PADDING * scale * 0.8)
|
const edgePad = Math.round(EDGE_PADDING * scale * 0.8)
|
||||||
|
|
||||||
// Vertical layout divisions
|
// Vertical layout divisions
|
||||||
const centerY = height / 2
|
const centerY = height / 2
|
||||||
const playerY = centerY + (height * 0.15)
|
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 benchOffsetY = cardH + padding // Full card height + padding
|
||||||
const handY = height - edgePad - cardH / 2
|
const handY = height - edgePad - cardH / 2
|
||||||
const oppHandY = edgePad + cardH / 2
|
const oppHandY = edgePad + cardH / 2
|
||||||
|
|
||||||
// Horizontal layout - tighter spacing for portrait
|
// Horizontal layout - tighter spacing for portrait
|
||||||
const centerX = width / 2
|
const centerX = width / 2
|
||||||
const benchSpacing = Math.min(cardW + padding, (width - edgePad * 2) / BENCH_SIZE)
|
const benchSpacing = Math.min(cardW + padding, (width - edgePad * 2) / benchSize)
|
||||||
const totalBenchWidth = (BENCH_SIZE - 1) * benchSpacing
|
const totalBenchWidth = (benchSize - 1) * benchSpacing
|
||||||
|
|
||||||
// Deck/discard positions (right side, more compact)
|
// Deck/discard positions (right side, more compact)
|
||||||
// Use reduced size (0.8) for these zones
|
// Use reduced size (0.8) for these zones
|
||||||
@ -352,7 +407,7 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
|
|||||||
rotation: 0,
|
rotation: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
myBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({
|
myBench: Array.from({ length: benchSize }, (_, i) => ({
|
||||||
x: centerX - totalBenchWidth / 2 + i * benchSpacing,
|
x: centerX - totalBenchWidth / 2 + i * benchSpacing,
|
||||||
y: playerY + benchOffsetY,
|
y: playerY + benchOffsetY,
|
||||||
width: cardW * 0.9, // Slightly smaller bench cards
|
width: cardW * 0.9, // Slightly smaller bench cards
|
||||||
@ -384,23 +439,28 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
|
|||||||
rotation: 0,
|
rotation: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
myPrizes: calculatePortraitPrizePositions(
|
myPrizes: usePrizeCards
|
||||||
prizesX,
|
? calculatePortraitPrizePositions(
|
||||||
playerY,
|
prizesX,
|
||||||
smallCardW,
|
playerY,
|
||||||
smallCardH,
|
smallCardW,
|
||||||
prizesRowSpacing,
|
smallCardH,
|
||||||
false
|
prizesRowSpacing,
|
||||||
),
|
false,
|
||||||
|
prizeCount
|
||||||
myEnergyZone: {
|
)
|
||||||
x: centerX + energyOffsetX,
|
: [],
|
||||||
y: playerY,
|
|
||||||
width: cardW * 0.8,
|
|
||||||
height: cardH * 0.8,
|
|
||||||
rotation: 0,
|
|
||||||
},
|
|
||||||
|
|
||||||
|
myEnergyZone: energyDeckEnabled
|
||||||
|
? {
|
||||||
|
x: centerX + energyOffsetX,
|
||||||
|
y: playerY,
|
||||||
|
width: cardW * 0.8,
|
||||||
|
height: cardH * 0.8,
|
||||||
|
rotation: 0,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
|
||||||
// Opponent zones (top, mirrored)
|
// Opponent zones (top, mirrored)
|
||||||
oppActive: {
|
oppActive: {
|
||||||
x: centerX,
|
x: centerX,
|
||||||
@ -410,7 +470,7 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
|
|||||||
rotation: Math.PI,
|
rotation: Math.PI,
|
||||||
},
|
},
|
||||||
|
|
||||||
oppBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({
|
oppBench: Array.from({ length: benchSize }, (_, i) => ({
|
||||||
x: centerX + totalBenchWidth / 2 - i * benchSpacing,
|
x: centerX + totalBenchWidth / 2 - i * benchSpacing,
|
||||||
y: oppY - benchOffsetY,
|
y: oppY - benchOffsetY,
|
||||||
width: cardW * 0.9,
|
width: cardW * 0.9,
|
||||||
@ -442,29 +502,34 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
|
|||||||
rotation: Math.PI,
|
rotation: Math.PI,
|
||||||
},
|
},
|
||||||
|
|
||||||
oppPrizes: calculatePortraitPrizePositions(
|
oppPrizes: usePrizeCards
|
||||||
width - prizesX,
|
? calculatePortraitPrizePositions(
|
||||||
oppY,
|
width - prizesX,
|
||||||
smallCardW,
|
oppY,
|
||||||
smallCardH,
|
smallCardW,
|
||||||
-prizesRowSpacing,
|
smallCardH,
|
||||||
true
|
-prizesRowSpacing,
|
||||||
),
|
true,
|
||||||
|
prizeCount
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
|
||||||
oppEnergyZone: {
|
oppEnergyZone: energyDeckEnabled
|
||||||
x: centerX - energyOffsetX,
|
? {
|
||||||
y: oppY,
|
x: centerX - energyOffsetX,
|
||||||
width: cardW * 0.8,
|
y: oppY,
|
||||||
height: cardH * 0.8,
|
width: cardW * 0.8,
|
||||||
rotation: Math.PI,
|
height: cardH * 0.8,
|
||||||
},
|
rotation: Math.PI,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
return layout
|
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 startX - X position of first column
|
||||||
* @param startY - Y position of first row center
|
* @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 colSpacing - Horizontal spacing between columns
|
||||||
* @param rowSpacing - Vertical spacing between rows
|
* @param rowSpacing - Vertical spacing between rows
|
||||||
* @param flipped - Whether cards are upside down (opponent)
|
* @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(
|
function calculatePrizePositions(
|
||||||
startX: number,
|
startX: number,
|
||||||
@ -482,13 +548,17 @@ function calculatePrizePositions(
|
|||||||
cardH: number,
|
cardH: number,
|
||||||
colSpacing: number,
|
colSpacing: number,
|
||||||
rowSpacing: number,
|
rowSpacing: number,
|
||||||
flipped: boolean
|
flipped: boolean,
|
||||||
|
count: number = PRIZE_SIZE
|
||||||
): ZonePosition[] {
|
): ZonePosition[] {
|
||||||
const prizes: ZonePosition[] = []
|
const prizes: ZonePosition[] = []
|
||||||
|
const cols = 2
|
||||||
for (let row = 0; row < 3; row++) {
|
const rows = Math.ceil(count / cols)
|
||||||
for (let col = 0; col < 2; col++) {
|
|
||||||
const index = row * 2 + col
|
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({
|
prizes.push({
|
||||||
x: startX + col * colSpacing,
|
x: startX + col * colSpacing,
|
||||||
y: startY + row * rowSpacing,
|
y: startY + row * rowSpacing,
|
||||||
@ -498,7 +568,7 @@ function calculatePrizePositions(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return prizes
|
return prizes
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -514,7 +584,8 @@ function calculatePrizePositions(
|
|||||||
* @param cardH - Card height
|
* @param cardH - Card height
|
||||||
* @param rowSpacing - Vertical spacing (can be negative for overlap)
|
* @param rowSpacing - Vertical spacing (can be negative for overlap)
|
||||||
* @param flipped - Whether cards are upside down
|
* @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(
|
function calculatePortraitPrizePositions(
|
||||||
x: number,
|
x: number,
|
||||||
@ -522,12 +593,14 @@ function calculatePortraitPrizePositions(
|
|||||||
cardW: number,
|
cardW: number,
|
||||||
cardH: number,
|
cardH: number,
|
||||||
rowSpacing: number,
|
rowSpacing: number,
|
||||||
flipped: boolean
|
flipped: boolean,
|
||||||
|
count: number = PRIZE_SIZE
|
||||||
): ZonePosition[] {
|
): ZonePosition[] {
|
||||||
const prizes: ZonePosition[] = []
|
const prizes: ZonePosition[] = []
|
||||||
const startY = centerY - (2.5 * Math.abs(rowSpacing)) // Center the stack
|
// Center the stack based on count
|
||||||
|
const startY = centerY - ((count - 1) / 2 * Math.abs(rowSpacing))
|
||||||
for (let i = 0; i < PRIZE_SIZE; i++) {
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
prizes.push({
|
prizes.push({
|
||||||
x,
|
x,
|
||||||
y: startY + i * Math.abs(rowSpacing),
|
y: startY + i * Math.abs(rowSpacing),
|
||||||
@ -536,7 +609,7 @@ function calculatePortraitPrizePositions(
|
|||||||
rotation: flipped ? Math.PI : 0,
|
rotation: flipped ? Math.PI : 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return prizes
|
return prizes
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -632,7 +705,7 @@ export function getAllZones(
|
|||||||
layout: BoardLayout
|
layout: BoardLayout
|
||||||
): Array<{ label: string; zone: ZonePosition }> {
|
): Array<{ label: string; zone: ZonePosition }> {
|
||||||
const zones: Array<{ label: string; zone: ZonePosition }> = []
|
const zones: Array<{ label: string; zone: ZonePosition }> = []
|
||||||
|
|
||||||
// My zones
|
// My zones
|
||||||
zones.push({ label: 'myActive', zone: layout.myActive })
|
zones.push({ label: 'myActive', zone: layout.myActive })
|
||||||
layout.myBench.forEach((z, i) => zones.push({ label: `myBench[${i}]`, zone: z }))
|
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: 'myDeck', zone: layout.myDeck })
|
||||||
zones.push({ label: 'myDiscard', zone: layout.myDiscard })
|
zones.push({ label: 'myDiscard', zone: layout.myDiscard })
|
||||||
layout.myPrizes.forEach((z, i) => zones.push({ label: `myPrizes[${i}]`, zone: z }))
|
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
|
// Opponent zones
|
||||||
zones.push({ label: 'oppActive', zone: layout.oppActive })
|
zones.push({ label: 'oppActive', zone: layout.oppActive })
|
||||||
layout.oppBench.forEach((z, i) => zones.push({ label: `oppBench[${i}]`, zone: z }))
|
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: 'oppDeck', zone: layout.oppDeck })
|
||||||
zones.push({ label: 'oppDiscard', zone: layout.oppDiscard })
|
zones.push({ label: 'oppDiscard', zone: layout.oppDiscard })
|
||||||
layout.oppPrizes.forEach((z, i) => zones.push({ label: `oppPrizes[${i}]`, zone: z }))
|
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
|
return zones
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ import type {
|
|||||||
} from '@/types/game'
|
} from '@/types/game'
|
||||||
import type { BoardLayout } from '@/types/phaser'
|
import type { BoardLayout } from '@/types/phaser'
|
||||||
import { getMyPlayerState, getOpponentState } from '@/types/game'
|
import { getMyPlayerState, getOpponentState } from '@/types/game'
|
||||||
import { calculateLayout } from '../layout'
|
import { calculateLayout, type LayoutOptions } from '../layout'
|
||||||
import { Card } from '../objects/Card'
|
import { Card } from '../objects/Card'
|
||||||
import { ActiveZone } from '../objects/ActiveZone'
|
import { ActiveZone } from '../objects/ActiveZone'
|
||||||
import { BenchZone } from '../objects/BenchZone'
|
import { BenchZone } from '../objects/BenchZone'
|
||||||
@ -51,8 +51,8 @@ interface PlayerZones {
|
|||||||
hand: HandZone
|
hand: HandZone
|
||||||
deck: PileZone
|
deck: PileZone
|
||||||
discard: PileZone
|
discard: PileZone
|
||||||
prizes: PrizeZone
|
prizes: PrizeZone | null // null when using points system instead of prize cards
|
||||||
energyZone: PileZone
|
energyZone: PileZone | null // null when energy deck is disabled (classic mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -128,8 +128,16 @@ export class StateRenderer {
|
|||||||
// Get canvas dimensions
|
// Get canvas dimensions
|
||||||
const { width, height } = this.scene.cameras.main
|
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
|
// Calculate layout
|
||||||
this.layout = calculateLayout(width, height)
|
this.layout = calculateLayout(width, height, layoutOptions)
|
||||||
|
|
||||||
// Create zones if needed
|
// Create zones if needed
|
||||||
if (!this.zones) {
|
if (!this.zones) {
|
||||||
@ -258,13 +266,17 @@ export class StateRenderer {
|
|||||||
return
|
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
|
// Create main container
|
||||||
this.container = this.scene.add.container(0, 0)
|
this.container = this.scene.add.container(0, 0)
|
||||||
|
|
||||||
// Create player zones
|
// Create player zones
|
||||||
this.zones = {
|
this.zones = {
|
||||||
my: this.createPlayerZones(myState.player_id, false),
|
my: this.createPlayerZones(myState.player_id, false, usePrizeCards, energyDeckEnabled),
|
||||||
opp: this.createPlayerZones(oppState.player_id, true),
|
opp: this.createPlayerZones(oppState.player_id, true, usePrizeCards, energyDeckEnabled),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,9 +285,16 @@ export class StateRenderer {
|
|||||||
*
|
*
|
||||||
* @param playerId - The player's ID
|
* @param playerId - The player's ID
|
||||||
* @param isOpponent - Whether this is the opponent (affects layout orientation)
|
* @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
|
* @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()
|
// Create zones at origin - positions updated in updateZonePositions()
|
||||||
const zones: PlayerZones = {
|
const zones: PlayerZones = {
|
||||||
active: new ActiveZone(this.scene, 0, 0, playerId),
|
active: new ActiveZone(this.scene, 0, 0, playerId),
|
||||||
@ -283,8 +302,8 @@ export class StateRenderer {
|
|||||||
hand: new HandZone(this.scene, 0, 0, playerId),
|
hand: new HandZone(this.scene, 0, 0, playerId),
|
||||||
deck: new PileZone(this.scene, 0, 0, playerId, 'deck'),
|
deck: new PileZone(this.scene, 0, 0, playerId, 'deck'),
|
||||||
discard: new PileZone(this.scene, 0, 0, playerId, 'discard'),
|
discard: new PileZone(this.scene, 0, 0, playerId, 'discard'),
|
||||||
prizes: new PrizeZone(this.scene, 0, 0, playerId),
|
prizes: usePrizeCards ? new PrizeZone(this.scene, 0, 0, playerId) : null,
|
||||||
energyZone: new PileZone(this.scene, 0, 0, playerId, 'deck'), // Energy deck uses pile zone
|
energyZone: energyDeckEnabled ? new PileZone(this.scene, 0, 0, playerId, 'deck') : null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all zones to container
|
// Add all zones to container
|
||||||
@ -294,8 +313,12 @@ export class StateRenderer {
|
|||||||
this.container.add(zones.hand)
|
this.container.add(zones.hand)
|
||||||
this.container.add(zones.deck)
|
this.container.add(zones.deck)
|
||||||
this.container.add(zones.discard)
|
this.container.add(zones.discard)
|
||||||
this.container.add(zones.prizes)
|
if (zones.prizes) {
|
||||||
this.container.add(zones.energyZone)
|
this.container.add(zones.prizes)
|
||||||
|
}
|
||||||
|
if (zones.energyZone) {
|
||||||
|
this.container.add(zones.energyZone)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return zones
|
return zones
|
||||||
@ -312,8 +335,8 @@ export class StateRenderer {
|
|||||||
zones.hand.destroy()
|
zones.hand.destroy()
|
||||||
zones.deck.destroy()
|
zones.deck.destroy()
|
||||||
zones.discard.destroy()
|
zones.discard.destroy()
|
||||||
zones.prizes.destroy()
|
zones.prizes?.destroy()
|
||||||
zones.energyZone.destroy()
|
zones.energyZone?.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
@ -331,7 +354,9 @@ export class StateRenderer {
|
|||||||
this.zones.my.hand.applyLayout(this.layout.myHand)
|
this.zones.my.hand.applyLayout(this.layout.myHand)
|
||||||
this.zones.my.deck.applyLayout(this.layout.myDeck)
|
this.zones.my.deck.applyLayout(this.layout.myDeck)
|
||||||
this.zones.my.discard.applyLayout(this.layout.myDiscard)
|
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
|
// Bench zone - use the center position and full width
|
||||||
const benchLayout = this.layout.myBench
|
const benchLayout = this.layout.myBench
|
||||||
@ -344,8 +369,8 @@ export class StateRenderer {
|
|||||||
this.zones.my.bench.setZoneDimensions(totalWidth, benchLayout[0].height)
|
this.zones.my.bench.setZoneDimensions(totalWidth, benchLayout[0].height)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prizes - use the first prize position for the zone
|
// Prizes - use the first prize position for the zone (only if using prize cards)
|
||||||
if (this.layout.myPrizes.length > 0) {
|
if (this.zones.my.prizes && this.layout.myPrizes.length > 0) {
|
||||||
const prizePositions = this.layout.myPrizes
|
const prizePositions = this.layout.myPrizes
|
||||||
const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2))
|
const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2))
|
||||||
const maxX = Math.max(...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.hand.applyLayout(this.layout.oppHand)
|
||||||
this.zones.opp.deck.applyLayout(this.layout.oppDeck)
|
this.zones.opp.deck.applyLayout(this.layout.oppDeck)
|
||||||
this.zones.opp.discard.applyLayout(this.layout.oppDiscard)
|
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
|
// Opponent bench
|
||||||
const oppBenchLayout = this.layout.oppBench
|
const oppBenchLayout = this.layout.oppBench
|
||||||
@ -375,8 +402,8 @@ export class StateRenderer {
|
|||||||
this.zones.opp.bench.setZoneDimensions(totalWidth, oppBenchLayout[0].height)
|
this.zones.opp.bench.setZoneDimensions(totalWidth, oppBenchLayout[0].height)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opponent prizes
|
// Opponent prizes (only if using prize cards)
|
||||||
if (this.layout.oppPrizes.length > 0) {
|
if (this.zones.opp.prizes && this.layout.oppPrizes.length > 0) {
|
||||||
const prizePositions = this.layout.oppPrizes
|
const prizePositions = this.layout.oppPrizes
|
||||||
const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2))
|
const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2))
|
||||||
const maxX = Math.max(...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
|
true // always visible
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update prizes
|
// Update prizes (only if using prize cards)
|
||||||
// Viewer sees their own prize count (cards still hidden)
|
// Viewer sees their own prize count (cards still hidden)
|
||||||
// Opponent just sees count
|
// Opponent just sees count
|
||||||
zones.prizes.setRemainingCount(playerState.prizes_count)
|
zones.prizes?.setRemainingCount(playerState.prizes_count)
|
||||||
|
|
||||||
// Update energy zone
|
// Update energy zone (only if energy deck is enabled)
|
||||||
this.updateZone(
|
if (zones.energyZone) {
|
||||||
zones.energyZone,
|
this.updateZone(
|
||||||
playerState.energy_zone.cards,
|
zones.energyZone,
|
||||||
registry,
|
playerState.energy_zone.cards,
|
||||||
playerId,
|
registry,
|
||||||
'energy_zone',
|
playerId,
|
||||||
true // always visible
|
'energy_zone',
|
||||||
)
|
true // always visible
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import type {
|
|||||||
CardDefinition,
|
CardDefinition,
|
||||||
TurnPhase,
|
TurnPhase,
|
||||||
Action,
|
Action,
|
||||||
|
RulesConfig,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { getMyPlayerState, getOpponentState, getCardDefinition, ConnectionStatus } 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
|
forcedAction.value?.player === gameState.value?.viewer_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Computed - Rules Config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Current game rules configuration */
|
||||||
|
const rulesConfig = computed<RulesConfig | null>(() =>
|
||||||
|
gameState.value?.rules_config ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether this game uses classic prize cards (vs points system) */
|
||||||
|
const usePrizeCards = computed<boolean>(() =>
|
||||||
|
rulesConfig.value?.prizes.use_prize_cards ?? false
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Number of prizes/points needed to win */
|
||||||
|
const prizeCount = computed<number>(() =>
|
||||||
|
rulesConfig.value?.prizes.count ?? 4
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Maximum number of Pokemon on the bench */
|
||||||
|
const benchSize = computed<number>(() =>
|
||||||
|
rulesConfig.value?.bench.max_size ?? 5
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether this game uses an energy deck zone (Pokemon Pocket style) */
|
||||||
|
const energyDeckEnabled = computed<boolean>(() =>
|
||||||
|
rulesConfig.value?.deck.energy_deck_enabled ?? true
|
||||||
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Computed - Card Lookup
|
// Computed - Card Lookup
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -337,6 +367,13 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
hasForcedAction,
|
hasForcedAction,
|
||||||
isConnected,
|
isConnected,
|
||||||
|
|
||||||
|
// Rules config
|
||||||
|
rulesConfig,
|
||||||
|
usePrizeCards,
|
||||||
|
prizeCount,
|
||||||
|
benchSize,
|
||||||
|
energyDeckEnabled,
|
||||||
|
|
||||||
// Card lookup
|
// Card lookup
|
||||||
lookupCard,
|
lookupCard,
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
* and sends this structure via WebSocket.
|
* and sends this structure via WebSocket.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ModifierMode } from './rules'
|
import type { ModifierMode, RulesConfig } from './rules'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Enums and Constants
|
// Enums and Constants
|
||||||
@ -407,6 +407,9 @@ export interface VisibleGameState {
|
|||||||
|
|
||||||
/** Card definitions for display (definition_id -> CardDefinition) */
|
/** Card definitions for display (definition_id -> CardDefinition) */
|
||||||
card_registry: Record<string, CardDefinition>
|
card_registry: Record<string, CardDefinition>
|
||||||
|
|
||||||
|
/** Rules configuration for UI rendering decisions (e.g., prize cards vs points) */
|
||||||
|
rules_config?: RulesConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@ -174,21 +174,21 @@ export interface ZonePosition {
|
|||||||
export interface BoardLayout {
|
export interface BoardLayout {
|
||||||
// My zones (bottom of screen)
|
// My zones (bottom of screen)
|
||||||
myActive: ZonePosition
|
myActive: ZonePosition
|
||||||
myBench: ZonePosition[] // 5 slots
|
myBench: ZonePosition[] // configurable slots (default 5)
|
||||||
myHand: ZonePosition
|
myHand: ZonePosition
|
||||||
myDeck: ZonePosition
|
myDeck: ZonePosition
|
||||||
myDiscard: ZonePosition
|
myDiscard: ZonePosition
|
||||||
myPrizes: ZonePosition[] // 6 slots
|
myPrizes: ZonePosition[] // configurable slots (default 6), empty when using points
|
||||||
myEnergyZone: ZonePosition
|
myEnergyZone: ZonePosition | null // null when energy deck is disabled
|
||||||
|
|
||||||
// Opponent zones (top of screen, mirrored)
|
// Opponent zones (top of screen, mirrored)
|
||||||
oppActive: ZonePosition
|
oppActive: ZonePosition
|
||||||
oppBench: ZonePosition[] // 5 slots
|
oppBench: ZonePosition[] // configurable slots (default 5)
|
||||||
oppHand: ZonePosition
|
oppHand: ZonePosition
|
||||||
oppDeck: ZonePosition
|
oppDeck: ZonePosition
|
||||||
oppDiscard: ZonePosition
|
oppDiscard: ZonePosition
|
||||||
oppPrizes: ZonePosition[] // 6 slots
|
oppPrizes: ZonePosition[] // configurable slots (default 6), empty when using points
|
||||||
oppEnergyZone: ZonePosition
|
oppEnergyZone: ZonePosition | null // null when energy deck is disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user