refactor(forbidden-island): replace global priorities with per-player direction system

Replace the single global priority buttons (focus treasure, regroup, defend)
with per-player direction assignments accessible via clickable pills on each
player's HUD. Each player can now be directed to: Auto, focus a specific
treasure (Earth/Wind/Fire/Ocean), Regroup, or Defend Temples.

- Remove old priority toggle buttons and renderPriorities()
- Add direction pills on partner HUDs and portrait panels
- Implement dropdown menu for selecting directions
- Convert direction choices to priority objects for AI execution
- Increase chat panel height to accommodate new layout
- Add click consumption guard on trade modal trigger
This commit is contained in:
Brian Fertig 2026-06-19 21:10:01 -06:00
parent 41818f7a4a
commit ea135b35ed
1 changed files with 148 additions and 52 deletions

View File

@ -65,6 +65,11 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
this.tradeModalObjs = []; // all objects in the trade modal this.tradeModalObjs = []; // all objects in the trade modal
this.tradeHighlightObjs = []; // highlight borders for selected card this.tradeHighlightObjs = []; // highlight borders for selected card
this.tradeSelection = null; // { seat, cardIdx, card, frame, worldX, worldY } this.tradeSelection = null; // { seat, cardIdx, card, frame, worldX, worldY }
this.seatDirections = {}; // seat → null|'earth'|'wind'|'fire'|'ocean'|'regroup'|'defend'
this.directionDropdown = null; // shared popup container (one at a time)
this.directionDismiss = null; // full-screen dismiss zone behind dropdown
this.hudPillTexts = {}; // seat → HUD pill Phaser.Text for live label updates
this._dirClickConsumed = false;
} }
create() { create() {
@ -225,7 +230,7 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
}); });
// Chat panel // Chat panel
const chatY = 248, chatH = 470; const chatY = 248, chatH = 548;
const cp = this.add.graphics().setDepth(DEPTH.ui); const cp = this.add.graphics().setDepth(DEPTH.ui);
cp.fillStyle(0x000000, 0.4); cp.fillRoundedRect(RAIL_X, chatY, RAIL_W, chatH, 12); cp.fillStyle(0x000000, 0.4); cp.fillRoundedRect(RAIL_X, chatY, RAIL_W, chatH, 12);
cp.lineStyle(2, COLORS.accent, 0.5); cp.strokeRoundedRect(RAIL_X, chatY, RAIL_W, chatH, 12); cp.lineStyle(2, COLORS.accent, 0.5); cp.strokeRoundedRect(RAIL_X, chatY, RAIL_W, chatH, 12);
@ -236,24 +241,6 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
wordWrap: { width: this.chatBox.w }, lineSpacing: 4, wordWrap: { width: this.chatBox.w }, lineSpacing: 4,
}).setDepth(DEPTH.ui); }).setDepth(DEPTH.ui);
// Priority buttons
const py = 740;
this.add.text(RAIL_X, py - 24, 'DIRECT THE TEAM', { fontFamily: 'Righteous', fontSize: '14px', color: COLORS.accentHex }).setDepth(DEPTH.ui);
this.priorityButtons = [];
const mkChip = (x, y, w, label, onClick) => {
const b = new Button(this, x + w / 2, y, label, onClick, { width: w, height: 40, fontSize: 16, variant: 'ghost' });
b.setDepth(DEPTH.ui); return b;
};
const fw = (RAIL_W - 18) / 4;
TREASURE_KEYS.forEach((k, i) => {
const b = mkChip(RAIL_X + i * (fw + 6), py + 16, fw, k[0].toUpperCase() + k.slice(1), () => this.toggleFocus(k));
b._key = k; this.priorityButtons.push(b);
});
const hw = (RAIL_W - 12) / 3;
this.regroupBtn = mkChip(RAIL_X, py + 64, hw, 'Regroup', () => this.toggleRegroup());
this.defendBtn = mkChip(RAIL_X + hw + 6, py + 64, hw, 'Defend Temples', () => this.toggleDefend());
mkChip(RAIL_X + 2 * (hw + 6), py + 64, hw, 'Clear', () => this.clearPriorities());
// Hand // Hand
this.add.text(RAIL_X, 856, 'YOUR HAND', { fontFamily: 'Righteous', fontSize: '14px', color: COLORS.accentHex }).setDepth(DEPTH.ui); this.add.text(RAIL_X, 856, 'YOUR HAND', { fontFamily: 'Righteous', fontSize: '14px', color: COLORS.accentHex }).setDepth(DEPTH.ui);
this.handLayer = this.add.container(0, 0).setDepth(DEPTH.ui); this.handLayer = this.add.container(0, 0).setDepth(DEPTH.ui);
@ -415,19 +402,11 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
this.drawWaterMeter(); this.drawWaterMeter();
this.renderBanner(); this.renderBanner();
this.renderButtons(); this.renderButtons();
this.renderPriorities();
this.renderChat(); this.renderChat();
this.highlightTargets(); this.highlightTargets();
this.updateDeckCounts(); this.updateDeckCounts();
} }
renderPriorities() {
const pr = this.gs.priorities;
for (const b of this.priorityButtons) b.setActive(pr.focusTreasure === b._key);
this.regroupBtn.setActive(!!pr.regroup);
this.defendBtn.setActive((pr.saveTiles ?? []).length > 0);
}
renderPawns() { renderPawns() {
this.pawnLayer.removeAll(true); this.pawnLayer.removeAll(true);
this.pawnObjects = {}; this.pawnObjects = {};
@ -584,7 +563,7 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
hitZone.on('pointerover', () => { this.input.setDefaultCursor('pointer'); }); hitZone.on('pointerover', () => { this.input.setDefaultCursor('pointer'); });
hitZone.on('pointerout', () => { this.input.setDefaultCursor('default'); }); hitZone.on('pointerout', () => { this.input.setDefaultCursor('default'); });
hitZone.on('pointerup', () => { hitZone.on('pointerup', () => {
if (this.busy || !this.introComplete || isGameOver(this.gs)) return; if (this._dirClickConsumed || this.busy || !this.introComplete || isGameOver(this.gs)) return;
this.openTradeModal(); this.openTradeModal();
}); });
@ -643,6 +622,28 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: role.colorHex, fontFamily: '"Julius Sans One"', fontSize: '11px', color: role.colorHex,
}).setDepth(DEPTH.ui + 1)); }).setDepth(DEPTH.ui + 1));
// Direction pill (right-aligned in slot)
const pillW = 74, pillH = 18;
const pillX = slotLeft + slotW - (idx < N - 1 ? 6 : 4);
const pillY = nameY - 1;
const pillBg = reg(this.add.graphics().setDepth(DEPTH.ui + 4));
pillBg.fillStyle(0x0a2538, 1);
pillBg.fillRoundedRect(pillX - pillW, pillY, pillW, pillH, 4);
pillBg.lineStyle(1, 0x2a5070, 1);
pillBg.strokeRoundedRect(pillX - pillW, pillY, pillW, pillH, 4);
const pillLabel = this.directionLabel(this.seatDirections[player.seat] ?? null) + ' ▼';
const pillText = reg(this.add.text(pillX - pillW / 2, pillY + pillH / 2, pillLabel, {
fontFamily: '"Julius Sans One"', fontSize: '10px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 5));
this.hudPillTexts[player.seat] = pillText;
const pillZone = reg(this.add.zone(pillX - pillW, pillY, pillW, pillH)
.setOrigin(0, 0).setInteractive({ useHandCursor: true }).setDepth(DEPTH.ui + 5));
pillZone.on('pointerup', () => {
this._dirClickConsumed = true;
this.time.delayedCall(0, () => { this._dirClickConsumed = false; });
this.openDirectionMenu(player.seat, pillX - pillW, pillY + pillH + 4);
});
// Store card slot origin for renderPartnerHUD // Store card slot origin for renderPartnerHUD
this.partnerCardSlots[player.seat] = { cardX: textX, cardY: cardsY }; this.partnerCardSlots[player.seat] = { cardX: textX, cardY: cardsY };
}); });
@ -1011,6 +1012,27 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: role.colorHex, fontFamily: '"Julius Sans One"', fontSize: '12px', color: role.colorHex,
}).setDepth(DEPTH.popup + 3)); }).setDepth(DEPTH.popup + 3));
// Direction pill (AI players only, right-aligned in column)
if (player.seat !== this.humanSeat) {
const pillW = 80, pillH = 20;
const pillRight = panelX + (i + 1) * colW - 12;
const pillTop = portCY - portR; // top of portrait area
const pillBg = reg(this.add.graphics().setDepth(DEPTH.popup + 4));
pillBg.fillStyle(0x0a2538, 1);
pillBg.fillRoundedRect(pillRight - pillW, pillTop, pillW, pillH, 4);
pillBg.lineStyle(1, 0x2a5070, 1);
pillBg.strokeRoundedRect(pillRight - pillW, pillTop, pillW, pillH, 4);
reg(this.add.text(pillRight - pillW / 2, pillTop + pillH / 2,
this.directionLabel(this.seatDirections[player.seat] ?? null) + ' ▼', {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.popup + 5));
const pillZone = reg(this.add.zone(pillRight - pillW, pillTop, pillW, pillH)
.setOrigin(0, 0).setInteractive({ useHandCursor: true }).setDepth(DEPTH.popup + 5));
pillZone.on('pointerup', () => {
this.openDirectionMenu(player.seat, pillRight - pillW, pillTop + pillH + 4, DEPTH.popup + 8);
});
}
// Hand cards (wrap to second row after CARDS_PER_ROW) // Hand cards (wrap to second row after CARDS_PER_ROW)
if (!player.hand.length) { if (!player.hand.length) {
reg(this.add.text(textX, cardsTop + cardH / 2, 'No cards', { reg(this.add.text(textX, cardsTop + cardH / 2, 'No cards', {
@ -1180,33 +1202,106 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
if (this.gs.phase === 'won') this.endGame(); if (this.gs.phase === 'won') this.endGame();
} }
// ── Priorities ────────────────────────────────────────────────────────────── // ── Per-player directions ──────────────────────────────────────────────────
toggleFocus(k) { directionLabel(dir) {
const cur = this.gs.priorities.focusTreasure; const MAP = { earth: 'Earth', wind: 'Wind', fire: 'Fire', ocean: 'Ocean', regroup: 'Regroup', defend: 'Defend' };
this.gs = setPriority(this.gs, { focusTreasure: cur === k ? null : k }); return MAP[dir] ?? 'Auto';
if (cur !== k) this.ackPriority(`focus on ${TREASURES[k].name}`); }
directionToPriority(gs, dir) {
if (!dir || dir === 'auto') return { focusTreasure: null, regroup: false, saveTiles: [], hold: false };
if (dir === 'regroup') return { regroup: true, focusTreasure: null, saveTiles: [] };
if (dir === 'defend') {
const tiles = Object.values(gs.tiles)
.filter((t) => t.treasure && t.state !== 'sunk' && !gs.players.some((p) => p.captured[t.treasure]))
.map((t) => t.id);
return { saveTiles: tiles, focusTreasure: null, regroup: false };
}
return { focusTreasure: dir, regroup: false, saveTiles: [] };
}
setDirection(seat, dir) {
this.seatDirections[seat] = dir || null;
this.closeDirectionMenu();
const label = this.directionLabel(dir);
if (this.hudPillTexts[seat]) this.hudPillTexts[seat].setText(label + ' ▼');
// Rebuild trade modal in place so its pill reflects the new direction
if (this.tradeModalObjs.length) { this.closeTradeModal(); this.openTradeModal(); }
if (dir && dir !== 'auto') {
const player = this.gs.players[seat];
if (player) { const l = lineForAck(player.role, label); this.post(player.role, l.text, seat); }
}
this.render(); this.render();
} }
toggleRegroup() {
const v = !this.gs.priorities.regroup; openDirectionMenu(seat, x, y, baseDepth = DEPTH.popup) {
this.gs = setPriority(this.gs, { regroup: v }); this.closeDirectionMenu();
if (v) this.ackPriority('regroup at Fools\' Landing');
this.render(); const OPTIONS = [
{ dir: null, label: 'Auto' },
{ dir: 'earth', label: 'Focus Earth' },
{ dir: 'wind', label: 'Focus Wind' },
{ dir: 'fire', label: 'Focus Fire' },
{ dir: 'ocean', label: 'Focus Ocean' },
{ dir: 'regroup', label: 'Regroup' },
{ dir: 'defend', label: 'Defend Temples'},
];
const rowH = 28, padX = 12, padY = 8, menuW = 140;
const menuH = OPTIONS.length * rowH + padY * 2;
const clampedX = Math.min(x, GAME_WIDTH - menuW - 8);
const clampedY = Math.min(y, GAME_HEIGHT - menuH - 8);
const curDir = this.seatDirections[seat] ?? null;
// Full-screen dismiss zone
const dismiss = this.add.zone(0, 0, GAME_WIDTH, GAME_HEIGHT)
.setOrigin(0, 0).setInteractive().setDepth(baseDepth - 1);
dismiss.on('pointerup', () => this.closeDirectionMenu());
this.directionDismiss = dismiss;
// Popup container
const ctr = this.add.container(clampedX, clampedY).setDepth(baseDepth);
const bg = this.add.graphics();
bg.fillStyle(0x061a2a, 1);
bg.fillRoundedRect(0, 0, menuW, menuH, 6);
bg.lineStyle(1, 0x2a5070, 1);
bg.strokeRoundedRect(0, 0, menuW, menuH, 6);
ctr.add(bg);
OPTIONS.forEach((opt, i) => {
const ry = padY + i * rowH;
const isCurrent = opt.dir === curDir;
const rowBg = this.add.graphics();
rowBg.fillStyle(0xffffff, 0);
rowBg.fillRect(1, ry, menuW - 2, rowH);
ctr.add(rowBg);
const bullet = this.add.text(padX, ry + rowH / 2, isCurrent ? '●' : '○', {
fontFamily: '"Julius Sans One"', fontSize: '10px',
color: isCurrent ? COLORS.accentHex : COLORS.mutedHex,
}).setOrigin(0, 0.5);
ctr.add(bullet);
const lbl = this.add.text(padX + 14, ry + rowH / 2, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '12px',
color: isCurrent ? COLORS.textHex : COLORS.mutedHex,
}).setOrigin(0, 0.5);
ctr.add(lbl);
const row = this.add.zone(1, ry, menuW - 2, rowH)
.setOrigin(0, 0).setInteractive({ useHandCursor: true });
row.on('pointerover', () => { rowBg.clear(); rowBg.fillStyle(0xffffff, 0.07); rowBg.fillRect(1, ry, menuW - 2, rowH); });
row.on('pointerout', () => { rowBg.clear(); rowBg.fillStyle(0xffffff, 0); rowBg.fillRect(1, ry, menuW - 2, rowH); });
row.on('pointerup', () => this.setDirection(seat, opt.dir));
ctr.add(row);
});
this.directionDropdown = ctr;
} }
toggleDefend() {
const on = (this.gs.priorities.saveTiles ?? []).length === 0; closeDirectionMenu() {
const tiles = on ? Object.values(this.gs.tiles).filter((t) => t.treasure && t.state !== 'sunk' && !this.gs.players.some((p) => p.captured[t.treasure])).map((t) => t.id) : []; if (this.directionDropdown) { try { this.directionDropdown.destroy(); } catch {} this.directionDropdown = null; }
this.gs = setPriority(this.gs, { saveTiles: tiles }); if (this.directionDismiss) { try { this.directionDismiss.destroy(); } catch {} this.directionDismiss = null; }
if (on) this.ackPriority('defend the temples');
this.render();
}
clearPriorities() {
this.gs = setPriority(this.gs, { focusTreasure: null, regroup: false, hold: false, saveTiles: [] });
this.render();
}
ackPriority(label) {
const ai = this.gs.players.find((p) => p.seat !== this.humanSeat);
if (ai) { const l = lineForAck(ai.role, label); this.post(ai.role, l.text, ai.seat); }
} }
// ── Initial flood animation ────────────────────────────────────────────────── // ── Initial flood animation ──────────────────────────────────────────────────
@ -1601,6 +1696,7 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
aiTurn(seat) { aiTurn(seat) {
this.busy = true; this.busy = true;
this.gs = setPriority(this.gs, this.directionToPriority(this.gs, this.seatDirections[seat] ?? null));
// Announce the plan. // Announce the plan.
const intent = describeIntent(this.gs, seat); const intent = describeIntent(this.gs, seat);
const line = lineForIntent(intent); const line = lineForIntent(intent);