Polish on Labyrinth

This commit is contained in:
Brian Fertig 2026-06-06 16:47:50 -06:00
parent 8f1e3faaec
commit 1c33302a13
7 changed files with 229 additions and 14 deletions

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

View File

@ -10,7 +10,7 @@ import {
} from './LabyrinthData.js'; } from './LabyrinthData.js';
import { import {
createInitialState, rotateSpare, withSpareRot, applyInsertion, applyMove, createInitialState, rotateSpare, withSpareRot, applyInsertion, applyMove,
reachableFrom, currentTarget, targetsRemaining, allCollected, isGameOver, reachableFrom, pathTo, currentTarget, targetsRemaining, allCollected, isGameOver,
} from './LabyrinthLogic.js'; } from './LabyrinthLogic.js';
import { chooseAction, nextThinkDelay } from './LabyrinthAI.js'; import { chooseAction, nextThinkDelay } from './LabyrinthAI.js';
@ -31,10 +31,11 @@ export default class LabyrinthGame extends Phaser.Scene {
this.opponents = data.opponents ?? []; this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null; this.playfield = data.playfield ?? null;
this.humanSeat = 0; this.humanSeat = 0;
this.gs = null; this.gs = null;
this.busy = false; this.busy = false;
this.dyn = []; this.animPawnSeat = null;
this.portraits = []; this.dyn = [];
this.portraits = [];
} }
create() { 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 }; } 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 }; } 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() { render() {
this.clearDyn(); this.clearDyn();
this.drawBoardFrame(); 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() { drawHomes() {
this.gs.players.forEach((p) => { this.gs.players.forEach((p) => {
const { x, y } = this.tileCenter(p.home.r, p.home.c); const { x, y } = this.tileCenter(p.home.r, p.home.c);
@ -348,7 +464,10 @@ export default class LabyrinthGame extends Phaser.Scene {
drawPawns() { drawPawns() {
const cells = {}; 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)) { for (const k of Object.keys(cells)) {
const list = cells[k]; const list = cells[k];
const r = Math.floor(k / GRID), c = k % GRID; 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) dot.fillStyle(i < found ? COLORS.gold : 0x4a4336, 1)
.fillCircle(px + pw - 18 - i * 16, py + ph - 16, 5); .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) { onMove(r, c) {
if (!this.isHumanMove()) return; if (!this.isHumanMove()) return;
const next = applyMove(this.gs, r, c); const seat = this.humanSeat;
this.gs = next; const p = this.gs.players[seat];
playSound(this, SFX.PIECE_CLICK); const target = currentTarget(p);
this.advance(); 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 ─────────────────────────────────────────────────────────────── // ── turn driver ───────────────────────────────────────────────────────────────
@ -570,10 +728,29 @@ export default class LabyrinthGame extends Phaser.Scene {
this.animateInsertion(act.slotId, () => { this.animateInsertion(act.slotId, () => {
this.render(); this.render();
this.time.delayedCall(480, () => { this.time.delayedCall(480, () => {
this.gs = applyMove(this.gs, act.dest.r, act.dest.c); const aiSeat = this.gs.current;
playSound(this, SFX.PIECE_CLICK); const aiP = this.gs.players[aiSeat];
this.busy = false; const aiTarget = currentTarget(aiP);
this.advance(); 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();
}
});
}); });
}); });
}); });

View File

@ -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); 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 ───────────────────────────────────────────────────────────────── // ── mutators ─────────────────────────────────────────────────────────────────
export function rotateSpare(state, dir = 1) { export function rotateSpare(state, dir = 1) {
const s = cloneState(state); const s = cloneState(state);

View File

@ -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-hit', '/assets/fx/battleship-hit.mp3');
this.load.audio('sfx-battleship-miss', '/assets/fx/battleship-miss.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-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 }); this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });

View File

@ -26,6 +26,7 @@ export const SFX = {
MASTERMIND_COLOR: 'sfx-mastermind-color', MASTERMIND_COLOR: 'sfx-mastermind-color',
MASTERMIND_MATCH: 'sfx-mastermind-match', MASTERMIND_MATCH: 'sfx-mastermind-match',
MASTERMIND_CALCULATE: 'sfx-mastermind-calculate', MASTERMIND_CALCULATE: 'sfx-mastermind-calculate',
VICTORY_SHORT: 'sfx-victory-short',
}; };
export function playSound(scene, key) { export function playSound(scene, key) {