Forbidden Island Initial Commit

This commit is contained in:
Brian Fertig 2026-06-01 20:36:18 -06:00
parent 9dda7f4487
commit 7eaf0183e2
13 changed files with 1919 additions and 4 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

View File

@ -0,0 +1,722 @@
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, legalActions, applyAction, endActions, discardCard,
resolveFlood, playSandbags, playHelicopter, attemptEscape, canEscape,
isGameOver, setPriority, capturedCount, handTreasureCounts,
} from './IslandLogic.js';
import {
TREASURES, TREASURE_KEYS, ROLES, ROLE_KEYS, SPECIAL, MAX_WATER, DIFFICULTY,
floodDrawCount, GRID, CARDS_TO_CAPTURE, HAND_LIMIT, TILE_FRAME_ROW,
} 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 : 'normal';
this.gs = null;
this.humanSeat = 0;
this.tileViews = {}; // id -> { container, bg, label, gem }
this.pawnLayer = null;
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;
}
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));
}
}
this.gs = createInitialState({ roles, difficulty: this.difficulty, humanSeat: this.humanSeat });
this.buildBoard();
this.buildRail();
this.buildWaterMeter();
this.pawnLayer = this.add.container(0, 0).setDepth(DEPTH.pawn);
this.post(null, `Welcome to Forbidden Island — ${DIFFICULTY[this.difficulty].name} difficulty. Capture all four treasures, then fly out from Fools' Landing together.`);
this.render();
this.advance();
}
// ── 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, 44, 'Forbidden Island', {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
new Button(this, 150, 48, '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);
}
const layers = img ? [img, bg, label] : [bg, label];
container.add(layers); if (gem) container.add(gem);
container.setSize(TILE, TILE).setInteractive(
new Phaser.Geom.Rectangle(-TILE / 2, -TILE / 2, TILE, TILE), Phaser.Geom.Rectangle.Contains);
container.on('pointerup', () => this.onTileClick(t.id));
container.on('pointerover', () => { if (!this.busy) container.setScale(1.03); });
container.on('pointerout', () => container.setScale(1));
this.tileViews[t.id] = { container, img, bg, label, gem, row };
}
}
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);
}
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)}`);
}
// ── Rendering ───────────────────────────────────────────────────────────────
render() {
for (const t of Object.values(this.gs.tiles)) this.drawTile(t);
this.renderPawns();
this.renderTreasures();
this.renderHand();
this.drawWaterMeter();
this.renderBanner();
this.renderButtons();
this.renderPriorities();
this.renderChat();
this.highlightTargets();
}
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);
// 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);
if (p.isHuman) {
const ring = this.add.circle(x + ox, y + 12, 19).setStrokeStyle(2, COLORS.gold, 0.9);
this.pawnLayer.add(ring);
}
});
}
}
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() {
this.handLayer.removeAll(true);
const me = this.gs.players[this.humanSeat];
const cardW = 92, cardH = 124, gap = 10;
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 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]);
cont.setSize(cardW, cardH).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.handLayer.add(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();
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(); }
if (card.startsWith('treasure:')) return this.startGive(card);
}
// Give a treasure card to a reachable partner.
startGive(card) {
const me = this.gs.players[this.humanSeat];
const recips = this.gs.players.filter((p) => p.seat !== this.humanSeat && (me.role === 'messenger' || p.tileId === me.tileId));
if (!recips.length) return this.flashHint('No teammate in reach to give to (move to their tile, unless you are the Messenger).');
this.closePopup();
const cont = this.add.container(RAIL_X + 200, 700).setDepth(DEPTH.popup);
const bg = this.add.graphics(); bg.fillStyle(0x000000, 0.85); bg.fillRoundedRect(-150, -30, 300, 40 + recips.length * 46, 10); cont.add(bg);
cont.add(this.add.text(0, -16, 'Give to:', { fontFamily: 'Righteous', fontSize: '16px', color: COLORS.accentHex }).setOrigin(0.5));
recips.forEach((p, i) => {
const b = new Button(this, 0, 24 + i * 46, this.partnerNames[p.seat], () => {
this.closePopup();
this.gs = applyAction(this.gs, this.humanSeat, { type: 'giveCard', seat: this.humanSeat, toSeat: p.seat, card });
this.render();
}, { width: 240, height: 40, fontSize: 16 });
cont.add(b);
});
this.popup = cont;
}
resolveSandbags(tileId) {
if (this.gs.tiles[tileId].state !== 'flooded') return;
this.gs = playSandbags(this.gs, this.humanSeat, tileId);
this.mode = null; this.render();
}
resolveHelicopter(tileId) {
if (this.gs.tiles[tileId].state === 'sunk') return;
this.gs = playHelicopter(this.gs, this.humanSeat, [this.humanSeat], tileId);
this.mode = null; 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.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); }
}
// ── Turn flow ───────────────────────────────────────────────────────────────
onEndTurn() {
if (this.gs.current !== this.humanSeat || this.gs.phase !== 'actions' || this.busy) return;
this.mode = null;
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;
const before = this.gs.log.length;
const next = resolveFlood(this.gs);
const events = next.log.slice(before);
this.gs = next;
// Voice notable events.
const sink = events.find((e) => e.kind === 'sink');
const rise = events.find((e) => e.kind === 'watersRise');
if (rise) this.post(null, lineForEvent('watersRise', { waterLevel: this.gs.waterLevel }).text);
if (sink) this.post(null, lineForEvent('sink', { tileName: this.gs.tiles[sink.tileId].name }).text);
this.render();
this.time.delayedCall(650, () => { this.busy = false; this.advance(); });
}
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 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() {
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; } });
}
// ── 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' };
}

View File

@ -0,0 +1,343 @@
// Forbidden Island — heuristic co-op planner. No Phaser, no network.
//
// This is the "AI partner". Because the game is cooperative there is no
// opponent to model: the planner just evaluates the whole-team situation and
// greedily picks the action that most improves it, one action at a time. It
// also exposes describeIntent() so the partner can announce what it's doing in
// the team-chat panel — the same decision data, rendered as speech.
import {
legalActions, applyAction, adjacentTiles, capturedCount, handTreasureCounts,
} from './IslandLogic.js';
import { TREASURES, TREASURE_KEYS, ROLES, SPECIAL, CARDS_TO_CAPTURE, HAND_LIMIT } from './IslandData.js';
const SKILL_PROFILES = {
1: { topN: 5, blunder: 0.40, noise: 220, delay: [700, 1200] },
2: { topN: 4, blunder: 0.24, noise: 140, delay: [650, 1100] },
3: { topN: 3, blunder: 0.12, noise: 80, delay: [600, 1000] },
4: { topN: 2, blunder: 0.04, noise: 35, delay: [520, 900] },
5: { topN: 1, blunder: 0.00, noise: 0, delay: [440, 820] },
};
function profileFor(skill) { return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3]; }
export function nextThinkDelay(skill) {
const [lo, hi] = profileFor(skill).delay;
return lo + Math.random() * (hi - lo);
}
// ---- distance over the (non-sunk) island ----------------------------------
function bfsDist(state, fromId, targetSet) {
if (targetSet.has(fromId)) return 0;
const seen = new Set([fromId]);
let frontier = [fromId], d = 0;
while (frontier.length) {
d++;
const next = [];
for (const id of frontier) {
for (const nb of adjacentTiles(state, id)) {
if (seen.has(nb) || state.tiles[nb].state === 'sunk') continue;
if (targetSet.has(nb)) return d;
seen.add(nb); next.push(nb);
}
}
frontier = next;
}
return 99; // unreachable
}
function liveTreasureTiles(state, key) {
return TREASURES[key].tiles.filter((id) => state.tiles[id].state !== 'sunk');
}
function isCaptured(state, key) { return state.players.some((p) => p.captured[key]); }
// How costly it would be to let a given flooded tile sink. Shared by the state
// evaluation and the special-card chooser so they agree on what's "critical".
function floodWeight(state, t) {
if (t.state !== 'flooded') return 0;
if (t.landing) return 130;
if (t.treasure && !isCaptured(state, t.treasure)) {
const pair = TREASURES[t.treasure].tiles.find((id) => id !== t.id);
if (state.tiles[pair].state === 'sunk') return 320; // last tile of the treasure
if (state.tiles[pair].state === 'flooded') return 120; // both temples flooded
return 55;
}
return 6;
}
// Which seat is the lead carrier for each uncaptured treasure (holds the most
// matching cards). Returns { key -> { seat, cards } }.
// The lead carrier for each uncaptured treasure: the player holding the most of
// its cards (ties broken by seat order).
function carriers(state) {
const out = {};
for (const key of TREASURE_KEYS) {
if (isCaptured(state, key)) continue;
let best = -1, bestSeat = -1;
for (const p of state.players) {
const c = handTreasureCounts(p)[key];
if (c > best) { best = c; bestSeat = p.seat; }
}
out[key] = { seat: bestSeat, cards: best };
}
return out;
}
// ---- whole-team state evaluation ------------------------------------------
// Higher is better. Greedy 1-ply over this drives competent co-op play.
export function evalState(state, seat) {
if (state.phase === 'won') return 1e9;
if (state.phase === 'lost') return -1e9;
const pr = state.priorities ?? {};
let v = capturedCount(state) * 5000;
const allCaptured = capturedCount(state) === 4;
const lead = carriers(state);
// Total team cards per uncaptured treasure, and an automatic "team focus":
// rally everyone on the treasure nearest completion (or most threatened)
// rather than splitting effort four ways and finishing none in time.
const teamCards = {};
for (const key of TREASURE_KEYS) {
if (!lead[key]) continue;
let t = 0; for (const p of state.players) t += handTreasureCounts(p)[key];
teamCards[key] = t;
}
let autoFocus = null, bestFocus = -1;
for (const key of Object.keys(teamCards)) {
const live = liveTreasureTiles(state, key);
if (!live.length) continue;
const score = teamCards[key] * 2 + (live.length === 1 ? 4 : 0);
if (score > bestFocus) { bestFocus = score; autoFocus = key; }
}
// Treasure progress + pulling carriers toward their tiles.
for (const key of TREASURE_KEYS) {
if (!lead[key]) continue; // already captured
const { seat: cs, cards } = lead[key];
const focus = pr.focusTreasure === key ? 2.2 : (autoFocus === key ? 1.8 : 1);
v += Math.min(cards, CARDS_TO_CAPTURE) * 40 * focus;
const live = liveTreasureTiles(state, key);
if (!live.length || cs < 0) continue;
const liveSet = new Set(live);
const carrierTile = state.players[cs].tileId;
const d = bfsDist(state, carrierTile, liveSet);
if (cards >= CARDS_TO_CAPTURE) {
// Ready to capture — marching the carrier to the temple must out-weigh
// routine shoring, so this dominates everything short of a true emergency.
v += (1600 - d * 180) * focus;
} else if (cards > 0) {
// Pre-position the lead carrier near its temple while it gathers cards.
v -= d * 8 * focus;
// Card logistics: other holders should converge on the carrier to hand
// their matching cards over (the Messenger can do it from anywhere).
for (const o of state.players) {
if (o.seat === cs || o.role === 'messenger') continue;
const oc = handTreasureCounts(o)[key];
if (oc > 0) v -= bfsDist(state, o.tileId, new Set([carrierTile])) * 5 * oc * focus;
}
}
// Defense: keep at least one adventurer near the live temple tiles so they
// can be shored before they sink (most losses are treasures drowning).
let nearest = 99;
for (const p of state.players) nearest = Math.min(nearest, bfsDist(state, p.tileId, liveSet));
v -= nearest * 2 * focus;
}
// Guard Fools' Landing — losing the helipad is an instant, common death. Keep
// someone within reach of it, and never leave it flooded if avoidable.
if (state.tiles['fools-landing'].state !== 'sunk') {
let nearLanding = 99;
for (const p of state.players) nearLanding = Math.min(nearLanding, bfsDist(state, p.tileId, new Set(['fools-landing'])));
v -= nearLanding * 6;
}
// Threat: flooded tiles weighted by what their loss would cost.
const saveSet = new Set(pr.saveTiles ?? []);
for (const t of Object.values(state.tiles)) {
if (t.state !== 'flooded') continue;
let w = floodWeight(state, t);
if (saveSet.has(t.id)) w += 50;
// A flooded tile a pawn stands on is more urgent (sinking forces a swim).
if (state.players.some((p) => p.tileId === t.id)) w += 10;
v -= w;
}
// Endgame: once everything's captured (or the human asked to regroup), pull
// everyone to Fools' Landing and reward holding a Helicopter Lift.
if (allCaptured || pr.regroup) {
const landing = new Set(['fools-landing']);
for (const p of state.players) v -= bfsDist(state, p.tileId, landing) * (allCaptured ? 10 : 3);
if (allCaptured && state.players.some((p) => p.hand.includes(SPECIAL.HELICOPTER))) v += 80;
}
// Hand-limit risk.
for (const p of state.players) if (p.hand.length > HAND_LIMIT) v -= 25 * (p.hand.length - HAND_LIMIT);
// "Hold position" damps wandering (handled by caller comparing to base).
return v;
}
// ---- pick one action (or null to end the turn) ----------------------------
export function chooseAction(state, seat, skill = 3) {
const acts = legalActions(state, seat);
if (acts.length === 0) return null;
const prof = profileFor(skill);
const base = evalState(state, seat);
let scored = acts.map((a) => ({ a, v: evalState(applyAction(state, seat, a), seat) }));
scored.sort((x, y) => y.v - x.v);
// Nothing improves the team's position — bank the remaining actions.
const hold = state.priorities?.hold ? 12 : 1; // require a bigger gain when "hold position" is set
if (scored[0].v <= base + hold) return null;
if (Math.random() < prof.blunder) {
// Blunder: still pick something that doesn't actively hurt, just not the best.
const ok = scored.filter((s) => s.v >= base);
return (ok[Math.floor(Math.random() * ok.length)] ?? scored[0]).a;
}
const pool = scored.slice(0, Math.min(prof.topN, scored.length));
let best = pool[0], bestV = -Infinity;
for (const c of pool) {
const v = c.v + (prof.noise ? (Math.random() * 2 - 1) * prof.noise : 0);
if (v > bestV) { bestV = v; best = c; }
}
return best.a;
}
// ---- free special-card plays (Sandbags / Helicopter Lift) -----------------
// These cost no action and can be played any time, so the planner gets a chance
// to use them before/between its regular actions. Returns a descriptor the
// caller applies via playSandbags / playHelicopter, or null. Only the current
// seat's own specials are considered (a simplification of "any time").
export function chooseFreeCard(state, seat) {
if (state.phase !== 'actions' || state.current !== seat) return null;
const p = state.players[seat];
// Sandbags: free-shore the single most critical flooded tile. Reserve them for
// genuine emergencies (a temple's last/second tile, or Fools' Landing) rather
// than spending them on routine flooding.
if (p.hand.includes(SPECIAL.SANDBAGS)) {
let best = null, bw = 0;
for (const t of Object.values(state.tiles)) {
const w = floodWeight(state, t);
if (w > bw) { bw = w; best = t; }
}
if (best && bw >= 100) return { type: 'sandbags', tileId: best.id };
}
// Helicopter Lift: deliver a ready (4-card) carrier straight to its temple,
// but only if the team can spare one (the last Helicopter is needed to escape).
if (p.hand.includes(SPECIAL.HELICOPTER)) {
const heli = state.players.reduce((n, pl) => n + pl.hand.filter((c) => c === SPECIAL.HELICOPTER).length, 0);
if (heli >= 2) {
for (const key of TREASURE_KEYS) {
if (isCaptured(state, key)) continue;
let cs = -1, cc = 0;
for (const pl of state.players) { const c = handTreasureCounts(pl)[key]; if (c > cc) { cc = c; cs = pl.seat; } }
if (cc < CARDS_TO_CAPTURE || cs < 0) continue;
const live = liveTreasureTiles(state, key);
const carrierTile = state.players[cs].tileId;
if (!live.length || live.includes(carrierTile)) continue;
const templeFlooded = live.some((id) => state.tiles[id].state === 'flooded');
const d = bfsDist(state, carrierTile, new Set(live));
// Lift yourself whenever it saves real travel; lift a teammate only when
// their temple is actively flooding (urgent) — they capture next turn.
if ((cs === seat && d >= 2) || templeFlooded) {
let dest = live[0], dd = 99;
for (const id of live) { const e = bfsDist(state, carrierTile, new Set([id])); if (e < dd) { dd = e; dest = id; } }
return { type: 'helicopter', pawnSeats: [cs], destTileId: dest, carrierSeat: cs };
}
}
}
}
return null;
}
// ---- discard choice (when a hand is over the limit) -----------------------
// Sheds the least useful card without breaking a capture set. Dead cards of an
// already-captured treasure go first; Helicopter Lift is kept to the last.
export function chooseDiscard(state, seat) {
const p = state.players[seat];
const counts = handTreasureCounts(p);
const captured = (k) => state.players.some((pl) => pl.captured[k]);
// 1. Treasure cards whose treasure is already captured (pure dead weight).
let dead = p.hand.find((c) => c.startsWith('treasure:') && captured(c.slice('treasure:'.length)));
if (dead) return dead;
// 2. Sandbags before anything still in play.
if (p.hand.includes(SPECIAL.SANDBAGS)) return SPECIAL.SANDBAGS;
// 3. A treasure card from the smallest (and not yet complete) pile.
const treasureCards = p.hand.filter((c) => c.startsWith('treasure:'));
if (treasureCards.length) {
treasureCards.sort((a, b) => counts[a.slice(9)] - counts[b.slice(9)]);
const safe = treasureCards.find((c) => counts[c.slice('treasure:'.length)] < CARDS_TO_CAPTURE);
if (safe) return safe;
return treasureCards[0];
}
// 4. Last resort: Helicopter Lift.
return p.hand[0];
}
// ---- describe the partner's plan for the chat panel ------------------------
// Pure read of the current state — returns a rationale object the chat library
// turns into a spoken line.
export function describeIntent(state, seat) {
const p = state.players[seat];
const role = p.role;
const lead = carriers(state);
const counts = handTreasureCounts(p);
// On a treasure tile with enough cards → capture.
const here = state.tiles[p.tileId];
if (here.treasure && !p.captured[here.treasure] && counts[here.treasure] >= CARDS_TO_CAPTURE) {
return { role, intent: 'CAPTURE', treasure: here.treasure, tile: here.id, threats: topThreats(state) };
}
// Am I the lead carrier of an uncaptured treasure with at least one card?
let mine = null;
for (const key of TREASURE_KEYS) {
if (!lead[key] || lead[key].seat !== seat || lead[key].cards < 1) continue;
if (!mine || lead[key].cards > counts[mine]) mine = key;
}
if (mine) {
// Need more cards? Is a teammate holding some?
const need = CARDS_TO_CAPTURE - counts[mine];
let request = null;
if (need > 0) {
const helper = state.players.find((o) => o.seat !== seat && handTreasureCounts(o)[mine] > 0);
if (helper) request = { treasure: mine, fromRole: helper.role };
}
return { role, intent: 'SEEK', treasure: mine, cards: counts[mine], request, threats: topThreats(state) };
}
// Endgame.
if (capturedCount(state) === 4) {
return { role, intent: 'ESCAPE', threats: topThreats(state) };
}
// Otherwise: shore something critical / support.
const threats = topThreats(state);
if (threats.length) return { role, intent: 'SHORE', tile: threats[0].id, threats };
return { role, intent: 'SUPPORT', threats };
}
// The flooded tiles whose loss would hurt most, worst first.
export function topThreats(state) {
const out = [];
for (const t of Object.values(state.tiles)) {
if (t.state !== 'flooded') continue;
let w = 1;
if (t.landing) w = 5;
else if (t.treasure && !state.players.some((p) => p.captured[t.treasure])) {
const pair = TREASURES[t.treasure].tiles.find((id) => id !== t.id);
w = state.tiles[pair].state === 'dry' ? 3 : 4;
}
if (w >= 3) out.push({ id: t.id, name: t.name, weight: w, treasure: t.treasure ?? null, landing: !!t.landing });
}
return out.sort((a, b) => b.weight - a.weight).slice(0, 3);
}

View File

@ -0,0 +1,124 @@
// Forbidden Island — team-chat phrase library. 100% offline string templates.
//
// The heuristic planner (IslandAI.describeIntent) produces a rationale object;
// this turns it into a line of table talk in the speaking role's "voice".
// Game events (a tile sinking, Waters Rise!, a capture) get their own lines so
// the chat reads like a real co-op table.
import { TREASURES, ROLES } from './IslandData.js';
const EMOJI = {
pilot: '✈️', engineer: '🔧', messenger: '✉️', navigator: '🧭', diver: '🤿', explorer: '🧗',
};
export function roleEmoji(role) { return EMOJI[role] ?? '🧩'; }
export function roleName(role) { return ROLES[role]?.name ?? role; }
export function roleColorHex(role) { return ROLES[role]?.colorHex ?? '#f2ead8'; }
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
const tName = (key) => TREASURES[key]?.name ?? 'the treasure';
// Little role-specific interjections, sprinkled in for character.
const FLAVOR = {
pilot: ['Wings ready.', 'I can be anywhere in a heartbeat.', 'Just say the word.'],
engineer: ['Efficient as always.', 'On it.', 'Consider it done.'],
messenger: ['Happy to help.', 'Cards incoming.', "I've got the network."],
navigator: ['Follow my lead.', 'Ill route us.', 'Stay coordinated.'],
diver: ['The water doesnt scare me.', 'Going deep.', 'Ill take the wet path.'],
explorer: ['Ill take the diagonal.', 'Scouting ahead.', 'I see a way.'],
};
const INTENT = {
CAPTURE: (r) => pick([
`I'm standing on the temple with a full set — claiming ${tName(r.treasure)} now!`,
`Four cards in hand and feet on the tile. ${tName(r.treasure)} is ours this turn.`,
`Securing ${tName(r.treasure)} — that's one less treasure to worry about.`,
]),
SEEK: (r) => {
const base = pick([
`I've got ${r.cards} card${r.cards === 1 ? '' : 's'} toward ${tName(r.treasure)} — pushing for its temple.`,
`Working on ${tName(r.treasure)}. Heading for the tile, ${r.cards}/4 so far.`,
`${tName(r.treasure)} is my project — making my way there.`,
]);
if (r.request) return `${base} ${roleName(r.request.fromRole)}, if you're holding a matching card, send it my way?`;
return base;
},
SHORE: (r) => pick([
`Shoring up — we can't afford to lose ground here.`,
`Patching the flooding before it spreads. Watch the map.`,
`Holding the line on the flooded tiles near me.`,
]),
ESCAPE: () => pick([
`All four treasures are in. Regrouping at Fools' Landing — let's fly out together!`,
`Treasures secured! Everyone to the helipad, we need a Helicopter Lift.`,
`This is the home stretch — converging on Fools' Landing.`,
]),
SUPPORT: () => pick([
`Nothing urgent for me — repositioning to help where it counts.`,
`Holding steady and keeping my options open.`,
`I'll back up whoever needs it this turn.`,
]),
};
// A line announcing the AI partner's plan for the turn.
export function lineForIntent(rationale) {
const make = INTENT[rationale.intent] ?? INTENT.SUPPORT;
let text = make(rationale);
// Occasionally append a threat warning.
const t = rationale.threats?.[0];
if (t && Math.random() < 0.5) {
text += ` ${pick([
`Heads up — ${t.name} floods next, someone keep an eye on it.`,
`Also: ${t.name} is in danger. Don't let it sink.`,
`Watch ${t.name}, it's one flood from trouble.`,
])}`;
} else if (Math.random() < 0.25) {
text += ` ${pick(FLAVOR[rationale.role] ?? [])}`;
}
return { role: rationale.role, text };
}
// Lines for notable game events. `ctx` carries the names already resolved.
export function lineForEvent(kind, ctx = {}) {
switch (kind) {
case 'sink': return { role: ctx.role ?? null, text: pick([
`${ctx.tileName} just sank beneath the waves.`,
`We lost ${ctx.tileName} — it's gone for good.`,
]) };
case 'watersRise': return { role: ctx.role ?? null, text: pick([
`Waters Rise! The flood is accelerating — water level ${ctx.waterLevel}.`,
`That's a Waters Rise card. Everything we flooded is coming back around.`,
]) };
case 'capture': return { role: ctx.role, text: pick([
`Got it! ${tName(ctx.treasure)} is secured. ${4 - (ctx.remaining ?? 0)} of 4 down.`,
`${tName(ctx.treasure)} claimed — great teamwork.`,
]) };
case 'sandbags': return { role: ctx.role, text: pick([
`Dropping sandbags on ${ctx.tileName} — that holds the line, no action spent.`,
`Sandbags out on ${ctx.tileName}. We can't lose that one.`,
]) };
case 'heliMove': return { role: ctx.role, text: pick([
`Helicopter lift — flying ${ctx.who} straight to ${ctx.tileName} for the capture.`,
`Burning a Helicopter to get ${ctx.who} onto ${ctx.tileName} before it's too late.`,
]) };
case 'swim': return { role: ctx.role, text: pick([
`Tile sank under me — swimming to safety.`,
`Had to bail to ${ctx.tileName} as the ground gave way.`,
]) };
case 'won': return { role: null, text: pick([
`We made it off the island — together! 🎉`,
`Helicopter's airborne with all four treasures. We win!`,
]) };
case 'lost': return { role: null, text: ctx.reason ?? 'The island is lost.' };
default: return null;
}
}
// Acknowledgement when the human sets a strategy priority.
export function lineForAck(role, label) {
return { role, text: pick([
`Copy that — ${label}.`,
`Understood. Re-planning around: ${label}.`,
`Roger. Prioritizing ${label}.`,
]) };
}

View File

@ -0,0 +1,131 @@
// Forbidden Island — static data. No Phaser, no game state here: just the board
// silhouette, the tile/treasure/role catalog, and deck composition. Everything
// dynamic lives in IslandLogic.js.
// The island is a 6×6 grid with the four corners of each edge removed, giving
// the classic 24-tile diamond:
//
// . . X X . .
// . X X X X .
// X X X X X X
// X X X X X X
// . X X X X .
// . . X X . .
//
export const GRID = 6;
// Playable cells in reading order (row, col). 24 of them.
export const CELLS = [
[0, 2], [0, 3],
[1, 1], [1, 2], [1, 3], [1, 4],
[2, 0], [2, 1], [2, 2], [2, 3], [2, 4], [2, 5],
[3, 0], [3, 1], [3, 2], [3, 3], [3, 4], [3, 5],
[4, 1], [4, 2], [4, 3], [4, 4],
[5, 2], [5, 3],
];
export function isCell(r, c) {
return CELLS.some(([cr, cc]) => cr === r && cc === c);
}
// Treasures, each tied to two island tiles. `element` keys the treasure cards.
export const TREASURES = {
earth: { key: 'earth', name: 'The Earth Stone', tiles: ['temple-moon', 'temple-sun'], color: 0x8d6e3c },
wind: { key: 'wind', name: 'The Statue of the Wind', tiles: ['whispering-garden', 'howling-garden'], color: 0x9aa7b0 },
fire: { key: 'fire', name: 'The Crystal of Fire', tiles: ['cave-embers', 'cave-shadows'], color: 0xc0492f },
ocean: { key: 'ocean', name: "The Ocean's Chalice", tiles: ['coral-palace', 'tidal-palace'], color: 0x2f7fb0 },
};
export const TREASURE_KEYS = ['earth', 'wind', 'fire', 'ocean'];
// All 24 tiles. `treasure` links a tile to a treasure; `landing` marks the
// helipad you must escape from. The rest are neutral.
export const TILES = [
{ id: 'temple-moon', name: 'Temple of the Moon', treasure: 'earth' },
{ id: 'temple-sun', name: 'Temple of the Sun', treasure: 'earth' },
{ id: 'whispering-garden', name: 'Whispering Garden', treasure: 'wind' },
{ id: 'howling-garden', name: 'Howling Garden', treasure: 'wind' },
{ id: 'cave-embers', name: 'Cave of Embers', treasure: 'fire' },
{ id: 'cave-shadows', name: 'Cave of Shadows', treasure: 'fire' },
{ id: 'coral-palace', name: 'Coral Palace', treasure: 'ocean' },
{ id: 'tidal-palace', name: 'Tidal Palace', treasure: 'ocean' },
{ id: 'fools-landing', name: "Fools' Landing", landing: true },
{ id: 'bronze-gate', name: 'Bronze Gate' },
{ id: 'copper-gate', name: 'Copper Gate' },
{ id: 'silver-gate', name: 'Silver Gate' },
{ id: 'gold-gate', name: 'Gold Gate' },
{ id: 'iron-gate', name: 'Iron Gate' },
{ id: 'watchtower', name: 'Watchtower' },
{ id: 'observatory', name: 'Observatory' },
{ id: 'phantom-rock', name: 'Phantom Rock' },
{ id: 'misty-marsh', name: 'Misty Marsh' },
{ id: 'breakers-bridge', name: 'Breakers Bridge' },
{ id: 'crimson-forest', name: 'Crimson Forest' },
{ id: 'dunes-deception', name: 'Dunes of Deception' },
{ id: 'lost-lagoon', name: 'Lost Lagoon' },
{ id: 'twilight-hollow', name: 'Twilight Hollow' },
{ id: 'cliffs-abandon', name: 'Cliffs of Abandon' },
];
// The six adventurer roles. `power` is read by the engine/AI to branch special
// abilities; `color` is the pawn colour.
export const ROLES = {
pilot: { key: 'pilot', name: 'Pilot', color: 0x4a90d9, colorHex: '#4a90d9', power: 'Once per turn, fly to any tile.' },
engineer: { key: 'engineer', name: 'Engineer', color: 0xd0473a, colorHex: '#d0473a', power: 'Shore up two tiles for one action.' },
messenger: { key: 'messenger', name: 'Messenger', color: 0xb9bfc6, colorHex: '#b9bfc6', power: 'Give Treasure cards to anyone, any distance.' },
navigator: { key: 'navigator', name: 'Navigator', color: 0xe2b53c, colorHex: '#e2b53c', power: 'Move another adventurer up to 2 tiles.' },
diver: { key: 'diver', name: 'Diver', color: 0x2b2b30, colorHex: '#2b2b30', power: 'Swim through flooded and missing tiles.' },
explorer: { key: 'explorer', name: 'Explorer', color: 0x49a25a, colorHex: '#49a25a', power: 'Move and shore up diagonally.' },
};
export const ROLE_KEYS = ['pilot', 'engineer', 'messenger', 'navigator', 'diver', 'explorer'];
// Special (non-treasure) Treasure-deck cards.
export const SPECIAL = {
WATERS_RISE: 'waters-rise',
HELICOPTER: 'helicopter',
SANDBAGS: 'sandbags',
};
// Treasure deck: 5 of each treasure (20) + 3 Waters Rise! + 3 Helicopter Lift +
// 2 Sandbags = 28 cards. Cards are plain ids: `treasure:earth`, `waters-rise`, …
export function buildTreasureDeck() {
const deck = [];
for (const key of TREASURE_KEYS) for (let i = 0; i < 5; i++) deck.push(`treasure:${key}`);
for (let i = 0; i < 3; i++) deck.push(SPECIAL.WATERS_RISE);
for (let i = 0; i < 3; i++) deck.push(SPECIAL.HELICOPTER);
for (let i = 0; i < 2; i++) deck.push(SPECIAL.SANDBAGS);
return deck;
}
// Water-level track. Index = level (1..10); value = flood cards drawn per turn.
// Level 10 is the skull: reaching it loses the game.
export const WATER_TRACK = [null, 2, 2, 3, 3, 3, 4, 4, 5, 5, 'skull'];
export const MAX_WATER = 10;
export const DIFFICULTY = {
novice: { key: 'novice', name: 'Novice', start: 1 },
normal: { key: 'normal', name: 'Normal', start: 2 },
elite: { key: 'elite', name: 'Elite', start: 3 },
legendary: { key: 'legendary', name: 'Legendary', start: 4 },
};
export function floodDrawCount(waterLevel) {
const v = WATER_TRACK[Math.min(waterLevel, MAX_WATER)];
return typeof v === 'number' ? v : 5;
}
// Cards needed to capture a treasure.
export const CARDS_TO_CAPTURE = 4;
// Hand limit; over this at end of turn you must discard.
export const HAND_LIMIT = 5;
// Actions per turn.
export const ACTIONS_PER_TURN = 3;
export function tileById(id) {
return TILES.find((t) => t.id === id) ?? null;
}
// Tile id → its row in the `forbiddenisland-tiles` spritesheet. Dry side is
// frame 2·row, flooded side is frame 2·row+1.
export const TILE_FRAME_ROW = Object.fromEntries(TILES.map((t, i) => [t.id, i]));

View File

@ -0,0 +1,527 @@
// Forbidden Island — pure state engine. No Phaser. The whole game is a
// cooperative race against the board: players take 3 actions, draw 2 Treasure
// cards, then the island floods. Everyone wins or loses together.
//
// State is treated as immutable from the outside: every mutator returns a fresh
// cloned state (the BlokusLogic idiom). Randomness is seeded so games are
// reproducible and unit-testable.
import {
CELLS, TILES, TREASURES, TREASURE_KEYS, ROLES, SPECIAL,
buildTreasureDeck, floodDrawCount, DIFFICULTY,
CARDS_TO_CAPTURE, HAND_LIMIT, ACTIONS_PER_TURN, MAX_WATER,
} from './IslandData.js';
const ORTH = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const DIAG = [[-1, -1], [-1, 1], [1, -1], [1, 1]];
// ---- seeded RNG (mulberry32) ----------------------------------------------
function rngFrom(seedState) {
let a = seedState >>> 0;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Advance the stored rng state deterministically by `n` draws and shuffle.
function shuffle(arr, rng) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// We keep an explicit integer rng cursor on the state so clones stay
// deterministic. Each helper pulls a fresh generator seeded by (seed + cursor)
// and bumps the cursor by how many values it consumed.
function makeRng(state) {
const rng = rngFrom((state.seed + state.rngCursor * 2654435761) >>> 0);
let calls = 0;
return {
next: () => { calls++; return rng(); },
commit: () => { state.rngCursor += calls + 1; },
};
}
// ---- construction ----------------------------------------------------------
export function createInitialState({ roles, difficulty = 'normal', seed, humanSeat = 0 } = {}) {
const roleKeys = (roles && roles.length) ? roles.slice(0, 4) : ['pilot', 'engineer', 'messenger', 'navigator'];
const s = {
seed: (seed ?? Math.floor(Math.random() * 1e9)) >>> 0,
rngCursor: 0,
difficulty,
tiles: {},
players: [],
treasureDeck: [],
treasureDiscard: [],
floodDeck: [],
floodDiscard: [],
waterLevel: DIFFICULTY[difficulty]?.start ?? 2,
current: 0,
actionsLeft: ACTIONS_PER_TURN,
phase: 'actions', // actions | discard | flood | won | lost
pendingDiscard: null, // seat that must discard
pendingSwim: null, // { seat, options } a pawn forced to leave a sunk tile
priorities: { focusTreasure: null, saveTiles: [], regroup: false, hold: false },
lossReason: null,
log: [],
};
const rng = makeRng(s);
// Assign the 24 tiles to the 24 board cells.
const shuffledTiles = shuffle(TILES, rng.next);
CELLS.forEach(([r, c], i) => {
const t = shuffledTiles[i];
s.tiles[t.id] = {
id: t.id, name: t.name, r, c,
state: 'dry',
treasure: t.treasure ?? null,
landing: !!t.landing,
};
});
// Pawns: everyone starts on Fools' Landing (safe, central rally point).
roleKeys.forEach((rk, seat) => {
s.players.push({
seat,
role: rk,
color: ROLES[rk].color,
tileId: 'fools-landing',
hand: [],
captured: {}, // treasureKey -> true
isHuman: seat === humanSeat,
pilotFlew: false,
});
});
// Treasure deck — deal 2 to each player, reshuffling any Waters Rise! back in.
let deck = shuffle(buildTreasureDeck(), rng.next);
for (const p of s.players) {
let need = 2;
while (need > 0) {
const card = deck.shift();
if (card === SPECIAL.WATERS_RISE) { deck.push(card); deck = shuffle(deck, rng.next); continue; }
p.hand.push(card);
need--;
}
}
s.treasureDeck = deck;
// Flood deck — shuffle one card per tile; flood the first 6.
s.floodDeck = shuffle(Object.keys(s.tiles), rng.next);
for (let i = 0; i < 6; i++) {
const id = s.floodDeck.shift();
s.tiles[id].state = 'flooded';
s.floodDiscard.push(id);
}
rng.commit();
s.log.push({ kind: 'setup' });
return s;
}
export function cloneState(state) {
return {
...state,
tiles: Object.fromEntries(Object.entries(state.tiles).map(([k, t]) => [k, { ...t }])),
players: state.players.map((p) => ({ ...p, hand: p.hand.slice(), captured: { ...p.captured } })),
treasureDeck: state.treasureDeck.slice(),
treasureDiscard: state.treasureDiscard.slice(),
floodDeck: state.floodDeck.slice(),
floodDiscard: state.floodDiscard.slice(),
priorities: {
...state.priorities,
saveTiles: state.priorities.saveTiles.slice(),
},
pendingSwim: state.pendingSwim ? { ...state.pendingSwim, options: state.pendingSwim.options.slice() } : null,
log: state.log.slice(),
};
}
// ---- geometry / adjacency --------------------------------------------------
function cellIndex(state) {
const map = {};
for (const t of Object.values(state.tiles)) map[`${t.r},${t.c}`] = t.id;
return map;
}
export function adjacentTiles(state, tileId, { diagonal = false } = {}) {
const t = state.tiles[tileId];
if (!t) return [];
const map = cellIndex(state);
const dirs = diagonal ? [...ORTH, ...DIAG] : ORTH;
const out = [];
for (const [dr, dc] of dirs) {
const id = map[`${t.r + dr},${t.c + dc}`];
if (id) out.push(id);
}
return out;
}
function isSunk(state, id) { return state.tiles[id].state === 'sunk'; }
function isFlooded(state, id) { return state.tiles[id].state === 'flooded'; }
// Tiles a Diver can reach: any non-sunk tile reachable by stepping through
// flooded/sunk tiles (orthogonally) from the start.
function diverDestinations(state, startId) {
const start = state.tiles[startId];
const map = cellIndex(state);
const seen = new Set([startId]);
const dest = new Set();
const stack = [start];
while (stack.length) {
const t = stack.pop();
for (const [dr, dc] of ORTH) {
const id = map[`${t.r + dr},${t.c + dc}`];
if (!id || seen.has(id)) continue;
seen.add(id);
const nt = state.tiles[id];
if (nt.state === 'sunk') { stack.push(nt); } // swim through
else { dest.add(id); if (nt.state === 'flooded') stack.push(nt); } // can stop, can continue through flooded
}
}
dest.delete(startId);
return [...dest];
}
function moveDestinations(state, player) {
const role = player.role;
if (role === 'diver') return diverDestinations(state, player.tileId);
const diagonal = role === 'explorer';
return adjacentTiles(state, player.tileId, { diagonal }).filter((id) => !isSunk(state, id));
}
function shoreTargets(state, player) {
const diagonal = player.role === 'explorer';
const here = player.tileId;
const ids = [here, ...adjacentTiles(state, here, { diagonal })];
return [...new Set(ids)].filter((id) => isFlooded(state, id));
}
// ---- legal actions ---------------------------------------------------------
// Returns a flat list of action descriptors the actor may take this turn.
export function legalActions(state, seat) {
if (state.phase !== 'actions' || state.current !== seat || state.actionsLeft <= 0) return [];
const p = state.players[seat];
const out = [];
// Move
for (const id of moveDestinations(state, p)) out.push({ type: 'move', seat, tileId: id });
// Pilot fly — once per turn, to any non-sunk tile.
if (p.role === 'pilot' && !p.pilotFlew) {
for (const t of Object.values(state.tiles)) {
if (t.id !== p.tileId && t.state !== 'sunk') out.push({ type: 'fly', seat, tileId: t.id });
}
}
// Shore up
const shoreable = shoreTargets(state, p);
for (const id of shoreable) out.push({ type: 'shoreUp', seat, tiles: [id] });
// Engineer: shore two at once.
if (p.role === 'engineer' && shoreable.length >= 2) {
for (let i = 0; i < shoreable.length; i++)
for (let j = i + 1; j < shoreable.length; j++)
out.push({ type: 'shoreUp', seat, tiles: [shoreable[i], shoreable[j]] });
}
// Give a Treasure card — to a player on the same tile (Messenger: anyone).
const treasureCards = [...new Set(p.hand.filter((c) => c.startsWith('treasure:')))];
for (const other of state.players) {
if (other.seat === seat) continue;
const reachable = p.role === 'messenger' || other.tileId === p.tileId;
if (!reachable) continue;
for (const card of treasureCards) out.push({ type: 'giveCard', seat, toSeat: other.seat, card });
}
// Capture a treasure.
const tile = state.tiles[p.tileId];
if (tile.treasure && !p.captured[tile.treasure]) {
const have = p.hand.filter((c) => c === `treasure:${tile.treasure}`).length;
if (have >= CARDS_TO_CAPTURE) out.push({ type: 'capture', seat, treasure: tile.treasure });
}
// Navigator: move another adventurer up to 2 tiles.
if (p.role === 'navigator') {
for (const other of state.players) {
if (other.seat === seat) continue;
for (const id of navDestinations(state, other)) out.push({ type: 'navMove', seat, targetSeat: other.seat, tileId: id });
}
}
return out;
}
function navDestinations(state, target) {
// Up to two orthogonal steps through non-sunk tiles.
const one = adjacentTiles(state, target.tileId).filter((id) => !isSunk(state, id));
const set = new Set(one);
for (const id of one) for (const id2 of adjacentTiles(state, id).filter((x) => !isSunk(state, x))) set.add(id2);
set.delete(target.tileId);
return [...set];
}
// ---- applying an action ----------------------------------------------------
export function applyAction(state, seat, action) {
if (state.phase !== 'actions' || state.current !== seat || state.actionsLeft <= 0) return state;
const s = cloneState(state);
const p = s.players[seat];
switch (action.type) {
case 'move':
p.tileId = action.tileId;
s.log.push({ kind: 'move', seat, tileId: action.tileId });
break;
case 'fly':
p.tileId = action.tileId;
p.pilotFlew = true;
s.log.push({ kind: 'fly', seat, tileId: action.tileId });
break;
case 'shoreUp':
for (const id of action.tiles) if (isFlooded(s, id)) s.tiles[id].state = 'dry';
s.log.push({ kind: 'shoreUp', seat, tiles: action.tiles.slice() });
break;
case 'giveCard': {
const idx = p.hand.indexOf(action.card);
if (idx < 0) return state;
p.hand.splice(idx, 1);
s.players[action.toSeat].hand.push(action.card);
s.log.push({ kind: 'giveCard', seat, toSeat: action.toSeat, card: action.card });
break;
}
case 'capture': {
let removed = 0;
p.hand = p.hand.filter((c) => {
if (removed < CARDS_TO_CAPTURE && c === `treasure:${action.treasure}`) { removed++; s.treasureDiscard.push(c); return false; }
return true;
});
p.captured[action.treasure] = true;
s.log.push({ kind: 'capture', seat, treasure: action.treasure });
break;
}
case 'navMove':
s.players[action.targetSeat].tileId = action.tileId;
s.log.push({ kind: 'navMove', seat, targetSeat: action.targetSeat, tileId: action.tileId });
break;
default:
return state;
}
s.actionsLeft -= 1;
return checkLoss(s) ?? s;
}
// ---- special cards (free, any time) ---------------------------------------
export function playSandbags(state, seat, tileId) {
const p = state.players[seat];
if (!p.hand.includes(SPECIAL.SANDBAGS) || !isFlooded(state, tileId)) return state;
const s = cloneState(state);
s.players[seat].hand.splice(s.players[seat].hand.indexOf(SPECIAL.SANDBAGS), 1);
s.tiles[tileId].state = 'dry';
s.treasureDiscard.push(SPECIAL.SANDBAGS);
s.log.push({ kind: 'sandbags', seat, tileId });
return s;
}
export function playHelicopter(state, seat, pawnSeats, destTileId) {
const p = state.players[seat];
if (!p.hand.includes(SPECIAL.HELICOPTER) || isSunk(state, destTileId)) return state;
const s = cloneState(state);
s.players[seat].hand.splice(s.players[seat].hand.indexOf(SPECIAL.HELICOPTER), 1);
for (const ps of pawnSeats) s.players[ps].tileId = destTileId;
s.treasureDiscard.push(SPECIAL.HELICOPTER);
s.log.push({ kind: 'helicopter', seat, pawnSeats: pawnSeats.slice(), destTileId });
return s;
}
export function canEscape(state) {
const allCaptured = TREASURE_KEYS.every((k) => state.players.some((p) => p.captured[k]));
const allOnLanding = state.players.every((p) => p.tileId === 'fools-landing');
const hasHeli = state.players.some((p) => p.hand.includes(SPECIAL.HELICOPTER));
return allCaptured && allOnLanding && hasHeli;
}
export function attemptEscape(state) {
if (!canEscape(state)) return state;
const s = cloneState(state);
s.phase = 'won';
s.log.push({ kind: 'won' });
return s;
}
// ---- end of actions: draw treasure, then flood ----------------------------
export function endActions(state) {
if (state.phase !== 'actions') return state;
let s = cloneState(state);
const p = s.players[s.current];
const rng = makeRng(s);
let drawn = 0;
while (drawn < 2) {
if (s.treasureDeck.length === 0) {
if (s.treasureDiscard.length === 0) break;
s.treasureDeck = shuffle(s.treasureDiscard, rng.next);
s.treasureDiscard = [];
}
const card = s.treasureDeck.shift();
if (card === SPECIAL.WATERS_RISE) {
s.treasureDiscard.push(card);
s.waterLevel = Math.min(MAX_WATER, s.waterLevel + 1);
// Reshuffle the flood discard back on TOP of the flood deck.
if (s.floodDiscard.length) {
s.floodDeck = [...shuffle(s.floodDiscard, rng.next), ...s.floodDeck];
s.floodDiscard = [];
}
s.log.push({ kind: 'watersRise', waterLevel: s.waterLevel });
if (s.waterLevel >= MAX_WATER) { rng.commit(); s.phase = 'lost'; s.lossReason = 'The water level reached the skull.'; return s; }
} else {
p.hand.push(card);
s.log.push({ kind: 'drawTreasure', seat: p.seat, card });
}
drawn++;
}
rng.commit();
if (p.hand.length > HAND_LIMIT) { s.phase = 'discard'; s.pendingDiscard = p.seat; return s; }
s.phase = 'flood';
return s;
}
export function discardCard(state, seat, cardId) {
if (state.phase !== 'discard' || state.pendingDiscard !== seat) return state;
const s = cloneState(state);
const hand = s.players[seat].hand;
const idx = hand.indexOf(cardId);
if (idx < 0) return state;
const [card] = hand.splice(idx, 1);
if (card.startsWith('treasure:') || card === SPECIAL.SANDBAGS || card === SPECIAL.HELICOPTER) s.treasureDiscard.push(card);
s.log.push({ kind: 'discard', seat, card });
if (s.players[seat].hand.length <= HAND_LIMIT) { s.phase = 'flood'; s.pendingDiscard = null; }
return s;
}
// ---- flood phase -----------------------------------------------------------
// Draws flood cards equal to the water level, flooding/sinking tiles, then
// auto-resolves forced swims and advances to the next player. Returns the new
// state; inspect `state.log` (entries since the call) to animate.
export function resolveFlood(state) {
if (state.phase !== 'flood') return state;
let s = cloneState(state);
const count = floodDrawCount(s.waterLevel);
const rng = makeRng(s);
for (let i = 0; i < count; i++) {
if (s.floodDeck.length === 0) {
if (s.floodDiscard.length === 0) break;
s.floodDeck = shuffle(s.floodDiscard, rng.next);
s.floodDiscard = [];
}
const id = s.floodDeck.shift();
const tile = s.tiles[id];
if (tile.state === 'dry') {
tile.state = 'flooded';
s.floodDiscard.push(id);
s.log.push({ kind: 'flood', tileId: id });
} else if (tile.state === 'flooded') {
tile.state = 'sunk';
s.log.push({ kind: 'sink', tileId: id });
// Card is removed from the game (not discarded) since the tile is gone.
}
}
rng.commit();
// Forced swims for any pawn whose tile has sunk.
for (const p of s.players) {
if (isSunk(s, p.tileId)) {
const opts = swimOptions(s, p);
if (opts.length === 0) { s.phase = 'lost'; s.lossReason = `${ROLES[p.role].name} was lost beneath the waves.`; return s; }
// Auto-swim to the safest option (nearest dry tile, else nearest tile).
p.tileId = bestSwim(s, opts);
s.log.push({ kind: 'swim', seat: p.seat, tileId: p.tileId });
}
}
const lost = checkLoss(s);
if (lost) return lost;
// Advance to next player.
advanceTurn(s);
return s;
}
function swimOptions(state, player) {
if (player.role === 'diver') return diverDestinations(state, player.tileId);
const diagonal = player.role === 'explorer' || player.role === 'pilot';
return adjacentTiles(state, player.tileId, { diagonal: player.role === 'explorer' })
.filter((id) => !isSunk(state, id));
}
function bestSwim(state, opts) {
const dry = opts.filter((id) => state.tiles[id].state === 'dry');
const pool = dry.length ? dry : opts;
// Prefer the tile closest to Fools' Landing.
const fl = state.tiles['fools-landing'];
if (!fl || isSunk(state, 'fools-landing')) return pool[0];
let best = pool[0], bestD = Infinity;
for (const id of pool) {
const t = state.tiles[id];
const d = Math.abs(t.r - fl.r) + Math.abs(t.c - fl.c);
if (d < bestD) { bestD = d; best = id; }
}
return best;
}
function advanceTurn(state) {
state.current = (state.current + 1) % state.players.length;
state.actionsLeft = ACTIONS_PER_TURN;
state.phase = 'actions';
state.players[state.current].pilotFlew = false;
}
// ---- loss conditions -------------------------------------------------------
// Returns a `lost`-phase clone if the game is over, else null. Safe to call on
// any state.
export function checkLoss(state) {
// Fools' Landing sunk.
if (isSunk(state, 'fools-landing')) {
const s = cloneState(state); s.phase = 'lost'; s.lossReason = "Fools' Landing sank — there is no way off the island."; return s;
}
// A treasure became unreachable (both tiles sunk) and was never captured.
for (const key of TREASURE_KEYS) {
const captured = state.players.some((p) => p.captured[key]);
if (captured) continue;
const bothSunk = TREASURES[key].tiles.every((id) => isSunk(state, id));
if (bothSunk) {
const s = cloneState(state); s.phase = 'lost'; s.lossReason = `${TREASURES[key].name} was lost forever as its temples sank.`; return s;
}
}
if (state.waterLevel >= MAX_WATER) {
const s = cloneState(state); s.phase = 'lost'; s.lossReason = 'The water level reached the skull.'; return s;
}
return null;
}
export function isGameOver(state) { return state.phase === 'won' || state.phase === 'lost'; }
// ---- human-set strategy hints ---------------------------------------------
export function setPriority(state, patch) {
const s = cloneState(state);
s.priorities = { ...s.priorities, ...patch, saveTiles: patch.saveTiles ?? s.priorities.saveTiles };
return s;
}
// ---- progress helpers (for UI / AI) ---------------------------------------
export function capturedCount(state) {
return TREASURE_KEYS.filter((k) => state.players.some((p) => p.captured[k])).length;
}
export function handTreasureCounts(player) {
const counts = {};
for (const k of TREASURE_KEYS) counts[k] = 0;
for (const c of player.hand) if (c.startsWith('treasure:')) counts[c.slice('treasure:'.length)]++;
return counts;
}

View File

@ -49,6 +49,7 @@ import OldMaidGame from './games/oldmaid/OldMaidGame.js';
import BlokusGame from './games/blokus/BlokusGame.js';
import SpellingBeeGame from './games/spellingbee/SpellingBeeGame.js';
import MiniCrosswordGame from './games/minicrossword/MiniCrosswordGame.js';
import ForbiddenIslandGame from './games/forbiddenisland/ForbiddenIslandGame.js';
const config = {
type: Phaser.AUTO,
@ -111,6 +112,7 @@ const config = {
BlokusGame,
SpellingBeeGame,
MiniCrosswordGame,
ForbiddenIslandGame,
],
};

View File

@ -18,10 +18,11 @@ export default class GameRoomScene extends Phaser.Scene {
this.deckMode = data.deckMode ?? 'standard';
this.wordLength = data.wordLength ?? 4;
this.secretRevealType = data.secretRevealType ?? 'standard';
this.difficulty = data.difficulty ?? 'normal';
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,
@ -34,6 +35,7 @@ export default class GameRoomScene extends Phaser.Scene {
deckMode: this.deckMode,
wordLength: this.wordLength,
secretRevealType: this.secretRevealType,
difficulty: this.difficulty,
});
return;
}

View File

@ -38,6 +38,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.selectedDeckMode = 'standard';
this.selectedWordLength = 4;
this.selectedSecretRevealType = 'standard';
this.selectedDifficulty = 'normal'; // Forbidden Island water-level start
this._initializing = false;
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
}
@ -131,6 +132,8 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (this.gameDef.slug === 'mastermind') this.buildSecretRevealTypeSection(340, 1013);
if (this.gameDef.slug === 'forbiddenisland') this.buildDifficultySection(340, 1013);
if (!isWordGame && this.gameDef.slug !== 'battleship' && this.gameDef.slug !== 'mastermind') {
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
@ -205,9 +208,14 @@ export default class OpponentSelectScene extends Phaser.Scene {
}
buildOpponentCardEl(opp, cardH) {
// Default each opponent to a random skill of 2 or 3 (Nerts uses this).
// Default each opponent to a random skill. Forbidden Island is co-op, so its
// "opponents" are partners — default them strong (4 or 5); every other game
// keeps the standard 2-or-3 default.
if (this.skillByOpp[opp.id] === undefined) {
this.skillByOpp[opp.id] = Math.random() < 0.5 ? 2 : 3;
const strong = this.gameDef.slug === 'forbiddenisland';
this.skillByOpp[opp.id] = strong
? (Math.random() < 0.5 ? 4 : 5)
: (Math.random() < 0.5 ? 2 : 3);
}
const el = document.createElement('div');
@ -376,7 +384,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
// Skill control: pips always show the level; the +/- buttons appear only
// when this opponent is selected. Enabled for games with a 15 AI skill.
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle'].includes(this.gameDef.slug)) {
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland'].includes(this.gameDef.slug)) {
bio.style.webkitLineClamp = '1';
const skillRow = document.createElement('div');
@ -911,6 +919,54 @@ export default class OpponentSelectScene extends Phaser.Scene {
});
}
// ── Forbidden Island: difficulty (starting water level) ───────────────────
buildDifficultySection(centerX, centerY) {
const options = [
{ id: 'novice', label: 'Novice', desc: 'Water starts low (level 1) — the gentlest island.' },
{ id: 'normal', label: 'Normal', desc: 'Water starts at level 2 — the standard challenge.' },
{ id: 'elite', label: 'Elite', desc: 'Water starts at level 3 — floods come fast.' },
{ id: 'legendary', label: 'Legendary', desc: 'Water starts at level 4 — only for veterans.' },
];
const pillW = 134, pillH = 38, pillGap = 10;
const totalW = options.length * pillW + (options.length - 1) * pillGap;
const labelY = centerY - 44;
const pillY = centerY - 6;
const descY = centerY + 26;
const labelText = this.add.text(centerX, labelY, 'Difficulty', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const labelBg = this.add.rectangle(centerX, labelY, labelText.width + 32, labelText.height + 14, 0x000000, 0.72);
this.children.moveBelow(labelBg, labelText);
const descText = this.add.text(centerX, descY, options.find((o) => o.id === this.selectedDifficulty)?.desc ?? '', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.textHex,
}).setOrigin(0.5);
const descBg = this.add.rectangle(centerX, descY, 640, descText.height + 10, 0x000000, 0.6);
this.children.moveBelow(descBg, descText);
this._difficultyBtns = [];
options.forEach((opt, i) => {
const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2;
const isSelected = this.selectedDifficulty === opt.id;
const bg = this.add.rectangle(x, pillY, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, pillY, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
this.add.text(x, pillY, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => this._difficultyBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedDifficulty ? COLORS.accent : COLORS.muted));
bg.on('pointerup', () => { this.selectedDifficulty = opt.id; descText.setText(opt.desc); refresh(); });
bg.on('pointerover', () => { if (this.selectedDifficulty !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedDifficulty !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._difficultyBtns.push({ bg, id: opt.id });
});
}
// ── Generic option section builder ─────────────────────────────────────────
buildOptionSection(label, labelY, items, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H, tileGap = TILE_GAP) {
@ -1038,6 +1094,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
deckMode: this.selectedDeckMode,
wordLength: this.selectedWordLength,
secretRevealType: this.selectedSecretRevealType,
difficulty: this.selectedDifficulty,
});
}
}

View File

@ -26,6 +26,12 @@ export default class PreloadScene extends Phaser.Scene {
frameWidth: 300,
frameHeight: 300,
});
// Forbidden Island tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame
// 2i, flooded frame 2i+1 (see IslandData.TILE_FRAME_ROW).
this.load.spritesheet('forbiddenisland-tiles', '/assets/images/forbiddenisland-tiles.png', {
frameWidth: 200,
frameHeight: 200,
});
this.load.spritesheet('cardbacks', '/assets/images/cardbacks.png', {
frameWidth: 320,
frameHeight: 420,

View File

@ -64,3 +64,4 @@ registerGame({ slug: 'oldmaid', name: 'Old Maid', category: 'ca
registerGame({ slug: 'blokus', name: 'Blokus', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 36 });
registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 37 });
registerGame({ slug: 'minicrossword', name: 'Mini Crossword', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 38 });
registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 39 });