Add F3 demo page with real card data and fix Phaser initialization

- Add /demo route with full game board demo using real card images
- Fix PhaserGame.vue to pass scenes array to createGame()
- Fix timing issue: listen for gameBridge ready event instead of Phaser core ready
- Add card images for Lightning and Fire starter decks (24 Pokemon + 5 energy)
- Add mockGameState.ts with realistic Lightning vs Fire matchup
- Add demoCards.json/ts with card definitions from backend
- Update Card.ts to use image_path from card definitions
- Add loadCardImageFromPath() to asset loader for new image format
- Update CardDefinition type with image_path and rarity fields

Demo verifies: Vue-Phaser state sync, card rendering, damage counters,
card click events, and debug controls. Layout issues noted for Phase F4.
This commit is contained in:
Cal Corum 2026-01-31 21:58:26 -06:00
parent f759d56d4d
commit 2986eed142
42 changed files with 1650 additions and 51 deletions

1
frontend/.gitignore vendored
View File

@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
*.vite
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -8,7 +8,7 @@
*/ */
import { ref, onMounted, onUnmounted, shallowRef } from 'vue' import { ref, onMounted, onUnmounted, shallowRef } from 'vue'
import { createGame } from '@/game' import { createGame, scenes } from '@/game'
import type Phaser from 'phaser' import type Phaser from 'phaser'
/** /**
@ -44,7 +44,7 @@ function initGame(): void {
try { try {
// Create the Phaser game instance // Create the Phaser game instance
game.value = createGame(container.value) game.value = createGame(container.value, scenes)
// Listen for Phaser ready event (emitted when game is fully initialized) // Listen for Phaser ready event (emitted when game is fully initialized)
// Using PHASER_READY_EVENT constant to avoid runtime Phaser dependency // Using PHASER_READY_EVENT constant to avoid runtime Phaser dependency

View File

@ -0,0 +1,700 @@
{
"a1-094-pikachu": {
"id": "a1-094-pikachu",
"name": "Pikachu",
"card_type": "pokemon",
"hp": 60,
"pokemon_type": "lightning",
"stage": "basic",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "common",
"attacks": [
{
"name": "Gnaw",
"cost": [
"lightning"
],
"damage": 20,
"damage_display": "20"
}
],
"weakness": {
"energy_type": "fighting",
"value": 20
},
"illustrator": "Mitsuhiro Arita",
"image_path": "a1/094-pikachu.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/094-pikachu.webp"
},
"a1-095-raichu": {
"id": "a1-095-raichu",
"name": "Raichu",
"card_type": "pokemon",
"hp": 100,
"pokemon_type": "lightning",
"stage": "stage_1",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "rare",
"evolves_from": "Pikachu",
"attacks": [
{
"name": "Thunderbolt",
"cost": [
"lightning",
"lightning",
"lightning"
],
"damage": 140,
"damage_display": "140",
"effect_description": "Discard all Energy from this Pok\u00e9mon."
}
],
"weakness": {
"energy_type": "fighting",
"value": 20
},
"illustrator": "AKIRA EGAWA",
"image_path": "a1/095-raichu.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/095-raichu.webp"
},
"a1-097-magnemite": {
"id": "a1-097-magnemite",
"name": "Magnemite",
"card_type": "pokemon",
"hp": 60,
"pokemon_type": "lightning",
"stage": "basic",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "common",
"attacks": [
{
"name": "Lightning Ball",
"cost": [
"lightning"
],
"damage": 20,
"damage_display": "20"
}
],
"weakness": {
"energy_type": "fighting",
"value": 20
},
"illustrator": "sowsow",
"image_path": "a1/097-magnemite.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/097-magnemite.webp"
},
"a1-098-magneton": {
"id": "a1-098-magneton",
"name": "Magneton",
"card_type": "pokemon",
"hp": 80,
"pokemon_type": "lightning",
"stage": "stage_1",
"variant": "normal",
"retreat_cost": 2,
"set_id": "a1",
"rarity": "rare",
"evolves_from": "Magnemite",
"attacks": [
{
"name": "Spinning Attack",
"cost": [
"lightning",
"colorless",
"colorless",
"colorless"
],
"damage": 60,
"damage_display": "60"
}
],
"abilities": [
{
"name": "Volt Charge",
"effect_id": "unimplemented",
"effect_description": "Once during your turn, you may take a Lightning Energy from your Energy Zone and attach it to this Pok\u00e9mon."
}
],
"weakness": {
"energy_type": "fighting",
"value": 20
},
"illustrator": "kirisAki",
"image_path": "a1/098-magneton.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/098-magneton.webp"
},
"a1-099-voltorb": {
"id": "a1-099-voltorb",
"name": "Voltorb",
"card_type": "pokemon",
"hp": 60,
"pokemon_type": "lightning",
"stage": "basic",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "common",
"attacks": [
{
"name": "Tackle",
"cost": [
"lightning"
],
"damage": 20,
"damage_display": "20"
}
],
"weakness": {
"energy_type": "fighting",
"value": 20
},
"illustrator": "SATOSHI NAKAI",
"image_path": "a1/099-voltorb.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/099-voltorb.webp"
},
"a1-100-electrode": {
"id": "a1-100-electrode",
"name": "Electrode",
"card_type": "pokemon",
"hp": 80,
"pokemon_type": "lightning",
"stage": "stage_1",
"variant": "normal",
"retreat_cost": 0,
"set_id": "a1",
"rarity": "uncommon",
"evolves_from": "Voltorb",
"attacks": [
{
"name": "Electro Ball",
"cost": [
"lightning",
"lightning"
],
"damage": 70,
"damage_display": "70"
}
],
"weakness": {
"energy_type": "fighting",
"value": 20
},
"illustrator": "Asako Ito",
"image_path": "a1/100-electrode.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/100-electrode.webp"
},
"a1-101-electabuzz": {
"id": "a1-101-electabuzz",
"name": "Electabuzz",
"card_type": "pokemon",
"hp": 70,
"pokemon_type": "lightning",
"stage": "basic",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "common",
"attacks": [
{
"name": "Thunder Punch",
"cost": [
"lightning",
"lightning"
],
"damage": 40,
"damage_display": "40x",
"effect_description": "Flip a coin. If heads, this attack does 40 more damage. If tails, this Pok\u00e9mon also does 20 damage to itself.",
"effect_params": {
"damage_modifier": "x"
}
}
],
"weakness": {
"energy_type": "fighting",
"value": 20
},
"illustrator": "Ryuta Fuse",
"image_path": "a1/101-electabuzz.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/101-electabuzz.webp"
},
"a1-105-blitzle": {
"id": "a1-105-blitzle",
"name": "Blitzle",
"card_type": "pokemon",
"hp": 60,
"pokemon_type": "lightning",
"stage": "basic",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "common",
"attacks": [
{
"name": "Zap Kick",
"cost": [
"lightning"
],
"damage": 20,
"damage_display": "20"
}
],
"weakness": {
"energy_type": "fighting",
"value": 20
},
"illustrator": "Shin Nagasawa",
"image_path": "a1/105-blitzle.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/105-blitzle.webp"
},
"a1-106-zebstrika": {
"id": "a1-106-zebstrika",
"name": "Zebstrika",
"card_type": "pokemon",
"hp": 90,
"pokemon_type": "lightning",
"stage": "stage_1",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "uncommon",
"evolves_from": "Blitzle",
"attacks": [
{
"name": "Thunder Spear",
"cost": [
"lightning"
],
"damage": 0,
"effect_description": "This attack does 30 damage to 1 of your opponent\u2019s Pok\u00e9mon."
}
],
"weakness": {
"energy_type": "fighting",
"value": 20
},
"illustrator": "Misa Tsutsui",
"image_path": "a1/106-zebstrika.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/106-zebstrika.webp"
},
"a1-033-charmander": {
"id": "a1-033-charmander",
"name": "Charmander",
"card_type": "pokemon",
"hp": 60,
"pokemon_type": "fire",
"stage": "basic",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "common",
"attacks": [
{
"name": "Ember",
"cost": [
"fire"
],
"damage": 30,
"damage_display": "30",
"effect_description": "Discard a Fire Energy from this Pok\u00e9mon."
}
],
"weakness": {
"energy_type": "water",
"value": 20
},
"illustrator": "Teeziro",
"image_path": "a1/033-charmander.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/033-charmander.webp"
},
"a1-034-charmeleon": {
"id": "a1-034-charmeleon",
"name": "Charmeleon",
"card_type": "pokemon",
"hp": 90,
"pokemon_type": "fire",
"stage": "stage_1",
"variant": "normal",
"retreat_cost": 2,
"set_id": "a1",
"rarity": "uncommon",
"evolves_from": "Charmander",
"attacks": [
{
"name": "Fire Claws",
"cost": [
"fire",
"colorless",
"colorless"
],
"damage": 60,
"damage_display": "60"
}
],
"weakness": {
"energy_type": "water",
"value": 20
},
"illustrator": "kantaro",
"image_path": "a1/034-charmeleon.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/034-charmeleon.webp"
},
"a1-035-charizard": {
"id": "a1-035-charizard",
"name": "Charizard",
"card_type": "pokemon",
"hp": 150,
"pokemon_type": "fire",
"stage": "stage_2",
"variant": "normal",
"retreat_cost": 2,
"set_id": "a1",
"rarity": "rare",
"evolves_from": "Charmeleon",
"attacks": [
{
"name": "Fire Spin",
"cost": [
"fire",
"fire",
"colorless",
"colorless"
],
"damage": 150,
"damage_display": "150",
"effect_description": "Discard 2 Fire Energy from this Pok\u00e9mon."
}
],
"weakness": {
"energy_type": "water",
"value": 20
},
"illustrator": "takuyoa",
"image_path": "a1/035-charizard.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/035-charizard.webp"
},
"a1-039-growlithe": {
"id": "a1-039-growlithe",
"name": "Growlithe",
"card_type": "pokemon",
"hp": 70,
"pokemon_type": "fire",
"stage": "basic",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "common",
"attacks": [
{
"name": "Bite",
"cost": [
"colorless",
"colorless"
],
"damage": 20,
"damage_display": "20"
}
],
"weakness": {
"energy_type": "water",
"value": 20
},
"illustrator": "Mizue",
"image_path": "a1/039-growlithe.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/039-growlithe.webp"
},
"a1-040-arcanine": {
"id": "a1-040-arcanine",
"name": "Arcanine",
"card_type": "pokemon",
"hp": 130,
"pokemon_type": "fire",
"stage": "stage_1",
"variant": "normal",
"retreat_cost": 2,
"set_id": "a1",
"rarity": "rare",
"evolves_from": "Growlithe",
"attacks": [
{
"name": "Heat Tackle",
"cost": [
"fire",
"fire",
"colorless"
],
"damage": 100,
"damage_display": "100",
"effect_description": "This Pok\u00e9mon also does 20 damage to itself."
}
],
"weakness": {
"energy_type": "water",
"value": 20
},
"illustrator": "kodama",
"image_path": "a1/040-arcanine.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/040-arcanine.webp"
},
"a1-042-ponyta": {
"id": "a1-042-ponyta",
"name": "Ponyta",
"card_type": "pokemon",
"hp": 60,
"pokemon_type": "fire",
"stage": "basic",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "common",
"attacks": [
{
"name": "Flare",
"cost": [
"fire"
],
"damage": 20,
"damage_display": "20"
}
],
"weakness": {
"energy_type": "water",
"value": 20
},
"illustrator": "Uta",
"image_path": "a1/042-ponyta.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/042-ponyta.webp"
},
"a1-043-rapidash": {
"id": "a1-043-rapidash",
"name": "Rapidash",
"card_type": "pokemon",
"hp": 100,
"pokemon_type": "fire",
"stage": "stage_1",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "uncommon",
"evolves_from": "Ponyta",
"attacks": [
{
"name": "Fire Mane",
"cost": [
"fire"
],
"damage": 40,
"damage_display": "40"
}
],
"weakness": {
"energy_type": "water",
"value": 20
},
"illustrator": "Misa Tsutsui",
"image_path": "a1/043-rapidash.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/043-rapidash.webp"
},
"a1-037-vulpix": {
"id": "a1-037-vulpix",
"name": "Vulpix",
"card_type": "pokemon",
"hp": 50,
"pokemon_type": "fire",
"stage": "basic",
"variant": "normal",
"retreat_cost": 1,
"set_id": "a1",
"rarity": "common",
"attacks": [
{
"name": "Tail Whip",
"cost": [
"colorless"
],
"damage": 0,
"effect_description": "Flip a coin. If heads, the Defending Pok\u00e9mon can\u2019t attack during your opponent\u2019s next turn."
}
],
"weakness": {
"energy_type": "water",
"value": 20
},
"illustrator": "Toshinao Aoki",
"image_path": "a1/037-vulpix.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/037-vulpix.webp"
},
"a1-044-magmar": {
"id": "a1-044-magmar",
"name": "Magmar",
"card_type": "pokemon",
"hp": 80,
"pokemon_type": "fire",
"stage": "basic",
"variant": "normal",
"retreat_cost": 2,
"set_id": "a1",
"rarity": "common",
"attacks": [
{
"name": "Magma Punch",
"cost": [
"fire",
"fire"
],
"damage": 50,
"damage_display": "50"
}
],
"weakness": {
"energy_type": "water",
"value": 20
},
"illustrator": "Ryuta Fuse",
"image_path": "a1/044-magmar.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/044-magmar.webp"
},
"a1-221-blaine": {
"id": "a1-221-blaine",
"name": "Blaine",
"card_type": "trainer",
"trainer_type": "supporter",
"set_id": "a1",
"rarity": "uncommon",
"effect_description": "During this turn, attacks used by your Ninetales , Rapidash , or Magmar do +30 damage to your opponent\u2019s Active Pok\u00e9mon. You may play only 1 Supporter card during your turn.",
"illustrator": "GOSSAN",
"image_path": "a1/221-blaine.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/221-blaine.webp"
},
"a1-224-brock": {
"id": "a1-224-brock",
"name": "Brock",
"card_type": "trainer",
"trainer_type": "supporter",
"set_id": "a1",
"rarity": "uncommon",
"effect_description": "Take a Fighting Energy from your Energy Zone and attach it to Golem or Onix . You may play only 1 Supporter card during your turn.",
"illustrator": "Taira Akitsu",
"image_path": "a1/224-brock.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/224-brock.webp"
},
"a1-216-helix-fossil": {
"id": "a1-216-helix-fossil",
"name": "Helix Fossil",
"card_type": "trainer",
"trainer_type": "item",
"set_id": "a1",
"rarity": "common",
"effect_description": "Play this card as if it were a 40 HP Basic Colorless Pok\u00e9mon. At any time during your turn, you may discard this card from play. This card can\u2019t retreat. You may play any number of Item cards during your turn.",
"illustrator": "Toyste Beach",
"image_path": "a1/216-helix-fossil.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/216-helix-fossil.webp"
},
"a1-217-dome-fossil": {
"id": "a1-217-dome-fossil",
"name": "Dome Fossil",
"card_type": "trainer",
"trainer_type": "item",
"set_id": "a1",
"rarity": "common",
"effect_description": "Play this card as if it were a 40 HP Basic Colorless Pok\u00e9mon. At any time during your turn, you may discard this card from play. This card can\u2019t retreat. You may play any number of Item cards during your turn.",
"illustrator": "Toyste Beach",
"image_path": "a1/217-dome-fossil.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/217-dome-fossil.webp"
},
"a1-218-old-amber": {
"id": "a1-218-old-amber",
"name": "Old Amber",
"card_type": "trainer",
"trainer_type": "item",
"set_id": "a1",
"rarity": "common",
"effect_description": "Play this card as if it were a 40 HP Basic Colorless Pok\u00e9mon. At any time during your turn, you may discard this card from play. This card can\u2019t retreat. You may play any number of Item cards during your turn.",
"illustrator": "Toyste Beach",
"image_path": "a1/218-old-amber.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/218-old-amber.webp"
},
"a1-226-lt-surge": {
"id": "a1-226-lt-surge",
"name": "Lt. Surge",
"card_type": "trainer",
"trainer_type": "supporter",
"set_id": "a1",
"rarity": "uncommon",
"effect_description": "Move all Lightning Energy from your Benched Pok\u00e9mon to your Raichu , Electrode , or Electabuzz in the Active Spot. You may play only 1 Supporter card during your turn.",
"illustrator": "nagimiso",
"image_path": "a1/226-lt-surge.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/a1/226-lt-surge.webp"
},
"energy-basic-lightning": {
"id": "energy-basic-lightning",
"name": "Lightning Energy",
"card_type": "energy",
"energy_type": "lightning",
"energy_provides": [
"lightning"
],
"rarity": "common",
"set_id": "basic",
"image_path": "basic/lightning.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/basic/lightning.webp"
},
"energy-basic-fire": {
"id": "energy-basic-fire",
"name": "Fire Energy",
"card_type": "energy",
"energy_type": "fire",
"energy_provides": [
"fire"
],
"rarity": "common",
"set_id": "basic",
"image_path": "basic/fire.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/basic/fire.webp"
},
"energy-basic-water": {
"id": "energy-basic-water",
"name": "Water Energy",
"card_type": "energy",
"energy_type": "water",
"energy_provides": [
"water"
],
"rarity": "common",
"set_id": "basic",
"image_path": "basic/water.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/basic/water.webp"
},
"energy-basic-grass": {
"id": "energy-basic-grass",
"name": "Grass Energy",
"card_type": "energy",
"energy_type": "grass",
"energy_provides": [
"grass"
],
"rarity": "common",
"set_id": "basic",
"image_path": "basic/grass.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/basic/grass.webp"
},
"energy-basic-psychic": {
"id": "energy-basic-psychic",
"name": "Psychic Energy",
"card_type": "energy",
"energy_type": "psychic",
"energy_provides": [
"psychic"
],
"rarity": "common",
"set_id": "basic",
"image_path": "basic/psychic.webp",
"image_url": "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/basic/psychic.webp"
}
}

View File

@ -0,0 +1,75 @@
/**
* Demo card data for F3 Phaser integration testing.
*
* Contains real card definitions from the backend, bundled statically
* for the demo page. Includes:
* - Lightning starter deck Pokemon (Pikachu line, Magnemite line, etc.)
* - Fire starter deck Pokemon (Charmander line, Growlithe line, etc.)
* - Trainer cards (supporters, items)
* - Basic energy cards
*
* Card images are served from /public/game/cards/ (copied from backend).
*/
import type { CardDefinition } from '@/types/game'
import demoCardsJson from './demoCards.json'
/**
* All demo card definitions, keyed by card ID.
*/
export const DEMO_CARD_DEFINITIONS: Record<string, CardDefinition> =
demoCardsJson as Record<string, CardDefinition>
/**
* Get a demo card definition by ID.
*
* @param id - The card ID (e.g., "a1-094-pikachu")
* @returns The card definition, or undefined if not found
*/
export function getDemoCard(id: string): CardDefinition | undefined {
return DEMO_CARD_DEFINITIONS[id]
}
/**
* Get all demo card IDs.
*
* @returns Array of all card IDs in the demo set
*/
export function getDemoCardIds(): string[] {
return Object.keys(DEMO_CARD_DEFINITIONS)
}
/**
* Get demo cards filtered by type.
*
* @param cardType - The card type to filter by
* @returns Array of matching card definitions
*/
export function getDemoCardsByType(
cardType: 'pokemon' | 'trainer' | 'energy'
): CardDefinition[] {
return Object.values(DEMO_CARD_DEFINITIONS).filter(
(card) => card.card_type === cardType
)
}
/**
* Get demo Pokemon cards filtered by energy type.
*
* @param energyType - The Pokemon type to filter by
* @returns Array of matching Pokemon card definitions
*/
export function getDemoPokemonByType(
energyType: 'lightning' | 'fire' | 'water' | 'grass' | 'psychic'
): CardDefinition[] {
return Object.values(DEMO_CARD_DEFINITIONS).filter(
(card) => card.card_type === 'pokemon' && card.pokemon_type === energyType
)
}
// Pre-filtered card lists for easy access
export const LIGHTNING_POKEMON = getDemoPokemonByType('lightning')
export const FIRE_POKEMON = getDemoPokemonByType('fire')
export const TRAINER_CARDS = getDemoCardsByType('trainer')
export const ENERGY_CARDS = getDemoCardsByType('energy')

View File

@ -133,43 +133,42 @@ function loadSingleAsset(scene: Phaser.Scene, asset: AssetDefinition): void {
// ============================================================================= // =============================================================================
/** /**
* Card image URL pattern. * Build the URL for a card image from its image_path.
* Format: /game/cards/{setId}/{cardNumber}.png
*/
const CARD_IMAGE_PATTERN = 'cards/{setId}/{cardNumber}.png'
/**
* Build the URL for a card image.
* *
* @param setId - The card set identifier (e.g., 'base', 'jungle') * Card definitions include an `image_path` field like "a1/094-pikachu.webp"
* @param cardNumber - The card number within the set * or "basic/lightning.webp". This function prepends the base cards path.
*
* @param imagePath - The image path from card definition
* @returns Full URL path to the card image * @returns Full URL path to the card image
*/ */
export function getCardImagePath(setId: string, cardNumber: string): string { export function getCardImageUrl(imagePath: string): string {
return CARD_IMAGE_PATTERN.replace('{setId}', setId).replace( return `${ASSET_BASE_URL}/cards/${imagePath}`
'{cardNumber}',
cardNumber
)
} }
/** /**
* Lazily load a card image when needed. * Legacy function for backwards compatibility.
* @deprecated Use getCardImageUrl with image_path instead
*/
export function getCardImagePath(setId: string, cardNumber: string): string {
return `cards/${setId}/${cardNumber}.webp`
}
/**
* Lazily load a card image from its image_path.
* *
* This function loads a specific card image on demand, rather than * This function loads a specific card image on demand using the
* preloading all card images upfront. If the image fails to load, * image_path from the card definition. If the image fails to load,
* it will fall back to the placeholder image. * it will fall back to the placeholder image.
* *
* @param scene - The Phaser scene to load the image into * @param scene - The Phaser scene to load the image into
* @param cardId - Unique identifier for the card (used as texture key) * @param cardId - Unique identifier for the card (used as texture key)
* @param setId - The card set identifier * @param imagePath - The image_path from card definition (e.g., "a1/094-pikachu.webp")
* @param cardNumber - The card number within the set
* @returns Promise that resolves when the image is loaded (or fallback is used) * @returns Promise that resolves when the image is loaded (or fallback is used)
*/ */
export async function loadCardImage( export async function loadCardImageFromPath(
scene: Phaser.Scene, scene: Phaser.Scene,
cardId: string, cardId: string,
setId: string, imagePath: string
cardNumber: string
): Promise<string> { ): Promise<string> {
// Check if already loaded // Check if already loaded
if (scene.textures.exists(cardId)) { if (scene.textures.exists(cardId)) {
@ -177,8 +176,7 @@ export async function loadCardImage(
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const imagePath = getCardImagePath(setId, cardNumber) const fullUrl = getCardImageUrl(imagePath)
const fullUrl = `${ASSET_BASE_URL}/${imagePath}`
// Set up success handler // Set up success handler
scene.load.once(`filecomplete-image-${cardId}`, () => { scene.load.once(`filecomplete-image-${cardId}`, () => {
@ -199,6 +197,27 @@ export async function loadCardImage(
}) })
} }
/**
* Lazily load a card image when needed.
*
* @deprecated Use loadCardImageFromPath with image_path instead
*
* @param scene - The Phaser scene to load the image into
* @param cardId - Unique identifier for the card (used as texture key)
* @param setId - The card set identifier
* @param cardNumber - The card number within the set
* @returns Promise that resolves when the image is loaded (or fallback is used)
*/
export async function loadCardImage(
scene: Phaser.Scene,
cardId: string,
setId: string,
cardNumber: string
): Promise<string> {
const imagePath = `${setId}/${cardNumber}.webp`
return loadCardImageFromPath(scene, cardId, imagePath)
}
/** /**
* Load multiple card images in parallel. * Load multiple card images in parallel.
* *

View File

@ -86,13 +86,13 @@ export const ASSET_MANIFEST: AssetManifest = {
{ {
key: 'card_back', key: 'card_back',
type: 'image', type: 'image',
path: 'cards/card_back.png', path: 'cards/card_back.webp',
required: true, required: true,
}, },
{ {
key: 'placeholder_card', key: 'placeholder_card',
type: 'image', type: 'image',
path: 'cards/placeholder_card.png', path: 'cards/card_back.webp', // Use card back as placeholder for now
required: true, required: true,
}, },
{ {

View File

@ -31,8 +31,8 @@ import { CARD_SIZES } from '@/types/phaser'
import { gameBridge } from '../bridge' import { gameBridge } from '../bridge'
import { DamageCounter } from './DamageCounter' import { DamageCounter } from './DamageCounter'
import { import {
loadCardImageFromPath,
loadCardImage, loadCardImage,
getCardTextureKey,
createPlaceholderTexture, createPlaceholderTexture,
} from '../assets/loader' } from '../assets/loader'
import { PLACEHOLDER_KEYS } from '../assets/manifest' import { PLACEHOLDER_KEYS } from '../assets/manifest'
@ -434,6 +434,9 @@ export class Card extends Phaser.GameObjects.Container {
/** /**
* Load and display the card face image. * Load and display the card face image.
*
* Uses image_path from card definition if available (preferred),
* otherwise falls back to constructing path from set_id and set_number.
*/ */
private async loadCardFace(): Promise<void> { private async loadCardFace(): Promise<void> {
if (!this.cardDefinition || this.isLoading) return if (!this.cardDefinition || this.isLoading) return
@ -450,16 +453,26 @@ export class Card extends Phaser.GameObjects.Container {
this.showLoadingIndicator() this.showLoadingIndicator()
try { try {
// Load the card image let loadedKey: string
const setId = this.cardDefinition.set_id || 'base'
const cardNumber = this.cardDefinition.set_number?.toString() || '001'
const loadedKey = await loadCardImage( // Prefer image_path from card definition (new format)
this.scene, if (this.cardDefinition.image_path) {
textureKey, loadedKey = await loadCardImageFromPath(
setId, this.scene,
cardNumber textureKey,
) this.cardDefinition.image_path
)
} else {
// Fallback to legacy set_id/set_number construction
const setId = this.cardDefinition.set_id || 'base'
const cardNumber = this.cardDefinition.set_number?.toString() || '001'
loadedKey = await loadCardImage(
this.scene,
textureKey,
setId,
cardNumber
)
}
// Display the loaded image // Display the loaded image
this.displayCardSprite(loadedKey) this.displayCardSprite(loadedKey)

View File

@ -0,0 +1,461 @@
<script setup lang="ts">
/**
* F3 Demo Page - Testing Phaser integration with real card data.
*
* This page provides a full game board demo with real card data to test:
* - Board zone layout and positioning
* - Card rendering with actual images
* - Card interactions (hover, click)
* - State sync between Vue and Phaser
* - Responsive scaling
* - Damage counters
*
* Uses Lightning starter deck (player) vs Fire starter deck (opponent).
*/
import { ref, onMounted, watch, computed } from 'vue'
import PhaserGame from '@/components/game/PhaserGame.vue'
import { useGameBridge } from '@/composables/useGameBridge'
import { createMockGameState, MOCK_CARD_DEFINITIONS } from '@/utils/mockGameState'
import type { VisibleGameState, TurnPhase } from '@/types/game'
import type Phaser from 'phaser'
// Phaser game refs
const phaserGameRef = ref<InstanceType<typeof PhaserGame> | null>(null)
const phaserGame = ref<Phaser.Game | null>(null)
const isReady = ref(false)
const initError = ref<string | null>(null)
// Game bridge for Vue-Phaser communication
const { emit: emitToBridge, on: onBridgeEvent } = useGameBridge()
// Mock game state
const gameState = ref<VisibleGameState | null>(null)
// Demo controls
const currentPhase = ref<TurnPhase>('main')
const isMyTurn = ref(true)
const showDebugPanel = ref(true)
const selectedCard = ref<{ instanceId: string; zone: string; name: string } | null>(null)
// Turn phases for cycling
const phases: TurnPhase[] = ['setup', 'draw', 'main', 'attack', 'end']
// Stats computed from state
const stats = computed(() => {
if (!gameState.value) return null
const myPlayer = Object.values(gameState.value.players).find(p => p.is_current_player)
const oppPlayer = Object.values(gameState.value.players).find(p => !p.is_current_player)
return {
myHand: myPlayer?.hand.count ?? 0,
myBench: myPlayer?.bench.cards.length ?? 0,
myDeck: myPlayer?.deck_count ?? 0,
myPrizes: myPlayer?.prizes_count ?? 0,
myActive: myPlayer?.active.cards[0]?.definition_id ?? 'None',
myActiveDamage: myPlayer?.active.cards[0]?.damage ?? 0,
oppHand: oppPlayer?.hand.count ?? 0,
oppBench: oppPlayer?.bench.cards.length ?? 0,
oppDeck: oppPlayer?.deck_count ?? 0,
oppPrizes: oppPlayer?.prizes_count ?? 0,
oppActive: oppPlayer?.active.cards[0]?.definition_id ?? 'None',
oppActiveDamage: oppPlayer?.active.cards[0]?.damage ?? 0,
}
})
/**
* Initialize mock game state.
*/
function initializeState(): void {
gameState.value = createMockGameState({
phase: currentPhase.value,
isMyTurn: isMyTurn.value,
})
}
/**
* Handle Phaser game ready event (from PhaserGame component).
* Note: This fires when Phaser core is ready, but scenes may not be created yet.
*/
function handlePhaserReady(game: Phaser.Game): void {
phaserGame.value = game
initError.value = null
console.log('[DemoPage] Phaser game instance ready')
}
/**
* Handle MatchScene ready event (from gameBridge).
* This fires after the MatchScene has subscribed to events.
*/
function handleSceneReady(): void {
isReady.value = true
console.log('[DemoPage] MatchScene ready, sending initial state')
// Send initial state to Phaser
if (gameState.value) {
emitToBridge('state:updated', gameState.value)
}
}
/**
* Handle Phaser game error.
*/
function handlePhaserError(error: Error): void {
console.error('[DemoPage] Phaser error:', error)
initError.value = error.message
}
/**
* Handle card click from Phaser.
*/
function handleCardClicked(data: {
instanceId: string
definitionId: string
zone: string
playerId: string
}): void {
console.log('[DemoPage] Card clicked:', data)
// Get card definition for display
const def = MOCK_CARD_DEFINITIONS[data.definitionId]
selectedCard.value = {
instanceId: data.instanceId,
zone: data.zone,
name: def?.name ?? data.definitionId,
}
}
/**
* Cycle to next turn phase.
*/
function nextPhase(): void {
const currentIndex = phases.indexOf(currentPhase.value)
const nextIndex = (currentIndex + 1) % phases.length
currentPhase.value = phases[nextIndex]
updateState()
}
/**
* Toggle whose turn it is.
*/
function toggleTurn(): void {
isMyTurn.value = !isMyTurn.value
updateState()
}
/**
* Add damage to my active Pokemon.
*/
function addDamageToMine(): void {
if (!gameState.value) return
const myPlayer = Object.values(gameState.value.players).find(p => p.is_current_player)
if (!myPlayer || !myPlayer.active.cards[0]) return
myPlayer.active.cards[0].damage += 10
emitToBridge('state:updated', gameState.value)
}
/**
* Add damage to opponent's active Pokemon.
*/
function addDamageToOpp(): void {
if (!gameState.value) return
const oppPlayer = Object.values(gameState.value.players).find(p => !p.is_current_player)
if (!oppPlayer || !oppPlayer.active.cards[0]) return
oppPlayer.active.cards[0].damage += 10
emitToBridge('state:updated', gameState.value)
}
/**
* Reset to initial state.
*/
function resetState(): void {
currentPhase.value = 'main'
isMyTurn.value = true
selectedCard.value = null
initializeState()
if (gameState.value) {
emitToBridge('state:updated', gameState.value)
}
}
/**
* Update state and sync to Phaser.
*/
function updateState(): void {
if (!gameState.value) return
gameState.value.phase = currentPhase.value
gameState.value.is_my_turn = isMyTurn.value
gameState.value.current_player_id = isMyTurn.value
? gameState.value.viewer_id
: Object.keys(gameState.value.players).find(id => id !== gameState.value!.viewer_id) ?? ''
emitToBridge('state:updated', gameState.value)
}
// Watch for state changes
watch(
gameState,
(newState) => {
if (newState && isReady.value) {
emitToBridge('state:updated', newState)
}
},
{ deep: true }
)
// Lifecycle
onMounted(() => {
initializeState()
// Listen for MatchScene ready event (fires after scene subscribes to events)
onBridgeEvent('ready', handleSceneReady)
// Listen for card clicks from Phaser
onBridgeEvent('card:clicked', handleCardClicked)
})
</script>
<template>
<div
class="fixed inset-0 bg-gray-900 overflow-hidden flex flex-col"
data-testid="demo-page"
>
<!-- Demo Header -->
<header
class="bg-gray-800 border-b border-gray-700 px-4 py-2 flex items-center justify-between z-10 flex-shrink-0"
>
<div class="flex items-center gap-4">
<h1 class="text-lg font-bold text-white">
F3 Phaser Demo
</h1>
<span
class="px-2 py-1 text-xs rounded"
:class="isReady ? 'bg-green-600 text-white' : 'bg-yellow-600 text-white'"
>
{{ isReady ? 'Ready' : 'Loading...' }}
</span>
<span
v-if="initError"
class="px-2 py-1 text-xs rounded bg-red-600 text-white"
>
Error: {{ initError }}
</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="px-3 py-1 text-sm bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors"
@click="showDebugPanel = !showDebugPanel"
>
{{ showDebugPanel ? 'Hide' : 'Show' }} Debug
</button>
<a
href="/"
class="px-3 py-1 text-sm bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors"
>
Exit Demo
</a>
</div>
</header>
<!-- Main Content -->
<div class="flex-1 flex overflow-hidden min-h-0">
<!-- Phaser Game Canvas -->
<div class="flex-1 relative min-w-0">
<PhaserGame
ref="phaserGameRef"
class="w-full h-full"
@ready="handlePhaserReady"
@error="handlePhaserError"
/>
</div>
<!-- Debug Panel -->
<aside
v-if="showDebugPanel"
class="w-72 bg-gray-800 border-l border-gray-700 p-4 overflow-y-auto flex-shrink-0"
>
<!-- Controls -->
<section class="mb-6">
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
Controls
</h2>
<div class="space-y-2">
<button
type="button"
class="w-full px-3 py-2 text-sm bg-blue-600 hover:bg-blue-500 text-white rounded transition-colors"
@click="nextPhase"
>
Next Phase ({{ currentPhase }})
</button>
<button
type="button"
class="w-full px-3 py-2 text-sm bg-purple-600 hover:bg-purple-500 text-white rounded transition-colors"
@click="toggleTurn"
>
Toggle Turn ({{ isMyTurn ? 'My Turn' : 'Opp Turn' }})
</button>
<button
type="button"
class="w-full px-3 py-2 text-sm bg-yellow-600 hover:bg-yellow-500 text-white rounded transition-colors"
@click="addDamageToMine"
>
+10 Damage (My Active)
</button>
<button
type="button"
class="w-full px-3 py-2 text-sm bg-red-600 hover:bg-red-500 text-white rounded transition-colors"
@click="addDamageToOpp"
>
+10 Damage (Opp Active)
</button>
<button
type="button"
class="w-full px-3 py-2 text-sm bg-gray-600 hover:bg-gray-500 text-white rounded transition-colors"
@click="resetState"
>
Reset State
</button>
</div>
</section>
<!-- Game State -->
<section class="mb-6">
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
Game State
</h2>
<div class="text-sm text-gray-300 space-y-1">
<div class="flex justify-between">
<span>Phase:</span>
<span class="text-white font-medium">{{ currentPhase }}</span>
</div>
<div class="flex justify-between">
<span>Turn:</span>
<span class="text-white font-medium">{{ isMyTurn ? 'Mine' : 'Opponent' }}</span>
</div>
<div class="flex justify-between">
<span>Turn #:</span>
<span class="text-white font-medium">{{ gameState?.turn_number ?? 0 }}</span>
</div>
</div>
</section>
<!-- Zone Counts -->
<section
v-if="stats"
class="mb-6"
>
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
Zone Counts
</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<h3 class="text-green-400 font-medium mb-1">
My Zones
</h3>
<div class="text-gray-300 space-y-1">
<div class="flex justify-between">
<span>Active:</span>
<span class="text-xs truncate max-w-[80px]">{{ stats.myActive.split('-').pop() }}</span>
</div>
<div class="flex justify-between">
<span>Damage:</span>
<span :class="stats.myActiveDamage > 0 ? 'text-red-400' : ''">{{ stats.myActiveDamage }}</span>
</div>
<div class="flex justify-between">
<span>Hand:</span>
<span>{{ stats.myHand }}</span>
</div>
<div class="flex justify-between">
<span>Bench:</span>
<span>{{ stats.myBench }}</span>
</div>
<div class="flex justify-between">
<span>Deck:</span>
<span>{{ stats.myDeck }}</span>
</div>
<div class="flex justify-between">
<span>Prizes:</span>
<span>{{ stats.myPrizes }}</span>
</div>
</div>
</div>
<div>
<h3 class="text-red-400 font-medium mb-1">
Opp Zones
</h3>
<div class="text-gray-300 space-y-1">
<div class="flex justify-between">
<span>Active:</span>
<span class="text-xs truncate max-w-[80px]">{{ stats.oppActive.split('-').pop() }}</span>
</div>
<div class="flex justify-between">
<span>Damage:</span>
<span :class="stats.oppActiveDamage > 0 ? 'text-red-400' : ''">{{ stats.oppActiveDamage }}</span>
</div>
<div class="flex justify-between">
<span>Hand:</span>
<span>{{ stats.oppHand }}</span>
</div>
<div class="flex justify-between">
<span>Bench:</span>
<span>{{ stats.oppBench }}</span>
</div>
<div class="flex justify-between">
<span>Deck:</span>
<span>{{ stats.oppDeck }}</span>
</div>
<div class="flex justify-between">
<span>Prizes:</span>
<span>{{ stats.oppPrizes }}</span>
</div>
</div>
</div>
</div>
</section>
<!-- Selected Card -->
<section
v-if="selectedCard"
class="mb-6"
>
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
Selected Card
</h2>
<div class="text-sm text-gray-300 space-y-1">
<div class="flex justify-between">
<span>Name:</span>
<span class="text-white font-medium">{{ selectedCard.name }}</span>
</div>
<div class="flex justify-between">
<span>Zone:</span>
<span class="text-white font-medium">{{ selectedCard.zone }}</span>
</div>
<div class="text-xs text-gray-500 truncate">
{{ selectedCard.instanceId }}
</div>
</div>
</section>
<!-- Instructions -->
<section>
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
Instructions
</h2>
<ul class="text-xs text-gray-400 space-y-1 list-disc list-inside">
<li>Click cards to select them</li>
<li>Hover over cards for scale effect</li>
<li>Use controls to modify game state</li>
<li>Resize window to test scaling</li>
<li>Opponent hand shows card backs</li>
</ul>
</section>
</aside>
</div>
</div>
</template>

View File

@ -250,8 +250,18 @@ onUnmounted(() => {
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
> >
<line x1="18" y1="6" x2="6" y2="18" /> <line
<line x1="6" y1="6" x2="18" y2="18" /> x1="18"
y1="6"
x2="6"
y2="18"
/>
<line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg> </svg>
</button> </button>
@ -268,7 +278,9 @@ onUnmounted(() => {
data-testid="loading-overlay" data-testid="loading-overlay"
> >
<div class="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mb-4" /> <div class="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mb-4" />
<p class="text-lg">Connecting to game...</p> <p class="text-lg">
Connecting to game...
</p>
</div> </div>
</Transition> </Transition>
@ -286,19 +298,48 @@ onUnmounted(() => {
> >
<template v-if="isReconnecting"> <template v-if="isReconnecting">
<div class="w-8 h-8 border-2 border-warning border-t-transparent rounded-full animate-spin mb-4" /> <div class="w-8 h-8 border-2 border-warning border-t-transparent rounded-full animate-spin mb-4" />
<p class="text-lg text-warning">Reconnecting...</p> <p class="text-lg text-warning">
<p class="text-sm text-muted mt-2">Please wait while we restore your connection</p> Reconnecting...
</p>
<p class="text-sm text-muted mt-2">
Please wait while we restore your connection
</p>
</template> </template>
<template v-else> <template v-else>
<div class="text-error mb-4"> <div class="text-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
<circle cx="12" cy="12" r="10" /> xmlns="http://www.w3.org/2000/svg"
<line x1="12" y1="8" x2="12" y2="12" /> class="w-12 h-12"
<line x1="12" y1="16" x2="12.01" y2="16" /> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
y1="8"
x2="12"
y2="12"
/>
<line
x1="12"
y1="16"
x2="12.01"
y2="16"
/>
</svg> </svg>
</div> </div>
<p class="text-lg text-error">Connection Lost</p> <p class="text-lg text-error">
<p class="text-sm text-muted mt-2">{{ errorMessage || 'Unable to connect to game server' }}</p> Connection Lost
</p>
<p class="text-sm text-muted mt-2">
{{ errorMessage || 'Unable to connect to game server' }}
</p>
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button <button
type="button" type="button"

View File

@ -95,6 +95,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/GamePage.vue'), component: () => import('@/pages/GamePage.vue'),
meta: { requiresAuth: true, requiresStarter: true, layout: 'game' }, meta: { requiresAuth: true, requiresStarter: true, layout: 'game' },
}, },
{
path: '/demo',
name: 'Demo',
component: () => import('@/pages/DemoPage.vue'),
meta: { requiresAuth: true, requiresStarter: false, layout: 'game' },
},
// ============================================ // ============================================
// Future Routes (Campaign - Phase 5+) // Future Routes (Campaign - Phase 5+)

View File

@ -217,18 +217,22 @@ export interface WeaknessResistance {
* Additional fields can be added as needed for new features. * Additional fields can be added as needed for new features.
*/ */
export interface CardDefinition { export interface CardDefinition {
/** Unique card identifier (e.g., "pikachu_base_001") */ /** Unique card identifier (e.g., "a1-094-pikachu") */
id: string id: string
/** Display name of the card */ /** Display name of the card */
name: string name: string
/** Primary card type */ /** Primary card type */
card_type: CardType card_type: CardType
/** URL to card image */ /** Local image path relative to cards directory (e.g., "a1/094-pikachu.webp") */
image_path?: string
/** Full URL to card image (e.g., S3 URL) */
image_url?: string image_url?: string
/** Set identifier */ /** Set identifier */
set_id?: string set_id?: string
/** Card number within the set */ /** Card number within the set */
set_number?: number set_number?: number
/** Card rarity */
rarity?: string
// Pokemon-specific fields // Pokemon-specific fields
/** Evolution stage */ /** Evolution stage */

View File

@ -0,0 +1,279 @@
/**
* Mock game state generator for F3 demo and testing.
*
* Uses REAL card data from the backend (bundled in demoCards.json).
* Creates a realistic Lightning vs Fire matchup for testing:
* - My side: Lightning starter deck (Pikachu, Magnemite, Voltorb lines)
* - Opponent: Fire starter deck (Charmander, Growlithe, Ponyta lines)
*
* Card images are served locally from /public/game/cards/.
*/
import type {
VisibleGameState,
VisiblePlayerState,
CardDefinition,
CardInstance,
TurnPhase,
} from '@/types/game'
import { DEMO_CARD_DEFINITIONS } from '@/data/demoCards'
// =============================================================================
// Card Registry
// =============================================================================
/**
* Card definitions for the demo.
* Uses real card data from the backend.
*/
export const MOCK_CARD_DEFINITIONS: Record<string, CardDefinition> = DEMO_CARD_DEFINITIONS
// =============================================================================
// Card Instance Factory
// =============================================================================
let instanceCounter = 0
/**
* Reset the instance counter.
* Useful for creating deterministic states in tests.
*/
export function resetInstanceCounter(): void {
instanceCounter = 0
}
/**
* Create a card instance from a definition ID.
*
* @param definitionId - The card definition ID (e.g., "a1-094-pikachu")
* @param options - Optional overrides for instance properties
* @returns A CardInstance
*/
export function createMockCardInstance(
definitionId: string,
options: Partial<CardInstance> = {}
): CardInstance {
instanceCounter++
return {
instance_id: options.instance_id ?? `inst_${instanceCounter}`,
definition_id: definitionId,
damage: options.damage ?? 0,
attached_energy: options.attached_energy ?? [],
attached_tools: options.attached_tools ?? [],
status_conditions: options.status_conditions ?? [],
ability_uses_this_turn: options.ability_uses_this_turn ?? {},
evolution_turn: options.evolution_turn ?? null,
}
}
// =============================================================================
// Player State Factory
// =============================================================================
/**
* Default card configurations for each player.
*/
const LIGHTNING_PLAYER_CONFIG = {
activeCard: 'a1-094-pikachu',
benchCards: ['a1-097-magnemite', 'a1-099-voltorb', 'a1-101-electabuzz'],
handCards: [
'a1-095-raichu',
'a1-226-lt-surge',
'energy-basic-lightning',
'energy-basic-lightning',
'a1-100-electrode',
],
attachedEnergy: ['energy-basic-lightning'],
energyZone: ['energy-basic-lightning', 'energy-basic-lightning', 'energy-basic-lightning'],
discardPile: ['a1-224-brock'],
}
const FIRE_PLAYER_CONFIG = {
activeCard: 'a1-035-charizard',
benchCards: ['a1-039-growlithe', 'a1-037-vulpix'],
handCards: [
'a1-040-arcanine',
'a1-221-blaine',
'energy-basic-fire',
'energy-basic-fire',
],
attachedEnergy: ['energy-basic-fire', 'energy-basic-fire'],
energyZone: ['energy-basic-fire', 'energy-basic-fire', 'energy-basic-fire'],
discardPile: ['a1-042-ponyta'],
}
/**
* Create a mock player state with realistic card data.
*
* @param playerId - The player's unique ID
* @param isViewer - Whether this is the viewing player (determines visibility)
* @param config - Optional card configuration overrides
* @returns A VisiblePlayerState
*/
export function createMockPlayerState(
playerId: string,
isViewer: boolean,
config?: {
activeCard?: string
benchCards?: string[]
handCards?: string[]
attachedEnergy?: string[]
energyZone?: string[]
discardPile?: string[]
activeDamage?: number
benchDamage?: number
}
): VisiblePlayerState {
// Use Lightning config for viewer, Fire for opponent
const defaultConfig = isViewer ? LIGHTNING_PLAYER_CONFIG : FIRE_PLAYER_CONFIG
const finalConfig = { ...defaultConfig, ...config }
// Create active Pokemon with attached energy
const activeInstance = createMockCardInstance(finalConfig.activeCard, {
damage: config?.activeDamage ?? (isViewer ? 0 : 30),
attached_energy: finalConfig.attachedEnergy.map((defId) =>
createMockCardInstance(defId)
),
})
// Create bench Pokemon
const benchInstances = finalConfig.benchCards.map((defId) =>
createMockCardInstance(defId, {
damage: config?.benchDamage ?? (isViewer ? 0 : 10),
})
)
// Create hand cards
const handInstances = finalConfig.handCards.map((defId) =>
createMockCardInstance(defId)
)
// Create energy zone cards
const energyZoneInstances = finalConfig.energyZone.map((defId) =>
createMockCardInstance(defId)
)
// Create discard pile
const discardInstances = finalConfig.discardPile.map((defId) =>
createMockCardInstance(defId)
)
return {
player_id: playerId,
is_current_player: isViewer,
deck_count: isViewer ? 32 : 35,
hand: {
count: handInstances.length,
cards: handInstances,
zone_type: 'hand',
},
prizes_count: isViewer ? 6 : 5,
energy_deck_count: isViewer ? 17 : 17,
active: {
count: 1,
cards: [activeInstance],
zone_type: 'active',
},
bench: {
count: benchInstances.length,
cards: benchInstances,
zone_type: 'bench',
},
discard: {
count: discardInstances.length,
cards: discardInstances,
zone_type: 'discard',
},
energy_zone: {
count: energyZoneInstances.length,
cards: energyZoneInstances,
zone_type: 'energy_zone',
},
score: 0,
gx_attack_used: false,
vstar_power_used: false,
}
}
// =============================================================================
// Game State Factory
// =============================================================================
export interface MockGameStateOptions {
/** Current turn phase */
phase?: TurnPhase
/** Whether it's the viewer's turn */
isMyTurn?: boolean
/** Current turn number */
turnNumber?: number
/** Viewer's player ID */
myPlayerId?: string
/** Opponent's player ID */
oppPlayerId?: string
/** Custom player config overrides */
myConfig?: Parameters<typeof createMockPlayerState>[2]
oppConfig?: Parameters<typeof createMockPlayerState>[2]
}
/**
* Create a complete mock game state.
*
* Creates a realistic Lightning vs Fire matchup with:
* - My side (Lightning): Pikachu active, bench with Magnemite/Voltorb/Electabuzz
* - Opponent (Fire): Charizard active (with 30 damage), bench with Growlithe/Vulpix
*
* @param options - Configuration options
* @returns A VisibleGameState ready for Phaser rendering
*/
export function createMockGameState(
options: MockGameStateOptions = {}
): VisibleGameState {
const {
phase = 'main',
isMyTurn = true,
turnNumber = 3,
myPlayerId = 'player_me',
oppPlayerId = 'player_opp',
myConfig,
oppConfig,
} = options
// Reset counter for consistent instance IDs
resetInstanceCounter()
const myPlayer = createMockPlayerState(myPlayerId, true, myConfig)
const oppPlayer = createMockPlayerState(oppPlayerId, false, oppConfig)
return {
game_id: 'demo_game_001',
viewer_id: myPlayerId,
players: {
[myPlayerId]: myPlayer,
[oppPlayerId]: oppPlayer,
},
current_player_id: isMyTurn ? myPlayerId : oppPlayerId,
turn_number: turnNumber,
phase,
is_my_turn: isMyTurn,
winner_id: null,
end_reason: null,
stadium_in_play: null,
stadium_owner_id: null,
forced_action_player: null,
forced_action_type: null,
forced_action_reason: null,
card_registry: MOCK_CARD_DEFINITIONS,
}
}
// =============================================================================
// Exports
// =============================================================================
export default {
MOCK_CARD_DEFINITIONS,
resetInstanceCounter,
createMockCardInstance,
createMockPlayerState,
createMockGameState,
}