From ea135b35eddcf3efbbc71a1a8849c55471177b3c Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 19 Jun 2026 21:10:01 -0600 Subject: [PATCH] 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 --- .../forbiddenisland/ForbiddenIslandGame.js | 200 +++++++++++++----- 1 file changed, 148 insertions(+), 52 deletions(-) diff --git a/public/src/games/forbiddenisland/ForbiddenIslandGame.js b/public/src/games/forbiddenisland/ForbiddenIslandGame.js index 3ebdb4a..b0f99e5 100644 --- a/public/src/games/forbiddenisland/ForbiddenIslandGame.js +++ b/public/src/games/forbiddenisland/ForbiddenIslandGame.js @@ -65,6 +65,11 @@ export default class ForbiddenIslandGame extends Phaser.Scene { this.tradeModalObjs = []; // all objects in the trade modal this.tradeHighlightObjs = []; // highlight borders for selected card 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() { @@ -225,7 +230,7 @@ export default class ForbiddenIslandGame extends Phaser.Scene { }); // Chat panel - const chatY = 248, chatH = 470; + const chatY = 248, chatH = 548; const cp = this.add.graphics().setDepth(DEPTH.ui); 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); @@ -236,24 +241,6 @@ export default class ForbiddenIslandGame extends Phaser.Scene { wordWrap: { width: this.chatBox.w }, lineSpacing: 4, }).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 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); @@ -415,19 +402,11 @@ export default class ForbiddenIslandGame extends Phaser.Scene { this.drawWaterMeter(); this.renderBanner(); this.renderButtons(); - this.renderPriorities(); this.renderChat(); this.highlightTargets(); 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() { this.pawnLayer.removeAll(true); this.pawnObjects = {}; @@ -584,7 +563,7 @@ export default class ForbiddenIslandGame extends Phaser.Scene { hitZone.on('pointerover', () => { this.input.setDefaultCursor('pointer'); }); hitZone.on('pointerout', () => { this.input.setDefaultCursor('default'); }); 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(); }); @@ -643,6 +622,28 @@ export default class ForbiddenIslandGame extends Phaser.Scene { fontFamily: '"Julius Sans One"', fontSize: '11px', color: role.colorHex, }).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 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, }).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) if (!player.hand.length) { 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(); } - // ── Priorities ────────────────────────────────────────────────────────────── - toggleFocus(k) { - const cur = this.gs.priorities.focusTreasure; - this.gs = setPriority(this.gs, { focusTreasure: cur === k ? null : k }); - if (cur !== k) this.ackPriority(`focus on ${TREASURES[k].name}`); + // ── Per-player directions ────────────────────────────────────────────────── + directionLabel(dir) { + const MAP = { earth: 'Earth', wind: 'Wind', fire: 'Fire', ocean: 'Ocean', regroup: 'Regroup', defend: 'Defend' }; + return MAP[dir] ?? 'Auto'; + } + + 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(); } - toggleRegroup() { - const v = !this.gs.priorities.regroup; - this.gs = setPriority(this.gs, { regroup: v }); - if (v) this.ackPriority('regroup at Fools\' Landing'); - this.render(); + + openDirectionMenu(seat, x, y, baseDepth = DEPTH.popup) { + this.closeDirectionMenu(); + + 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; - 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) : []; - this.gs = setPriority(this.gs, { saveTiles: tiles }); - 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); } + + closeDirectionMenu() { + if (this.directionDropdown) { try { this.directionDropdown.destroy(); } catch {} this.directionDropdown = null; } + if (this.directionDismiss) { try { this.directionDismiss.destroy(); } catch {} this.directionDismiss = null; } } // ── Initial flood animation ────────────────────────────────────────────────── @@ -1601,6 +1696,7 @@ export default class ForbiddenIslandGame extends Phaser.Scene { aiTurn(seat) { this.busy = true; + this.gs = setPriority(this.gs, this.directionToPriority(this.gs, this.seatDirections[seat] ?? null)); // Announce the plan. const intent = describeIntent(this.gs, seat); const line = lineForIntent(intent);