fertig-classic-games/public/src/games/forbiddenisland/ForbiddenIslandGame.js

1937 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
createInitialState, cloneState, applyPendingFlood, peekFloodDraw, peekTreasureDraw,
legalActions, applyAction, endActions, discardCard,
resolveFlood, playSandbags, playHelicopter, attemptEscape, canEscape,
isGameOver, setPriority, capturedCount, handTreasureCounts, swapCards,
} from './IslandLogic.js';
import {
TREASURES, TREASURE_KEYS, ROLES, ROLE_KEYS, SPECIAL, MAX_WATER, DIFFICULTY,
floodDrawCount, GRID, CARDS_TO_CAPTURE, HAND_LIMIT, TILE_FRAME_ROW, cardFrame,
} from './IslandData.js';
import { chooseAction, chooseFreeCard, chooseDiscard, describeIntent, nextThinkDelay } from './IslandAI.js';
import { lineForIntent, lineForEvent, lineForAck, roleEmoji, roleName, roleColorHex } from './IslandChat.js';
// ── Layout ──────────────────────────────────────────────────────────────────
const TILE = 110, GAP = 8, PITCH = TILE + GAP;
const BOARD_W = GRID * PITCH - GAP; // 700
const BX0 = 70; // board left
const BY0 = 158; // board top
const RAIL_X = BX0 + BOARD_W + 50; // right rail left edge (~820)
const RAIL_W = GAME_WIDTH - RAIL_X - 110; // leaves room for water meter
const DEPTH = { bg: 0, board: 5, tile: 10, pawn: 20, ui: 40, popup: 60, banner: 90 };
// Tile state colours.
const TC = {
dry: 0x5c7d54, dryStone: 0x7a7059,
flooded: 0x2f6f9f, floodEdge: 0x67b6e0,
sunk: 0x0c2738,
border: 0x2b231a,
};
export default class ForbiddenIslandGame extends Phaser.Scene {
constructor() { super('ForbiddenIslandGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.difficulty = DIFFICULTY[data.difficulty] ? data.difficulty : 'novice';
this.gs = null;
this.humanSeat = 0;
this.tileViews = {}; // id -> { container, bg, label, gem }
this.pawnLayer = null;
this.pawnObjects = {}; // seat -> [circle, ?ring] — cleared & repopulated by renderPawns
this.busy = false; // input locked during animations / AI turns
this.mode = null; // null | 'sandbags' | 'helicopter' | 'give' | 'navigate'
this.modeData = null;
this.messages = []; // chat log
this.partnerNames = {}; // seat -> display name
this.popup = null;
this.introComplete = false; // pawns hidden until intro sequence finishes
this.deckCountTexts = null; // { flood, treasure, adventurer }
this.floodDeckPos = null; // world position of flood deck pile (for animation)
this.treasureDeckPos = null; // world position of treasure deck pile (for animation)
this.tempHandImages = []; // animated card images parked at hand slots; cleared by renderHand
this.handCards = []; // active card containers added directly to scene; cleared by renderHand
this.partnerHUDObjs = []; // static HUD objects (portraits, names) — built once on startGameplay
this.partnerCardObjs = []; // dynamic card thumbnails — rebuilt by renderPartnerHUD each render
this.partnerCardSlots = {}; // seat -> { cardX, cardY } set by buildPartnerHUD
this.beginModal = null;
this.tradeModalObjs = []; // all objects in the trade modal
this.tradeHighlightObjs = []; // highlight borders for selected card
this.tradeSelection = null; // { seat, cardIdx, card, frame, worldX, worldY }
}
create() {
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch (e) { /* music optional */ }
this.buildBackground();
// Roster: human + up to 3 AI partners. Distinct random roles.
const playerCount = Math.max(2, Math.min(4, 1 + this.opponents.length));
const roles = Phaser.Utils.Array.Shuffle(ROLE_KEYS.slice()).slice(0, playerCount);
this.skillBySeat = {};
for (let seat = 0; seat < playerCount; seat++) {
if (seat === this.humanSeat) { this.partnerNames[seat] = 'You'; this.skillBySeat[seat] = 5; }
else {
const opp = this.opponents[seat - 1];
this.partnerNames[seat] = opp?.name ?? `Partner ${seat}`;
this.skillBySeat[seat] = Math.max(1, Math.min(5, opp?.skill ?? 4));
}
}
// All tiles start dry; the 6 initial floods are deferred for the animation.
this.gs = createInitialState({ roles, difficulty: this.difficulty, humanSeat: this.humanSeat, skipInitialFlood: true });
this.buildBoard();
this.buildRail();
this.buildWaterMeter();
this.buildDeckDisplay();
this.pawnLayer = this.add.container(0, 0).setDepth(DEPTH.pawn);
this.render();
this.showBeginModal();
}
// ── Background ──────────────────────────────────────────────────────────────
buildBackground() {
const g = this.add.graphics().setDepth(DEPTH.bg);
g.fillGradientStyle(0x07314a, 0x07314a, 0x041824, 0x041824, 1);
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
this.add.text(GAME_WIDTH / 2, 26, 'Forbidden Island', {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
new Button(this, GAME_WIDTH - 200, GAME_HEIGHT - 46, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 180, height: 52 });
}
// ── Board ───────────────────────────────────────────────────────────────────
tileWorld(t) { return { x: BX0 + t.c * PITCH + TILE / 2, y: BY0 + t.r * PITCH + TILE / 2 }; }
buildBoard() {
// soft island sea bed under the tiles
const sea = this.add.graphics().setDepth(DEPTH.board);
sea.fillStyle(0x062536, 0.6);
sea.fillRoundedRect(BX0 - 24, BY0 - 24, BOARD_W + 48, BOARD_W + 48, 30);
const hasArt = this.textures.exists('forbiddenisland-tiles');
for (const t of Object.values(this.gs.tiles)) {
const { x, y } = this.tileWorld(t);
const container = this.add.container(x, y).setDepth(DEPTH.tile);
const row = TILE_FRAME_ROW[t.id];
const img = hasArt ? this.add.image(0, 0, 'forbiddenisland-tiles', row * 2).setDisplaySize(TILE, TILE) : null;
const bg = this.add.graphics();
const label = this.add.text(0, TILE / 2 - 14, t.name, {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: '#f4efe2',
align: 'center', wordWrap: { width: TILE - 12 },
}).setOrigin(0.5, 1);
let gem = null;
if (t.treasure) {
gem = this.add.graphics();
// small corner badge so it doesn't cover the tile art
const gx = hasArt ? -TILE / 2 + 15 : 0, gy = hasArt ? -TILE / 2 + 15 : -8;
gem.fillStyle(0x000000, 0.5); gem.fillCircle(gx, gy, 13);
gem.fillStyle(TREASURES[t.treasure].color, 1); gem.fillCircle(gx, gy, 11);
gem.lineStyle(2, 0xffffff, 0.85); gem.strokeCircle(gx, gy, 11);
}
// Input goes on an invisible, centered Rectangle child rather than a manual
// hitArea on the Container — the latter mis-aligns the hitbox (offset up/left).
const hit = this.add.rectangle(0, 0, TILE, TILE, 0x000000, 0).setInteractive({ useHandCursor: true });
const layers = img ? [img, bg, label] : [bg, label];
container.add(layers); if (gem) container.add(gem);
container.add(hit);
hit.on('pointerup', () => this.onTileClick(t.id));
hit.on('pointerover', () => { if (!this.busy) container.setScale(1.03); });
hit.on('pointerout', () => container.setScale(1));
this.tileViews[t.id] = { container, img, bg, label, gem, row, hit };
}
}
drawTile(t) {
const v = this.tileViews[t.id];
const g = v.bg; g.clear();
const hw = TILE / 2;
// Sprite-art path (with vector fallback below if the sheet didn't load).
if (v.img) {
const flooded = t.state === 'flooded';
const sunk = t.state === 'sunk';
v.img.setFrame(v.row * 2 + (sunk || flooded ? 1 : 0));
if (sunk) {
v.img.setTint(0x21465a).setAlpha(0.5);
v.container.setAngle(0).setAlpha(0.9);
v.label.setAlpha(0.3); if (v.gem) v.gem.setAlpha(0.25);
g.lineStyle(2, TC.sunk, 0.8); g.strokeRect(-hw, -hw, TILE, TILE);
return;
}
v.img.clearTint().setAlpha(1);
v.container.setAlpha(1).setAngle(flooded ? -3 : 0);
v.label.setAlpha(1); if (v.gem) v.gem.setAlpha(1);
g.fillStyle(0x000000, 0.45); g.fillRect(-hw, hw - 22, TILE, 22); // label backing
g.lineStyle(t.landing ? 4 : (t.treasure ? 3 : 2), t.landing ? COLORS.gold : (t.treasure ? TREASURES[t.treasure].color : 0x10202a), 1);
g.strokeRect(-hw, -hw, TILE, TILE);
if (flooded) { g.lineStyle(2, TC.floodEdge, 0.55); g.strokeRect(-hw + 2, -hw + 2, TILE - 4, TILE - 4); }
return;
}
// ---- vector fallback (no spritesheet) ----
if (t.state === 'sunk') {
v.container.setAngle(0);
g.fillStyle(TC.sunk, 0.55);
g.fillRoundedRect(-hw, -hw, TILE, TILE, 14);
v.label.setAlpha(0.25); if (v.gem) v.gem.setAlpha(0.2);
v.container.setAlpha(0.85);
return;
}
v.container.setAlpha(1); v.label.setAlpha(1); if (v.gem) v.gem.setAlpha(1);
const flooded = t.state === 'flooded';
g.fillStyle(flooded ? TC.flooded : (t.treasure || t.landing ? TC.dryStone : TC.dry), 1);
g.fillRoundedRect(-hw, -hw, TILE, TILE, 14);
if (flooded) {
g.fillStyle(TC.floodEdge, 0.28); g.fillRoundedRect(-hw, -hw, TILE, TILE, 14);
}
g.lineStyle(t.landing ? 4 : (t.treasure ? 3 : 2), t.landing ? COLORS.gold : (t.treasure ? TREASURES[t.treasure].color : TC.border), 1);
g.strokeRoundedRect(-hw, -hw, TILE, TILE, 14);
if (t.landing) { // helipad mark
g.lineStyle(3, COLORS.gold, 0.9); g.strokeCircle(0, -2, 22);
g.lineStyle(3, COLORS.gold, 0.9);
g.beginPath(); g.moveTo(-12, -12); g.lineTo(12, 8); g.moveTo(12, -12); g.lineTo(-12, 8); g.strokePath();
}
v.container.setAngle(flooded ? -3 : 0);
}
// ── Right rail: banner, treasures, chat, priorities, hand, buttons ──────────
buildRail() {
// Turn banner
this.bannerBg = this.add.graphics().setDepth(DEPTH.ui);
this.bannerText = this.add.text(RAIL_X, 110, '', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, wordWrap: { width: RAIL_W },
}).setDepth(DEPTH.ui);
// Treasure tracker
this.treasureChips = {};
TREASURE_KEYS.forEach((k, i) => {
const x = RAIL_X + i * (RAIL_W / 4);
const cx = x + RAIL_W / 8;
const g = this.add.graphics().setDepth(DEPTH.ui);
const t = this.add.text(cx, 196, TREASURES[k].name, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: COLORS.mutedHex,
align: 'center', wordWrap: { width: RAIL_W / 4 - 8 },
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
this.treasureChips[k] = { g, t, cx };
});
// Chat panel
const chatY = 248, chatH = 470;
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);
this.add.text(RAIL_X + 14, chatY + 8, 'TEAM CHAT', { fontFamily: 'Righteous', fontSize: '15px', color: COLORS.accentHex }).setDepth(DEPTH.ui);
this.chatBox = { x: RAIL_X + 14, y: chatY + 34, w: RAIL_W - 28, h: chatH - 44 };
this.chatText = this.add.text(this.chatBox.x, this.chatBox.y, '', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
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);
// Action buttons
this.endBtn = new Button(this, RAIL_X + 110, 1030, 'End Turn', () => this.onEndTurn(), { width: 200, height: 50, fontSize: 20 });
this.captureBtn = new Button(this, RAIL_X + 330, 1030, 'Capture', () => this.onCapture(), { width: 200, height: 50, fontSize: 20 });
this.escapeBtn = new Button(this, RAIL_X + 550, 1030, 'Escape!', () => this.onEscape(), { width: 200, height: 50, fontSize: 20 });
[this.endBtn, this.captureBtn, this.escapeBtn].forEach((b) => b.setDepth(DEPTH.ui));
}
buildWaterMeter() {
const x = GAME_WIDTH - 70, top = 160, bottom = 900;
this.add.text(x, top - 34, 'WATER', { fontFamily: 'Righteous', fontSize: '16px', color: COLORS.accentHex }).setOrigin(0.5).setDepth(DEPTH.ui);
this.waterX = x; this.waterTop = top; this.waterBottom = bottom;
this.waterG = this.add.graphics().setDepth(DEPTH.ui);
this.waterLabel = this.add.text(x, bottom + 18, '', { fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.textHex }).setOrigin(0.5).setDepth(DEPTH.ui);
}
// ── Deck display (below board) ───────────────────────────────────────────────
buildDeckDisplay() {
const areaY = BY0 + BOARD_W + 46; // start well below the board hint area (~904)
const colW = BOARD_W / 3; // ~233px per column
const pileW = 80, pileH = 108, pileR = 8;
const slots = [
{ key: 'flood', label: 'Flood Deck', col: 0 },
{ key: 'treasure', label: 'Treasure Deck', col: 1 },
{ key: 'adventurer', label: 'Adventurer Cards', col: 2 },
];
this.deckCountTexts = {};
for (const { key, label, col } of slots) {
const cx = BX0 + col * colW; // left edge of column
const pileX = cx + 14; // left edge of pile
const pileY = areaY + 14;
const pileCX = pileX + pileW / 2;
const pileCY = pileY + pileH / 2;
// Stacked card pile (3 offset backs → depth illusion)
const g = this.add.graphics().setDepth(DEPTH.ui);
for (let i = 2; i >= 0; i--) {
const ox = i * 2, oy = i * -2;
g.fillStyle(0x0a2030, 1);
g.fillRoundedRect(pileX + ox, pileY + oy, pileW, pileH, pileR);
g.lineStyle(1, 0x2a4a60, 1);
g.strokeRoundedRect(pileX + ox, pileY + oy, pileW, pileH, pileR);
}
// Subtle pattern on top card
g.lineStyle(1, 0x1a3a50, 0.6);
g.strokeRoundedRect(pileX + 8, pileY + 8, pileW - 16, pileH - 16, 4);
// Text to the right of pile
const tx = pileX + pileW + 14;
this.add.text(tx, pileY + 8, label, {
fontFamily: 'Righteous', fontSize: '14px', color: COLORS.accentHex,
}).setDepth(DEPTH.ui);
const countText = this.add.text(tx, pileY + 32, '', {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex,
}).setDepth(DEPTH.ui);
this.deckCountTexts[key] = countText;
// Store pile centers for card-deal animation origins
if (key === 'flood') this.floodDeckPos = { x: pileCX, y: pileCY };
if (key === 'treasure') this.treasureDeckPos = { x: pileCX, y: pileCY };
}
}
updateDeckCounts() {
if (!this.deckCountTexts || !this.gs) return;
const pendingCount = (this.gs.pendingFlood ?? []).length;
this.deckCountTexts.flood.setText(`${this.gs.floodDeck.length + pendingCount} cards`);
this.deckCountTexts.treasure.setText(`${this.gs.treasureDeck.length} cards`);
const unused = ROLE_KEYS.length - this.gs.players.length;
this.deckCountTexts.adventurer.setText(unused > 0 ? `${unused} cards` : 'All assigned');
}
drawWaterMeter() {
const g = this.waterG; g.clear();
const segH = (this.waterBottom - this.waterTop) / MAX_WATER;
const w = 40, x = this.waterX - w / 2;
for (let lvl = MAX_WATER; lvl >= 1; lvl--) {
const y = this.waterTop + (MAX_WATER - lvl) * segH;
const on = lvl <= this.gs.waterLevel;
const danger = lvl >= 8;
const skull = lvl === MAX_WATER;
g.fillStyle(on ? (skull ? 0xb22a2a : danger ? 0xd1632f : 0x2f8fd0) : 0x14313f, on ? 1 : 0.7);
g.fillRoundedRect(x, y + 2, w, segH - 4, 6);
g.lineStyle(1, 0x000000, 0.4); g.strokeRoundedRect(x, y + 2, w, segH - 4, 6);
}
// marker
const my = this.waterTop + (MAX_WATER - this.gs.waterLevel) * segH + segH / 2;
g.fillStyle(COLORS.gold, 1);
g.fillTriangle(x - 12, my - 8, x - 12, my + 8, x - 2, my);
this.waterLabel.setText(`Lvl ${this.gs.waterLevel} · draw ${floodDrawCount(this.gs.waterLevel)}`);
}
// ── Intro sequence ──────────────────────────────────────────────────────────
showBeginModal() {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const pw = 800, ph = 380;
const diff = DIFFICULTY[this.difficulty];
const cont = this.add.container(0, 0).setDepth(DEPTH.popup + 1);
const overlay = this.add.graphics();
overlay.fillStyle(0x000000, 0.72);
overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
cont.add(overlay);
const panel = this.add.graphics();
panel.fillStyle(0x061a2a, 1);
panel.fillRoundedRect(cx - pw / 2, cy - ph / 2, pw, ph, 20);
panel.lineStyle(3, COLORS.gold, 1);
panel.strokeRoundedRect(cx - pw / 2, cy - ph / 2, pw, ph, 20);
cont.add(panel);
cont.add(this.add.text(cx, cy - 138, 'Forbidden Island', {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.goldHex,
}).setOrigin(0.5));
cont.add(this.add.text(cx, cy - 68, 'Gather your team and prepare to explore…', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5));
cont.add(this.add.text(cx, cy - 24, `Difficulty: ${diff.name}`, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5));
const btn = new Button(this, cx, cy + 120, 'Begin Game', () => this.beginGame(), {
width: 280, height: 60, fontSize: 24,
});
btn.setDepth(0);
cont.add(btn);
this.beginModal = cont;
}
beginGame() {
this.beginModal.destroy();
this.beginModal = null;
this.runInitialFlood(this.gs.pendingFlood.slice(), () => {
this.gs = applyPendingFlood(this.gs);
this.updateDeckCounts();
this.showPlayerIntros(
this.gs.players.map((p) => p.seat),
() => this.startGameplay(),
);
});
}
// ── Rendering ───────────────────────────────────────────────────────────────
render() {
for (const t of Object.values(this.gs.tiles)) this.drawTile(t);
this.renderPawns();
this.renderPartnerHUD();
this.renderTreasures();
this.renderHand();
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 = {};
if (!this.introComplete) return;
// group pawns by tile to cluster
const byTile = {};
for (const p of this.gs.players) (byTile[p.tileId] ??= []).push(p);
for (const [tileId, group] of Object.entries(byTile)) {
const t = this.gs.tiles[tileId]; if (!t) continue;
const { x, y } = this.tileWorld(t);
group.forEach((p, i) => {
const n = group.length;
const ox = (i - (n - 1) / 2) * 22;
const c = this.add.circle(x + ox, y + 12, 15, ROLES[p.role].color).setStrokeStyle(3, p.seat === this.gs.current ? 0xffffff : 0x000000, 1);
this.pawnLayer.add(c);
this.pawnObjects[p.seat] = [c];
if (p.isHuman) {
const ring = this.add.circle(x + ox, y + 12, 19).setStrokeStyle(2, COLORS.gold, 0.9);
this.pawnLayer.add(ring);
this.pawnObjects[p.seat].push(ring);
}
});
}
}
animatePawnMove(seat, fromTileId, toTileId, onDone) {
const player = this.gs.players[seat];
const role = ROLES[player.role];
const { x: fx, y: fy } = this.tileWorld(this.gs.tiles[fromTileId]);
const { x: tx, y: ty } = this.tileWorld(this.gs.tiles[toTileId]);
const YOFF = 12;
const depth = DEPTH.pawn + 1;
// Use the exact rendered position and hide the static pawn while flying.
const staticObjs = this.pawnObjects[seat] ?? [];
let startX = fx, startY = fy + YOFF;
if (staticObjs[0]) { startX = staticObjs[0].x; startY = staticObjs[0].y; }
for (const o of staticObjs) o.setAlpha(0);
const pawn = this.add.circle(startX, startY, 15, role.color)
.setStrokeStyle(3, seat === this.gs.current ? 0xffffff : 0x000000, 1)
.setDepth(depth);
const toDestroy = [pawn];
if (player.isHuman) {
const ring = this.add.circle(startX, startY, 19)
.setStrokeStyle(2, COLORS.gold, 0.9)
.setDepth(depth);
this.tweens.add({ targets: ring, x: tx, y: ty + YOFF, duration: 1200, ease: 'Cubic.easeInOut' });
toDestroy.push(ring);
}
this.tweens.add({
targets: pawn,
x: tx, y: ty + YOFF,
duration: 1200,
ease: 'Cubic.easeInOut',
onComplete: () => {
for (const o of toDestroy) o.destroy();
onDone();
},
});
}
animateShoreUp(tileId, onDone) {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const { x: tx, y: ty } = this.tileWorld(this.gs.tiles[tileId]);
const depth = DEPTH.popup + 2;
const rowIdx = TILE_FRAME_ROW[tileId];
const tileScale = TILE / 200;
const img = this.add.image(0, 0, 'forbiddenisland-tiles', rowIdx * 2 + 1).setDisplaySize(200, 200);
const borderGfx = this.add.graphics();
borderGfx.lineStyle(3, 0x67b6e0, 1);
borderGfx.strokeRoundedRect(-100, -100, 200, 200, 12);
const cont = this.add.container(tx, ty, [img, borderGfx]).setDepth(depth).setScale(tileScale);
if (this.cache.audio.exists('sfx-card-deal')) this.sound.play('sfx-card-deal', { volume: 0.4 });
// 1. Fly from tile board position to center, scaling up
this.tweens.add({
targets: cont,
x: cx, y: cy,
scaleX: 1, scaleY: 1,
duration: 380,
ease: 'Cubic.easeOut',
onComplete: () => {
// 2. Flip: collapse then expand with dry face
this.tweens.add({
targets: cont, scaleX: 0, duration: 150, ease: 'Linear',
onComplete: () => {
img.setFrame(rowIdx * 2);
borderGfx.clear();
borderGfx.lineStyle(3, 0xd4c08a, 1);
borderGfx.strokeRoundedRect(-100, -100, 200, 200, 12);
if (this.cache.audio.exists('sfx-card-show')) this.sound.play('sfx-card-show', { volume: 0.35 });
this.tweens.add({
targets: cont, scaleX: 1, duration: 150, ease: 'Linear',
onComplete: () => {
// 3. Pause then fly back, shrinking
this.time.delayedCall(800, () => {
if (this.cache.audio.exists('sfx-card-place')) this.sound.play('sfx-card-place', { volume: 0.3 });
this.tweens.add({
targets: cont,
x: tx, y: ty,
scaleX: tileScale, scaleY: tileScale,
duration: 400, ease: 'Cubic.easeIn',
onComplete: () => { cont.destroy(); onDone(); },
});
});
},
});
},
});
},
});
}
// ── Partner HUD (above the board) ──────────────────────────────────────────
// Static elements (portraits, names) built once; card thumbnails rebuilt each render.
buildPartnerHUD() {
for (const o of this.partnerHUDObjs) try { o.destroy(); } catch {}
this.partnerHUDObjs = [];
this.partnerCardSlots = {};
const reg = (o) => { this.partnerHUDObjs.push(o); return o; };
const aiSeats = this.gs.players.filter((p) => p.seat !== this.humanSeat);
if (!aiSeats.length) return;
const N = aiSeats.length;
const slotW = BOARD_W / N;
const PAWN_R = 26; // portrait circle radius
const CARD_H = 33;
const portCY = 68; // portrait center — panel centered between screen top and board top
const nameY = portCY - 20; // player name
const roleY = portCY - 3; // role label
const cardsY = portCY + 16; // card strip top
const textX0 = PAWN_R * 2 + 16;
// Panel sized snugly around the content
const panelTop = portCY - PAWN_R - 8;
const panelBot = cardsY + CARD_H + 8;
const panelG = reg(this.add.graphics().setDepth(DEPTH.ui - 1));
panelG.fillStyle(0x041824, 0.82);
panelG.fillRoundedRect(BX0 - 2, panelTop, BOARD_W + 4, panelBot - panelTop, 6);
panelG.lineStyle(1, 0x1a3a50, 0.5);
panelG.strokeRoundedRect(BX0 - 2, panelTop, BOARD_W + 4, panelBot - panelTop, 6);
// Clickable zone over the whole panel — opens trade modal
const hitZone = reg(this.add.zone(BX0 - 2, panelTop, BOARD_W + 4, panelBot - panelTop)
.setOrigin(0, 0).setInteractive().setDepth(DEPTH.ui + 3));
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;
this.openTradeModal();
});
// Visual affordance label
reg(this.add.text(BX0 + BOARD_W - 6, panelTop + 4, '↔ TRADE', {
fontFamily: 'Righteous', fontSize: '10px', color: COLORS.accentHex,
}).setOrigin(1, 0).setDepth(DEPTH.ui + 2));
aiSeats.forEach((player, idx) => {
const slotLeft = BX0 + idx * slotW;
const portCX = slotLeft + PAWN_R + 8;
const textX = slotLeft + textX0;
const role = ROLES[player.role];
const opp = this.opponents[player.seat - 1] ?? null;
// Slot divider (skip first)
if (idx > 0) {
const div = reg(this.add.graphics().setDepth(DEPTH.ui));
div.lineStyle(1, 0x1a3a50, 0.6);
div.lineBetween(slotLeft, panelTop + 4, slotLeft, panelBot - 4);
}
// Portrait dark circle + pawn-colour ring
const portG = reg(this.add.graphics().setDepth(DEPTH.ui));
portG.fillStyle(0x07202e, 1);
portG.fillCircle(portCX, portCY, PAWN_R);
portG.lineStyle(4, role.color, 1);
portG.strokeCircle(portCX, portCY, PAWN_R);
if (opp && this.textures.exists('opponents')) {
// Sprite clipped to circle
const maskG = this.add.graphics();
maskG.fillStyle(0xffffff); maskG.fillCircle(portCX, portCY, PAWN_R - 3);
maskG.setVisible(false);
const mask = maskG.createGeometryMask();
reg(this.add.image(portCX, portCY, 'opponents', opp.spriteIndex ?? 0)
.setDisplaySize((PAWN_R - 3) * 2, (PAWN_R - 3) * 2)
.setDepth(DEPTH.ui + 1)
.setMask(mask));
reg(maskG);
} else {
// Fallback: role initial in tinted circle
const avG = reg(this.add.graphics().setDepth(DEPTH.ui + 1));
avG.fillStyle(role.color, 0.35);
avG.fillCircle(portCX, portCY, PAWN_R - 3);
reg(this.add.text(portCX, portCY, role.name[0], {
fontFamily: 'Righteous', fontSize: '20px', color: '#ffffff',
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
}
// Name + role label
reg(this.add.text(textX, nameY, this.partnerNames[player.seat], {
fontFamily: 'Righteous', fontSize: '13px', color: COLORS.textHex,
}).setDepth(DEPTH.ui + 1));
reg(this.add.text(textX, roleY, role.name, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: role.colorHex,
}).setDepth(DEPTH.ui + 1));
// Store card slot origin for renderPartnerHUD
this.partnerCardSlots[player.seat] = { cardX: textX, cardY: cardsY };
});
}
renderPartnerHUD() {
if (!this.introComplete) return;
for (const o of this.partnerCardObjs) try { o.destroy(); } catch {}
this.partnerCardObjs = [];
const reg = (o) => { this.partnerCardObjs.push(o); return o; };
const CARD_W = 24, CARD_H = 33, CARD_GAP = 3;
const hasArt = this.textures.exists('forbiddenisland-cards');
for (const player of this.gs.players) {
if (player.seat === this.humanSeat) continue;
const slot = this.partnerCardSlots[player.seat];
if (!slot) continue;
const { cardX, cardY } = slot;
if (!player.hand.length) {
reg(this.add.text(cardX, cardY + CARD_H / 2, 'No cards', {
fontFamily: '"Julius Sans One"', fontSize: '10px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui + 1));
continue;
}
player.hand.forEach((card, ci) => {
const cx = cardX + ci * (CARD_W + CARD_GAP) + CARD_W / 2;
const cy = cardY + CARD_H / 2;
const frame = hasArt ? cardFrame(card) : null;
if (frame != null) {
reg(this.add.image(cx, cy, 'forbiddenisland-cards', frame)
.setDisplaySize(CARD_W, CARD_H)
.setDepth(DEPTH.ui + 1));
} else {
const info = cardInfo(card);
const g = reg(this.add.graphics().setDepth(DEPTH.ui + 1));
g.fillStyle(info.color, 1);
g.fillRoundedRect(cx - CARD_W / 2, cy - CARD_H / 2, CARD_W, CARD_H, 2);
}
const border = reg(this.add.graphics().setDepth(DEPTH.ui + 2));
border.lineStyle(1, 0xffffff, 0.35);
border.strokeRoundedRect(cx - CARD_W / 2, cy - CARD_H / 2, CARD_W, CARD_H, 2);
});
}
}
renderTreasures() {
for (const k of TREASURE_KEYS) {
const chip = this.treasureChips[k]; const g = chip.g; g.clear();
const captured = this.gs.players.some((p) => p.captured[k]);
g.fillStyle(TREASURES[k].color, captured ? 1 : 0.28);
g.fillCircle(chip.cx, 176, 15);
g.lineStyle(2, captured ? 0xffffff : COLORS.muted, 0.9); g.strokeCircle(chip.cx, 176, 15);
if (captured) { g.lineStyle(3, 0xffffff, 1); g.beginPath(); g.moveTo(chip.cx - 6, 176); g.lineTo(chip.cx - 1, 181); g.lineTo(chip.cx + 7, 170); g.strokePath(); }
chip.t.setColor(captured ? COLORS.textHex : COLORS.mutedHex);
}
}
renderHand() {
// Clear any in-flight animated card images parked at hand positions.
for (const img of this.tempHandImages) { try { img.destroy(); } catch { /* ignore */ } }
this.tempHandImages = [];
// Destroy previous hand card containers (added directly to scene, not to handLayer).
for (const c of this.handCards) { try { c.destroy(); } catch { /* ignore */ } }
this.handCards = [];
const me = this.gs.players[this.humanSeat];
const cardW = 92, cardH = 124, gap = 10;
const hasArt = this.textures.exists('forbiddenisland-cards');
me.hand.forEach((card, i) => {
const x = RAIL_X + 8 + i * (cardW + gap) + cardW / 2;
const y = 884 + cardH / 2;
const cont = this.add.container(x, y);
const frame = hasArt ? cardFrame(card) : null;
if (frame != null) {
const img = this.add.image(0, 0, 'forbiddenisland-cards', frame).setDisplaySize(cardW, cardH);
const border = this.add.graphics();
border.lineStyle(2, 0xffffff, 0.6); border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 10);
cont.add([img, border]);
} else {
const g = this.add.graphics();
const info = cardInfo(card);
g.fillStyle(info.color, 1); g.fillRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 10);
g.lineStyle(2, 0xffffff, 0.6); g.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 10);
const label = this.add.text(0, 0, info.label, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: info.text, align: 'center', wordWrap: { width: cardW - 12 },
}).setOrigin(0.5);
cont.add([g, label]);
}
// Add directly to scene (not to handLayer) so the hit area transform is
// computed correctly — nested containers break Phaser's input matrix chain.
cont.setDepth(DEPTH.ui)
.setInteractive(new Phaser.Geom.Rectangle(-cardW / 2, -cardH / 2, cardW, cardH), Phaser.Geom.Rectangle.Contains);
cont.on('pointerup', () => this.onCardClick(card, i));
cont.on('pointerover', () => { if (!this.busy) cont.y = y - 8; });
cont.on('pointerout', () => { cont.y = y; });
this.handCards.push(cont);
});
}
renderBanner() {
const cur = this.gs.players[this.gs.current];
const isHuman = cur.seat === this.humanSeat;
this.bannerBg.clear();
this.bannerBg.fillStyle(isHuman ? 0x2e5a2e : 0x3a2c14, 0.8);
this.bannerBg.fillRoundedRect(RAIL_X, 96, RAIL_W, 56, 10);
const name = this.partnerNames[cur.seat];
let msg;
if (this.gs.phase === 'discard') msg = `${this.partnerNames[this.gs.pendingDiscard]} must discard a card (hand limit ${HAND_LIMIT})`;
else if (isHuman) msg = `Your turn — ${ROLES[cur.role].name} · ${this.gs.actionsLeft} action${this.gs.actionsLeft === 1 ? '' : 's'} left`;
else msg = `${name}'s turn — ${ROLES[cur.role].name}`;
this.bannerText.setText(msg);
}
renderButtons() {
const me = this.gs.players[this.humanSeat];
const myTurn = this.gs.current === this.humanSeat && this.gs.phase === 'actions' && !this.busy;
this.endBtn.setEnabled(myTurn);
const here = this.gs.tiles[me.tileId];
const canCap = myTurn && here.treasure && !me.captured[here.treasure] && handTreasureCounts(me)[here.treasure] >= CARDS_TO_CAPTURE;
this.captureBtn.setEnabled(canCap).setAlpha(canCap ? 1 : 0.4);
const esc = canEscape(this.gs) && this.gs.current === this.humanSeat && !this.busy;
this.escapeBtn.setEnabled(esc).setAlpha(esc ? 1 : 0.4);
}
renderChat() {
// Show the last messages that fit the box.
const lines = this.messages.map((m) => m.role ? `${roleEmoji(m.role)} ${this.speaker(m)}: ${m.text}` : `${m.text}`);
// keep last ~10
this.chatText.setText(lines.slice(-10).join('\n'));
// if overflowing, trim from top until it fits
while (this.chatText.height > this.chatBox.h && this.messages.length > 1) {
this.messages.shift();
this.chatText.setText(this.messages.slice(-10).map((m) => m.role ? `${roleEmoji(m.role)} ${this.speaker(m)}: ${m.text}` : `${m.text}`).join('\n'));
}
}
speaker(m) {
if (m.seat != null) return this.partnerNames[m.seat];
return roleName(m.role);
}
// ── Target highlighting for the human ───────────────────────────────────────
highlightTargets() {
// clear previous
for (const v of Object.values(this.tileViews)) if (v.hl) { v.hl.destroy(); v.hl = null; }
if (this.busy) return;
let tiles = new Set();
if (this.mode === 'sandbags') tiles = new Set(Object.values(this.gs.tiles).filter((t) => t.state === 'flooded').map((t) => t.id));
else if (this.mode === 'helicopter') tiles = new Set(Object.values(this.gs.tiles).filter((t) => t.state !== 'sunk' && t.id !== this.gs.players[this.humanSeat].tileId).map((t) => t.id));
else if (this.mode === 'navigate' && this.modeData?.targetSeat != null) {
for (const a of legalActions(this.gs, this.humanSeat)) if (a.type === 'navMove' && a.targetSeat === this.modeData.targetSeat) tiles.add(a.tileId);
} else if (this.gs.current === this.humanSeat && this.gs.phase === 'actions') {
for (const a of legalActions(this.gs, this.humanSeat)) {
if (a.type === 'move' || a.type === 'fly') tiles.add(a.tileId);
if (a.type === 'shoreUp') a.tiles.forEach((id) => tiles.add(id));
}
}
for (const id of tiles) {
const v = this.tileViews[id];
const hl = this.add.graphics().setDepth(DEPTH.tile - 1);
const { x, y } = this.tileWorld(this.gs.tiles[id]);
hl.lineStyle(4, 0xfff3b0, 0.9); hl.strokeRoundedRect(x - TILE / 2 - 3, y - TILE / 2 - 3, TILE + 6, TILE + 6, 16);
v.hl = hl;
}
}
// ── Human interactions ──────────────────────────────────────────────────────
onTileClick(tileId) {
if (this.busy) return;
if (this.mode === 'sandbags') return this.resolveSandbags(tileId);
if (this.mode === 'helicopter') return this.resolveHelicopter(tileId);
if (this.mode === 'navigate') return this.resolveNavigate(tileId);
if (this.gs.current !== this.humanSeat || this.gs.phase !== 'actions') return;
const acts = legalActions(this.gs, this.humanSeat).filter((a) =>
(a.type === 'move' && a.tileId === tileId) ||
(a.type === 'fly' && a.tileId === tileId) ||
(a.type === 'shoreUp' && a.tiles.length === 1 && a.tiles[0] === tileId));
// Clicking a partner pawn's tile while you're the Navigator → navigate them.
const me = this.gs.players[this.humanSeat];
const partnersHere = this.gs.players.filter((p) => p.seat !== this.humanSeat && p.tileId === tileId);
if (me.role === 'navigator' && partnersHere.length && !acts.length) {
return this.startNavigate(partnersHere[0].seat);
}
if (acts.length === 0) return;
if (acts.length === 1) return this.doAction(acts[0]);
this.showActionPopup(tileId, acts);
}
showActionPopup(tileId, acts) {
this.closePopup();
const t = this.gs.tiles[tileId]; const { x, y } = this.tileWorld(t);
const labels = { move: 'Move here', fly: 'Fly here', shoreUp: 'Shore up' };
const cont = this.add.container(x, y - 70).setDepth(DEPTH.popup);
acts.forEach((a, i) => {
const b = new Button(this, 0, i * 46, labels[a.type] ?? a.type, () => { this.closePopup(); this.doAction(a); }, { width: 150, height: 40, fontSize: 16 });
cont.add(b);
});
this.popup = cont;
}
closePopup() { if (this.popup) { this.popup.destroy(); this.popup = null; } }
doAction(action) {
this.closePopup();
if (action.type === 'move' || action.type === 'fly') {
const fromTileId = this.gs.players[this.humanSeat].tileId;
if (fromTileId !== action.tileId) {
this.busy = true;
this.animatePawnMove(this.humanSeat, fromTileId, action.tileId, () => {
const before = this.gs;
this.gs = applyAction(this.gs, this.humanSeat, action);
this.busy = false;
if (this.gs !== before) this.render();
});
return;
}
}
if (action.type === 'shoreUp') {
this.busy = true;
const tiles = action.tiles.slice();
const next = () => {
if (!tiles.length) {
const before = this.gs;
this.gs = applyAction(this.gs, this.humanSeat, action);
this.busy = false;
if (this.gs !== before) this.render();
return;
}
this.animateShoreUp(tiles.shift(), next);
};
next();
return;
}
const before = this.gs;
this.gs = applyAction(this.gs, this.humanSeat, action);
if (this.gs === before) return;
if (action.type === 'capture') {
this.post(this.gs.players[this.humanSeat].role, lineForEvent('capture', { role: this.gs.players[this.humanSeat].role, treasure: action.treasure, remaining: 4 - capturedCount(this.gs) }).text, this.humanSeat);
}
this.render();
}
onCapture() {
const me = this.gs.players[this.humanSeat];
const here = this.gs.tiles[me.tileId];
this.doAction({ type: 'capture', seat: this.humanSeat, treasure: here.treasure });
}
onCardClick(card, idx) {
if (this.busy) return;
// Discard mode (over hand limit on your draw)
if (this.gs.phase === 'discard' && this.gs.pendingDiscard === this.humanSeat) {
this.gs = discardCard(this.gs, this.humanSeat, card);
this.render();
if (this.gs.phase !== 'discard') this.progress();
return;
}
if (this.gs.current !== this.humanSeat || this.gs.phase !== 'actions') return;
if (card === SPECIAL.SANDBAGS) { this.mode = 'sandbags'; this.flashHint('Sandbags: click any flooded tile to shore it up.'); return this.render(); }
if (card === SPECIAL.HELICOPTER) { this.mode = 'helicopter'; this.flashHint('Helicopter Lift: click a tile to fly your pawn there.'); return this.render(); }
}
// ── Trade Modal ──────────────────────────────────────────────────────────────
openTradeModal() {
if (this.tradeModalObjs.length) return; // already open
const reg = (o) => { this.tradeModalObjs.push(o); return o; };
const panelX = 160, panelY = 310, panelW = 1600;
const cx = GAME_WIDTH / 2;
const portR = 36;
// Push portrait below the title/instruction block (~panelY+75) with a clear gap
const portCY = panelY + 130;
const nameY = portCY + portR + 14;
const roleY = nameY + 22;
const cardsTop = roleY + 30;
const cardW = 70, cardH = 95, cardGap = 6;
const CARDS_PER_ROW = 4;
const hasSecondRow = this.gs.players.some((p) => p.hand.length > CARDS_PER_ROW);
// Panel height: one row of cards, or two if any hand overflows
const cardsAreaH = hasSecondRow ? cardH * 2 + cardGap : cardH;
const panelH = (cardsTop - panelY) + cardsAreaH + 24;
const hasArt = this.textures.exists('forbiddenisland-cards');
const N = this.gs.players.length;
const colW = panelW / N;
// Full-screen dim overlay
const overlay = reg(this.add.graphics().setDepth(DEPTH.popup));
overlay.fillStyle(0x000000, 0.72);
overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
overlay.setInteractive(new Phaser.Geom.Rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT), Phaser.Geom.Rectangle.Contains);
// Panel background + gold border
const panelG = reg(this.add.graphics().setDepth(DEPTH.popup + 1));
panelG.fillStyle(0x061a2a, 1);
panelG.fillRoundedRect(panelX, panelY, panelW, panelH, 18);
panelG.lineStyle(2, COLORS.gold, 1);
panelG.strokeRoundedRect(panelX, panelY, panelW, panelH, 18);
// Title
reg(this.add.text(cx, panelY + 20, 'Trade Cards', {
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.goldHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.popup + 2));
// Instruction
reg(this.add.text(cx, panelY + 56, 'Click two cards from different players to trade', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.popup + 2));
// Close button (top-right)
const closeBtn = new Button(this, panelX + panelW - 44, panelY + 24, '✕', () => this.closeTradeModal(), { width: 44, height: 36, fontSize: 18 });
closeBtn.setDepth(DEPTH.popup + 3);
reg(closeBtn);
// Column dividers
for (let i = 1; i < N; i++) {
const divX = panelX + i * colW;
const divG = reg(this.add.graphics().setDepth(DEPTH.popup + 2));
divG.lineStyle(1, 0x1a3a50, 0.6);
divG.lineBetween(divX, panelY + 8, divX, panelY + panelH - 8);
}
// Per-player columns
this.gs.players.forEach((player, i) => {
const role = ROLES[player.role];
const portCX = panelX + i * colW + portR + 16;
const textX = panelX + i * colW + portR * 2 + 28;
// Portrait circle + ring
const portG = reg(this.add.graphics().setDepth(DEPTH.popup + 2));
portG.fillStyle(0x07202e, 1);
portG.fillCircle(portCX, portCY, portR);
portG.lineStyle(4, role.color, 1);
portG.strokeCircle(portCX, portCY, portR);
// Portrait image or initial fallback
const opp = player.seat > 0 ? (this.opponents[player.seat - 1] ?? null) : null;
if (opp && this.textures.exists('opponents')) {
const maskG = this.add.graphics();
maskG.fillStyle(0xffffff); maskG.fillCircle(portCX, portCY, portR - 3);
maskG.setVisible(false);
const mask = maskG.createGeometryMask();
reg(this.add.image(portCX, portCY, 'opponents', opp.spriteIndex ?? 0)
.setDisplaySize((portR - 3) * 2, (portR - 3) * 2)
.setDepth(DEPTH.popup + 3).setMask(mask));
reg(maskG);
} else {
const avG = reg(this.add.graphics().setDepth(DEPTH.popup + 3));
avG.fillStyle(role.color, 0.35);
avG.fillCircle(portCX, portCY, portR - 3);
const initial = player.seat === this.humanSeat ? 'Y' : role.name[0];
reg(this.add.text(portCX, portCY, initial, {
fontFamily: 'Righteous', fontSize: '24px', color: '#ffffff',
}).setOrigin(0.5).setDepth(DEPTH.popup + 4));
}
// Name + role
const displayName = player.seat === this.humanSeat ? 'You' : (this.partnerNames[player.seat] ?? `Player ${player.seat}`);
reg(this.add.text(textX, nameY, displayName, {
fontFamily: 'Righteous', fontSize: '14px', color: COLORS.textHex,
}).setDepth(DEPTH.popup + 3));
reg(this.add.text(textX, roleY, role.name, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: role.colorHex,
}).setDepth(DEPTH.popup + 3));
// Hand cards (wrap to second row after CARDS_PER_ROW)
if (!player.hand.length) {
reg(this.add.text(textX, cardsTop + cardH / 2, 'No cards', {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.popup + 3));
} else {
player.hand.forEach((card, ci) => {
const row = ci < CARDS_PER_ROW ? 0 : 1;
const colIdx = ci < CARDS_PER_ROW ? ci : ci - CARDS_PER_ROW;
const wx = textX + colIdx * (cardW + cardGap) + cardW / 2;
const wy = cardsTop + row * (cardH + cardGap) + cardH / 2;
const frame = hasArt ? cardFrame(card) : null;
let cardObj;
if (frame != null) {
cardObj = reg(this.add.image(wx, wy, 'forbiddenisland-cards', frame)
.setDisplaySize(cardW, cardH).setDepth(DEPTH.popup + 3));
} else {
const info = cardInfo(card);
const g = this.add.graphics().setDepth(DEPTH.popup + 3);
g.fillStyle(info.color, 1); g.fillRoundedRect(wx - cardW / 2, wy - cardH / 2, cardW, cardH, 6);
cardObj = reg(g);
}
// Normal border
const border = reg(this.add.graphics().setDepth(DEPTH.popup + 3));
border.lineStyle(1, 0xffffff, 0.4);
border.strokeRoundedRect(wx - cardW / 2, wy - cardH / 2, cardW, cardH, 6);
// Hit zone for this card
const hit = reg(this.add.zone(wx - cardW / 2, wy - cardH / 2, cardW, cardH)
.setOrigin(0, 0).setInteractive().setDepth(DEPTH.popup + 5));
hit.on('pointerover', () => { this.input.setDefaultCursor('pointer'); });
hit.on('pointerout', () => { this.input.setDefaultCursor('default'); });
hit.on('pointerup', () => {
this.onTradeCardClick(player.seat, ci, card, frame, wx, wy);
});
});
}
});
}
closeTradeModal() {
for (const o of this.tradeModalObjs) { try { o.destroy(); } catch {} }
this.tradeModalObjs = [];
this.tradeHighlightObjs = [];
this.tradeSelection = null;
this.input.setDefaultCursor('default');
}
onTradeCardClick(seat, cardIdx, card, frame, wx, wy) {
if (!this.tradeSelection) {
this.tradeSelection = { seat, cardIdx, card, frame, worldX: wx, worldY: wy };
this.refreshTradeHighlight();
} else if (this.tradeSelection.seat === seat) {
// Same player — change selection
this.tradeSelection = { seat, cardIdx, card, frame, worldX: wx, worldY: wy };
this.refreshTradeHighlight();
} else {
// Different player — animate swap then apply
const sel = this.tradeSelection;
this.tradeSelection = null;
this.animateCardSwap(sel.worldX, sel.worldY, sel.frame, sel.card, wx, wy, frame, card, () => {
this.gs = swapCards(this.gs, sel.seat, sel.card, seat, card);
this.closeTradeModal();
this.openTradeModal();
this.render();
});
}
}
refreshTradeHighlight() {
for (const o of this.tradeHighlightObjs) { try { o.destroy(); } catch {} }
this.tradeHighlightObjs = [];
if (!this.tradeSelection) return;
const { worldX: wx, worldY: wy } = this.tradeSelection;
const cardW = 70, cardH = 95;
const glow = this.add.graphics().setDepth(DEPTH.popup + 6);
glow.lineStyle(3, COLORS.gold, 1);
glow.strokeRoundedRect(wx - cardW / 2 - 2, wy - cardH / 2 - 2, cardW + 4, cardH + 4, 8);
this.tradeModalObjs.push(glow);
this.tradeHighlightObjs.push(glow);
}
animateCardSwap(xA, yA, frameA, cardA, xB, yB, frameB, cardB, onDone) {
const D = DEPTH.popup + 7;
const cardW = 70, cardH = 95;
const hasArt = this.textures.exists('forbiddenisland-cards');
const makeCard = (x, y, frame, card) => {
const inner = (frame != null && hasArt)
? this.add.image(0, 0, 'forbiddenisland-cards', frame).setDisplaySize(cardW, cardH)
: (() => {
const g = this.add.graphics();
const info = cardInfo(card ?? '');
g.fillStyle(info?.color ?? 0x334455, 1); g.fillRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 6);
return g;
})();
return this.add.container(x, y, [inner]).setDepth(D);
};
const imgA = makeCard(xA, yA, frameA, cardA);
const imgB = makeCard(xB, yB, frameB, cardB);
this.tweens.add({ targets: imgA, x: xB, y: yB, duration: 700, ease: 'Cubic.easeInOut' });
this.tweens.add({
targets: imgB, x: xA, y: yA, duration: 700, ease: 'Cubic.easeInOut',
onComplete: () => { imgA.destroy(); imgB.destroy(); onDone(); },
});
}
resolveSandbags(tileId) {
if (this.gs.tiles[tileId].state !== 'flooded') return;
this.mode = null;
this.busy = true;
this.animateShoreUp(tileId, () => {
this.gs = playSandbags(this.gs, this.humanSeat, tileId);
this.busy = false;
this.render();
});
}
resolveHelicopter(tileId) {
if (this.gs.tiles[tileId].state === 'sunk') return;
const fromTileId = this.gs.players[this.humanSeat].tileId;
this.mode = null;
if (fromTileId !== tileId) {
this.busy = true;
this.animatePawnMove(this.humanSeat, fromTileId, tileId, () => {
this.gs = playHelicopter(this.gs, this.humanSeat, [this.humanSeat], tileId);
this.busy = false;
this.render();
});
} else {
this.gs = playHelicopter(this.gs, this.humanSeat, [this.humanSeat], tileId);
this.render();
}
}
startNavigate(targetSeat) {
this.mode = 'navigate'; this.modeData = { targetSeat };
this.flashHint(`Navigating ${this.partnerNames[targetSeat]} — click a highlighted tile (up to 2 steps).`);
this.render();
}
resolveNavigate(tileId) {
const act = legalActions(this.gs, this.humanSeat).find((a) => a.type === 'navMove' && a.targetSeat === this.modeData.targetSeat && a.tileId === tileId);
this.mode = null; this.modeData = null;
if (!act) { this.render(); return; }
const fromTileId = this.gs.players[act.targetSeat].tileId;
if (fromTileId !== tileId) {
this.busy = true;
this.animatePawnMove(act.targetSeat, fromTileId, tileId, () => {
this.gs = applyAction(this.gs, this.humanSeat, act);
this.busy = false;
this.render();
});
} else {
this.gs = applyAction(this.gs, this.humanSeat, act);
this.render();
}
}
onEscape() {
this.gs = attemptEscape(this.gs);
this.render();
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}`);
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();
}
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); }
}
// ── Initial flood animation ──────────────────────────────────────────────────
runInitialFlood(ids, onDone) {
if (!ids.length) { onDone(); return; }
const [head, ...tail] = ids;
this.animateFloodCard(head, () => this.runInitialFlood(tail, onDone));
}
animateFloodCard(tileId, onDone) {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const { x: tx, y: ty } = this.tileWorld(this.gs.tiles[tileId]);
const depth = DEPTH.popup + 2;
const frameIdx = TILE_FRAME_ROW[tileId] * 2; // always show dry face on the card
const willSink = this.gs.tiles[tileId].state === 'flooded'; // already flooded → will sink
// 1. Face-down card at deck origin
const back = this.add.graphics().setDepth(depth);
const bw = 80, bh = 108;
back.fillStyle(0x0a2030, 1);
back.fillRoundedRect(-bw / 2, -bh / 2, bw, bh, 8);
back.lineStyle(2, 0x2a4a60, 1);
back.strokeRoundedRect(-bw / 2, -bh / 2, bw, bh, 8);
back.lineStyle(1, 0x1a3a50, 0.5);
back.strokeRoundedRect(-bw / 2 + 8, -bh / 2 + 8, bw - 16, bh - 16, 4);
back.x = this.floodDeckPos.x;
back.y = this.floodDeckPos.y;
if (this.sound.get('sfx-card-deal') || this.cache.audio.exists('sfx-card-deal')) {
this.sound.play('sfx-card-deal', { volume: 0.4 });
}
// 2. Tween to center, scale up to 200×200
const scaleTarget = 200 / 200; // native size = 1.0
this.tweens.add({
targets: back,
x: cx, y: cy,
scaleX: scaleTarget * (200 / bw),
scaleY: scaleTarget * (200 / bh),
duration: 380,
ease: 'Cubic.easeOut',
onComplete: () => {
// 3. Flip: collapse scaleX
this.tweens.add({
targets: back,
scaleX: 0,
duration: 150,
ease: 'Linear',
onComplete: () => {
back.destroy();
// Show face-up tile in a container so the border tracks the card
const img = this.add.image(0, 0, 'forbiddenisland-tiles', frameIdx)
.setDisplaySize(200, 200);
const borderGfx = this.add.graphics();
borderGfx.lineStyle(3, 0xd4c08a, 1);
borderGfx.strokeRoundedRect(-100, -100, 200, 200, 12);
const faceCard = this.add.container(cx, cy, [img, borderGfx]).setDepth(depth);
if (this.sound.get('sfx-card-show') || this.cache.audio.exists('sfx-card-show')) {
this.sound.play('sfx-card-show', { volume: 0.35 });
}
// Flip open
faceCard.scaleX = 0;
this.tweens.add({
targets: faceCard,
scaleX: 1,
duration: 150,
ease: 'Linear',
onComplete: () => {
// 4. Pause 1.2s
this.time.delayedCall(1200, () => {
// 5. Tween to tile position, shrink to 110×110
const targetScale = TILE / 200;
this.tweens.add({
targets: faceCard,
x: tx, y: ty,
scaleX: targetScale,
scaleY: targetScale,
duration: 400,
ease: 'Cubic.easeIn',
onComplete: () => {
faceCard.destroy();
// 6. Apply tile state (flooded or sunk) and animate
this.gs.tiles[tileId].state = willSink ? 'sunk' : 'flooded';
this.drawTile(this.gs.tiles[tileId]);
if (this.sound.get('sfx-card-place') || this.cache.audio.exists('sfx-card-place')) {
this.sound.play('sfx-card-place', { volume: willSink ? 0.55 : 0.3 });
}
const tileView = this.tileViews[tileId];
if (willSink && tileView) {
// Brief shake on the tile to signal sinking
this.tweens.add({
targets: tileView.container,
x: { from: tileView.container.x - 6, to: tileView.container.x },
yoyo: true, repeat: 3, duration: 40, ease: 'Linear',
onComplete: () => this.time.delayedCall(150, onDone),
});
} else {
this.time.delayedCall(200, onDone);
}
},
});
});
},
});
},
});
},
});
}
// ── Turn flow ───────────────────────────────────────────────────────────────
onEndTurn() {
if (this.gs.current !== this.humanSeat || this.gs.phase !== 'actions' || this.busy) return;
this.mode = null;
this.busy = true;
this.animateTreasureDraw(this.humanSeat, () => {
this.gs = endActions(this.gs);
this.render();
this.progress();
});
}
// Drive the game forward through discard/flood phases and AI turns until it is
// the human's action phase again (or the game ends).
advance() {
if (isGameOver(this.gs)) return this.endGame();
if (this.gs.phase === 'actions') {
if (this.gs.current === this.humanSeat) { this.busy = false; this.render(); return; }
return this.aiTurn(this.gs.current);
}
this.progress();
}
progress() {
if (isGameOver(this.gs)) { this.render(); return this.endGame(); }
if (this.gs.phase === 'discard') {
const seat = this.gs.pendingDiscard;
if (seat === this.humanSeat) { this.busy = false; this.flashHint(`Over the hand limit — click a card to discard.`); this.render(); return; }
this.gs = discardCard(this.gs, seat, chooseDiscard(this.gs, seat));
this.render();
return this.time.delayedCall(350, () => this.progress());
}
if (this.gs.phase === 'flood') return this.animateFlood();
return this.advance();
}
animateFlood() {
this.busy = true;
// Deep-clone BEFORE animation so tile-state mutations during animateFloodCard
// don't corrupt the state that resolveFlood reads.
const preFloodState = cloneState(this.gs);
const before = preFloodState.log.length;
const tileIds = peekFloodDraw(preFloodState);
this.runInitialFlood(tileIds, () => {
// Resolve the flood properly from the saved pre-animation state so all
// immutable logic (swims, loss checks, turn advance) runs correctly.
const next = resolveFlood(preFloodState);
const events = next.log.slice(before);
this.gs = next;
// Voice notable events.
for (const ev of events) {
if (ev.kind === 'sink') {
const name = this.gs.tiles[ev.tileId]?.name ?? ev.tileId;
this.post(null, lineForEvent('sink', { tileName: name }).text);
}
}
this.render();
this.time.delayedCall(200, () => { this.busy = false; this.advance(); });
});
}
// ── Treasure card draw animation ─────────────────────────────────────────────
animateTreasureDraw(seat, onDone) {
const cards = peekTreasureDraw(this.gs);
const player = this.gs.players[seat];
let handSlot = player.hand.length; // next free slot index
const step = ([card, ...rest]) => {
if (card === undefined) { onDone(); return; }
const goesToHand = card !== SPECIAL.WATERS_RISE;
const slot = goesToHand ? handSlot++ : -1;
this.animateOneTreasureCard(card, seat, slot, () => step(rest));
};
step(cards);
}
animateOneTreasureCard(card, seat, handSlot, onDone) {
const isHuman = seat === this.humanSeat;
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const depth = DEPTH.popup + 2;
// Sprite frame constants
const FRAME_W = 320, FRAME_H = 420;
const DRAW_W = 270, DRAW_H = 390; // full-size display at center
const HAND_W = 92, HAND_H = 124; // hand slot display size
const BACK_W = 80, BACK_H = 108; // back-card drawn size (matches pile)
const SX_DRAW = DRAW_W / FRAME_W; // 0.844
const SY_DRAW = DRAW_H / FRAME_H; // 0.929
const SX_HAND = HAND_W / FRAME_W; // 0.288
const SY_HAND = HAND_H / FRAME_H; // 0.295
const hasArt = this.textures.exists('forbiddenisland-cards');
const frame = hasArt ? cardFrame(card) : null;
// 1. Face-down back card at the treasure deck pile position
const back = this.add.graphics().setDepth(depth);
back.fillStyle(0x0a2030, 1);
back.fillRoundedRect(-BACK_W / 2, -BACK_H / 2, BACK_W, BACK_H, 8);
back.lineStyle(2, 0x2a4a60, 1);
back.strokeRoundedRect(-BACK_W / 2, -BACK_H / 2, BACK_W, BACK_H, 8);
back.lineStyle(1, 0x1a3a50, 0.5);
back.strokeRoundedRect(-BACK_W / 2 + 8, -BACK_H / 2 + 8, BACK_W - 16, BACK_H - 16, 4);
back.x = this.treasureDeckPos.x;
back.y = this.treasureDeckPos.y;
if (this.cache.audio.exists('sfx-card-deal')) this.sound.play('sfx-card-deal', { volume: 0.4 });
// 2. Tween to center, scale up to 270×390
this.tweens.add({
targets: back,
x: cx, y: cy,
scaleX: DRAW_W / BACK_W,
scaleY: DRAW_H / BACK_H,
duration: 380,
ease: 'Cubic.easeOut',
onComplete: () => {
// 3. Flip: collapse scaleX
this.tweens.add({
targets: back,
scaleX: 0,
duration: 150,
ease: 'Linear',
onComplete: () => {
back.destroy();
// Create face-up card Container (image/vector + border) at center.
// Using a Container means the border travels and scales with the card
// for all three fly destinations (human hand, AI HUD, chat fade).
let faceContent;
if (frame != null) {
const img = this.add.image(0, 0, 'forbiddenisland-cards', frame).setScale(SX_DRAW, SY_DRAW);
const borderGfx = this.add.graphics();
borderGfx.lineStyle(3, 0xd4c08a, 1);
borderGfx.strokeRoundedRect(-DRAW_W / 2, -DRAW_H / 2, DRAW_W, DRAW_H, 12);
faceContent = [img, borderGfx];
} else {
// Vector fallback
const info = cardInfo(card);
const g = this.add.graphics();
g.fillStyle(info.color, 1);
g.fillRoundedRect(-DRAW_W / 2, -DRAW_H / 2, DRAW_W, DRAW_H, 16);
g.lineStyle(3, 0xd4c08a, 1);
g.strokeRoundedRect(-DRAW_W / 2, -DRAW_H / 2, DRAW_W, DRAW_H, 16);
const lbl = this.add.text(0, 0, info.label, {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: info.text, align: 'center',
}).setOrigin(0.5);
faceContent = [g, lbl];
}
const faceObj = this.add.container(cx, cy, faceContent).setDepth(depth);
faceObj.scaleX = 0;
if (this.cache.audio.exists('sfx-card-show')) this.sound.play('sfx-card-show', { volume: 0.35 });
// Flip open — container collapses to scaleX=0 then reopens to 1
this.tweens.add({
targets: faceObj,
scaleX: 1,
duration: 150,
ease: 'Linear',
onComplete: () => {
// 4. Pause 1.2 seconds
this.time.delayedCall(1200, () => {
if (isHuman && handSlot >= 0) {
// 5a. Human player — fly to hand slot; park container so renderHand can destroy it
const tx = RAIL_X + 8 + handSlot * (HAND_W + 10) + HAND_W / 2;
const ty = 884 + HAND_H / 2;
// Container scale needed to render at HAND_W × HAND_H from DRAW_W × DRAW_H content
const csX = HAND_W / DRAW_W, csY = HAND_H / DRAW_H;
this.tweens.add({
targets: faceObj,
x: tx, y: ty,
scaleX: csX, scaleY: csY,
duration: 350,
ease: 'Cubic.easeIn',
onComplete: () => {
if (this.cache.audio.exists('sfx-card-place')) this.sound.play('sfx-card-place', { volume: 0.3 });
this.tempHandImages.push(faceObj);
onDone();
},
});
} else if (!isHuman && handSlot >= 0 && this.partnerCardSlots[seat]) {
// 5b. AI player drawing a hand card — fly to partner HUD thumbnail slot
const slot = this.partnerCardSlots[seat];
const HUD_W = 24, HUD_H = 33, HUD_GAP = 3;
const tx = slot.cardX + handSlot * (HUD_W + HUD_GAP) + HUD_W / 2;
const ty = slot.cardY + HUD_H / 2;
// Container scale to render at HUD thumbnail size from DRAW content size
const csX = HUD_W / DRAW_W, csY = HUD_H / DRAW_H;
this.tweens.add({
targets: faceObj,
x: tx, y: ty,
scaleX: csX, scaleY: csY,
duration: 380,
ease: 'Cubic.easeIn',
onComplete: () => {
if (this.cache.audio.exists('sfx-card-place')) this.sound.play('sfx-card-place', { volume: 0.2 });
faceObj.destroy();
onDone();
},
});
} else if (card === SPECIAL.WATERS_RISE && this.waterX != null) {
// 5c. Waters Rise — card flies to the next unfilled meter segment, then level rises
const oldLevel = this.gs.waterLevel;
const newLevel = Math.min(oldLevel + 1, MAX_WATER);
const mSegH = (this.waterBottom - this.waterTop) / MAX_WATER;
const targetX = this.waterX;
const targetY = this.waterTop + (MAX_WATER - newLevel) * mSegH + mSegH / 2;
this.tweens.add({
targets: faceObj,
x: targetX, y: targetY,
scaleX: 0.12, scaleY: 0.08,
duration: 500,
ease: 'Cubic.easeIn',
onComplete: () => {
faceObj.destroy();
this.animateWaterRise(oldLevel, onDone);
},
});
} else {
// Fallback — destroy and continue
faceObj.destroy();
onDone();
}
});
},
});
},
});
},
});
}
// Animate the water level rising: fade in the new segment, slide the triangle marker up.
animateWaterRise(oldLevel, onDone) {
const D = DEPTH.ui + 2;
const newLevel = Math.min(oldLevel + 1, MAX_WATER);
const segH = (this.waterBottom - this.waterTop) / MAX_WATER;
const w = 40, barX = this.waterX - w / 2;
const segColor = newLevel >= MAX_WATER ? 0xb22a2a
: newLevel >= 8 ? 0xd1632f
: 0x2f8fd0;
// New-level segment overlay fades in from transparent
const newSegY = this.waterTop + (MAX_WATER - newLevel) * segH;
const segG = this.add.graphics().setDepth(D);
segG.fillStyle(segColor, 1);
segG.fillRoundedRect(barX, newSegY + 2, w, segH - 4, 6);
segG.lineStyle(1, 0x000000, 0.4);
segG.strokeRoundedRect(barX, newSegY + 2, w, segH - 4, 6);
segG.alpha = 0;
// Gold triangle marker slides from old-level center to new-level center
const oldMarkerY = this.waterTop + (MAX_WATER - oldLevel) * segH + segH / 2;
const newMarkerY = this.waterTop + (MAX_WATER - newLevel) * segH + segH / 2;
const markG = this.add.graphics().setDepth(D + 1);
markG.fillStyle(COLORS.gold, 1);
markG.fillTriangle(-12, -8, -12, 8, -2, 0);
markG.x = barX;
markG.y = oldMarkerY;
if (this.cache.audio.exists('sfx-card-place')) this.sound.play('sfx-card-place', { volume: 0.5 });
// Marker slides up quickly, segment fades in slowly
this.tweens.add({
targets: markG, y: newMarkerY, duration: 600, ease: 'Cubic.easeOut',
onComplete: () => markG.destroy(),
});
this.tweens.add({
targets: segG, alpha: 1, duration: 900, ease: 'Cubic.easeOut',
onComplete: () => {
// Call onDone first (triggers endActions + render → drawWaterMeter redraws the
// filled segment on waterG), then destroy the overlay so there's no flash.
onDone();
segG.destroy();
},
});
}
aiTurn(seat) {
this.busy = true;
// Announce the plan.
const intent = describeIntent(this.gs, seat);
const line = lineForIntent(intent);
this.post(line.role, line.text, seat);
this.render();
const step = () => {
if (isGameOver(this.gs)) { this.render(); return this.endGame(); }
if (canEscape(this.gs)) {
this.gs = attemptEscape(this.gs); this.render();
return this.time.delayedCall(400, () => this.endGame());
}
// Free special-card plays (Sandbags / Helicopter) before regular actions.
const free = chooseFreeCard(this.gs, seat);
if (free) {
const role = this.gs.players[seat].role;
if (free.type === 'sandbags') {
this.gs = playSandbags(this.gs, seat, free.tileId);
this.post(role, lineForEvent('sandbags', { role, tileName: this.gs.tiles[free.tileId].name }).text, seat);
} else {
const who = roleName(this.gs.players[free.carrierSeat].role);
this.gs = playHelicopter(this.gs, seat, free.pawnSeats, free.destTileId);
this.post(role, lineForEvent('heliMove', { role, who, tileName: this.gs.tiles[free.destTileId].name }).text, seat);
}
this.render();
return this.time.delayedCall(560, step);
}
const action = chooseAction(this.gs, seat, this.skillBySeat[seat]);
if (action) {
const isMoveAction = action.type === 'move' || action.type === 'fly' || action.type === 'navMove';
if (isMoveAction) {
const movingSeat = action.type === 'navMove' ? action.targetSeat : seat;
const fromTileId = this.gs.players[movingSeat].tileId;
const toTileId = action.tileId;
if (fromTileId !== toTileId) {
this.animatePawnMove(movingSeat, fromTileId, toTileId, () => {
const before = this.gs;
this.gs = applyAction(this.gs, seat, action);
if (this.gs === before) { this.finishAiTurn(); return; }
this.render();
step();
});
return;
}
}
if (action.type === 'shoreUp') {
const tiles = action.tiles.slice();
const next = () => {
if (!tiles.length) {
const before = this.gs;
this.gs = applyAction(this.gs, seat, action);
if (this.gs === before) { this.finishAiTurn(); return; }
this.render();
step();
return;
}
this.animateShoreUp(tiles.shift(), next);
};
next();
return;
}
const before = this.gs;
this.gs = applyAction(this.gs, seat, action);
if (this.gs === before) { this.finishAiTurn(); return; }
if (action.type === 'capture') this.post(this.gs.players[seat].role, lineForEvent('capture', { role: this.gs.players[seat].role, treasure: action.treasure, remaining: 4 - capturedCount(this.gs) }).text, seat);
this.render();
this.time.delayedCall(520, step);
} else {
this.finishAiTurn();
}
};
this.time.delayedCall(nextThinkDelay(this.skillBySeat[seat]), step);
}
finishAiTurn() {
const seat = this.gs.current;
this.animateTreasureDraw(seat, () => {
this.gs = endActions(this.gs);
this.render();
this.time.delayedCall(400, () => this.progress());
});
}
// ── Chat + hints ────────────────────────────────────────────────────────────
post(role, text, seat = null) {
this.messages.push({ role, text, seat });
if (this.messages.length > 40) this.messages.shift();
if (this.chatText) this.renderChat();
}
flashHint(text) {
if (this.hint) this.hint.destroy();
this.hint = this.add.text(BX0 + BOARD_W / 2, BY0 + BOARD_W + 16, text, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex, align: 'center', wordWrap: { width: BOARD_W },
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
this.time.delayedCall(4000, () => { if (this.hint) { this.hint.destroy(); this.hint = null; } });
}
// ── Player intro modals ──────────────────────────────────────────────────────
showPlayerIntros(seats, onDone) {
const [first, ...rest] = seats;
if (first === undefined) { onDone(); return; }
this.showAdventurerModal(first, () => this.showPlayerIntros(rest, onDone));
}
showAdventurerModal(seat, onNext) {
// DOM video elements can't live inside Phaser Containers, so we place
// everything directly in the scene and track objects for manual cleanup.
const objs = [];
const reg = (o) => { if (o) objs.push(o); return o; };
const player = this.gs.players[seat];
const role = ROLES[player.role];
const isLast = seat === this.gs.players[this.gs.players.length - 1].seat;
const playerName = this.partnerNames[seat] ?? 'You';
const opp = seat !== this.humanSeat ? (this.opponents[seat - 1] ?? null) : null;
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const pw = 1100, ph = 680;
const px = cx - pw / 2, py = cy - ph / 2;
const D = DEPTH.popup + 3;
// Derive a darkened card color without modifying the original
const roleColorObj = Phaser.Display.Color.IntegerToColor(role.color);
const darkCardColor = Phaser.Display.Color.GetColor(
Math.round(roleColorObj.red * 0.35),
Math.round(roleColorObj.green * 0.35),
Math.round(roleColorObj.blue * 0.35),
);
// Overlay + panel
const overlay = reg(this.add.graphics().setDepth(D));
overlay.fillStyle(0x000000, 0.82);
overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
const panel = reg(this.add.graphics().setDepth(D + 1));
panel.fillStyle(0x061a2a, 1);
panel.fillRoundedRect(px, py, pw, ph, 20);
panel.lineStyle(3, COLORS.accent, 0.8);
panel.strokeRoundedRect(px, py, pw, ph, 20);
panel.lineStyle(2, role.color, 0.55);
panel.strokeRoundedRect(px + 4, py + 4, pw - 8, ph - 8, 17);
// ── Left column: portrait ───────────────────────────────────────────────
const portraitW = 300, portraitH = ph - 40;
const portraitX = px + 20, portraitY = py + 20;
const portraitCX = portraitX + portraitW / 2;
const portraitCY = portraitY + portraitH / 2;
const portraitR = Math.min(portraitW, portraitH) / 2 - 10;
const pbg = reg(this.add.graphics().setDepth(D + 1));
pbg.fillStyle(0x030f18, 1);
pbg.fillRoundedRect(portraitX, portraitY, portraitW, portraitH, 12);
pbg.lineStyle(2, role.color, 0.45);
pbg.strokeRoundedRect(portraitX, portraitY, portraitW, portraitH, 12);
// Sprite from the opponents spritesheet (if available and this is an AI seat)
if (opp && this.textures.exists('opponents')) {
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillCircle(portraitCX, portraitCY - 20, portraitR);
reg(this.add.image(portraitCX, portraitCY - 20, 'opponents', opp.spriteIndex ?? 0)
.setDisplaySize(portraitR * 2, portraitR * 2)
.setMask(maskG.createGeometryMask())
.setDepth(D + 2));
}
// DOM video for AI opponent (floated on top of sprite)
let domVid = null;
if (opp) {
const vidEl = document.createElement('video');
vidEl.muted = true; vidEl.loop = true; vidEl.playsInline = true; vidEl.autoplay = true;
const size = portraitR * 2;
vidEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;`;
vidEl.src = `/assets/videos/${opp.id}-idle.mp4`;
vidEl.addEventListener('error', () => { vidEl.style.display = 'none'; }, { once: true });
vidEl.play().catch(() => {});
domVid = reg(this.add.dom(portraitCX, portraitCY - 20, vidEl).setDepth(D + 3));
} else {
// Human player — role-colored avatar circle
const avG = reg(this.add.graphics().setDepth(D + 2));
avG.fillStyle(role.color, 0.85);
avG.fillCircle(portraitCX, portraitCY - 20, portraitR);
avG.lineStyle(3, 0xffffff, 0.35);
avG.strokeCircle(portraitCX, portraitCY - 20, portraitR);
reg(this.add.text(portraitCX, portraitCY - 20, role.name[0], {
fontFamily: 'Righteous', fontSize: '72px', color: '#ffffff',
}).setOrigin(0.5).setDepth(D + 3));
}
// Player name below portrait
reg(this.add.text(portraitCX, portraitY + portraitH - 44, playerName, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(D + 2));
// ── Right column: info ──────────────────────────────────────────────────
const rightX = px + 336;
const rightW = pw - 336 - 16;
const descW = rightW - 246; // leave room for the adventurer card
let ry = py + 30;
const textD = D + 2;
// "PlayerName as The RoleName"
const titleBase = reg(this.add.text(rightX, ry, `${playerName} as The `, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
}).setDepth(textD));
reg(this.add.text(rightX + titleBase.width, ry, role.name, {
fontFamily: 'Righteous', fontSize: '26px', color: role.colorHex,
}).setDepth(textD));
ry += 44;
// Divider
const divG = reg(this.add.graphics().setDepth(textD));
divG.lineStyle(1, role.color, 0.45);
divG.lineBetween(rightX, ry, rightX + descW, ry);
ry += 16;
// Description
reg(this.add.text(rightX, ry, role.description, {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
wordWrap: { width: descW - 8 }, lineSpacing: 4,
}).setDepth(textD));
ry += 148;
// Ability
reg(this.add.text(rightX, ry, 'SPECIAL ABILITY', {
fontFamily: 'Righteous', fontSize: '13px', color: COLORS.accentHex,
}).setDepth(textD));
ry += 22;
reg(this.add.text(rightX, ry, role.power, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex,
wordWrap: { width: descW - 8 },
}).setDepth(textD));
// ── Adventurer card placeholder (270×390, scaled to fit) ────────────────
const cardW = 220, cardH = 316, cardR = 14;
const cardX = rightX + rightW - cardW - 6;
const cardY = py + 28;
const iconR = 52;
const iconCX = cardX + cardW / 2;
const iconCY = cardY + cardH * 0.36;
const cardG = reg(this.add.graphics().setDepth(D + 2));
cardG.fillStyle(darkCardColor, 1);
cardG.fillRoundedRect(cardX, cardY, cardW, cardH, cardR);
cardG.fillStyle(0x000000, 0.4);
cardG.fillRoundedRect(cardX, cardY + cardH * 0.38, cardW, cardH * 0.62, cardR);
cardG.lineStyle(3, role.color, 1);
cardG.strokeRoundedRect(cardX, cardY, cardW, cardH, cardR);
cardG.lineStyle(1, COLORS.gold, 0.35);
cardG.strokeRoundedRect(cardX + 6, cardY + 6, cardW - 12, cardH - 12, cardR - 4);
// Name banner
cardG.fillStyle(0x000000, 0.5);
cardG.fillRoundedRect(cardX + 8, cardY + 8, cardW - 16, 32, 6);
// Icon circle
cardG.fillStyle(0x000000, 0.35);
cardG.fillCircle(iconCX, iconCY, iconR + 4);
cardG.fillStyle(role.color, 0.65);
cardG.fillCircle(iconCX, iconCY, iconR);
cardG.lineStyle(2, COLORS.gold, 0.5);
cardG.strokeCircle(iconCX, iconCY, iconR);
reg(this.add.text(iconCX, iconCY, role.name[0], {
fontFamily: 'Righteous', fontSize: '56px', color: '#ffffff',
}).setOrigin(0.5).setDepth(D + 3));
reg(this.add.text(cardX + cardW / 2, cardY + 24, role.name.toUpperCase(), {
fontFamily: 'Righteous', fontSize: '15px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D + 3));
reg(this.add.text(cardX + cardW / 2, cardY + cardH * 0.68, role.power, {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.textHex,
align: 'center', wordWrap: { width: cardW - 20 }, lineSpacing: 3,
}).setOrigin(0.5).setDepth(D + 3));
reg(this.add.text(cardX + cardW / 2, cardY + cardH - 18, 'FORBIDDEN ISLAND', {
fontFamily: 'Righteous', fontSize: '10px', color: COLORS.mutedHex,
}).setOrigin(0.5, 1).setDepth(D + 3));
// ── Next / Let's Play button ─────────────────────────────────────────────
const btnLabel = isLast ? "Let's Play!" : 'Next';
const btn = new Button(this, cx + pw / 2 - 140, py + ph - 44, btnLabel, () => {
if (domVid?.node) { try { domVid.node.pause(); domVid.node.src = ''; } catch { /* ignore */ } }
for (const o of objs) { try { o.destroy(); } catch { /* ignore */ } }
onNext();
}, { width: 240, height: 52, fontSize: 20 });
btn.setDepth(D + 4);
reg(btn);
}
startGameplay() {
this.introComplete = true;
this.buildPartnerHUD();
this.render();
this.post(null, `Welcome to Forbidden Island — ${DIFFICULTY[this.difficulty].name} difficulty. Capture all four treasures, then fly out from Fools' Landing together.`);
this.advance();
}
// ── End ─────────────────────────────────────────────────────────────────────
endGame() {
this.busy = true;
const won = this.gs.phase === 'won';
const ev = lineForEvent(won ? 'won' : 'lost', { reason: this.gs.lossReason });
this.post(null, ev.text);
this.render();
const overlay = this.add.container(GAME_WIDTH / 2, GAME_HEIGHT / 2).setDepth(DEPTH.banner);
const g = this.add.graphics();
g.fillStyle(0x000000, 0.8); g.fillRoundedRect(-460, -200, 920, 400, 24);
g.lineStyle(4, won ? COLORS.gold : COLORS.danger, 1); g.strokeRoundedRect(-460, -200, 920, 400, 24);
overlay.add(g);
overlay.add(this.add.text(0, -120, won ? 'You Escaped!' : 'The Island Is Lost', {
fontFamily: 'Righteous', fontSize: '54px', color: won ? COLORS.goldHex : COLORS.dangerHex,
}).setOrigin(0.5));
overlay.add(this.add.text(0, -30, won ? `All four treasures recovered — the adventurers fly to safety.` : (this.gs.lossReason ?? ''), {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', wordWrap: { width: 840 },
}).setOrigin(0.5));
overlay.add(this.add.text(0, 50, `Treasures recovered: ${capturedCount(this.gs)} / 4`, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5));
const again = new Button(this, -150, 130, 'Play Again', () => this.scene.restart(this._restartData()), { width: 260, height: 56 });
const leave = new Button(this, 150, 130, 'Leave Table', () => this.scene.start('GameMenu'), { width: 260, height: 56 });
overlay.add([again, leave]);
}
_restartData() {
return { game: this.gameDef, opponents: this.opponents, difficulty: this.difficulty };
}
}
// ── Card display helper ───────────────────────────────────────────────────────
function cardInfo(card) {
if (card === SPECIAL.WATERS_RISE) return { label: 'Waters\nRise!', color: 0x1d3f57, text: '#9fd8ff' };
if (card === SPECIAL.HELICOPTER) return { label: 'Helicopter\nLift', color: 0x394b2a, text: '#d8f0b0' };
if (card === SPECIAL.SANDBAGS) return { label: 'Sand\nbags', color: 0x5a4a28, text: '#f0dca0' };
const key = card.slice('treasure:'.length);
return { label: TREASURES[key].name, color: TREASURES[key].color, text: '#ffffff' };
}