1937 lines
86 KiB
JavaScript
1937 lines
86 KiB
JavaScript
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' };
|
||
}
|