diff --git a/public/assets/fx/victory-short.mp3 b/public/assets/fx/victory-short.mp3 new file mode 100644 index 0000000..8f7e953 Binary files /dev/null and b/public/assets/fx/victory-short.mp3 differ diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 1bce167..cc7fd41 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 0e01260..1b9ffdf 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/src/games/labyrinth/LabyrinthGame.js b/public/src/games/labyrinth/LabyrinthGame.js index 99523ab..954f022 100644 --- a/public/src/games/labyrinth/LabyrinthGame.js +++ b/public/src/games/labyrinth/LabyrinthGame.js @@ -10,7 +10,7 @@ import { } from './LabyrinthData.js'; import { createInitialState, rotateSpare, withSpareRot, applyInsertion, applyMove, - reachableFrom, currentTarget, targetsRemaining, allCollected, isGameOver, + reachableFrom, pathTo, currentTarget, targetsRemaining, allCollected, isGameOver, } from './LabyrinthLogic.js'; import { chooseAction, nextThinkDelay } from './LabyrinthAI.js'; @@ -31,10 +31,11 @@ export default class LabyrinthGame extends Phaser.Scene { this.opponents = data.opponents ?? []; this.playfield = data.playfield ?? null; this.humanSeat = 0; - this.gs = null; - this.busy = false; - this.dyn = []; - this.portraits = []; + this.gs = null; + this.busy = false; + this.animPawnSeat = null; + this.dyn = []; + this.portraits = []; } create() { @@ -111,6 +112,13 @@ export default class LabyrinthGame extends Phaser.Scene { tileCenter(r, c) { return { x: BX0 + c * PITCH + TILE / 2, y: BY0 + r * PITCH + TILE / 2 }; } spareCenterXY() { return { x: RAIL_X + RAIL_W - 130, y: 150 }; } + // Screen center of the idx-th collected-card thumbnail slot for a given player. + collectedCardSlot(seat, idx) { + const a = this.playerRowAnchor(seat, this.gs.players.length); + const CW = 25, CH = 36, GAP = 3; + return { x: RAIL_X + 92 + idx * (CW + GAP) + CW / 2, y: a.rowTop + 6 + 66 + CH / 2, w: CW, h: CH }; + } + render() { this.clearDyn(); this.drawBoardFrame(); @@ -319,6 +327,114 @@ export default class LabyrinthGame extends Phaser.Scene { }); } + // Five staggered firework bursts around (cx, cy). + _playTreasureFireworks(cx, cy) { + const palettes = [ + [0xffd700, 0xffbb00, 0xffffff], + [0xff8c00, 0xffaa44, 0xffd700], + [0xffd700, 0xffffff, 0xffaa00], + [0xf3ead2, 0xffd700, 0xffcc44], + [0xff6644, 0xffaa00, 0xffd700], + ]; + for (let b = 0; b < 5; b++) { + const delay = b * 200 + Math.random() * 60; + const angle = (b / 5) * Math.PI * 2 + (Math.random() - 0.5) * 0.6; + const bx = cx + Math.cos(angle) * (65 + Math.random() * 35); + const by = cy + Math.sin(angle) * (85 + Math.random() * 35); + const palette = palettes[b % palettes.length]; + const sparkCount = 16 + Math.floor(Math.random() * 8); + this.time.delayedCall(delay, () => { + try { const fw = new Audio('/assets/fx/firework.mp3'); fw.volume = 0.6; fw.play(); } catch { /* */ } + const flash = this.add.circle(bx, by, 7, palette[0], 1).setDepth(DEPTH.popup + 2); + this.tweens.add({ + targets: flash, scaleX: 5, scaleY: 5, alpha: 0, duration: 280, ease: 'Cubic.easeOut', + onComplete: () => { try { flash.destroy(); } catch { /* */ } }, + }); + for (let i = 0; i < sparkCount; i++) { + const sa = (i / sparkCount) * Math.PI * 2 + (Math.random() - 0.5) * 0.3; + const spd = 80 + Math.random() * 150; + const spark = this.add.circle(bx, by, 2.5 + Math.random() * 4.5, + palette[Math.floor(Math.random() * palette.length)], 1).setDepth(DEPTH.popup + 2); + this.tweens.add({ + targets: spark, + x: bx + Math.cos(sa) * spd, y: by + Math.sin(sa) * spd + spd * 0.28, + scaleX: 0.1, scaleY: 0.1, alpha: 0, + duration: 600 + Math.random() * 450, ease: 'Cubic.easeOut', + onComplete: () => { try { spark.destroy(); } catch { /* */ } }, + }); + } + }); + } + } + + // Show the just-collected treasure card at the board centre, play fireworks, + // then animate it down to the player's collected-card slot in the rail panel. + animateTreasureCollect(seat, treasureIdx, onComplete) { + const cx = BX0 + BOARD_W / 2, cy = BY0 + BOARD_W / 2; + const CW = 270, CH = 390; + + const cont = this.add.container(cx, cy).setDepth(DEPTH.popup + 1).setAlpha(0); + const bg = this.add.graphics(); + bg.fillStyle(0x14110b, 1).fillRoundedRect(-CW / 2, -CH / 2, CW, CH, 8); + cont.add(bg); + if (this.hasCards) { + cont.add(this.add.image(0, 0, 'labyrinth-cards', treasureIdx).setDisplaySize(CW, CH)); + } else if (this.hasTreasures) { + cont.add(this.add.image(0, -12, 'labyrinth-treasures', treasureIdx).setDisplaySize(64, 64)); + cont.add(this.add.text(0, 40, TREASURES[treasureIdx].name, { + fontFamily: 'Righteous', fontSize: '11px', color: '#f3ead2', align: 'center', + wordWrap: { width: CW - 8 }, + }).setOrigin(0.5)); + } else { + const tg = this.add.graphics(); + tg.fillStyle(COLORS.gold, 1).fillCircle(0, -12, 28); + cont.add(tg); + cont.add(this.add.text(0, 40, TREASURES[treasureIdx].name, { + fontFamily: 'Righteous', fontSize: '11px', color: '#f3ead2', align: 'center', + wordWrap: { width: CW - 8 }, + }).setOrigin(0.5)); + } + const border = this.add.graphics(); + border.lineStyle(2, COLORS.gold, 1).strokeRoundedRect(-CW / 2, -CH / 2, CW, CH, 8); + cont.add(border); + + this.tweens.add({ targets: cont, alpha: 1, duration: 150, ease: 'Linear' }); + playSound(this, SFX.VICTORY_SHORT); + this._playTreasureFireworks(cx, cy); + + // After fireworks settle, fly card to player panel slot. + const slotIdx = this.gs.players[seat].targetIdx; + const slot = this.collectedCardSlot(seat, slotIdx); + this.time.delayedCall(1700, () => { + this.tweens.add({ + targets: cont, + x: slot.x, y: slot.y, + scaleX: slot.w / CW, scaleY: slot.h / CH, + duration: 500, ease: 'Cubic.easeIn', + onComplete: () => { cont.destroy(); onComplete(); }, + }); + }); + } + + // Animate a pawn moving step-by-step along `path` (array of {r,c} cells). + animatePawnMove(path, seat, onComplete) { + if (path.length <= 1) { onComplete(); return; } + const p = this.gs.players[seat]; + const start = this.tileCenter(path[0].r, path[0].c); + const cont = this.add.container(start.x, start.y).setDepth(DEPTH.pawn + 5); + const g = this.add.graphics(); + g.fillStyle(0x000000, 0.4).fillCircle(0, 3, 14); + g.fillStyle(p.color, 1).fillCircle(0, 0, 13); + g.lineStyle(4, 0xffffff, 1).strokeCircle(0, 0, 13); + cont.add(g); + const stepTo = (i) => { + if (i >= path.length) { cont.destroy(); onComplete(); return; } + const { x, y } = this.tileCenter(path[i].r, path[i].c); + this.tweens.add({ targets: cont, x, y, duration: 400, ease: 'Linear', onComplete: () => stepTo(i + 1) }); + }; + stepTo(1); + } + drawHomes() { this.gs.players.forEach((p) => { const { x, y } = this.tileCenter(p.home.r, p.home.c); @@ -348,7 +464,10 @@ export default class LabyrinthGame extends Phaser.Scene { drawPawns() { const cells = {}; - this.gs.players.forEach((p) => { const k = p.r * GRID + p.c; (cells[k] ??= []).push(p); }); + this.gs.players.forEach((p) => { + if (p.seat === this.animPawnSeat) return; + const k = p.r * GRID + p.c; (cells[k] ??= []).push(p); + }); for (const k of Object.keys(cells)) { const list = cells[k]; const r = Math.floor(k / GRID), c = k % GRID; @@ -505,6 +624,25 @@ export default class LabyrinthGame extends Phaser.Scene { dot.fillStyle(i < found ? COLORS.gold : 0x4a4336, 1) .fillCircle(px + pw - 18 - i * 16, py + ph - 16, 5); } + // collected card thumbnails (below "Found" text) + const CW = 25, CH = 36, GAP = 3; + for (let i = 0; i < found; i++) { + const tIdx = p.targets[i]; + const tcx = px + 92 + i * (CW + GAP) + CW / 2; + const tcy = py + 66 + CH / 2; + if (this.hasCards) { + this.reg(this.add.image(tcx, tcy, 'labyrinth-cards', tIdx) + .setDisplaySize(CW, CH).setDepth(DEPTH.ui + 1)); + } else if (this.hasTreasures) { + this.reg(this.add.image(tcx, tcy, 'labyrinth-treasures', tIdx) + .setDisplaySize(CW, CW).setDepth(DEPTH.ui + 1)); + } else { + this.reg(this.add.graphics().setDepth(DEPTH.ui + 1)) + .fillStyle(COLORS.gold, 1).fillRoundedRect(tcx - CW / 2, tcy - CH / 2, CW, CH, 3); + } + this.reg(this.add.graphics().setDepth(DEPTH.ui + 2)) + .lineStyle(1, COLORS.accent, 0.7).strokeRoundedRect(tcx - CW / 2, tcy - CH / 2, CW, CH, 3); + } }); } @@ -543,10 +681,30 @@ export default class LabyrinthGame extends Phaser.Scene { onMove(r, c) { if (!this.isHumanMove()) return; - const next = applyMove(this.gs, r, c); - this.gs = next; - playSound(this, SFX.PIECE_CLICK); - this.advance(); + const seat = this.humanSeat; + const p = this.gs.players[seat]; + const target = currentTarget(p); + const willCollect = target != null && this.gs.board[r][c].treasure === target; + const path = pathTo(this.gs, p.r, p.c, r, c); + this.busy = true; + this.animPawnSeat = seat; + this.render(); + this.animatePawnMove(path, seat, () => { + this.animPawnSeat = null; + if (willCollect) { + this.animateTreasureCollect(seat, target, () => { + this.gs = applyMove(this.gs, r, c); + playSound(this, SFX.PIECE_CLICK); + this.busy = false; + this.advance(); + }); + } else { + this.gs = applyMove(this.gs, r, c); + playSound(this, SFX.PIECE_CLICK); + this.busy = false; + this.advance(); + } + }); } // ── turn driver ─────────────────────────────────────────────────────────────── @@ -570,10 +728,29 @@ export default class LabyrinthGame extends Phaser.Scene { this.animateInsertion(act.slotId, () => { this.render(); this.time.delayedCall(480, () => { - this.gs = applyMove(this.gs, act.dest.r, act.dest.c); - playSound(this, SFX.PIECE_CLICK); - this.busy = false; - this.advance(); + const aiSeat = this.gs.current; + const aiP = this.gs.players[aiSeat]; + const aiTarget = currentTarget(aiP); + const willCollect = aiTarget != null && this.gs.board[act.dest.r][act.dest.c].treasure === aiTarget; + const path = pathTo(this.gs, aiP.r, aiP.c, act.dest.r, act.dest.c); + this.animPawnSeat = aiSeat; + this.render(); + this.animatePawnMove(path, aiSeat, () => { + this.animPawnSeat = null; + if (willCollect) { + this.animateTreasureCollect(aiSeat, aiTarget, () => { + this.gs = applyMove(this.gs, act.dest.r, act.dest.c); + playSound(this, SFX.PIECE_CLICK); + this.busy = false; + this.advance(); + }); + } else { + this.gs = applyMove(this.gs, act.dest.r, act.dest.c); + playSound(this, SFX.PIECE_CLICK); + this.busy = false; + this.advance(); + } + }); }); }); }); diff --git a/public/src/games/labyrinth/LabyrinthLogic.js b/public/src/games/labyrinth/LabyrinthLogic.js index 2d8fa3e..30c9357 100644 --- a/public/src/games/labyrinth/LabyrinthLogic.js +++ b/public/src/games/labyrinth/LabyrinthLogic.js @@ -155,6 +155,42 @@ export function isReachable(state, sr, sc, tr, tc) { return reachableFrom(state, sr, sc).some((q) => q.r === tr && q.c === tc); } +// BFS shortest path from (sr,sc) to (tr,tc) along connected corridors. +// Returns the path as [{r,c}…] including both endpoints, or [{r:sr,c:sc}] if +// unreachable. The caller must ensure (tr,tc) is actually reachable. +export function pathTo(state, sr, sc, tr, tc) { + if (sr === tr && sc === tc) return [{ r: sr, c: sc }]; + const b = state.board; + const prev = new Map(); + const seen = new Set([keyOf(sr, sc)]); + const queue = [{ r: sr, c: sc }]; + let found = false; + outer: while (queue.length) { + const { r, c } = queue.shift(); + for (const side of openSides(b[r][c].type, b[r][c].rot)) { + const { dr, dc } = DELTA[side]; + const nr = r + dr, nc = c + dc; + if (nr < 0 || nr >= GRID || nc < 0 || nc >= GRID) continue; + if (!isOpen(b[nr][nc].type, b[nr][nc].rot, OPPOSITE[side])) continue; + const k = keyOf(nr, nc); + if (seen.has(k)) continue; + seen.add(k); prev.set(k, { r, c }); + if (nr === tr && nc === tc) { found = true; break outer; } + queue.push({ r: nr, c: nc }); + } + } + if (!found) return [{ r: sr, c: sc }]; + const path = []; + let pos = { r: tr, c: tc }; + for (;;) { + path.unshift(pos); + if (pos.r === sr && pos.c === sc) break; + pos = prev.get(keyOf(pos.r, pos.c)); + if (!pos) break; + } + return path; +} + // ── mutators ───────────────────────────────────────────────────────────────── export function rotateSpare(state, dir = 1) { const s = cloneState(state); diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index ffc1833..d85d0e9 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -90,6 +90,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.audio('sfx-battleship-hit', '/assets/fx/battleship-hit.mp3'); this.load.audio('sfx-battleship-miss', '/assets/fx/battleship-miss.mp3'); this.load.audio('sfx-battleship-launch', '/assets/fx/battleship-launch.mp3'); + this.load.audio('sfx-victory-short', '/assets/fx/victory-short.mp3'); this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 }); diff --git a/public/src/ui/Sounds.js b/public/src/ui/Sounds.js index 1e71783..39eff6d 100644 --- a/public/src/ui/Sounds.js +++ b/public/src/ui/Sounds.js @@ -26,6 +26,7 @@ export const SFX = { MASTERMIND_COLOR: 'sfx-mastermind-color', MASTERMIND_MATCH: 'sfx-mastermind-match', MASTERMIND_CALCULATE: 'sfx-mastermind-calculate', + VICTORY_SHORT: 'sfx-victory-short', }; export function playSound(scene, key) {