Add conditional energy deck zone based on RulesConfig

Extends RulesConfig support in the frontend game board to conditionally
render energy deck zones based on deck.energy_deck_enabled setting.
When disabled (classic mode), energy zones are null and omitted from
the layout, saving screen space.

Changes:
- Add energyDeckEnabled option to LayoutOptions interface
- Update landscape/portrait layouts to conditionally generate energy zones
- Make myEnergyZone/oppEnergyZone nullable in BoardLayout type
- Update StateRenderer to conditionally create and update energy zones
- Add energyDeckEnabled computed property to game store
- Add 7 tests for conditional energy deck rendering

https://claude.ai/code/session_01AAxKmpq2AGde327eX1nzUC
This commit is contained in:
Claude 2026-02-02 09:41:25 +00:00
parent c430a43e19
commit 42e0116aec
No known key found for this signature in database
5 changed files with 230 additions and 62 deletions

View File

@ -822,3 +822,137 @@ describe('calculateLayout - bench size options', () => {
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()
})
})

View File

@ -73,6 +73,8 @@ export interface LayoutOptions {
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
}
// =============================================================================
@ -165,6 +167,7 @@ function calculateLandscapeLayout(
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
@ -256,14 +259,16 @@ function calculateLandscapeLayout(
)
: [],
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)
oppActive: {
x: centerX,
@ -318,15 +323,17 @@ function calculateLandscapeLayout(
)
: [],
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
}
@ -352,6 +359,7 @@ function calculatePortraitLayout(
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
@ -443,14 +451,16 @@ function calculatePortraitLayout(
)
: [],
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)
oppActive: {
x: centerX,
@ -504,15 +514,17 @@ function calculatePortraitLayout(
)
: [],
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
}
@ -693,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 }))
@ -701,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 }))
@ -710,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
}

View File

@ -52,7 +52,7 @@ interface PlayerZones {
deck: PileZone
discard: PileZone
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)
}
/**
@ -133,6 +133,7 @@ export class StateRenderer {
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
@ -265,16 +266,17 @@ export class StateRenderer {
return
}
// Check if we should create prize zones
// 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, usePrizeCards),
opp: this.createPlayerZones(oppState.player_id, true, usePrizeCards),
my: this.createPlayerZones(myState.player_id, false, usePrizeCards, energyDeckEnabled),
opp: this.createPlayerZones(oppState.player_id, true, usePrizeCards, energyDeckEnabled),
}
}
@ -284,12 +286,14 @@ 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,
usePrizeCards: boolean
usePrizeCards: boolean,
energyDeckEnabled: boolean
): PlayerZones {
// Create zones at origin - positions updated in updateZonePositions()
const zones: PlayerZones = {
@ -299,7 +303,7 @@ export class StateRenderer {
deck: new PileZone(this.scene, 0, 0, playerId, 'deck'),
discard: new PileZone(this.scene, 0, 0, playerId, 'discard'),
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
@ -312,7 +316,9 @@ export class StateRenderer {
if (zones.prizes) {
this.container.add(zones.prizes)
}
this.container.add(zones.energyZone)
if (zones.energyZone) {
this.container.add(zones.energyZone)
}
}
return zones
@ -330,7 +336,7 @@ export class StateRenderer {
zones.deck.destroy()
zones.discard.destroy()
zones.prizes?.destroy()
zones.energyZone.destroy()
zones.energyZone?.destroy()
}
// ===========================================================================
@ -348,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
@ -379,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
@ -471,15 +481,17 @@ export class StateRenderer {
// Opponent just sees 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
)
}
}
/**

View File

@ -241,6 +241,11 @@ export const useGameStore = defineStore('game', () => {
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
// ---------------------------------------------------------------------------
@ -367,6 +372,7 @@ export const useGameStore = defineStore('game', () => {
usePrizeCards,
prizeCount,
benchSize,
energyDeckEnabled,
// Card lookup
lookupCard,

View File

@ -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
}
/**