Polish on Labyrinth
This commit is contained in:
parent
8f1e3faaec
commit
1c33302a13
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue