feat(draft): implement alternating draft mode with dual-deck UI

- Add alternating draft where player and AI take turns picking commander then cards
- Implement shared card pool tracking with "taken" state overlays in card grid
- Introduce dual-deck panel showing both sides' budgets, commanders, and card lists
- Refactor AI draft logic into state machine with `_playerPick`, `_aiPickCommander`, and `_aiPickOneCard`
- Add status banner indicating whose turn it is, with pulsing animation on AI turns
- Adjust card skill values in `cards.json` (e.g., rally: 3→4, heal upgrades, strike boosts)
This commit is contained in:
Brian Fertig 2026-04-02 21:04:25 -06:00
parent 762dc77556
commit 1ca1b5bf80
2 changed files with 706 additions and 164 deletions

View File

@ -27,7 +27,7 @@
"skills": [
{
"name": "rally",
"value": 3,
"value": 4,
"trigger": "preBattle"
}
]
@ -40,7 +40,8 @@
"skills": [
{
"name": "rally",
"value": 5,
"value": 4,
"all": true,
"trigger": "preBattle"
}
]
@ -53,7 +54,8 @@
"skills": [
{
"name": "rally",
"value": 7,
"value": 6,
"all": true,
"trigger": "preBattle"
}
]
@ -66,8 +68,14 @@
"skills": [
{
"name": "rally",
"value": 10,
"value": 8,
"all": true,
"trigger": "preBattle"
},
{
"name": "counter",
"value": 10,
"trigger": "on_defend"
}
]
}
@ -89,9 +97,8 @@
"delay": 0,
"skills": [
{
"name": "fortify",
"value": 1,
"all": true,
"name": "heal",
"value": 2,
"trigger": "preBattle"
}
]
@ -103,9 +110,8 @@
"delay": 0,
"skills": [
{
"name": "fortify",
"value": 2,
"all": true,
"name": "heal",
"value": 5,
"trigger": "preBattle"
}
]
@ -117,7 +123,7 @@
"delay": 0,
"skills": [
{
"name": "fortify",
"name": "heal",
"value": 4,
"all": true,
"trigger": "preBattle"
@ -131,8 +137,8 @@
"delay": 0,
"skills": [
{
"name": "fortify",
"value": 6,
"name": "heal",
"value": 8,
"all": true,
"trigger": "preBattle"
}
@ -144,9 +150,15 @@
"armor": 20,
"delay": 0,
"skills": [
{
"name": "heal",
"value": 10,
"all": true,
"trigger": "preBattle"
},
{
"name": "fortify",
"value": 8,
"value": 5,
"all": true,
"trigger": "preBattle"
}
@ -171,8 +183,7 @@
"skills": [
{
"name": "strike",
"value": 1,
"all": true,
"value": 3,
"trigger": "preAttack"
}
]
@ -185,8 +196,7 @@
"skills": [
{
"name": "strike",
"value": 4,
"all": true,
"value": 6,
"trigger": "preAttack"
}
]
@ -230,6 +240,12 @@
"value": 23,
"all": true,
"trigger": "preAttack"
},
{
"name": "rally",
"value": 8,
"all": true,
"trigger": "preBattle"
}
]
}
@ -250,12 +266,6 @@
"armor": 3,
"delay": 0,
"skills": [
{
"name": "rally",
"value": 1,
"all": true,
"trigger": "preBattle"
},
{
"name": "enfeeble",
"value": 1,
@ -270,12 +280,6 @@
"armor": 5,
"delay": 0,
"skills": [
{
"name": "rally",
"value": 2,
"all": true,
"trigger": "preBattle"
},
{
"name": "enfeeble",
"value": 2,
@ -291,10 +295,10 @@
"delay": 0,
"skills": [
{
"name": "rally",
"value": 3,
"name": "cleanse",
"value": 6,
"all": true,
"trigger": "preBattle"
"trigger": "postBattle"
},
{
"name": "enfeeble",
@ -311,10 +315,10 @@
"delay": 0,
"skills": [
{
"name": "rally",
"value": 5,
"name": "cleanse",
"value": 9,
"all": true,
"trigger": "preBattle"
"trigger": "postBattle"
},
{
"name": "enfeeble",
@ -331,16 +335,21 @@
"delay": 0,
"skills": [
{
"name": "rally",
"value": 7,
"name": "cleanse",
"value": 13,
"all": true,
"trigger": "preBattle"
"trigger": "postBattle"
},
{
"name": "enfeeble",
"value": 7,
"all": true,
"trigger": "preBattle"
},
{
"name": "counter",
"value": 10,
"trigger": "on_defend"
}
]
}
@ -2046,7 +2055,7 @@
"skills": [
{
"name": "siege",
"value": 3,
"value": 4,
"trigger": "preBattle"
}
]
@ -2059,7 +2068,7 @@
"skills": [
{
"name": "siege",
"value": 5,
"value": 10,
"trigger": "preBattle"
}
]
@ -2072,7 +2081,13 @@
"skills": [
{
"name": "siege",
"value": 20,
"trigger": "preBattle"
},
{
"name": "weaken",
"value": 7,
"all": true,
"trigger": "preBattle"
}
]
@ -2085,7 +2100,13 @@
"skills": [
{
"name": "siege",
"value": 20,
"trigger": "preBattle"
},
{
"name": "weaken",
"value": 10,
"all": true,
"trigger": "preBattle"
}
]
@ -2098,7 +2119,13 @@
"skills": [
{
"name": "siege",
"value": 25,
"trigger": "preBattle"
},
{
"name": "weaken",
"value": 15,
"all": true,
"trigger": "preBattle"
}
]
@ -2125,11 +2152,6 @@
"value": 1,
"all": true,
"trigger": "preBattle"
},
{
"name": "strike",
"value": 2,
"trigger": "preAttack"
}
]
},
@ -2141,14 +2163,9 @@
"skills": [
{
"name": "weaken",
"value": 3,
"value": 4,
"all": true,
"trigger": "preBattle"
},
{
"name": "strike",
"value": 4,
"trigger": "preAttack"
}
]
},
@ -2166,7 +2183,7 @@
},
{
"name": "strike",
"value": 6,
"value": 15,
"trigger": "preAttack"
}
]
@ -2185,7 +2202,7 @@
},
{
"name": "strike",
"value": 9,
"value": 20,
"trigger": "preAttack"
}
]
@ -2204,7 +2221,7 @@
},
{
"name": "strike",
"value": 13,
"value": 25,
"trigger": "preAttack"
}
]
@ -2231,11 +2248,6 @@
"value": 1,
"all": true,
"trigger": "preBattle"
},
{
"name": "wound",
"value": 2,
"trigger": "on_attack"
}
]
},
@ -2250,11 +2262,6 @@
"value": 5,
"all": true,
"trigger": "preBattle"
},
{
"name": "wound",
"value": 4,
"trigger": "on_attack"
}
]
},
@ -2304,7 +2311,7 @@
"skills": [
{
"name": "enfeeble",
"value": 15,
"value": 13,
"all": true,
"trigger": "preBattle"
},
@ -7234,7 +7241,7 @@
"skills": [
{
"name": "venom",
"value": 8,
"value": 13,
"trigger": "on_attack",
"all": true
}
@ -7248,7 +7255,7 @@
"skills": [
{
"name": "venom",
"value": 11,
"value": 16,
"trigger": "on_attack",
"all": true
}
@ -7262,7 +7269,7 @@
"skills": [
{
"name": "venom",
"value": 16,
"value": 20,
"trigger": "on_attack",
"all": true
}

View File

@ -22,10 +22,6 @@ export class SkirmishDraftScene extends Phaser.Scene {
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;
@ -36,7 +32,10 @@ export class SkirmishDraftScene extends Phaser.Scene {
// Track copies in draft: { cardId: count }
this.draftCopies = {};
// Generate the shared card pool
// Alternating draft mode: both sides are drafting
this.isAlternatingDraft = (this.playerChoice === 'draft' && this.opponentChoice === 'draft');
// Generate the shared card pool (larger pool for alternating draft)
this.pool = this._generatePool();
// Deck panel elements (right side, rebuilt on changes)
@ -64,10 +63,35 @@ export class SkirmishDraftScene extends Phaser.Scene {
const playerNeedsDraft = this.playerChoice === 'draft';
if (playerNeedsDraft) {
this._renderCardGrid();
this._renderDeckPanel();
this._setupScrolling();
if (this.isAlternatingDraft) {
// Alternating draft state
this.draftState = 'PLAYER_COMMANDER';
this.inputLocked = false;
this.aiCommander = null;
this.aiCards = [];
this.aiCopies = {};
this.aiBudget = this.totalBudget;
this.takenCards = {}; // { cardId: 'player' | 'ai' }
this.bannerGroup = [];
this._renderStatusBanner();
this._renderCardGrid();
this._renderDualDeckPanel();
this._setupScrolling();
} else {
this.add.text(width / 2, 30, 'Draft Cards', {
fontSize: '36px', color: '#d4af37', fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
this._renderCardGrid();
this._renderDeckPanel();
this._setupScrolling();
}
} else {
this.add.text(width / 2, 30, 'Draft Cards', {
fontSize: '36px', color: '#d4af37', fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
// Player has premade deck — show simplified view
this._renderPremadeView();
}
@ -86,9 +110,10 @@ export class SkirmishDraftScene extends Phaser.Scene {
const rareAssault = allCards.filter(c => c.type === 'assault' && c.rarity === 'rare');
const commonAssault = allCards.filter(c => c.type === 'assault' && c.rarity === 'common');
const fraction = this.isAlternatingDraft ? 2 : 3;
const pick = (arr) => {
const shuffled = [...arr].sort(() => Math.random() - 0.5);
return shuffled.slice(0, Math.ceil(arr.length / 3));
return shuffled.slice(0, Math.ceil(arr.length / fraction));
};
return {
@ -202,53 +227,131 @@ export class SkirmishDraftScene extends Phaser.Scene {
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;
// ── Determine if card can be added ──
let canAdd = false;
if (isCommander) {
canAdd = !this.draftedCommander;
let takenBy = null; // 'player' | 'ai' | null
if (this.isAlternatingDraft) {
takenBy = this.takenCards[card.id] || null;
if (takenBy || this.inputLocked) {
canAdd = false;
} else if (isCommander) {
canAdd = this.draftState === 'PLAYER_COMMANDER' && !this.draftedCommander;
} else {
canAdd = this.draftState === 'PLAYER_CARD' &&
cost <= this.remainingBudget &&
this.draftedCards.length < 10;
}
} else {
canAdd = cost <= this.remainingBudget &&
currentCopies < maxCopies &&
this.draftedCards.length < 10;
const maxCopies = isCommander ? 1 : (MAX_COPIES[card.rarity] || 1);
const currentCopies = this.draftCopies[card.id] || 0;
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);
if (!canAdd) cardObj.setAlpha(takenBy ? 0.3 : 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);
// Taken overlay (alternating draft only)
if (this.isAlternatingDraft && takenBy) {
const overlayColor = takenBy === 'player' ? 0x225522 : 0x552222;
const overlay = this.add.rectangle(cx, cy, this.CARD_W, this.CARD_H, overlayColor, 0.5);
this.scrollContainer.add(overlay);
// 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);
const labelText = takenBy === 'player' ? 'YOURS' : 'TAKEN';
const labelColor = takenBy === 'player' ? '#44ff44' : '#ff4444';
const overlayTxt = this.add.text(cx, cy, labelText, {
fontSize: '20px', color: labelColor, fontFamily: 'Audiowide', fontStyle: 'bold'
}).setOrigin(0.5);
this.scrollContainer.add(overlayTxt);
}
// 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')));
// Cost gem badge (top-left corner of card)
const gemX = cx - this.CARD_W / 2 + 18;
const gemY = cy - this.CARD_H / 2 + 18;
const gemRadius = 16;
const gemFillColors = { 1: 0x1a6632, 2: 0x1a3a88, 4: 0x5a1a88, 6: 0x884400 };
const gemStrokeColors = { 1: 0x44ff88, 2: 0x4488ff, 4: 0xbb44ff, 6: 0xffaa22 };
const gemTextColors = { 1: '#44ff88', 2: '#88bbff', 4: '#dd88ff', 6: '#ffbb44' };
const gemGraphics = this.add.graphics();
if (isCommander) {
// Gold star-like badge for FREE commanders
gemGraphics.fillStyle(canAdd ? 0x5a4400 : 0x222200, 1);
gemGraphics.fillCircle(gemX, gemY, gemRadius);
gemGraphics.lineStyle(2, canAdd ? 0xffd700 : 0x554400, 1);
gemGraphics.strokeCircle(gemX, gemY, gemRadius);
} else {
gemGraphics.fillStyle(canAdd ? (gemFillColors[cost] || 0x333333) : 0x111111, 1);
gemGraphics.fillCircle(gemX, gemY, gemRadius);
gemGraphics.lineStyle(2, canAdd ? (gemStrokeColors[cost] || 0x666666) : 0x333333, 1);
gemGraphics.strokeCircle(gemX, gemY, gemRadius);
}
this.scrollContainer.add(gemGraphics);
const gemLabel = isCommander ? '★' : String(cost);
const gemLabelColor = isCommander
? (canAdd ? '#ffd700' : '#554400')
: (canAdd ? (gemTextColors[cost] || '#888888') : '#333333');
const gemTxt = this.add.text(gemX, gemY, gemLabel, {
fontSize: isCommander ? '16px' : '18px',
color: gemLabelColor,
fontFamily: 'Audiowide',
fontStyle: 'bold'
}).setOrigin(0.5);
this.scrollContainer.add(gemTxt);
// Copies indicator (non-alternating only)
if (!this.isAlternatingDraft && !isCommander) {
const maxCopies = MAX_COPIES[card.rarity] || 1;
const currentCopies = this.draftCopies[card.id] || 0;
if (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/Pick button
let btnLabel;
if (this.isAlternatingDraft) {
if (takenBy === 'player') btnLabel = 'YOURS';
else if (takenBy === 'ai') btnLabel = 'TAKEN';
else if (this.inputLocked) btnLabel = 'WAIT...';
else if (isCommander && this.draftState !== 'PLAYER_COMMANDER') btnLabel = 'NOT YET';
else if (!isCommander && this.draftState !== 'PLAYER_CARD') btnLabel = 'NOT YET';
else if (canAdd) btnLabel = '+ PICK';
else if (!isCommander && cost > this.remainingBudget) btnLabel = 'NO BUDGET';
else if (!isCommander && this.draftedCards.length >= 10) btnLabel = 'DECK FULL';
else btnLabel = '—';
} else {
const maxCopies = isCommander ? 1 : (MAX_COPIES[card.rarity] || 1);
const currentCopies = this.draftCopies[card.id] || 0;
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 currentCopiesForBtn = this.draftCopies[card.id] || 0;
const btnYPos = cy + this.CARD_H / 2 + ((!this.isAlternatingDraft && currentCopiesForBtn > 0) ? 32 : 18);
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)
const btnBg = this.add.rectangle(cx, btnYPos, this.CARD_W - 10, this.BTN_H, btnBgColor)
.setStrokeStyle(1, btnStroke);
const btnTxt = this.add.text(cx, btnY, btnLabel, {
const btnTxt = this.add.text(cx, btnYPos, btnLabel, {
fontSize: '12px', color: btnTextColor, fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.scrollContainer.add(btnBg);
@ -259,16 +362,20 @@ export class SkirmishDraftScene extends Phaser.Scene {
btnBg.on('pointerover', () => btnBg.setFillStyle(0x336633));
btnBg.on('pointerout', () => btnBg.setFillStyle(0x225522));
btnBg.on('pointerdown', () => {
if (isCommander) {
this.draftedCommander = card.id;
if (this.isAlternatingDraft) {
this._playerPick(card);
} else {
this.draftedCards.push(card.id);
this.draftCopies[card.id] = (this.draftCopies[card.id] || 0) + 1;
this.remainingBudget -= cost;
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();
}
this.scrollY = Math.max(0, this.scrollY); // preserve scroll
this._renderCardGrid();
this._renderDeckPanel();
});
}
});
@ -463,8 +570,259 @@ export class SkirmishDraftScene extends Phaser.Scene {
}
}
// ── Dual deck panel (alternating draft) ───────────────────────────
_renderDualDeckPanel() {
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;
// Fixed vertical zones so sections never overlap
const PLAYER_TOP = 70;
const DIVIDER_Y = height / 2 - 20;
const OPPONENT_TOP = DIVIDER_Y + 14;
const BUTTON_Y = height - 55;
const ENTRY_H = 26;
const barW = PANEL_W - 200;
const barX = PANEL_X + 180;
const rarityColors = { common: 0x888888, rare: 0x4488ff, epic: 0xaa44ff, legendary: 0xffaa00 };
// Panel background
const panelBg = this.add.rectangle(panelCX, height / 2, PANEL_W, height - 20, 0x0a0f1a)
.setStrokeStyle(2, 0x334466);
this.deckPanelGroup.push(panelBg);
// ── YOUR DECK (top half) ──
let y = PLAYER_TOP;
const yourTitle = this.add.text(panelCX, y, 'YOUR DECK', {
fontSize: '18px', color: '#d4af37', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.deckPanelGroup.push(yourTitle);
// Player budget
y += 26;
const pBudgetPct = this.remainingBudget / this.totalBudget;
const pBudgetColor = pBudgetPct > 0.5 ? '#44ff44' : pBudgetPct > 0.2 ? '#ffaa00' : '#ff4444';
const pBudgetTxt = this.add.text(PANEL_X + 20, y, `Budget: ${this.remainingBudget}/${this.totalBudget}`, {
fontSize: '14px', color: pBudgetColor, fontFamily: 'Audiowide'
});
this.deckPanelGroup.push(pBudgetTxt);
const pBarBg = this.add.rectangle(barX + barW / 2, y + 8, barW, 10, 0x222222).setStrokeStyle(1, 0x444444);
const pFillW = Math.max(1, barW * pBudgetPct);
const pBarColor = pBudgetPct > 0.5 ? 0x44aa44 : pBudgetPct > 0.2 ? 0xaa8800 : 0xaa3333;
const pBarFill = this.add.rectangle(barX + pFillW / 2, y + 8, pFillW, 8, pBarColor);
this.deckPanelGroup.push(pBarBg, pBarFill);
// Player commander slot
y += 38;
const pCmd = this.draftedCommander ? this.cardManager.getCard(this.draftedCommander) : null;
const pCmdBg = this.add.rectangle(panelCX, y, PANEL_W - 40, 32, pCmd ? 0x1a3a5c : 0x1a1a2e)
.setStrokeStyle(2, 0xffd700);
const pCmdTxt = this.add.text(panelCX, y, pCmd ? `Commander: ${pCmd.name}` : 'No Commander', {
fontSize: '13px', color: pCmd ? '#ffd700' : '#555555', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.deckPanelGroup.push(pCmdBg, pCmdTxt);
// Player card list
y += 26;
const pCounts = {};
for (const id of this.draftedCards) pCounts[id] = (pCounts[id] || 0) + 1;
for (const [cardId, count] of Object.entries(pCounts)) {
if (y + ENTRY_H > DIVIDER_Y - 30) break; // don't overflow into divider
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: '12px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0, 0.5);
const cTxt = this.add.text(PANEL_X + 300, y, `x${count}`, {
fontSize: '12px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0, 0.5);
const costTxt = this.add.text(PANEL_X + 340, y, `(${cost * count} pts)`, {
fontSize: '10px', color: '#8888cc', fontFamily: 'Audiowide'
}).setOrigin(0, 0.5);
this.deckPanelGroup.push(bg, txt, cTxt, costTxt);
y += ENTRY_H;
}
// Player card count
const pFilled = this.draftedCards.length;
const pCountTxt = this.add.text(PANEL_X + 20, DIVIDER_Y - 22, `Cards: ${pFilled}/10`, {
fontSize: '13px', color: pFilled >= 3 ? '#aaaaff' : '#888888', fontFamily: 'Audiowide'
});
this.deckPanelGroup.push(pCountTxt);
// ── Divider ──
const divider = this.add.rectangle(panelCX, DIVIDER_Y, PANEL_W - 30, 2, 0x334466);
this.deckPanelGroup.push(divider);
// ── OPPONENT DECK (bottom half) ──
y = OPPONENT_TOP;
const oppTitle = this.add.text(panelCX, y, 'OPPONENT DECK', {
fontSize: '18px', color: '#ff6644', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.deckPanelGroup.push(oppTitle);
// AI budget
y += 26;
const aBudgetPct = this.aiBudget / this.totalBudget;
const aBudgetColor = aBudgetPct > 0.5 ? '#44ff44' : aBudgetPct > 0.2 ? '#ffaa00' : '#ff4444';
const aBudgetTxt = this.add.text(PANEL_X + 20, y, `Budget: ${this.aiBudget}/${this.totalBudget}`, {
fontSize: '14px', color: aBudgetColor, fontFamily: 'Audiowide'
});
this.deckPanelGroup.push(aBudgetTxt);
const aBarBg = this.add.rectangle(barX + barW / 2, y + 8, barW, 10, 0x222222).setStrokeStyle(1, 0x444444);
const aFillW = Math.max(1, barW * aBudgetPct);
const aBarColor = aBudgetPct > 0.5 ? 0x44aa44 : aBudgetPct > 0.2 ? 0xaa8800 : 0xaa3333;
const aBarFill = this.add.rectangle(barX + aFillW / 2, y + 8, aFillW, 8, aBarColor);
this.deckPanelGroup.push(aBarBg, aBarFill);
// AI commander slot
y += 38;
const aCmd = this.aiCommander ? this.cardManager.getCard(this.aiCommander) : null;
const aCmdBg = this.add.rectangle(panelCX, y, PANEL_W - 40, 32, aCmd ? 0x3a1a1a : 0x1a1a2e)
.setStrokeStyle(2, aCmd ? 0xff6644 : 0x444444);
const aCmdTxt = this.add.text(panelCX, y, aCmd ? `Commander: ${aCmd.name}` : 'No Commander', {
fontSize: '13px', color: aCmd ? '#ff6644' : '#555555', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.deckPanelGroup.push(aCmdBg, aCmdTxt);
// AI card list
y += 26;
const aCounts = {};
for (const id of this.aiCards) aCounts[id] = (aCounts[id] || 0) + 1;
const maxAiListY = BUTTON_Y - 50;
for (const [cardId, count] of Object.entries(aCounts)) {
if (y + ENTRY_H > maxAiListY) break; // don't overflow into button area
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, 0x3a1a1a)
.setStrokeStyle(1, rarityColors[card.rarity] || 0x663333);
const txt = this.add.text(PANEL_X + 30, y, card.name, {
fontSize: '12px', color: '#ffcccc', fontFamily: 'Audiowide'
}).setOrigin(0, 0.5);
const cTxt = this.add.text(PANEL_X + 300, y, `x${count}`, {
fontSize: '12px', color: '#aa8888', fontFamily: 'Audiowide'
}).setOrigin(0, 0.5);
const costTxt = this.add.text(PANEL_X + 340, y, `(${cost * count} pts)`, {
fontSize: '10px', color: '#cc8888', fontFamily: 'Audiowide'
}).setOrigin(0, 0.5);
this.deckPanelGroup.push(bg, txt, cTxt, costTxt);
y += ENTRY_H;
}
// AI card count
const aFilled = this.aiCards.length;
const aCountTxt = this.add.text(PANEL_X + 20, maxAiListY + 4, `Cards: ${aFilled}/10`, {
fontSize: '13px', color: aFilled >= 3 ? '#ffaaaa' : '#888888', fontFamily: 'Audiowide'
});
this.deckPanelGroup.push(aCountTxt);
// ── Validation & Start Battle (fixed at bottom) ──
const isValid = this.draftedCommander && pFilled >= 3;
if (isValid && !this.inputLocked) {
const startBg = this.add.rectangle(panelCX, BUTTON_Y, 240, 50, 0x226622)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x44aa44);
const startTxt = this.add.text(panelCX, BUTTON_Y, '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._aiFinishDraft();
this._startBattle();
});
this.deckPanelGroup.push(startBg, startTxt);
} else if (!isValid) {
const validMsg = !this.draftedCommander
? 'Select a commander first'
: `Need at least ${3 - pFilled} more card(s)`;
const validTxt = this.add.text(panelCX, BUTTON_Y, validMsg, {
fontSize: '14px', color: '#ff6644', fontFamily: 'Audiowide'
}).setOrigin(0.5);
this.deckPanelGroup.push(validTxt);
}
}
_aiFinishDraft() {
// If AI doesn't have a commander yet, pick one
if (!this.aiCommander) {
this._aiPickCommander();
}
// Fill remaining AI cards without delay
let safety = 20;
while (this.aiCards.length < 10 && this._canSidePick('ai') && safety-- > 0) {
if (!this._aiPickOneCard()) break;
}
}
// ── Status banner (alternating draft) ────────────────────────────
_renderStatusBanner() {
if (this.bannerGroup) {
for (const o of this.bannerGroup) { if (o.scene) o.destroy(); }
}
this.bannerGroup = [];
const { width } = this.scale;
const messages = {
PLAYER_COMMANDER: { text: 'Choose Your Commander', color: '#44aaff', bgColor: 0x112233 },
AI_COMMANDER: { text: 'Opponent Choosing Commander...', color: '#ff6644', bgColor: 0x331111 },
PLAYER_CARD: { text: 'Your Turn \u2014 Pick a Card', color: '#44aaff', bgColor: 0x112233 },
AI_CARD: { text: 'Opponent Picking...', color: '#ff6644', bgColor: 0x331111 },
DRAFT_COMPLETE: { text: 'Draft Complete!', color: '#44ff44', bgColor: 0x113311 }
};
const msg = messages[this.draftState] || messages.PLAYER_COMMANDER;
const bg = this.add.rectangle(width / 2, 30, width, 50, msg.bgColor).setDepth(10);
const txt = this.add.text(width / 2, 30, msg.text, {
fontSize: '28px', color: msg.color, fontFamily: 'RaiderCrusader'
}).setOrigin(0.5).setDepth(11);
this.bannerGroup = [bg, txt];
// Pulse effect during AI turns
if (this.draftState === 'AI_COMMANDER' || this.draftState === 'AI_CARD') {
this.tweens.add({
targets: txt,
alpha: { from: 1, to: 0.4 },
duration: 600,
yoyo: true,
repeat: -1
});
}
}
// ── AI Draft Algorithm ───────────────────────────────────────────────
_getRarityTargets() {
const targets = {
Easy: { legendary: 0, epic: 0, rare: 2, common: 8 },
Medium: { legendary: 1, epic: 2, rare: 4, common: 3 },
Hard: { legendary: 2, epic: 4, rare: 4, common: 0 },
Legendary: { legendary: 4, epic: 6, rare: 0, common: 0 }
};
return targets[this.selectedDifficulty] || targets.Medium;
}
_aiDraft() {
let budget = this.totalBudget;
const taken = new Set();
@ -478,70 +836,242 @@ export class SkirmishDraftScene extends Phaser.Scene {
const cmdPool = availCmds.length > 0 ? availCmds : this.pool.commanders;
const commander = cmdPool[Math.floor(Math.random() * cmdPool.length)];
const preferredFaction = commander.faction;
taken.add(commander.id);
// Gather all available assault cards from pool
const allAssault = [
...this.pool.legendary,
...this.pool.epic,
...this.pool.rare,
...this.pool.common
const getAvailable = () => [
...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 targets = this._getRarityTargets();
const rarityOrder = ['legendary', 'epic', 'rare', 'common'];
const aiCards = [];
const aiCopies = {};
for (const entry of scored) {
if (aiCards.length >= 10) break;
if (entry.cost > budget) continue;
// Fill each rarity tier in order, highest first
for (const rarity of rarityOrder) {
const target = targets[rarity] || 0;
if (target <= 0) continue;
const maxC = MAX_COPIES[entry.card.rarity] || 1;
const current = aiCopies[entry.card.id] || 0;
if (current >= maxC) continue;
const cost = COSTS[rarity] || 1;
const candidates = getAvailable()
.filter(c => c.rarity === rarity && cost <= budget)
.map(c => this._scoreCard(c, preferredFaction))
.sort((a, b) => b.score - a.score);
aiCards.push(entry.card.id);
aiCopies[entry.card.id] = current + 1;
budget -= entry.cost;
let filled = 0;
for (const entry of candidates) {
if (filled >= target || aiCards.length >= 10) break;
if (entry.cost > budget) continue;
aiCards.push(entry.card.id);
taken.add(entry.card.id);
budget -= entry.cost;
filled++;
}
}
// 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))
// Fallback: fill remaining slots if under minimum, cheapest first
if (aiCards.length < 3) {
const fallback = getAvailable()
.filter(c => (COSTS[c.rarity] || 1) <= budget)
.map(c => this._scoreCard(c, preferredFaction))
.sort((a, b) => a.cost - b.cost);
for (const entry of cheapest) {
if (aiCards.length >= 10) break;
if (entry.cost > budget) break;
for (const entry of fallback) {
if (aiCards.length >= 10 || entry.cost > budget) break;
aiCards.push(entry.card.id);
aiCopies[entry.card.id] = (aiCopies[entry.card.id] || 0) + 1;
taken.add(entry.card.id);
budget -= entry.cost;
}
}
return {
commander: commander.id,
cards: aiCards
};
return { commander: commander.id, cards: aiCards };
}
// ── Alternating Draft: State Machine ──────────────────────────────
_playerPick(card) {
if (this.inputLocked) return;
const isCommander = card.type === 'commander';
if (isCommander) {
this.draftedCommander = card.id;
} else {
const cost = COSTS[card.rarity] || 1;
this.draftedCards.push(card.id);
this.draftCopies[card.id] = (this.draftCopies[card.id] || 0) + 1;
this.remainingBudget -= cost;
}
this.takenCards[card.id] = 'player';
this.sound.play('sfx_menu_select', { volume: 0.5 });
this._advanceDraftState();
}
_canSidePick(side) {
const budget = side === 'player' ? this.remainingBudget : this.aiBudget;
const cardCount = side === 'player' ? this.draftedCards.length : this.aiCards.length;
if (cardCount >= 10) return false;
// Check if any affordable assault card remains untaken
const allAssault = [
...this.pool.legendary, ...this.pool.epic,
...this.pool.rare, ...this.pool.common
];
return allAssault.some(c => !this.takenCards[c.id] && (COSTS[c.rarity] || 1) <= budget);
}
_advanceDraftState() {
const playerCan = this._canSidePick('player');
const aiCan = this._canSidePick('ai');
let aiTurn = false;
if (this.draftState === 'PLAYER_COMMANDER') {
this.draftState = 'AI_COMMANDER';
aiTurn = true;
} else if (this.draftState === 'AI_COMMANDER') {
if (playerCan) {
this.draftState = 'PLAYER_CARD';
this.inputLocked = false;
} else if (aiCan) {
this.draftState = 'AI_CARD';
aiTurn = true;
} else {
this.draftState = 'DRAFT_COMPLETE';
this.inputLocked = false;
}
} else if (this.draftState === 'PLAYER_CARD') {
if (aiCan) {
this.draftState = 'AI_CARD';
aiTurn = true;
} else if (playerCan) {
// AI is done, player keeps picking
this.draftState = 'PLAYER_CARD';
this.inputLocked = false;
} else {
this.draftState = 'DRAFT_COMPLETE';
this.inputLocked = false;
}
} else if (this.draftState === 'AI_CARD') {
if (playerCan) {
this.draftState = 'PLAYER_CARD';
this.inputLocked = false;
} else if (aiCan) {
// Player is done, AI keeps picking
this.draftState = 'AI_CARD';
aiTurn = true;
} else {
this.draftState = 'DRAFT_COMPLETE';
this.inputLocked = false;
}
}
if (aiTurn) {
this._scheduleAiTurn();
} else {
this._renderStatusBanner();
this._renderCardGrid();
this._renderDualDeckPanel();
}
}
_scheduleAiTurn() {
this.inputLocked = true;
this._renderStatusBanner();
this._renderCardGrid();
this._renderDualDeckPanel();
this.time.delayedCall(1500, () => {
if (!this.scene.isActive()) return;
if (this.draftState === 'AI_COMMANDER') {
this._aiPickCommander();
} else {
this._aiPickOneCard();
}
this._advanceDraftState();
});
}
_aiPickCommander() {
const available = this.pool.commanders.filter(c => !this.takenCards[c.id]);
const pool = available.length > 0 ? available : this.pool.commanders;
const commander = pool[Math.floor(Math.random() * pool.length)];
this.aiCommander = commander.id;
this.takenCards[commander.id] = 'ai';
}
_aiPickOneCard() {
const available = [
...this.pool.legendary, ...this.pool.epic,
...this.pool.rare, ...this.pool.common
].filter(c => !this.takenCards[c.id]);
const aiCmd = this.cardManager.getCard(this.aiCommander);
const preferredFaction = aiCmd ? aiCmd.faction : null;
// Count what the AI has already picked by rarity
const rarityCounts = {};
for (const id of this.aiCards) {
const c = this.cardManager.getCard(id);
if (c) rarityCounts[c.rarity] = (rarityCounts[c.rarity] || 0) + 1;
}
const targets = this._getRarityTargets();
const rarityOrder = ['legendary', 'epic', 'rare', 'common'];
// Try to pick the highest-priority rarity whose target isn't met yet
for (const rarity of rarityOrder) {
const target = targets[rarity] || 0;
if (target <= 0 || (rarityCounts[rarity] || 0) >= target) continue;
const candidates = available
.filter(c => c.rarity === rarity && (COSTS[rarity] || 1) <= this.aiBudget)
.map(c => this._scoreCard(c, preferredFaction))
.sort((a, b) => b.score - a.score);
if (candidates.length > 0) {
const pick = candidates[0];
this.aiCards.push(pick.card.id);
this.aiCopies[pick.card.id] = (this.aiCopies[pick.card.id] || 0) + 1;
this.aiBudget -= pick.cost;
this.takenCards[pick.card.id] = 'ai';
return true;
}
}
// Fallback: all targets met or unavailable — pick best affordable in rarity order
for (const rarity of rarityOrder) {
const candidates = available
.filter(c => c.rarity === rarity && (COSTS[rarity] || 1) <= this.aiBudget)
.map(c => this._scoreCard(c, preferredFaction))
.sort((a, b) => b.score - a.score);
if (candidates.length > 0) {
const pick = candidates[0];
this.aiCards.push(pick.card.id);
this.aiCopies[pick.card.id] = (this.aiCopies[pick.card.id] || 0) + 1;
this.aiBudget -= pick.cost;
this.takenCards[pick.card.id] = 'ai';
return true;
}
}
return false;
}
_scoreCard(card, preferredFaction) {
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;
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 };
}
// ── Start Battle ─────────────────────────────────────────────────────
@ -563,7 +1093,12 @@ export class SkirmishDraftScene extends Phaser.Scene {
// Build opponent deck
let opponentDeck;
if (this.opponentChoice === 'draft') {
if (this.isAlternatingDraft) {
opponentDeck = {
commander: this.aiCommander,
cards: [...this.aiCards]
};
} else if (this.opponentChoice === 'draft') {
opponentDeck = this._aiDraft();
} else {
opponentDeck = {