diff --git a/public/assets/images/forbiddenisland-cards.png b/public/assets/images/forbiddenisland-cards.png index 2aadd34..3eb81a2 100644 Binary files a/public/assets/images/forbiddenisland-cards.png and b/public/assets/images/forbiddenisland-cards.png differ diff --git a/public/assets/images/forbiddenisland-cards.psd b/public/assets/images/forbiddenisland-cards.psd index 53add27..d9131ae 100644 Binary files a/public/assets/images/forbiddenisland-cards.psd and b/public/assets/images/forbiddenisland-cards.psd differ diff --git a/public/src/games/forbiddenisland/ForbiddenIslandGame.js b/public/src/games/forbiddenisland/ForbiddenIslandGame.js index d2e9378..a703c6e 100644 --- a/public/src/games/forbiddenisland/ForbiddenIslandGame.js +++ b/public/src/games/forbiddenisland/ForbiddenIslandGame.js @@ -3,9 +3,10 @@ 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, + createInitialState, cloneState, applyPendingFlood, peekFloodDraw, peekTreasureDraw, + legalActions, applyAction, endActions, discardCard, resolveFlood, playSandbags, playHelicopter, attemptEscape, canEscape, - isGameOver, setPriority, capturedCount, handTreasureCounts, + isGameOver, setPriority, capturedCount, handTreasureCounts, swapCards, } from './IslandLogic.js'; import { TREASURES, TREASURE_KEYS, ROLES, ROLE_KEYS, SPECIAL, MAX_WATER, DIFFICULTY, @@ -36,19 +37,33 @@ 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; + 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() { @@ -68,16 +83,17 @@ export default class ForbiddenIslandGame extends Phaser.Scene { } } - this.gs = createInitialState({ roles, difficulty: this.difficulty, humanSeat: this.humanSeat }); + // 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.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(); + this.showBeginModal(); } // ── Background ────────────────────────────────────────────────────────────── @@ -85,10 +101,10 @@ export default class ForbiddenIslandGame extends Phaser.Scene { 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', { + 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, 150, 48, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 180, height: 52 }); + new Button(this, GAME_WIDTH - 200, GAME_HEIGHT - 46, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 180, height: 52 }); } // ── Board ─────────────────────────────────────────────────────────────────── @@ -256,6 +272,64 @@ export default class ForbiddenIslandGame extends Phaser.Scene { 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; @@ -276,10 +350,65 @@ export default class ForbiddenIslandGame extends Phaser.Scene { 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(); @@ -288,6 +417,7 @@ export default class ForbiddenIslandGame extends Phaser.Scene { this.renderPriorities(); this.renderChat(); this.highlightTargets(); + this.updateDeckCounts(); } renderPriorities() { @@ -299,6 +429,8 @@ export default class ForbiddenIslandGame extends Phaser.Scene { 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); @@ -310,14 +442,253 @@ export default class ForbiddenIslandGame extends Phaser.Scene { 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(); @@ -331,7 +702,12 @@ export default class ForbiddenIslandGame extends Phaser.Scene { } renderHand() { - this.handLayer.removeAll(true); + // 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'); @@ -355,11 +731,14 @@ export default class ForbiddenIslandGame extends Phaser.Scene { }).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); + // 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.handLayer.add(cont); + this.handCards.push(cont); }); } @@ -469,6 +848,35 @@ export default class ForbiddenIslandGame extends Phaser.Scene { 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; @@ -496,39 +904,240 @@ export default class ForbiddenIslandGame extends Phaser.Scene { 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); + // ── Trade Modal ────────────────────────────────────────────────────────────── + openTradeModal() { + if (this.tradeModalObjs.length) return; // already open + const reg = (o) => { this.tradeModalObjs.push(o); return o; }; + + const panelX = 160, panelY = 330, panelW = 1600, panelH = 400; + const cx = GAME_WIDTH / 2; + const portR = 36; + const portCY = panelY + 60; + const nameY = portCY + portR + 14; + const roleY = nameY + 22; + const cardsTop = roleY + 30; + const cardW = 70, cardH = 95, cardGap = 6; + 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 + 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 wx = textX + ci * (cardW + cardGap) + cardW / 2; + const wy = cardsTop + 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(); }, }); - 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(); + 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; - this.gs = playHelicopter(this.gs, this.humanSeat, [this.humanSeat], tileId); - this.mode = null; this.render(); + 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) { @@ -540,8 +1149,19 @@ export default class ForbiddenIslandGame extends Phaser.Scene { 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(); + 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() { @@ -579,13 +1199,124 @@ export default class ForbiddenIslandGame extends Phaser.Scene { 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.gs = endActions(this.gs); - this.render(); - this.progress(); + 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 @@ -614,17 +1345,171 @@ export default class ForbiddenIslandGame extends Phaser.Scene { 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(); }); + // 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 at center + let faceObj; + if (frame != null) { + faceObj = this.add.image(cx, cy, 'forbiddenisland-cards', frame) + .setScale(SX_DRAW, SY_DRAW) + .setDepth(depth); + } else { + // Vector fallback + const info = cardInfo(card); + const g = this.add.graphics().setDepth(depth); + g.fillStyle(info.color, 1); + g.fillRoundedRect(-DRAW_W / 2, -DRAW_H / 2, DRAW_W, DRAW_H, 16); + g.lineStyle(3, 0xffffff, 0.6); + g.strokeRoundedRect(-DRAW_W / 2, -DRAW_H / 2, DRAW_W, DRAW_H, 16); + this.add.text(cx, cy, info.label, { + fontFamily: '"Julius Sans One"', fontSize: '28px', color: info.text, align: 'center', + }).setOrigin(0.5).setDepth(depth + 1); + faceObj = g; + } + + faceObj.scaleX = 0; + if (this.cache.audio.exists('sfx-card-show')) this.sound.play('sfx-card-show', { volume: 0.35 }); + + // Flip open + this.tweens.add({ + targets: faceObj, + scaleX: frame != null ? SX_DRAW : 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, keep image visible until renderHand replaces it + const tx = RAIL_X + 8 + handSlot * (HAND_W + 10) + HAND_W / 2; + const ty = 884 + HAND_H / 2; + this.tweens.add({ + targets: faceObj, + x: tx, y: ty, + scaleX: SX_HAND, + scaleY: SY_HAND, + duration: 350, + ease: 'Cubic.easeIn', + onComplete: () => { + if (this.cache.audio.exists('sfx-card-place')) this.sound.play('sfx-card-place', { volume: 0.3 }); + // Park the image at the hand slot; renderHand will destroy it when re-rendering + this.tempHandImages.push(faceObj); + onDone(); + }, + }); + } else { + // 5b. AI player or Waters Rise — shrink and fade toward chat panel + const chatCX = RAIL_X + RAIL_W / 2; + const chatCY = 248 + 235; + this.tweens.add({ + targets: faceObj, + x: chatCX, y: chatCY, + scaleX: 0.05, scaleY: 0.05, + alpha: 0, + duration: 380, + ease: 'Cubic.easeIn', + onComplete: () => { + faceObj.destroy(); + onDone(); + }, + }); + } + }); + }, + }); + }, + }); + }, + }); } aiTurn(seat) { @@ -658,6 +1543,38 @@ export default class ForbiddenIslandGame extends Phaser.Scene { } 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; } @@ -672,9 +1589,12 @@ export default class ForbiddenIslandGame extends Phaser.Scene { } finishAiTurn() { - this.gs = endActions(this.gs); - this.render(); - this.time.delayedCall(400, () => this.progress()); + const seat = this.gs.current; + this.animateTreasureDraw(seat, () => { + this.gs = endActions(this.gs); + this.render(); + this.time.delayedCall(400, () => this.progress()); + }); } // ── Chat + hints ──────────────────────────────────────────────────────────── @@ -692,6 +1612,203 @@ export default class ForbiddenIslandGame extends Phaser.Scene { 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; diff --git a/public/src/games/forbiddenisland/IslandAI.js b/public/src/games/forbiddenisland/IslandAI.js index ed86680..92acd77 100644 --- a/public/src/games/forbiddenisland/IslandAI.js +++ b/public/src/games/forbiddenisland/IslandAI.js @@ -132,7 +132,7 @@ export function evalState(state, seat) { // 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; + if (o.seat === cs) continue; const oc = handTreasureCounts(o)[key]; if (oc > 0) v -= bfsDist(state, o.tileId, new Set([carrierTile])) * 5 * oc * focus; } diff --git a/public/src/games/forbiddenisland/IslandData.js b/public/src/games/forbiddenisland/IslandData.js index f0581a2..e14c764 100644 --- a/public/src/games/forbiddenisland/IslandData.js +++ b/public/src/games/forbiddenisland/IslandData.js @@ -70,15 +70,21 @@ export const TILES = [ // 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.' }, + pilot: { key: 'pilot', name: 'Pilot', color: 0x4a90d9, colorHex: '#4a90d9', power: 'Once per turn, fly to any tile.', + description: 'A fearless aviator who has logged more hours above the clouds than on solid ground. When the island is too treacherous to cross on foot, the Pilot takes to the skies — once per turn they may spend one action to fly directly to any tile on the island, no matter how far.' }, + engineer: { key: 'engineer', name: 'Engineer', color: 0xd0473a, colorHex: '#d0473a', power: 'Shore up two tiles for one action.', + description: 'A meticulous builder who arrived on the island with nothing but a toolkit and a plan. While others spend two actions to shore up two separate tiles, the Engineer patches them both in a single action — efficiency born of hard-won experience.' }, + messenger: { key: 'messenger', name: 'Messenger', color: 0xb9bfc6, colorHex: '#b9bfc6', power: 'Give Treasure cards to anyone, any distance.', + description: 'A seasoned courier who has crossed every terrain imaginable to deliver what matters most. Unlike other adventurers who must stand side-by-side to exchange cards, the Messenger can hand off Treasure cards to any teammate — regardless of where they are on the island.' }, + navigator: { key: 'navigator', name: 'Navigator', color: 0xe2b53c, colorHex: '#e2b53c', power: 'Move another adventurer up to 2 tiles.', + description: 'A brilliant strategist who can read the island like a map. The Navigator does not just chart their own path — each turn they can spend actions to move another adventurer up to two tiles, positioning teammates exactly where the team needs them most.' }, + diver: { key: 'diver', name: 'Diver', color: 0x2b2b30, colorHex: '#2b2b30', power: 'Swim through flooded and missing tiles.', + description: 'A deep-sea explorer who is equally at home beneath the surface as above it. Where the rising waters block every other adventurer, the Diver simply slips beneath the waves — swimming through flooded and even completely sunken tiles without a second thought.' }, + explorer: { key: 'explorer', name: 'Explorer', color: 0x49a25a, colorHex: '#49a25a', power: 'Move and shore up diagonally.', + description: 'A nimble trailblazer who has mapped uncharted territory on six continents. The Explorer refuses to be boxed in — while others are limited to the four cardinal directions, this adventurer can move and shore up tiles diagonally, opening paths no one else can take.' }, }; -export const ROLE_KEYS = ['pilot', 'engineer', 'messenger', 'navigator', 'diver', 'explorer']; +export const ROLE_KEYS = ['pilot', 'engineer', 'navigator', 'diver', 'explorer']; // Special (non-treasure) Treasure-deck cards. export const SPECIAL = { diff --git a/public/src/games/forbiddenisland/IslandLogic.js b/public/src/games/forbiddenisland/IslandLogic.js index 8ebed84..1e3a504 100644 --- a/public/src/games/forbiddenisland/IslandLogic.js +++ b/public/src/games/forbiddenisland/IslandLogic.js @@ -48,8 +48,8 @@ function makeRng(state) { } // ---- construction ---------------------------------------------------------- -export function createInitialState({ roles, difficulty = 'normal', seed, humanSeat = 0 } = {}) { - const roleKeys = (roles && roles.length) ? roles.slice(0, 4) : ['pilot', 'engineer', 'messenger', 'navigator']; +export function createInitialState({ roles, difficulty = 'normal', seed, humanSeat = 0, skipInitialFlood = false } = {}) { + const roleKeys = (roles && roles.length) ? roles.slice(0, 4) : ['pilot', 'engineer', 'navigator', 'diver']; const s = { seed: (seed ?? Math.floor(Math.random() * 1e9)) >>> 0, rngCursor: 0, @@ -111,12 +111,17 @@ export function createInitialState({ roles, difficulty = 'normal', seed, humanSe } s.treasureDeck = deck; - // Flood deck — shuffle one card per tile; flood the first 6. + // Flood deck — shuffle one card per tile; flood the first 6 (or defer for animation). 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); + if (skipInitialFlood) { + s.pendingFlood = s.floodDeck.splice(0, 6); + } else { + for (let i = 0; i < 6; i++) { + const id = s.floodDeck.shift(); + s.tiles[id].state = 'flooded'; + s.floodDiscard.push(id); + } + s.pendingFlood = []; } rng.commit(); @@ -124,6 +129,58 @@ export function createInitialState({ roles, difficulty = 'normal', seed, humanSe return s; } +// Finalise deferred initial flood after the animation completes. +export function applyPendingFlood(state) { + const s = cloneState(state); + for (const id of (s.pendingFlood ?? [])) { + s.tiles[id].state = 'flooded'; + s.floodDiscard.push(id); + } + s.pendingFlood = []; + return s; +} + +// Preview which tile IDs the next flood phase will draw (in order), without +// modifying state. Uses the same RNG seeding as resolveFlood so reshuffles match. +export function peekFloodDraw(state) { + const count = floodDrawCount(state.waterLevel); + let deck = state.floodDeck.slice(); + let discard = state.floodDiscard.slice(); + const rng = makeRng(state); + const ids = []; + for (let i = 0; i < count; i++) { + if (deck.length === 0) { + if (discard.length === 0) break; + deck = shuffle(discard, rng.next); + discard = []; + } + ids.push(deck.shift()); + } + return ids; +} + +// Preview all cards that will be drawn from the treasure deck during endActions +// (including any Waters Rise cards, which don't count toward the draw-2 quota). +// Uses the same RNG seeding as endActions so reshuffles match exactly. +export function peekTreasureDraw(state) { + let deck = state.treasureDeck.slice(); + let discard = state.treasureDiscard.slice(); + const rng = makeRng(state); + const cards = []; + let drawn = 0; + while (drawn < 2) { + if (deck.length === 0) { + if (discard.length === 0) break; + deck = shuffle(discard, rng.next); + discard = []; + } + const card = deck.shift(); + cards.push(card); + if (card !== SPECIAL.WATERS_RISE) drawn++; + } + return cards; +} + export function cloneState(state) { return { ...state, @@ -138,6 +195,7 @@ export function cloneState(state) { saveTiles: state.priorities.saveTiles.slice(), }, pendingSwim: state.pendingSwim ? { ...state.pendingSwim, options: state.pendingSwim.options.slice() } : null, + pendingFlood: (state.pendingFlood ?? []).slice(), log: state.log.slice(), }; } @@ -229,15 +287,6 @@ export function legalActions(state, seat) { 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]) { @@ -285,14 +334,6 @@ export function applyAction(state, seat, action) { 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) => { @@ -338,6 +379,22 @@ export function playHelicopter(state, seat, pawnSeats, destTileId) { return s; } +// Swap one card between two players — free action (no action cost, no tile check). +export function swapCards(state, seatA, cardA, seatB, cardB) { + if (seatA === seatB) return state; + const pA = state.players[seatA]; + const pB = state.players[seatB]; + if (!pA || !pB) return state; + const iA = pA.hand.indexOf(cardA); + const iB = pB.hand.indexOf(cardB); + if (iA < 0 || iB < 0) return state; + const s = cloneState(state); + s.players[seatA].hand[iA] = cardB; + s.players[seatB].hand[iB] = cardA; + s.log.push({ kind: 'swapCards', seatA, cardA, seatB, cardB }); + 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'); diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 0bceab3..e6c4a7a 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -18,7 +18,7 @@ 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'; + this.difficulty = data.difficulty ?? 'novice'; } create() { diff --git a/public/src/scenes/OpponentSelectScene.js b/public/src/scenes/OpponentSelectScene.js index 923b1b6..fbffbd5 100644 --- a/public/src/scenes/OpponentSelectScene.js +++ b/public/src/scenes/OpponentSelectScene.js @@ -38,7 +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.selectedDifficulty = 'novice'; // Forbidden Island water-level start this._initializing = false; this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only) }