Polish on Forbidden Island

This commit is contained in:
Brian Fertig 2026-06-06 14:24:54 -06:00
parent 88dcaf8e15
commit ab84b32f1d
3 changed files with 77 additions and 3 deletions

Binary file not shown.

View File

@ -1770,6 +1770,10 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
// DOM video for AI opponent (floated on top of sprite)
let domVid = null;
let vizCanvas = null;
let vizRafId = null; // for cleanup on modal close
let startVisualizer = () => {}; // no-op default, overridden below for AI
let stopVisualizer = () => {}; // no-op default, overridden below for AI
if (opp) {
const vidEl = document.createElement('video');
vidEl.muted = true; vidEl.loop = true; vidEl.playsInline = true; vidEl.autoplay = true;
@ -1779,6 +1783,66 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
vidEl.addEventListener('error', () => { vidEl.style.display = 'none'; }, { once: true });
vidEl.play().catch(() => {});
domVid = reg(this.add.dom(portraitCX, portraitCY - 20, vidEl).setDepth(D + 3));
// Speech visualizer canvas — animated bars overlay during intro speech
vizCanvas = document.createElement('canvas');
vizCanvas.width = size; vizCanvas.height = size;
vizCanvas.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;pointer-events:none;opacity:0;transition:opacity 0.2s ease;`;
const vizDom = reg(this.add.dom(portraitCX, portraitCY - 20, vizCanvas).setDepth(D + 4));
const vctx = vizCanvas.getContext('2d');
const BAR_COUNT = 6;
const bars = Array.from({ length: BAR_COUNT }, () => ({ cur: 0.1, target: 0.5 }));
let vizActive = false; let retargetTimer = null;
let vizColor = [0, 200, 255];
function _vizFrame() {
if (!vizActive) { vizRafId = null; vctx.clearRect(0, 0, size, size); return; }
vctx.clearRect(0, 0, size, size);
const gy = size * 0.58;
const grad = vctx.createLinearGradient(0, gy, 0, size);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, 'rgba(0,0,0,0.38)');
vctx.fillStyle = grad;
vctx.fillRect(0, gy, size, size - gy);
const [r, g, b] = vizColor;
const barW = size * 0.072;
const gap = size * 0.03;
const totalW = BAR_COUNT * barW + (BAR_COUNT - 1) * gap;
const startX = (size - totalW) / 2;
const maxH = size * 0.36;
const baseY = size * 0.88;
bars.forEach((bar, i) => {
bar.cur += (bar.target - bar.cur) * 0.14;
const h = Math.max(bar.cur * maxH, size * 0.04);
const x = startX + i * (barW + gap);
vctx.shadowColor = `rgba(${r},${g},${b},0.7)`;
vctx.shadowBlur = 5;
vctx.fillStyle = `rgba(${r},${g},${b},0.88)`;
vctx.beginPath();
vctx.roundRect(x, baseY - h, barW, h, 2);
vctx.fill();
});
vctx.shadowBlur = 0;
vizRafId = requestAnimationFrame(_vizFrame);
}
startVisualizer = function() {
vizColor = [0, 200, 255];
vizActive = true;
vizCanvas.style.opacity = '1';
if (!retargetTimer) {
retargetTimer = setInterval(() => {
bars.forEach(bb => { bb.target = 0.2 + Math.random() * 0.8; });
}, 120);
}
if (!vizRafId) vizRafId = requestAnimationFrame(_vizFrame);
}
stopVisualizer = function() {
vizActive = false;
vizCanvas.style.opacity = '0';
clearInterval(retargetTimer); retargetTimer = null;
}
} else {
// Human player — role-colored avatar circle
const avG = reg(this.add.graphics().setDepth(D + 2));
@ -1791,10 +1855,10 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
}).setOrigin(0.5).setDepth(D + 3));
}
// Intro speech for AI opponents
// Intro speech for AI opponents (with visual overlay)
if (opp?.speech?.intro?.length) {
const clip = opp.speech.intro[Math.floor(Math.random() * opp.speech.intro.length)];
enqueueSpeech(clip);
enqueueSpeech(clip, { onStart: startVisualizer, onEnd: stopVisualizer });
}
// Player name below portrait
@ -1886,6 +1950,8 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
// ── 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, () => {
stopVisualizer();
if (vizRafId) { cancelAnimationFrame(vizRafId); vizRafId = null; }
if (domVid?.node) { try { domVid.node.pause(); domVid.node.src = ''; } catch { /* ignore */ } }
resetSpeechQueue();
for (const o of objs) { try { o.destroy(); } catch { /* ignore */ } }

View File

@ -471,6 +471,10 @@ export function resolveFlood(state) {
const count = floodDrawCount(s.waterLevel);
const rng = makeRng(s);
// Buffer cards flooded this phase; only merge into floodDiscard after all
// draws complete so mid-phase reshuffles can't redraw a card from this phase.
const phaseDiscard = [];
for (let i = 0; i < count; i++) {
if (s.floodDeck.length === 0) {
if (s.floodDiscard.length === 0) break;
@ -481,7 +485,7 @@ export function resolveFlood(state) {
const tile = s.tiles[id];
if (tile.state === 'dry') {
tile.state = 'flooded';
s.floodDiscard.push(id);
phaseDiscard.push(id);
s.log.push({ kind: 'flood', tileId: id });
} else if (tile.state === 'flooded') {
tile.state = 'sunk';
@ -489,6 +493,10 @@ export function resolveFlood(state) {
// Card is removed from the game (not discarded) since the tile is gone.
}
}
// Merge phase discards now that all draws are done — ensures a mid-phase
// reshuffle only contains cards from previous phases, not this one.
s.floodDiscard.push(...phaseDiscard);
rng.commit();
// Forced swims for any pawn whose tile has sunk.