feat: add skirmish deck selection and drafting system

- Added `skirmish_decks.json` with 8 premade decks across difficulty levels (Easy to Legendary)
- Implemented `SkirmishDeckSelectScene` for choosing player/opponent decks (draft or premade)
- Implemented `SkirmishDraftScene` with interactive card drafting, budget system, and AI opponent deck generation
- Updated `BattleScene` to support skirmish mode with custom decks and gold rewards (200/400)
- Updated `BattleResultScene` to return players to SkirmishSetup for quick replay
- Integrated new scenes into game flow via config and BootScene data loading
This commit is contained in:
Brian Fertig 2026-04-01 20:04:06 -06:00
parent 7513cb2bf5
commit a5598bfcd0
8 changed files with 981 additions and 17 deletions

124
data/skirmish_decks.json Normal file
View File

@ -0,0 +1,124 @@
[
{
"id": "sk_easy_imp_01",
"title": "Imperial Garrison",
"summary": "A straightforward Imperial force. Troopers strike from range while Guardians hold the line with counter attacks.",
"difficulty": "Easy",
"commander": "imp_cmd_1",
"cards": [
"imp_trooper_1", "imp_trooper_1", "imp_trooper_1",
"imp_guardian_1", "imp_guardian_1",
"imp_gunner_1", "imp_gunner_1"
]
},
{
"id": "sk_easy_raider_01",
"title": "Raider Warband",
"summary": "An aggressive raider force. Grunts grow stronger with berserk while Scouts jam the enemy's defenses.",
"difficulty": "Easy",
"commander": "raider_cmd_1",
"cards": [
"raider_grunt_1", "raider_grunt_1", "raider_grunt_1",
"raider_brute_1", "raider_brute_1",
"raider_scout_1", "raider_scout_1"
]
},
{
"id": "sk_medium_bt_01",
"title": "Blood Covenant",
"summary": "A vampiric force that drains enemy strength. Blood magic keeps your units alive while weakening foes.",
"difficulty": "Medium",
"commander": "bt_cmd_1",
"cards": [
"bt_thrall_1", "bt_thrall_1",
"bt_leech_1", "bt_leech_1",
"bt_initiate_1", "bt_initiate_1",
"bt_revenant_1",
"bt_bloodmage_1"
]
},
{
"id": "sk_medium_xeno_01",
"title": "Xeno Hive",
"summary": "An alien swarm that poisons and overwhelms. Carapace provides natural armor while venom eats through foes.",
"difficulty": "Medium",
"commander": "xeno_cmd_1",
"cards": [
"xeno_drone_1", "xeno_drone_1",
"xeno_spitter_1", "xeno_spitter_1",
"xeno_beetle_1", "xeno_beetle_1",
"xeno_mantis_1",
"xeno_guard_1"
]
},
{
"id": "sk_hard_right_01",
"title": "Righteous Crusade",
"summary": "A holy force of smite and fortification. Templars hack enemy defenses while Crusaders deliver divine punishment.",
"difficulty": "Hard",
"commander": "right_cmd_1",
"cards": [
"right_zealot_1", "right_zealot_1",
"right_crusader_1", "right_crusader_1",
"right_priest_1",
"right_warden_1",
"right_templar_1",
"right_purifier_1",
"right_inquisitor_1"
]
},
{
"id": "sk_hard_raider_01",
"title": "Raider Devastation",
"summary": "A brutal force of berserkers and reavers. Relentless aggression with pierce and berserk tears through defenses.",
"difficulty": "Hard",
"commander": "raider_cmd_2",
"cards": [
"raider_berserker_1", "raider_berserker_1",
"raider_scavenger_1", "raider_scavenger_1",
"raider_marauder_1",
"raider_defiler_1",
"raider_warlord_1",
"raider_reaver_1",
"raider_warbandbreaker_1"
]
},
{
"id": "sk_legendary_imp_01",
"title": "Imperial War Machine",
"summary": "The Empire's finest. A devastating combination of legendary war machines backed by elite support units.",
"difficulty": "Legendary",
"commander": "imp_cmd_3",
"cards": [
"imp_mech_1",
"imp_juggernaut_1",
"imp_praetorian_1",
"imp_warbanner_1",
"imp_sentinel_1",
"imp_dreadnought_1",
"imp_centurion_1",
"imp_orbital_1",
"imp_sniper_1",
"imp_shieldwall_1"
]
},
{
"id": "sk_legendary_bt_01",
"title": "Bloodthirsty Dominion",
"summary": "An ancient bloodthirsty horde. Blood pacts fuel devastating power while drain and siphon sustain the onslaught.",
"difficulty": "Legendary",
"commander": "bt_cmd_2",
"cards": [
"bt_bloodlord_1",
"bt_hemomancer_1",
"bt_crimsonguard_1",
"bt_hemorrhage_1",
"bt_archon_1",
"bt_crimson_fury_1",
"bt_blood_martyr_1",
"bt_sanguine_bastion_1",
"bt_harbinger_1",
"bt_revenant_1"
]
}
]

View File

@ -4,6 +4,8 @@ import { MainMenuScene } from './scenes/MainMenuScene.js';
import { CampaignSelectScene } from './scenes/CampaignSelectScene.js';
import { CampaignScene } from './scenes/CampaignScene.js';
import { SkirmishSetupScene } from './scenes/SkirmishSetupScene.js';
import { SkirmishDeckSelectScene } from './scenes/SkirmishDeckSelectScene.js';
import { SkirmishDraftScene } from './scenes/SkirmishDraftScene.js';
import { BattleScene } from './scenes/BattleScene.js';
import { BattleResultScene } from './scenes/BattleResultScene.js';
import { DeckBuilderScene } from './scenes/DeckBuilderScene.js';
@ -28,6 +30,8 @@ const config = {
CampaignSelectScene,
CampaignScene,
SkirmishSetupScene,
SkirmishDeckSelectScene,
SkirmishDraftScene,
BattleScene,
BattleResultScene,
DeckBuilderScene,

View File

@ -985,8 +985,8 @@ export class BattleResultScene extends Phaser.Scene {
}
if (!this.missionData) {
// Skirmish → Main Menu
this.scene.start('MainMenuScene');
// Skirmish → Skirmish Setup for quick replay
this.scene.start('SkirmishSetupScene');
} else if (this.isFinalMission) {
// Final campaign mission → Campaign Select
this.scene.start('CampaignSelectScene');

View File

@ -38,6 +38,7 @@ export class BattleScene extends Phaser.Scene {
// Load a random battle background for the opponent's faction (if available)
const opponentDeck = this.missionData?.opponent
|| this.opponentDeckData
|| { commander: 'raider_cmd_1' }; // skirmish fallback
const cardManager = this.registry.get('cardManager');
const cmdCard = cardManager?.getCard(opponentDeck.commander);
@ -59,6 +60,7 @@ export class BattleScene extends Phaser.Scene {
this.playerLevel = data.playerLevel || 1;
this.enemyLevel = data.enemyLevel || 1;
this.campaignId = data.campaignId || null;
this.opponentDeckData = data.opponentDeck || null;
this._resultShown = false; // Set this to null when a mission begins.
}
@ -153,8 +155,11 @@ export class BattleScene extends Phaser.Scene {
let opponentDeck;
if (this.missionData) {
opponentDeck = this.missionData.opponent;
} else if (this.opponentDeckData) {
// Skirmish: use the deck passed from deck select / draft
opponentDeck = this.opponentDeckData;
} else {
// Skirmish: use a raider deck
// Fallback raider deck
opponentDeck = {
commander: 'raider_cmd_1',
cards: [
@ -4723,8 +4728,16 @@ export class BattleScene extends Phaser.Scene {
this.registry.set('save', save);
}
// Skirmish gold rewards
if (this.isSkirmish) {
rewardGold = won ? 400 : 200;
SaveManager.addGold(save, rewardGold);
this.registry.set('save', save);
}
// ── Determine opponent commander ID ──────────────────────────────────────
const opponentCommanderId = this.missionData?.opponent?.commander
|| this.opponentDeckData?.commander
|| this.engine.opponentCommander?.id
|| 'raider_cmd_1';

View File

@ -25,6 +25,7 @@ export class BootScene extends Phaser.Scene {
this.load.json('missions', 'data/missions.json');
this.load.json('campaigns', 'data/campaigns.json');
this.load.json('skills', 'data/skills.json');
this.load.json('skirmish_decks', 'data/skirmish_decks.json');
this.load.audio('sfx_attack', 'assets/audio/fx/attack.mp3');
this.load.audio('sfx_damage', 'assets/audio/fx/damage.mp3');
@ -102,6 +103,7 @@ export class BootScene extends Phaser.Scene {
const packsData = this.cache.json.get('packs');
const missionsData = this.cache.json.get('missions');
const campaignsData = this.cache.json.get('campaigns');
const skirmishDecksData = this.cache.json.get('skirmish_decks');
const cardManager = new CardManager(cardsData);
const packManager = new PackManager(packsData, cardManager);
@ -127,6 +129,7 @@ export class BootScene extends Phaser.Scene {
this.registry.set('packManager', packManager);
this.registry.set('missions', missionsData);
this.registry.set('campaigns', campaignsData);
this.registry.set('skirmishDecks', skirmishDecksData);
this.registry.set('save', save);
this.scene.start('IntroScene');

View File

@ -0,0 +1,228 @@
export class SkirmishDeckSelectScene extends Phaser.Scene {
constructor() { super('SkirmishDeckSelectScene'); }
init(data) {
this.selectedLevel = data.selectedLevel || 1;
this.selectedDifficulty = data.selectedDifficulty || 'Medium';
}
create() {
const { width, height } = this.scale;
this.add.rectangle(width / 2, height / 2, width, height, 0x0d1b2a);
this.add.text(width / 2, 40, 'Select Decks', {
fontSize: '42px', color: '#d4af37', fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
this.add.text(width / 2, 90, `${this.selectedDifficulty} Difficulty • Card Level ${this.selectedLevel}`, {
fontSize: '18px', color: '#888888', fontFamily: 'Audiowide'
}).setOrigin(0.5);
const cardManager = this.registry.get('cardManager');
const allDecks = this.registry.get('skirmishDecks') || [];
this.availableDecks = allDecks.filter(d => d.difficulty === this.selectedDifficulty);
this.cardManager = cardManager;
this.playerChoice = 'draft'; // 'draft' or deck id
this.opponentChoice = 'draft';
// ── Left panel: Your Deck ──
this._buildPanel(160, 'Your Deck', 'player');
// ── Right panel: Opponent Deck ──
this._buildPanel(1040, 'Opponent Deck', 'opponent');
// ── Divider ──
this.add.rectangle(width / 2, height / 2, 3, height - 200, 0x334466, 0.5);
// ── Action button ──
this.actionBtnBg = null;
this.actionBtnTxt = null;
this._updateActionButton();
this._makeBackButton();
}
_buildPanel(startX, title, side) {
const panelW = 720;
const panelCX = startX + panelW / 2;
const { height } = this.scale;
this.add.text(panelCX, 130, title, {
fontSize: '28px', color: '#d4af37', fontFamily: 'Audiowide'
}).setOrigin(0.5);
// Draft Deck option
const draftY = 200;
const draftBg = this.add.rectangle(panelCX, draftY, panelW - 60, 65, 0x1a3a5c)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff);
const draftTxt = this.add.text(panelCX, draftY - 8, 'Draft Deck', {
fontSize: '22px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5);
const draftSub = this.add.text(panelCX, draftY + 16, 'Build a custom deck from a random card pool', {
fontSize: '13px', color: '#88aacc', fontFamily: 'Audiowide'
}).setOrigin(0.5);
draftBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
if (side === 'player') this.playerChoice = 'draft';
else this.opponentChoice = 'draft';
this._refreshPanelHighlights();
this._updateActionButton();
});
const panelKey = `${side}Panel`;
this[panelKey] = { draftBg, draftTxt, draftSub, deckEntries: [] };
// ── Premade deck list ──
const listTop = 260;
const entryH = 140;
this.availableDecks.forEach((deck, i) => {
const y = listTop + i * (entryH + 12) + entryH / 2;
if (y + entryH / 2 > height - 100) return; // safety clamp
const commander = this.cardManager.getCard(deck.commander);
const cmdName = commander ? commander.name : deck.commander;
const bg = this.add.rectangle(panelCX, y, panelW - 60, entryH, 0x112233)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x334466);
const titleTxt = this.add.text(startX + 50, y - 42, deck.title, {
fontSize: '20px', color: '#ffffff', fontFamily: 'Audiowide'
});
const cmdTxt = this.add.text(startX + 50, y - 16, `Commander: ${cmdName}`, {
fontSize: '14px', color: '#88aaff', fontFamily: 'Audiowide'
});
const summaryTxt = this.add.text(startX + 50, y + 8, deck.summary, {
fontSize: '12px', color: '#999999', fontFamily: 'Audiowide',
wordWrap: { width: panelW - 100 }
});
const countTxt = this.add.text(startX + panelW - 80, y - 42, `${deck.cards.length} cards`, {
fontSize: '13px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(1, 0);
bg.on('pointerover', () => {
const choice = side === 'player' ? this.playerChoice : this.opponentChoice;
if (choice !== deck.id) bg.setFillStyle(0x1a3344);
});
bg.on('pointerout', () => {
const choice = side === 'player' ? this.playerChoice : this.opponentChoice;
if (choice !== deck.id) bg.setFillStyle(0x112233);
});
bg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
if (side === 'player') this.playerChoice = deck.id;
else this.opponentChoice = deck.id;
this._refreshPanelHighlights();
this._updateActionButton();
});
this[panelKey].deckEntries.push({ bg, titleTxt, cmdTxt, summaryTxt, countTxt, deckId: deck.id });
});
this._refreshPanelHighlights();
}
_refreshPanelHighlights() {
for (const side of ['player', 'opponent']) {
const panel = this[`${side}Panel`];
const choice = side === 'player' ? this.playerChoice : this.opponentChoice;
// Draft button highlight
const isDraft = choice === 'draft';
panel.draftBg.setFillStyle(isDraft ? 0x2a5a8c : 0x1a2a3a);
panel.draftBg.setStrokeStyle(2, isDraft ? 0x88ccff : 0x444444);
panel.draftTxt.setColor(isDraft ? '#ffffff' : '#888888');
panel.draftSub.setColor(isDraft ? '#88aacc' : '#555555');
// Premade deck highlights
for (const entry of panel.deckEntries) {
const isSelected = choice === entry.deckId;
entry.bg.setFillStyle(isSelected ? 0x1a3a5c : 0x112233);
entry.bg.setStrokeStyle(2, isSelected ? 0x88ccff : 0x334466);
entry.titleTxt.setColor(isSelected ? '#ffffff' : '#aaaaaa');
}
}
}
_updateActionButton() {
const { width, height } = this.scale;
const btnY = height - 60;
// Destroy existing button
if (this.actionBtnBg) { this.actionBtnBg.destroy(); this.actionBtnBg = null; }
if (this.actionBtnTxt) { this.actionBtnTxt.destroy(); this.actionBtnTxt = null; }
const bothPremade = this.playerChoice !== 'draft' && this.opponentChoice !== 'draft';
if (bothPremade) {
// Start Battle
this.actionBtnBg = this.add.rectangle(width / 2, btnY, 300, 60, 0x226622)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x44aa44);
this.actionBtnTxt = this.add.text(width / 2, btnY, 'Start Battle', {
fontSize: '26px', color: '#ffffff', fontStyle: 'bold', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.actionBtnBg.on('pointerover', () => this.actionBtnBg.setFillStyle(0x338833));
this.actionBtnBg.on('pointerout', () => this.actionBtnBg.setFillStyle(0x226622));
this.actionBtnBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
const playerDeck = this._getDeckById(this.playerChoice);
const opponentDeck = this._getDeckById(this.opponentChoice);
this.scene.start('BattleScene', {
skirmish: true,
deck: { commander: playerDeck.commander, cards: [...playerDeck.cards] },
opponentDeck: { commander: opponentDeck.commander, cards: [...opponentDeck.cards] },
playerLevel: this.selectedLevel,
enemyLevel: this.selectedLevel,
difficulty: this.selectedDifficulty
});
});
} else {
// Draft Cards
this.actionBtnBg = this.add.rectangle(width / 2, btnY, 300, 60, 0x224488)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff);
this.actionBtnTxt = this.add.text(width / 2, btnY, 'Draft Cards', {
fontSize: '26px', color: '#ffffff', fontStyle: 'bold', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.actionBtnBg.on('pointerover', () => this.actionBtnBg.setFillStyle(0x3366aa));
this.actionBtnBg.on('pointerout', () => this.actionBtnBg.setFillStyle(0x224488));
this.actionBtnBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.scene.start('SkirmishDraftScene', {
selectedLevel: this.selectedLevel,
selectedDifficulty: this.selectedDifficulty,
playerChoice: this.playerChoice,
opponentChoice: this.opponentChoice,
playerPremadeDeck: this.playerChoice !== 'draft' ? this._getDeckById(this.playerChoice) : null,
opponentPremadeDeck: this.opponentChoice !== 'draft' ? this._getDeckById(this.opponentChoice) : null
});
});
}
}
_getDeckById(id) {
return this.availableDecks.find(d => d.id === id) || null;
}
_makeBackButton() {
const bg = this.add.rectangle(80, 45, 160, 50, 0x333333)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x888888);
this.add.text(80, 45, 'Back', { fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide' }).setOrigin(0.5);
bg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.scene.start('SkirmishSetupScene');
});
}
}

View File

@ -0,0 +1,600 @@
import { CardObject } from '../objects/CardObject.js';
const BUDGETS = { Easy: 12, Medium: 24, Hard: 36, Legendary: 48 };
const COSTS = { common: 1, rare: 2, epic: 4, legendary: 6 };
const MAX_COPIES = { common: 10, rare: 2, epic: 2, legendary: 1 };
export class SkirmishDraftScene extends Phaser.Scene {
constructor() { super('SkirmishDraftScene'); }
init(data) {
this.selectedLevel = data.selectedLevel || 1;
this.selectedDifficulty = data.selectedDifficulty || 'Medium';
this.playerChoice = data.playerChoice || 'draft'; // 'draft' or premade deck id
this.opponentChoice = data.opponentChoice || 'draft';
this.playerPremadeDeck = data.playerPremadeDeck || null;
this.opponentPremadeDeck = data.opponentPremadeDeck || null;
}
create() {
const { width, height } = this.scale;
this.cardManager = this.registry.get('cardManager');
this.add.rectangle(width / 2, height / 2, width, height, 0x0d1b2a);
this.add.text(width / 2, 30, 'Draft Cards', {
fontSize: '36px', color: '#d4af37', fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
this.totalBudget = BUDGETS[this.selectedDifficulty] || 24;
this.remainingBudget = this.totalBudget;
// Player's working deck
this.draftedCommander = null;
this.draftedCards = []; // array of card IDs
// Track copies in draft: { cardId: count }
this.draftCopies = {};
// Generate the shared card pool
this.pool = this._generatePool();
// Deck panel elements (right side, rebuilt on changes)
this.deckPanelGroup = [];
// Card grid
this.scrollContainer = null;
this.scrollMask = null;
this.scrollY = 0;
this.maxScrollY = 0;
// Grid layout constants (same as DeckBuilderScene)
this.CARD_W = 155;
this.CARD_H = 200;
this.BTN_H = 28;
this.GAP_X = 14;
this.GAP_Y = 14;
this.CELL_W = this.CARD_W + this.GAP_X;
this.CELL_H = this.CARD_H + this.BTN_H + this.GAP_Y + 18;
this.COLS = 7;
this.GRID_LEFT = 80;
this.SCROLL_TOP = 70;
this.SCROLL_BOTTOM = height - 60;
const playerNeedsDraft = this.playerChoice === 'draft';
if (playerNeedsDraft) {
this._renderCardGrid();
this._renderDeckPanel();
this._setupScrolling();
} else {
// Player has premade deck — show simplified view
this._renderPremadeView();
}
this._makeBackButton();
}
// ── Pool generation ──────────────────────────────────────────────────
_generatePool() {
const allCards = this.cardManager.getAllCards();
const commanders = allCards.filter(c => c.type === 'commander');
const legAssault = allCards.filter(c => c.type === 'assault' && c.rarity === 'legendary');
const epicAssault = allCards.filter(c => c.type === 'assault' && c.rarity === 'epic');
const rareAssault = allCards.filter(c => c.type === 'assault' && c.rarity === 'rare');
const commonAssault = allCards.filter(c => c.type === 'assault' && c.rarity === 'common');
const pick = (arr) => {
const shuffled = [...arr].sort(() => Math.random() - 0.5);
return shuffled.slice(0, Math.ceil(arr.length / 3));
};
return {
commanders: pick(commanders),
legendary: pick(legAssault),
epic: pick(epicAssault),
rare: pick(rareAssault),
common: pick(commonAssault)
};
}
// ── Premade view (player chose a premade deck) ───────────────────────
_renderPremadeView() {
const { width, height } = this.scale;
const cx = width / 2;
this.add.text(cx, 120, 'Your deck is set!', {
fontSize: '28px', color: '#88ccff', fontFamily: 'Audiowide'
}).setOrigin(0.5);
const deck = this.playerPremadeDeck;
const commander = this.cardManager.getCard(deck.commander);
this.add.text(cx, 180, deck.title, {
fontSize: '24px', color: '#d4af37', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.add.text(cx, 220, `Commander: ${commander ? commander.name : deck.commander}`, {
fontSize: '18px', color: '#88aaff', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.add.text(cx, 260, deck.summary, {
fontSize: '16px', color: '#999999', fontFamily: 'Audiowide',
wordWrap: { width: 700 }, align: 'center'
}).setOrigin(0.5);
this.add.text(cx, 310, `${deck.cards.length} cards`, {
fontSize: '16px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0.5);
// Card list
const counts = {};
for (const id of deck.cards) counts[id] = (counts[id] || 0) + 1;
let y = 370;
for (const [cardId, count] of Object.entries(counts)) {
const card = this.cardManager.getCard(cardId);
if (!card) continue;
this.add.text(cx, y, `${card.name} x${count}`, {
fontSize: '15px', color: '#cccccc', fontFamily: 'Audiowide'
}).setOrigin(0.5);
y += 28;
}
// Start Battle button
const btnY = height - 80;
const startBg = this.add.rectangle(cx, btnY, 300, 60, 0x226622)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x44aa44);
this.add.text(cx, btnY, 'Start Battle', {
fontSize: '26px', color: '#ffffff', fontStyle: 'bold', fontFamily: 'Audiowide'
}).setOrigin(0.5);
startBg.on('pointerover', () => startBg.setFillStyle(0x338833));
startBg.on('pointerout', () => startBg.setFillStyle(0x226622));
startBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this._startBattle();
});
}
// ── Card grid (left side) ────────────────────────────────────────────
_renderCardGrid() {
if (this.scrollContainer) { this.scrollContainer.destroy(); this.scrollContainer = null; }
if (this.scrollMask) { this.scrollMask.destroy(); this.scrollMask = null; }
this.scrollContainer = this.add.container(0, 0);
let cursorY = this.SCROLL_TOP;
const sections = [
{ label: 'Commanders', cards: this.pool.commanders, costKey: null },
{ label: 'Legendary', cards: this.pool.legendary, costKey: 'legendary' },
{ label: 'Epic', cards: this.pool.epic, costKey: 'epic' },
{ label: 'Rare', cards: this.pool.rare, costKey: 'rare' },
{ label: 'Common', cards: this.pool.common, costKey: 'common' }
];
const rarityColors = {
Commanders: '#ffd700', Legendary: '#ffaa00', Epic: '#bb66ff', Rare: '#6699ff', Common: '#999999'
};
for (const section of sections) {
if (section.cards.length === 0) continue;
const sectionLabel = this.add.text(this.GRID_LEFT, cursorY, section.label, {
fontSize: '18px', color: rarityColors[section.label] || '#d4af37', fontFamily: 'Audiowide'
});
this.scrollContainer.add(sectionLabel);
cursorY += 30;
const startY = cursorY;
section.cards.forEach((card, i) => {
const col = i % this.COLS;
const row = Math.floor(i / this.COLS);
const cx = this.GRID_LEFT + col * this.CELL_W + this.CARD_W / 2;
const cy = startY + row * this.CELL_H + this.CARD_H / 2;
const isCommander = card.type === 'commander';
const cost = isCommander ? 0 : (COSTS[card.rarity] || 1);
const maxCopies = isCommander ? 1 : (MAX_COPIES[card.rarity] || 1);
const currentCopies = this.draftCopies[card.id] || 0;
let canAdd = false;
if (isCommander) {
canAdd = !this.draftedCommander;
} else {
canAdd = cost <= this.remainingBudget &&
currentCopies < maxCopies &&
this.draftedCards.length < 10;
}
// Card visual
const inst = this.cardManager.createInstance(card.id, this.selectedLevel);
const cardObj = new CardObject(this, cx, cy, inst, { width: this.CARD_W, height: this.CARD_H });
if (!canAdd) cardObj.setAlpha(0.4);
this.scrollContainer.add(cardObj);
// Cost badge
const costStr = isCommander ? 'FREE' : `Cost: ${cost}`;
const costColor = isCommander ? '#44ff44' : (canAdd ? '#ffcc44' : '#666666');
const costBadge = this.add.text(cx, cy + this.CARD_H / 2 + 4, costStr, {
fontSize: '12px', color: costColor, fontFamily: 'Audiowide'
}).setOrigin(0.5, 0);
this.scrollContainer.add(costBadge);
// Copies indicator (for assault cards with copies already drafted)
if (!isCommander && currentCopies > 0) {
const copiesTxt = this.add.text(cx, cy + this.CARD_H / 2 + 18, `In deck: ${currentCopies}/${maxCopies}`, {
fontSize: '11px', color: '#88cc88', fontFamily: 'Audiowide'
}).setOrigin(0.5, 0);
this.scrollContainer.add(copiesTxt);
}
// Add button
const btnY = cy + this.CARD_H / 2 + (currentCopies > 0 ? 32 : 18);
const btnLabel = isCommander
? (this.draftedCommander === card.id ? 'SELECTED' : (this.draftedCommander ? 'SLOT TAKEN' : '+ SELECT'))
: (canAdd ? '+ ADD' : (currentCopies >= maxCopies ? 'MAX COPIES' : (this.draftedCards.length >= 10 ? 'DECK FULL' : 'NO BUDGET')));
const btnBgColor = canAdd ? 0x225522 : 0x222222;
const btnStroke = canAdd ? 0x44aa44 : 0x333333;
const btnTextColor = canAdd ? '#44ff44' : '#444444';
const btnBg = this.add.rectangle(cx, btnY, this.CARD_W - 10, this.BTN_H, btnBgColor)
.setStrokeStyle(1, btnStroke);
const btnTxt = this.add.text(cx, btnY, btnLabel, {
fontSize: '12px', color: btnTextColor, fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.scrollContainer.add(btnBg);
this.scrollContainer.add(btnTxt);
if (canAdd) {
btnBg.setInteractive({ useHandCursor: true });
btnBg.on('pointerover', () => btnBg.setFillStyle(0x336633));
btnBg.on('pointerout', () => btnBg.setFillStyle(0x225522));
btnBg.on('pointerdown', () => {
if (isCommander) {
this.draftedCommander = card.id;
} else {
this.draftedCards.push(card.id);
this.draftCopies[card.id] = (this.draftCopies[card.id] || 0) + 1;
this.remainingBudget -= cost;
}
this.scrollY = Math.max(0, this.scrollY); // preserve scroll
this._renderCardGrid();
this._renderDeckPanel();
});
}
});
const rows = Math.ceil(section.cards.length / this.COLS);
cursorY = startY + rows * this.CELL_H + 10;
}
// Scrolling bounds
const totalContentH = cursorY;
const visibleH = this.SCROLL_BOTTOM - this.SCROLL_TOP;
this.maxScrollY = Math.max(0, totalContentH - visibleH);
this.scrollY = Phaser.Math.Clamp(this.scrollY, 0, this.maxScrollY);
this.scrollContainer.y = -this.scrollY;
// Mask
const maskShape = this.make.graphics({ x: 0, y: 0 });
maskShape.fillStyle(0xffffff);
maskShape.fillRect(0, this.SCROLL_TOP, 1320, visibleH);
this.scrollMask = maskShape;
this.scrollContainer.setMask(new Phaser.Display.Masks.GeometryMask(this, maskShape));
}
_setupScrolling() {
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY) => {
if (pointer.x > 1320) return;
this.scrollY = Phaser.Math.Clamp(this.scrollY + deltaY * 0.5, 0, this.maxScrollY);
if (this.scrollContainer) this.scrollContainer.y = -this.scrollY;
});
}
// ── Deck panel (right side) ──────────────────────────────────────────
_renderDeckPanel() {
for (const o of this.deckPanelGroup) { if (o.scene) o.destroy(); }
this.deckPanelGroup = [];
const PANEL_X = 1340;
const PANEL_W = 540;
const panelCX = PANEL_X + PANEL_W / 2;
const { height } = this.scale;
// Panel background
const panelBg = this.add.rectangle(panelCX, height / 2, PANEL_W, height - 20, 0x0a0f1a)
.setStrokeStyle(2, 0x334466);
this.deckPanelGroup.push(panelBg);
// Budget bar
const budgetPct = this.remainingBudget / this.totalBudget;
const budgetColor = budgetPct > 0.5 ? '#44ff44' : budgetPct > 0.2 ? '#ffaa00' : '#ff4444';
const budgetTitle = this.add.text(panelCX, 80, 'Draft Budget', {
fontSize: '20px', color: '#d4af37', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.deckPanelGroup.push(budgetTitle);
const budgetText = this.add.text(panelCX, 108, `${this.remainingBudget} / ${this.totalBudget}`, {
fontSize: '28px', color: budgetColor, fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.deckPanelGroup.push(budgetText);
// Budget bar visual
const barW = PANEL_W - 80;
const barH = 14;
const barX = panelCX - barW / 2;
const barY = 135;
const barBg = this.add.rectangle(panelCX, barY, barW, barH, 0x222222).setStrokeStyle(1, 0x444444);
const fillW = Math.max(1, barW * budgetPct);
const barColor = budgetPct > 0.5 ? 0x44aa44 : budgetPct > 0.2 ? 0xaa8800 : 0xaa3333;
const barFill = this.add.rectangle(barX + fillW / 2, barY, fillW, barH - 2, barColor);
this.deckPanelGroup.push(barBg, barFill);
// Commander slot
const cmdY = 175;
const cmdCard = this.draftedCommander ? this.cardManager.getCard(this.draftedCommander) : null;
const cmdBg = this.add.rectangle(panelCX, cmdY, PANEL_W - 40, 46, cmdCard ? 0x1a3a5c : 0x1a1a2e)
.setStrokeStyle(2, 0xffd700);
const cmdText = this.add.text(panelCX, cmdY, cmdCard ? `Commander: ${cmdCard.name}` : 'No Commander Selected', {
fontSize: '17px', color: cmdCard ? '#ffd700' : '#555555', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.deckPanelGroup.push(cmdBg, cmdText);
if (cmdCard) {
const rmBtn = this.add.rectangle(panelCX + PANEL_W / 2 - 40, cmdY, 34, 28, 0x662222)
.setInteractive({ useHandCursor: true }).setStrokeStyle(1, 0xff4444);
const rmTxt = this.add.text(panelCX + PANEL_W / 2 - 40, cmdY, 'X', {
fontSize: '14px', color: '#ff4444', fontFamily: 'Audiowide'
}).setOrigin(0.5);
rmBtn.on('pointerdown', () => {
this.draftedCommander = null;
this._renderCardGrid();
this._renderDeckPanel();
});
this.deckPanelGroup.push(rmBtn, rmTxt);
}
// Assault cards list
const ENTRY_H = 40;
const rarityColors = { common: 0x888888, rare: 0x4488ff, epic: 0xaa44ff, legendary: 0xffaa00 };
const counts = {};
for (const id of this.draftedCards) counts[id] = (counts[id] || 0) + 1;
let y = 225;
for (const [cardId, count] of Object.entries(counts)) {
const card = this.cardManager.getCard(cardId);
if (!card) continue;
const cost = COSTS[card.rarity] || 1;
const bg = this.add.rectangle(panelCX, y, PANEL_W - 40, ENTRY_H - 4, 0x1a3a5c)
.setStrokeStyle(1, rarityColors[card.rarity] || 0x336699);
const txt = this.add.text(PANEL_X + 30, y, card.name, {
fontSize: '15px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0, 0.5);
const countTxt = this.add.text(PANEL_X + 300, y, `x${count}`, {
fontSize: '15px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0, 0.5);
const costTxt = this.add.text(PANEL_X + 360, y, `(${cost * count} pts)`, {
fontSize: '13px', color: '#8888cc', fontFamily: 'Audiowide'
}).setOrigin(0, 0.5);
const rmBtn = this.add.rectangle(panelCX + PANEL_W / 2 - 40, y, 34, 28, 0x662222)
.setInteractive({ useHandCursor: true }).setStrokeStyle(1, 0xff4444);
const rmTxt = this.add.text(panelCX + PANEL_W / 2 - 40, y, '-', {
fontSize: '18px', color: '#ff4444', fontFamily: 'Audiowide'
}).setOrigin(0.5);
rmBtn.on('pointerdown', () => {
const idx = this.draftedCards.lastIndexOf(cardId);
if (idx !== -1) {
this.draftedCards.splice(idx, 1);
this.draftCopies[cardId] = (this.draftCopies[cardId] || 1) - 1;
if (this.draftCopies[cardId] <= 0) delete this.draftCopies[cardId];
this.remainingBudget += cost;
this._renderCardGrid();
this._renderDeckPanel();
}
});
this.deckPanelGroup.push(bg, txt, countTxt, costTxt, rmBtn, rmTxt);
y += ENTRY_H;
}
// Empty slots
const filled = this.draftedCards.length;
for (let s = filled; s < Math.min(10, filled + 3); s++) {
const slotBg = this.add.rectangle(panelCX, y, PANEL_W - 40, ENTRY_H - 4, 0x0d0d1a)
.setStrokeStyle(1, 0x222244);
const slotTxt = this.add.text(panelCX, y, '— empty slot —', {
fontSize: '13px', color: '#333355', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.deckPanelGroup.push(slotBg, slotTxt);
y += ENTRY_H;
}
// Card count
const countColor = filled >= 3 ? '#aaaaff' : '#888888';
const totalTxt = this.add.text(panelCX, y + 16, `Cards: ${filled} / 10`, {
fontSize: '18px', color: countColor, fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.deckPanelGroup.push(totalTxt);
// Validation
const isValid = this.draftedCommander && filled >= 3;
const validMsg = !this.draftedCommander
? 'Select a commander'
: filled < 3
? `Need at least ${3 - filled} more card(s)`
: 'Deck ready!';
const validColor = isValid ? '#44ff44' : '#ff6644';
const validTxt = this.add.text(panelCX, y + 44, validMsg, {
fontSize: '15px', color: validColor, fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.deckPanelGroup.push(validTxt);
// Start Battle button
if (isValid) {
const btnY = height - 60;
const startBg = this.add.rectangle(panelCX, btnY, 240, 50, 0x226622)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x44aa44);
const startTxt = this.add.text(panelCX, btnY, 'Start Battle', {
fontSize: '22px', color: '#ffffff', fontStyle: 'bold', fontFamily: 'Audiowide'
}).setOrigin(0.5);
startBg.on('pointerover', () => startBg.setFillStyle(0x338833));
startBg.on('pointerout', () => startBg.setFillStyle(0x226622));
startBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this._startBattle();
});
this.deckPanelGroup.push(startBg, startTxt);
}
}
// ── AI Draft Algorithm ───────────────────────────────────────────────
_aiDraft() {
let budget = this.totalBudget;
const taken = new Set();
// Track what the player already drafted from the shared pool
if (this.draftedCommander) taken.add(this.draftedCommander);
for (const id of this.draftedCards) taken.add(id);
// Pick commander: prefer one not taken by player
const availCmds = this.pool.commanders.filter(c => !taken.has(c.id));
const cmdPool = availCmds.length > 0 ? availCmds : this.pool.commanders;
const commander = cmdPool[Math.floor(Math.random() * cmdPool.length)];
const preferredFaction = commander.faction;
// Gather all available assault cards from pool
const allAssault = [
...this.pool.legendary,
...this.pool.epic,
...this.pool.rare,
...this.pool.common
].filter(c => !taken.has(c.id));
// Score each card
const scored = allAssault.map(card => {
const cost = COSTS[card.rarity] || 1;
const lv1 = card.levels[0];
const baseValue = (lv1.attack + lv1.health / 3 + (lv1.armor || 0) * 2) / cost;
const factionBonus = card.faction === preferredFaction ? 3 : 0;
// Synergy: count useful skills
let synergyBonus = 0;
const skills = lv1.skills || [];
for (const sk of skills) {
if (['strike', 'pierce', 'siege', 'swipe', 'mortar'].includes(sk.name)) synergyBonus += 1;
if (['heal', 'protect', 'rally', 'fortify'].includes(sk.name)) synergyBonus += 0.5;
}
return { card, cost, score: baseValue + factionBonus + synergyBonus };
});
// Sort by score descending
scored.sort((a, b) => b.score - a.score);
const aiCards = [];
const aiCopies = {};
for (const entry of scored) {
if (aiCards.length >= 10) break;
if (entry.cost > budget) continue;
const maxC = MAX_COPIES[entry.card.rarity] || 1;
const current = aiCopies[entry.card.id] || 0;
if (current >= maxC) continue;
aiCards.push(entry.card.id);
aiCopies[entry.card.id] = current + 1;
budget -= entry.cost;
}
// Multi-pass: try to fill remaining slots with cheaper cards
if (aiCards.length < 3 && budget > 0) {
const cheapest = scored
.filter(e => e.cost <= budget && (aiCopies[e.card.id] || 0) < (MAX_COPIES[e.card.rarity] || 1))
.sort((a, b) => a.cost - b.cost);
for (const entry of cheapest) {
if (aiCards.length >= 10) break;
if (entry.cost > budget) break;
aiCards.push(entry.card.id);
aiCopies[entry.card.id] = (aiCopies[entry.card.id] || 0) + 1;
budget -= entry.cost;
}
}
return {
commander: commander.id,
cards: aiCards
};
}
// ── Start Battle ─────────────────────────────────────────────────────
_startBattle() {
// Build player deck
let playerDeck;
if (this.playerChoice === 'draft') {
playerDeck = {
commander: this.draftedCommander,
cards: [...this.draftedCards]
};
} else {
playerDeck = {
commander: this.playerPremadeDeck.commander,
cards: [...this.playerPremadeDeck.cards]
};
}
// Build opponent deck
let opponentDeck;
if (this.opponentChoice === 'draft') {
opponentDeck = this._aiDraft();
} else {
opponentDeck = {
commander: this.opponentPremadeDeck.commander,
cards: [...this.opponentPremadeDeck.cards]
};
}
this.scene.start('BattleScene', {
skirmish: true,
deck: playerDeck,
opponentDeck,
playerLevel: this.selectedLevel,
enemyLevel: this.selectedLevel,
difficulty: this.selectedDifficulty
});
}
// ── Back button ──────────────────────────────────────────────────────
_makeBackButton() {
const bg = this.add.rectangle(80, 45, 160, 50, 0x333333)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x888888);
this.add.text(80, 45, 'Back', { fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide' }).setOrigin(0.5);
bg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.scene.start('SkirmishDeckSelectScene', {
selectedLevel: this.selectedLevel,
selectedDifficulty: this.selectedDifficulty
});
});
}
}

View File

@ -59,7 +59,7 @@ export class SkirmishSetupScene extends Phaser.Scene {
fontSize: '24px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.add.text(width / 2, 415, 'Label only — no stat changes yet', {
this.add.text(width / 2, 415, 'Affects draft budget and premade deck options', {
fontSize: '14px', color: '#888888', fontFamily: 'Audiowide'
}).setOrigin(0.5);
@ -94,11 +94,11 @@ export class SkirmishSetupScene extends Phaser.Scene {
this.diffButtons.push({ bg, text, diff });
});
// ── Start Battle button ───────────────────────────────────────────
// ── Select Decks button ─────────────────────────────────────────
const startBg = this.add.rectangle(width / 2, 620, 300, 70, 0x224488)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff);
this.add.text(width / 2, 620, 'Start Battle', {
this.add.text(width / 2, 620, 'Select Decks', {
fontSize: '28px', color: '#ffffff', fontStyle: 'bold', fontFamily: 'Audiowide'
}).setOrigin(0.5);
@ -106,17 +106,9 @@ export class SkirmishSetupScene extends Phaser.Scene {
startBg.on('pointerout', () => startBg.setFillStyle(0x224488));
startBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
const deck = save.decks[0];
if (!deck) {
this._showError('No deck! Create one in Deck Builder first.');
return;
}
this.scene.start('BattleScene', {
skirmish: true,
deck,
playerLevel: this.selectedLevel,
enemyLevel: this.selectedLevel,
difficulty: this.selectedDifficulty
this.scene.start('SkirmishDeckSelectScene', {
selectedLevel: this.selectedLevel,
selectedDifficulty: this.selectedDifficulty
});
});