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';
|
} 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue