diff --git a/public/assets/fx/water-splash.mp3 b/public/assets/fx/water-splash.mp3 index 0e2d69e..3add1dd 100644 Binary files a/public/assets/fx/water-splash.mp3 and b/public/assets/fx/water-splash.mp3 differ diff --git a/public/src/games/forbiddenisland/ForbiddenIslandGame.js b/public/src/games/forbiddenisland/ForbiddenIslandGame.js index 98290f0..3ebdb4a 100644 --- a/public/src/games/forbiddenisland/ForbiddenIslandGame.js +++ b/public/src/games/forbiddenisland/ForbiddenIslandGame.js @@ -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 */ } } diff --git a/public/src/games/forbiddenisland/IslandLogic.js b/public/src/games/forbiddenisland/IslandLogic.js index 1e3a504..2f93eb0 100644 --- a/public/src/games/forbiddenisland/IslandLogic.js +++ b/public/src/games/forbiddenisland/IslandLogic.js @@ -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.