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' }; }