Compare commits
2 Commits
f5b0c545f4
...
2b224ff62b
| Author | SHA1 | Date |
|---|---|---|
|
|
2b224ff62b | |
|
|
59ee3eca0c |
Binary file not shown.
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 244 KiB |
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,928 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import {
|
||||
makeRng, dateSeed, localDateString, generateBoard, isSolved, manhattanAdjacent,
|
||||
} from './DotLinkLogic.js';
|
||||
|
||||
// ── Cyberpunk / synthwave palette ───────────────────────────────────────────
|
||||
const C = {
|
||||
bg: 0x05060f,
|
||||
bgGlow: 0x1a0a3a,
|
||||
sun: 0xff2d95,
|
||||
grid: 0x2b1a55,
|
||||
panel: 0x0a0b1e,
|
||||
panelEdge: 0x3a2c66,
|
||||
neon: 0x00f0ff,
|
||||
neonHex: '#00f0ff',
|
||||
magenta: 0xff2bd6,
|
||||
magentaHex: '#ff2bd6',
|
||||
textHex: '#d8e8ff',
|
||||
dimHex: '#7a86b8',
|
||||
goldHex: '#ffd24a',
|
||||
};
|
||||
|
||||
// Up to 12 distinct neon flow colours.
|
||||
const FLOW = [
|
||||
{ n: 0x00f0ff, hex: '#00f0ff' }, // cyan
|
||||
{ n: 0xff2bd6, hex: '#ff2bd6' }, // magenta
|
||||
{ n: 0x39ff14, hex: '#39ff14' }, // lime
|
||||
{ n: 0xff8a00, hex: '#ff8a00' }, // orange
|
||||
{ n: 0xb14aff, hex: '#b14aff' }, // violet
|
||||
{ n: 0xffe600, hex: '#ffe600' }, // yellow
|
||||
{ n: 0xff2d6f, hex: '#ff2d6f' }, // hot pink
|
||||
{ n: 0x2d9bff, hex: '#2d9bff' }, // azure
|
||||
{ n: 0x00ffc6, hex: '#00ffc6' }, // teal
|
||||
{ n: 0xff4d4d, hex: '#ff4d4d' }, // red
|
||||
{ n: 0xc6ff00, hex: '#c6ff00' }, // chartreuse
|
||||
{ n: 0xffffff, hex: '#ffffff' }, // white
|
||||
];
|
||||
|
||||
const DAILY_STAGES = [
|
||||
{ key: 'easy', label: 'EASY', rows: 5, cols: 5, colors: 4 },
|
||||
{ key: 'medium', label: 'MEDIUM', rows: 7, cols: 7, colors: 6 },
|
||||
{ key: 'difficult', label: 'DIFFICULT', rows: 8, cols: 8, colors: 8 },
|
||||
{ key: 'hard', label: 'HARD', rows: 10, cols: 10, colors: 9 },
|
||||
{ key: 'legendary', label: 'LEGENDARY', rows: 11, cols: 11, colors: 11 },
|
||||
];
|
||||
|
||||
const D = { chrome: 0, glow: 1, core: 2, dots: 3, hud: 5, overlay: 20, overlayUI: 22 };
|
||||
|
||||
const DAILY_BEST_KEY = 'dotlink-daily-best';
|
||||
const dailyRecKey = (date) => `dotlink-daily-${date}`;
|
||||
|
||||
function lsGet(key) { try { return localStorage.getItem(key); } catch (_) { return null; } }
|
||||
function lsSet(key, val) { try { localStorage.setItem(key, val); } catch (_) { /* ignore */ } }
|
||||
|
||||
function fmtTime(totalSec) {
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = String(Math.floor(totalSec % 60)).padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
export default class DotLinkGame extends Phaser.Scene {
|
||||
constructor() { super('DotLinkGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'dotlink', name: 'Dot Link' };
|
||||
this.bank = [];
|
||||
this.levelsCompleted = 0;
|
||||
this.canPersist = true;
|
||||
this.view = 'home';
|
||||
|
||||
// play state
|
||||
this.board = null;
|
||||
this.solution = null;
|
||||
this.geom = null;
|
||||
this.paths = null;
|
||||
this.owner = null;
|
||||
this.endpointGrid = null;
|
||||
this.draw = null;
|
||||
this.drawing = false;
|
||||
this.overlayUp = false;
|
||||
|
||||
// daily
|
||||
this.dailyBoards = null;
|
||||
this.dailyIndex = 0;
|
||||
this.dailyElapsed = 0;
|
||||
this.dailyDate = localDateString();
|
||||
this.timerEvent = null;
|
||||
}
|
||||
|
||||
async create() {
|
||||
try {
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||
} catch (_) { /* optional */ }
|
||||
|
||||
this.buildTextures();
|
||||
this.buildBackground();
|
||||
|
||||
const raw = this.cache.json.get('dotlink');
|
||||
this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
|
||||
|
||||
try {
|
||||
const res = await api.get('/puzzles/dotlink/progress');
|
||||
this.levelsCompleted = res?.levelsCompleted ?? 0;
|
||||
} catch (_) {
|
||||
this.canPersist = false;
|
||||
this.levelsCompleted = 0;
|
||||
}
|
||||
|
||||
this.layer = this.add.container(0, 0);
|
||||
this.registerInput();
|
||||
this.showHome();
|
||||
}
|
||||
|
||||
// ── Background & textures ───────────────────────────────────────────────────
|
||||
buildTextures() {
|
||||
if (!this.textures.exists('dlScan')) {
|
||||
const s = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
s.fillStyle(0x00f0ff, 0.06); s.fillRect(0, 0, GAME_WIDTH, 2);
|
||||
s.generateTexture('dlScan', GAME_WIDTH, 6);
|
||||
s.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
buildBackground() {
|
||||
// Deep gradient via stacked rectangles (indigo -> black).
|
||||
const top = Phaser.Display.Color.ValueToColor(0x140a33);
|
||||
const bot = Phaser.Display.Color.ValueToColor(C.bg);
|
||||
const g = this.add.graphics().setDepth(-20);
|
||||
const steps = 120;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const t = i / (steps - 1);
|
||||
const col = Phaser.Display.Color.Interpolate.ColorWithColor(top, bot, 100, Math.round(t * 100));
|
||||
g.fillStyle(Phaser.Display.Color.GetColor(col.r, col.g, col.b), 1);
|
||||
g.fillRect(0, Math.floor(t * GAME_HEIGHT), GAME_WIDTH, Math.ceil(GAME_HEIGHT / steps) + 1);
|
||||
}
|
||||
|
||||
// Synthwave "sun" glow low on the screen.
|
||||
const sun = this.add.graphics().setDepth(-19);
|
||||
for (let r = 520; r > 0; r -= 18) {
|
||||
sun.fillStyle(C.sun, 0.015);
|
||||
sun.fillCircle(GAME_WIDTH / 2, GAME_HEIGHT + 120, r);
|
||||
}
|
||||
sun.setBlendMode(Phaser.BlendModes.ADD);
|
||||
|
||||
// Perspective grid: horizontals condensing toward a horizon + verticals.
|
||||
const horizon = GAME_HEIGHT * 0.62;
|
||||
const grid = this.add.graphics().setDepth(-18);
|
||||
grid.lineStyle(1, C.grid, 0.55);
|
||||
for (let i = 1; i <= 22; i++) {
|
||||
const y = horizon + (i * i) * 1.7;
|
||||
if (y > GAME_HEIGHT) break;
|
||||
grid.lineBetween(0, y, GAME_WIDTH, y);
|
||||
}
|
||||
for (let i = -14; i <= 14; i++) {
|
||||
const x = GAME_WIDTH / 2 + i * 64;
|
||||
grid.lineBetween(GAME_WIDTH / 2 + i * 14, horizon, x, GAME_HEIGHT);
|
||||
}
|
||||
grid.setAlpha(0.5);
|
||||
|
||||
// Scrolling scanlines.
|
||||
const scan = this.add.tileSprite(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 'dlScan')
|
||||
.setDepth(-10).setAlpha(0.5);
|
||||
scan.setBlendMode(Phaser.BlendModes.ADD);
|
||||
this.tweens.add({ targets: scan, tilePositionY: GAME_HEIGHT, duration: 11000, repeat: -1, ease: 'Linear' });
|
||||
}
|
||||
|
||||
// Neon title with a magenta chromatic-aberration ghost.
|
||||
neonTitle(cx, y, text, size = 64) {
|
||||
const main = this.add.text(cx, y, text, {
|
||||
fontFamily: 'Righteous', fontSize: `${size}px`, color: C.neonHex,
|
||||
}).setOrigin(0.5);
|
||||
main.postFX.addGlow(C.neon, 6, 0, false, 0.1, 12);
|
||||
const ghost = this.add.text(cx + 3, y + 2, text, {
|
||||
fontFamily: 'Righteous', fontSize: `${size}px`, color: C.magentaHex,
|
||||
}).setOrigin(0.5).setAlpha(0.35).setBlendMode(Phaser.BlendModes.ADD);
|
||||
this.tweens.add({ targets: ghost, x: cx - 3, duration: 1700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
|
||||
this.layer.add([ghost, main]);
|
||||
return main;
|
||||
}
|
||||
|
||||
clearLayer() {
|
||||
this.drawing = false;
|
||||
this.draw = null;
|
||||
if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
|
||||
this.glowGfx = null; this.coreGfx = null; this.dotsGfx = null;
|
||||
this.timerText = null; this.stageChips = null;
|
||||
this.layer.removeAll(true);
|
||||
}
|
||||
|
||||
// ── HOME (mode select) ──────────────────────────────────────────────────────
|
||||
showHome() {
|
||||
this.view = 'home';
|
||||
this.board = null;
|
||||
this.overlayUp = false;
|
||||
this.clearLayer();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.neonTitle(cx, 150, 'D O T L I N K', 90);
|
||||
const sub = this.add.text(cx, 240, 'Link every pair · fill the grid · don\'t cross the streams', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: C.dimHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(sub);
|
||||
|
||||
const cardW = 520;
|
||||
const cardH = 460;
|
||||
const gap = 80;
|
||||
const leftX = cx - cardW / 2 - gap / 2;
|
||||
const rightX = cx + cardW / 2 + gap / 2;
|
||||
const cardY = 640;
|
||||
|
||||
this.modeCard(leftX, cardY, cardW, cardH, C.neon, C.neonHex, 'LEVELS',
|
||||
`Campaign of 100 hand-picked puzzles\nof rising difficulty.`,
|
||||
`Cleared ${this.levelsCompleted} / ${this.bank.length}`,
|
||||
() => this.showLevelSelect());
|
||||
|
||||
const best = lsGet(DAILY_BEST_KEY);
|
||||
const todayDone = lsGet(dailyRecKey(this.dailyDate));
|
||||
let dailyInfo = best ? `Best run: ${fmtTime(Number(best))}` : 'No run yet';
|
||||
if (todayDone) dailyInfo += `\nToday: ${fmtTime(Number(todayDone))} ✓`;
|
||||
this.modeCard(rightX, cardY, cardW, cardH, C.magenta, C.magentaHex, "TODAY'S RUN",
|
||||
`Five daily puzzles — same for everyone.\nRace the clock for your best time.`,
|
||||
dailyInfo,
|
||||
() => this.showDaily());
|
||||
|
||||
const back = new Button(this, cx, GAME_HEIGHT - 70, 'Back to Menu', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 240, height: 56, fontSize: 24 });
|
||||
this.layer.add(back);
|
||||
}
|
||||
|
||||
modeCard(x, y, w, h, accentN, accentHex, title, body, info, onClick) {
|
||||
const g = this.add.graphics();
|
||||
const draw = (hover) => {
|
||||
g.clear();
|
||||
g.fillStyle(C.panel, 0.92);
|
||||
g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 22);
|
||||
g.lineStyle(hover ? 5 : 3, accentN, 1);
|
||||
g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 22);
|
||||
};
|
||||
draw(false);
|
||||
g.postFX.addGlow(accentN, 4, 0, false, 0.06, 10);
|
||||
|
||||
const t = this.add.text(x, y - h / 2 + 70, title, {
|
||||
fontFamily: 'Righteous', fontSize: '56px', color: accentHex,
|
||||
}).setOrigin(0.5);
|
||||
const b = this.add.text(x, y - 10, body, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '25px', color: C.textHex, align: 'center', lineSpacing: 10,
|
||||
}).setOrigin(0.5);
|
||||
const inf = this.add.text(x, y + h / 2 - 86, info, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.goldHex, align: 'center', lineSpacing: 8,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Decorative mini dot-pair preview.
|
||||
const py = y + h / 2 - 150;
|
||||
const prev = this.add.graphics();
|
||||
prev.lineStyle(8, accentN, 0.9);
|
||||
prev.lineBetween(x - 120, py, x + 120, py);
|
||||
prev.fillStyle(accentN, 1);
|
||||
prev.fillCircle(x - 120, py, 14);
|
||||
prev.fillCircle(x + 120, py, 14);
|
||||
prev.setBlendMode(Phaser.BlendModes.ADD);
|
||||
|
||||
const hit = this.add.rectangle(x, y, w, h, 0xffffff, 0.001).setInteractive({ useHandCursor: true });
|
||||
hit.on('pointerover', () => draw(true));
|
||||
hit.on('pointerout', () => draw(false));
|
||||
hit.on('pointerup', () => { playSound(this, SFX.PIECE_CLICK); onClick(); });
|
||||
|
||||
this.layer.add([g, t, b, inf, prev, hit]);
|
||||
}
|
||||
|
||||
// ── LEVEL SELECT ────────────────────────────────────────────────────────────
|
||||
showLevelSelect() {
|
||||
this.view = 'select';
|
||||
this.board = null;
|
||||
this.overlayUp = false;
|
||||
this.clearLayer();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.neonTitle(cx, 80, 'L E V E L S', 60);
|
||||
const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
|
||||
const prog = this.add.text(cx, 138, `Cleared ${this.levelsCompleted} / ${this.bank.length}`, {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: C.textHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(prog);
|
||||
|
||||
if (!this.bank.length) {
|
||||
const msg = this.add.text(cx, 520, 'No puzzles found.\nRun: node server/scripts/genDotLink.js', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.showHome(), { variant: 'ghost' });
|
||||
this.layer.add([msg, back]);
|
||||
return;
|
||||
}
|
||||
|
||||
const COLS = 20;
|
||||
const SIZE = 78;
|
||||
const GAP = 10;
|
||||
const gridW = COLS * SIZE + (COLS - 1) * GAP;
|
||||
const left = cx - gridW / 2 + SIZE / 2;
|
||||
const top = 220;
|
||||
|
||||
this.bank.forEach((p, i) => {
|
||||
const col = i % COLS;
|
||||
const row = Math.floor(i / COLS);
|
||||
const x = left + col * (SIZE + GAP);
|
||||
const y = top + row * (SIZE + GAP);
|
||||
const level = p.level;
|
||||
const cleared = level <= this.levelsCompleted;
|
||||
const playable = level <= nextLevel;
|
||||
const accent = cleared ? FLOW[2].n : playable ? C.neon : C.panelEdge;
|
||||
const fill = cleared ? 0x10241a : playable ? 0x0d1430 : 0x0a0b16;
|
||||
|
||||
const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, accent, 1);
|
||||
const num = this.add.text(x, y - 6, String(level), {
|
||||
fontFamily: 'Righteous', fontSize: '26px',
|
||||
color: playable || cleared ? C.textHex : '#3b4366',
|
||||
}).setOrigin(0.5);
|
||||
const tag = this.add.text(x, y + 22, cleared ? '✓' : playable ? `${p.rows}×${p.cols}` : '🔒', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '13px',
|
||||
color: cleared ? '#7df0a8' : playable ? C.dimHex : '#3b4366',
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([tile, num, tag]);
|
||||
|
||||
if (playable) {
|
||||
tile.setInteractive({ useHandCursor: true });
|
||||
tile.on('pointerover', () => tile.setStrokeStyle(4, C.magenta, 1));
|
||||
tile.on('pointerout', () => tile.setStrokeStyle(3, accent, 1));
|
||||
tile.on('pointerup', () => this.playLevel(level));
|
||||
}
|
||||
});
|
||||
|
||||
const resume = new Button(this, cx - 150, GAME_HEIGHT - 70, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel),
|
||||
{ width: 300, height: 58, fontSize: 24 });
|
||||
const back = new Button(this, cx + 180, GAME_HEIGHT - 70, 'Back', () => this.showHome(),
|
||||
{ variant: 'ghost', width: 180, height: 58, fontSize: 24 });
|
||||
const reset = new Button(this, 230, GAME_HEIGHT - 70, 'Reset Progress', () => this.confirmReset(),
|
||||
{ variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex });
|
||||
this.layer.add([resume, back, reset]);
|
||||
|
||||
if (!this.canPersist) {
|
||||
const note = this.add.text(cx, GAME_HEIGHT - 24, 'Sign in to save your progress across devices.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: C.dimHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(note);
|
||||
}
|
||||
}
|
||||
|
||||
confirmReset() {
|
||||
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.66).setInteractive().setDepth(D.overlay);
|
||||
const panel = this.add.graphics().setDepth(D.overlay);
|
||||
panel.fillStyle(C.panel, 0.98); panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20);
|
||||
panel.lineStyle(3, COLORS.danger, 1); panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20);
|
||||
const title = this.add.text(cx, cy - 90, 'Reset Progress?', {
|
||||
fontFamily: 'Righteous', fontSize: '50px', color: COLORS.dangerHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
const msg = this.add.text(cx, cy - 8, 'This clears every level you have cleared\nand starts you back at Level 1.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: C.textHex, align: 'center', lineSpacing: 6,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => {
|
||||
api.post('/puzzles/dotlink/reset').catch(() => {});
|
||||
this.levelsCompleted = 0;
|
||||
this.showLevelSelect();
|
||||
}, { width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex }).setDepth(D.overlayUI);
|
||||
const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(),
|
||||
{ variant: 'ghost', width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI);
|
||||
this.layer.add([dim, panel, title, msg, yes, no]);
|
||||
}
|
||||
|
||||
// ── DAILY ───────────────────────────────────────────────────────────────────
|
||||
generateDaily() {
|
||||
const base = dateSeed(this.dailyDate);
|
||||
this.dailyBoards = DAILY_STAGES.map((s, i) => {
|
||||
const rng = makeRng((base ^ Math.imul(0x9e3779b9, i + 1)) >>> 0);
|
||||
return generateBoard(s.rows, s.cols, s.colors, rng);
|
||||
});
|
||||
}
|
||||
|
||||
showDaily() {
|
||||
this.view = 'daily-intro';
|
||||
this.board = null;
|
||||
this.overlayUp = false;
|
||||
this.clearLayer();
|
||||
if (!this.dailyBoards) this.generateDaily();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.neonTitle(cx, 110, "TODAY'S RUN", 64);
|
||||
const dateText = this.add.text(cx, 178, this.dailyDate, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: C.dimHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(dateText);
|
||||
|
||||
// Five stage chips.
|
||||
const chipW = 300; const chipH = 110; const gap = 24;
|
||||
const totalW = DAILY_STAGES.length * chipW + (DAILY_STAGES.length - 1) * gap;
|
||||
const startX = cx - totalW / 2 + chipW / 2;
|
||||
const chipY = 360;
|
||||
DAILY_STAGES.forEach((s, i) => {
|
||||
const x = startX + i * (chipW + gap);
|
||||
const accent = FLOW[i * 2 % FLOW.length].n;
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(C.panel, 0.9); g.fillRoundedRect(x - chipW / 2, chipY - chipH / 2, chipW, chipH, 16);
|
||||
g.lineStyle(3, accent, 1); g.strokeRoundedRect(x - chipW / 2, chipY - chipH / 2, chipW, chipH, 16);
|
||||
const lbl = this.add.text(x, chipY - 18, s.label, {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: '#' + accent.toString(16).padStart(6, '0'),
|
||||
}).setOrigin(0.5);
|
||||
const sz = this.add.text(x, chipY + 28, `${s.rows}×${s.cols} · ${s.colors} links`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: C.dimHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([g, lbl, sz]);
|
||||
});
|
||||
|
||||
const best = lsGet(DAILY_BEST_KEY);
|
||||
const todayDone = lsGet(dailyRecKey(this.dailyDate));
|
||||
const info = this.add.text(cx, 500,
|
||||
`${best ? `Personal best: ${fmtTime(Number(best))}` : 'Set your first time today.'}` +
|
||||
`${todayDone ? ` · Today solved in ${fmtTime(Number(todayDone))}` : ''}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: C.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
const rules = this.add.text(cx, 560,
|
||||
'Solve all five in order. The clock runs across the whole set.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.dimHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([info, rules]);
|
||||
|
||||
const start = new Button(this, cx, 690, todayDone ? 'Play Again' : 'Start Run', () => this.startDailyRun(),
|
||||
{ width: 320, height: 66, fontSize: 30 });
|
||||
const back = new Button(this, cx, 780, 'Back', () => this.showHome(),
|
||||
{ variant: 'ghost', width: 200, height: 56, fontSize: 24 });
|
||||
this.layer.add([start, back]);
|
||||
}
|
||||
|
||||
startDailyRun() {
|
||||
this.dailyIndex = 0;
|
||||
this.dailyElapsed = 0;
|
||||
this.startDailyStage(0);
|
||||
}
|
||||
|
||||
startDailyStage(i) {
|
||||
this.dailyIndex = i;
|
||||
const stage = DAILY_STAGES[i];
|
||||
const g = this.dailyBoards[i];
|
||||
this.view = 'play';
|
||||
this.beginBoard(g.board, g.solution, {
|
||||
mode: 'daily',
|
||||
title: stage.label,
|
||||
subtitle: `Puzzle ${i + 1} / ${DAILY_STAGES.length}`,
|
||||
onWin: () => this.onDailyStageSolved(),
|
||||
});
|
||||
// (Re)start the run timer on the first stage; keep it running across stages.
|
||||
if (!this.timerEvent) {
|
||||
this.timerEvent = this.time.addEvent({
|
||||
delay: 1000, loop: true, callback: () => {
|
||||
this.dailyElapsed++;
|
||||
if (this.timerText) this.timerText.setText(`⏱ ${fmtTime(this.dailyElapsed)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onDailyStageSolved() {
|
||||
this.overlayUp = true; // freeze input during the transition (clock keeps running)
|
||||
playSound(this, SFX.VICTORY_SHORT);
|
||||
if (this.dailyIndex < DAILY_STAGES.length - 1) {
|
||||
this.flashBanner(`${DAILY_STAGES[this.dailyIndex].label} CLEARED`, () => {
|
||||
this.startDailyStage(this.dailyIndex + 1);
|
||||
});
|
||||
} else {
|
||||
if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
|
||||
this.finishDailyRun();
|
||||
}
|
||||
}
|
||||
|
||||
finishDailyRun() {
|
||||
const total = this.dailyElapsed;
|
||||
const prevBest = Number(lsGet(DAILY_BEST_KEY) ?? 0);
|
||||
const isBest = !prevBest || total < prevBest;
|
||||
if (isBest) lsSet(DAILY_BEST_KEY, String(total));
|
||||
lsSet(dailyRecKey(this.dailyDate), String(total));
|
||||
api.post('/history/single-player', {
|
||||
slug: 'dotlink', score: total, opponentScores: [], result: 'win',
|
||||
}).catch(() => {});
|
||||
|
||||
this.overlayUp = true;
|
||||
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7).setDepth(D.overlay).setInteractive();
|
||||
const panel = this.add.graphics().setDepth(D.overlay);
|
||||
panel.fillStyle(C.panel, 0.98); panel.fillRoundedRect(cx - 360, cy - 220, 720, 440, 22);
|
||||
panel.lineStyle(3, C.magenta, 1); panel.strokeRoundedRect(cx - 360, cy - 220, 720, 440, 22);
|
||||
panel.postFX.addGlow(C.magenta, 8, 0, false, 0.06, 12);
|
||||
this.layer.add([dim, panel]);
|
||||
|
||||
this.neonTitle(cx, cy - 130, 'RUN COMPLETE', 60);
|
||||
const stat = this.add.text(cx, cy - 30,
|
||||
`All five solved in ${fmtTime(total)}` + (isBest ? '\n★ NEW PERSONAL BEST ★' : `\nPersonal best: ${fmtTime(prevBest)}`), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '30px', color: isBest ? C.goldHex : C.textHex, align: 'center', lineSpacing: 12,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
this.layer.add(stat);
|
||||
const again = new Button(this, cx - 130, cy + 110, 'Replay', () => this.showDaily(),
|
||||
{ width: 220, height: 58, fontSize: 24, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||
const home = new Button(this, cx + 130, cy + 110, 'Home', () => this.showHome(),
|
||||
{ width: 220, height: 58, fontSize: 24 }).setDepth(D.overlayUI);
|
||||
this.layer.add([again, home]);
|
||||
}
|
||||
|
||||
flashBanner(text, onDone) {
|
||||
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
|
||||
const t = this.add.text(cx, cy, text, {
|
||||
fontFamily: 'Righteous', fontSize: '72px', color: C.neonHex,
|
||||
}).setOrigin(0.5).setAlpha(0).setScale(0.7).setDepth(D.overlayUI);
|
||||
t.postFX.addGlow(C.neon, 8, 0, false, 0.1, 14);
|
||||
this.layer.add(t);
|
||||
this.tweens.add({
|
||||
targets: t, alpha: 1, scale: 1, duration: 360, ease: 'Back.easeOut',
|
||||
onComplete: () => this.tweens.add({
|
||||
targets: t, alpha: 0, scale: 1.15, delay: 520, duration: 320,
|
||||
onComplete: () => { t.destroy(); onDone(); },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// ── LEVELS PLAY ─────────────────────────────────────────────────────────────
|
||||
playLevel(level) {
|
||||
const lv = this.bank.find((p) => p.level === level);
|
||||
if (!lv) return;
|
||||
this.level = level;
|
||||
this.view = 'play';
|
||||
this.beginBoard({ rows: lv.rows, cols: lv.cols, endpoints: lv.endpoints }, lv.solution, {
|
||||
mode: 'level',
|
||||
title: `Level ${level}`,
|
||||
subtitle: `${lv.rows} × ${lv.cols} · ${lv.colors} links`,
|
||||
onWin: () => this.onLevelSolved(),
|
||||
});
|
||||
}
|
||||
|
||||
onLevelSolved() {
|
||||
this.overlayUp = true;
|
||||
if (this.level > this.levelsCompleted) this.levelsCompleted = this.level;
|
||||
api.post('/puzzles/dotlink/complete', { level: this.level })
|
||||
.then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); })
|
||||
.catch(() => {});
|
||||
api.post('/history/single-player', { slug: 'dotlink', score: this.level, opponentScores: [], result: 'win' }).catch(() => {});
|
||||
playSound(this, SFX.VICTORY_SHORT);
|
||||
|
||||
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.66).setDepth(D.overlay).setInteractive();
|
||||
const panel = this.add.graphics().setDepth(D.overlay);
|
||||
panel.fillStyle(C.panel, 0.98); panel.fillRoundedRect(cx - 320, cy - 190, 640, 380, 22);
|
||||
panel.lineStyle(3, FLOW[2].n, 1); panel.strokeRoundedRect(cx - 320, cy - 190, 640, 380, 22);
|
||||
panel.postFX.addGlow(FLOW[2].n, 8, 0, false, 0.06, 12);
|
||||
this.layer.add([dim, panel]);
|
||||
|
||||
this.neonTitle(cx, cy - 110, 'LINKED!', 64);
|
||||
const hasNext = this.level < this.bank.length;
|
||||
const stat = this.add.text(cx, cy - 30, hasNext ? `Level ${this.level} complete` : 'You cleared every level. Legend.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '28px', color: C.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
this.layer.add(stat);
|
||||
|
||||
const btns = [];
|
||||
if (hasNext) {
|
||||
btns.push(new Button(this, cx, cy + 50, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1),
|
||||
{ width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI));
|
||||
}
|
||||
const replay = new Button(this, cx - 120, cy + 130, 'Replay', () => this.playLevel(this.level),
|
||||
{ width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||
const levels = new Button(this, cx + 120, cy + 130, 'Levels', () => this.showLevelSelect(),
|
||||
{ width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||
btns.push(replay, levels);
|
||||
this.layer.add(btns);
|
||||
}
|
||||
|
||||
// ── Board setup & rendering ──────────────────────────────────────────────────
|
||||
beginBoard(board, solution, opts) {
|
||||
this.clearLayer();
|
||||
this.board = board;
|
||||
this.solution = solution ?? null;
|
||||
this.onWin = opts.onWin;
|
||||
this.mode = opts.mode;
|
||||
this.overlayUp = false;
|
||||
this.drawing = false;
|
||||
this.draw = null;
|
||||
|
||||
const { rows, cols } = board;
|
||||
this.paths = Array.from({ length: board.endpoints.length }, () => []);
|
||||
this.owner = new Int16Array(rows * cols).fill(-1);
|
||||
this.endpointGrid = new Int16Array(rows * cols).fill(-1);
|
||||
this.endA = []; this.endB = [];
|
||||
board.endpoints.forEach((e, k) => {
|
||||
const a = e.a[0] * cols + e.a[1];
|
||||
const b = e.b[0] * cols + e.b[1];
|
||||
this.endpointGrid[a] = k; this.endpointGrid[b] = k;
|
||||
this.endA[k] = a; this.endB[k] = b;
|
||||
});
|
||||
|
||||
this.computeGeom();
|
||||
this.drawChrome();
|
||||
this.drawHud(opts.title, opts.subtitle);
|
||||
|
||||
// Path graphics layers (back to front): glow then core then dots.
|
||||
this.glowGfx = this.add.graphics().setBlendMode(Phaser.BlendModes.ADD);
|
||||
this.coreGfx = this.add.graphics();
|
||||
this.dotsGfx = this.add.graphics();
|
||||
this.layer.add([this.glowGfx, this.coreGfx, this.dotsGfx]);
|
||||
this.drawDots();
|
||||
this.redrawPaths();
|
||||
}
|
||||
|
||||
computeGeom() {
|
||||
const { rows, cols } = this.board;
|
||||
const maxW = 1000; const maxH = 720;
|
||||
const cell = Math.floor(Math.min(maxW / cols, maxH / rows));
|
||||
const boardW = cell * cols; const boardH = cell * rows;
|
||||
const ox = Math.round((GAME_WIDTH - boardW) / 2);
|
||||
const oy = Math.round(225 + (maxH - boardH) / 2);
|
||||
this.geom = { cell, ox, oy, boardW, boardH };
|
||||
}
|
||||
|
||||
cellCenter(idx) {
|
||||
const { cell, ox, oy } = this.geom;
|
||||
const cols = this.board.cols;
|
||||
const r = Math.floor(idx / cols); const c = idx % cols;
|
||||
return { x: ox + c * cell + cell / 2, y: oy + r * cell + cell / 2 };
|
||||
}
|
||||
|
||||
cellFromXY(x, y) {
|
||||
const { cell, ox, oy } = this.geom;
|
||||
const { rows, cols } = this.board;
|
||||
const c = Math.floor((x - ox) / cell);
|
||||
const r = Math.floor((y - oy) / cell);
|
||||
if (r < 0 || r >= rows || c < 0 || c >= cols) return -1;
|
||||
return r * cols + c;
|
||||
}
|
||||
|
||||
drawChrome() {
|
||||
const { cell, ox, oy, boardW, boardH } = this.geom;
|
||||
const g = this.add.graphics().setDepth(0);
|
||||
g.fillStyle(C.panel, 0.85);
|
||||
g.fillRoundedRect(ox - 18, oy - 18, boardW + 36, boardH + 36, 18);
|
||||
g.lineStyle(3, C.panelEdge, 1);
|
||||
g.strokeRoundedRect(ox - 18, oy - 18, boardW + 36, boardH + 36, 18);
|
||||
g.lineStyle(1, C.grid, 0.9);
|
||||
for (let c = 0; c <= this.board.cols; c++) g.lineBetween(ox + c * cell, oy, ox + c * cell, oy + boardH);
|
||||
for (let r = 0; r <= this.board.rows; r++) g.lineBetween(ox, oy + r * cell, ox + boardW, oy + r * cell);
|
||||
g.postFX.addGlow(C.neon, 3, 0, false, 0.04, 6);
|
||||
this.layer.add(g);
|
||||
}
|
||||
|
||||
drawHud(title, subtitle) {
|
||||
const left = this.geom.ox - 18;
|
||||
const t = this.add.text(left, 96, title, {
|
||||
fontFamily: 'Righteous', fontSize: '48px', color: C.neonHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.hud);
|
||||
const s = this.add.text(left, 144, subtitle, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.dimHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.hud);
|
||||
this.layer.add([t, s]);
|
||||
|
||||
if (this.mode === 'daily') {
|
||||
this.timerText = this.add.text(this.geom.ox + this.geom.boardW + 18, 96, `⏱ ${fmtTime(this.dailyElapsed)}`, {
|
||||
fontFamily: 'Righteous', fontSize: '40px', color: C.goldHex,
|
||||
}).setOrigin(1, 0.5).setDepth(D.hud);
|
||||
this.layer.add(this.timerText);
|
||||
}
|
||||
|
||||
// Side controls.
|
||||
const BTN_W = 160; const BTN_H = 54; const GAP = 12;
|
||||
const bx = left / 2 + 20;
|
||||
let by = GAME_HEIGHT / 2 - (3 * BTN_H + 2 * GAP) / 2;
|
||||
const clear = new Button(this, bx, by, 'Clear', () => this.clearAllPaths(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
|
||||
by += BTN_H + GAP;
|
||||
const hint = new Button(this, bx, by, 'Hint', () => this.useHint(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
|
||||
by += BTN_H + GAP;
|
||||
const quit = new Button(this, bx, by, this.mode === 'daily' ? 'Quit Run' : 'Levels',
|
||||
() => (this.mode === 'daily' ? this.showDaily() : this.showLevelSelect()),
|
||||
{ width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
|
||||
this.layer.add([clear, hint, quit]);
|
||||
}
|
||||
|
||||
drawDots() {
|
||||
const g = this.dotsGfx;
|
||||
g.clear();
|
||||
const rDot = this.geom.cell * 0.30;
|
||||
for (let k = 0; k < this.board.endpoints.length; k++) {
|
||||
const col = FLOW[k % FLOW.length].n;
|
||||
for (const idx of [this.endA[k], this.endB[k]]) {
|
||||
const { x, y } = this.cellCenter(idx);
|
||||
g.fillStyle(col, 0.18); g.fillCircle(x, y, rDot * 1.7);
|
||||
g.fillStyle(col, 1); g.fillCircle(x, y, rDot);
|
||||
g.fillStyle(0xffffff, 0.55); g.fillCircle(x - rDot * 0.28, y - rDot * 0.28, rDot * 0.34);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
redrawPaths() {
|
||||
const glow = this.glowGfx; const core = this.coreGfx;
|
||||
glow.clear(); core.clear();
|
||||
const cell = this.geom.cell;
|
||||
const coreW = cell * 0.34; const glowW = cell * 0.6;
|
||||
for (let k = 0; k < this.paths.length; k++) {
|
||||
const path = this.paths[k];
|
||||
if (!path.length) continue;
|
||||
const col = FLOW[k % FLOW.length].n;
|
||||
this.strokePath(glow, path, col, glowW, 0.22);
|
||||
this.strokePath(core, path, col, coreW, 1);
|
||||
}
|
||||
}
|
||||
|
||||
strokePath(g, path, col, width, alpha) {
|
||||
if (path.length === 1) {
|
||||
const p = this.cellCenter(path[0]);
|
||||
g.fillStyle(col, alpha); g.fillCircle(p.x, p.y, width / 2);
|
||||
return;
|
||||
}
|
||||
g.lineStyle(width, col, alpha);
|
||||
g.beginPath();
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const p = this.cellCenter(path[i]);
|
||||
if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y);
|
||||
}
|
||||
g.strokePath();
|
||||
// Round the joints/caps.
|
||||
g.fillStyle(col, alpha);
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const p = this.cellCenter(path[i]);
|
||||
g.fillCircle(p.x, p.y, width / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Interaction ──────────────────────────────────────────────────────────────
|
||||
registerInput() {
|
||||
this.input.on('pointerdown', (ptr) => this.onPointerDown(ptr));
|
||||
this.input.on('pointermove', (ptr) => { if (ptr.isDown) this.onPointerMove(ptr); });
|
||||
this.input.on('pointerup', () => this.onPointerUp());
|
||||
}
|
||||
|
||||
inPlay() { return this.view === 'play' && this.board && !this.overlayUp; }
|
||||
|
||||
pathCells(k) { return this.paths[k]; }
|
||||
|
||||
colorAt(idx) { return this.owner[idx]; }
|
||||
|
||||
resetColor(k) {
|
||||
for (const cell of this.paths[k]) this.owner[cell] = -1;
|
||||
this.paths[k] = [];
|
||||
}
|
||||
|
||||
truncateColorTo(k, index) {
|
||||
const path = this.paths[k];
|
||||
for (let i = index + 1; i < path.length; i++) this.owner[path[i]] = -1;
|
||||
path.length = index + 1;
|
||||
}
|
||||
|
||||
onPointerDown(ptr) {
|
||||
if (!this.inPlay()) return;
|
||||
const idx = this.cellFromXY(ptr.x, ptr.y);
|
||||
if (idx < 0) return;
|
||||
const ec = this.endpointGrid[idx];
|
||||
if (ec >= 0) {
|
||||
// Start a fresh path from this endpoint.
|
||||
this.resetColor(ec);
|
||||
this.paths[ec] = [idx];
|
||||
this.owner[idx] = ec;
|
||||
const start = idx;
|
||||
const target = start === this.endA[ec] ? this.endB[ec] : this.endA[ec];
|
||||
this.draw = { color: ec, target };
|
||||
this.drawing = true;
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this.redrawPaths();
|
||||
return;
|
||||
}
|
||||
const oc = this.owner[idx];
|
||||
if (oc >= 0) {
|
||||
// Continue an existing path from the touched cell.
|
||||
const path = this.paths[oc];
|
||||
const pi = path.indexOf(idx);
|
||||
if (pi < 0) return;
|
||||
this.truncateColorTo(oc, pi);
|
||||
const start = path[0];
|
||||
const target = start === this.endA[oc] ? this.endB[oc] : this.endA[oc];
|
||||
this.draw = { color: oc, target };
|
||||
this.drawing = true;
|
||||
this.redrawPaths();
|
||||
}
|
||||
}
|
||||
|
||||
onPointerMove(ptr) {
|
||||
if (!this.inPlay() || !this.drawing || !this.draw) return;
|
||||
const target = this.cellFromXY(ptr.x, ptr.y);
|
||||
if (target < 0) return;
|
||||
const cols = this.board.cols;
|
||||
let guard = 0;
|
||||
while (guard++ < 80) {
|
||||
const path = this.paths[this.draw.color];
|
||||
const head = path[path.length - 1];
|
||||
if (head === target) break;
|
||||
const next = this.stepToward(head, target, cols);
|
||||
if (next < 0) break;
|
||||
const moved = this.tryEnter(next);
|
||||
if (!moved) break;
|
||||
if (!this.drawing) break; // completed
|
||||
}
|
||||
}
|
||||
|
||||
stepToward(head, target, cols) {
|
||||
const hr = Math.floor(head / cols); const hc = head % cols;
|
||||
const tr = Math.floor(target / cols); const tc = target % cols;
|
||||
const dr = tr - hr; const dc = tc - hc;
|
||||
// Prefer the axis with the larger remaining distance.
|
||||
if (Math.abs(dr) >= Math.abs(dc) && dr !== 0) return (hr + Math.sign(dr)) * cols + hc;
|
||||
if (dc !== 0) return hr * cols + (hc + Math.sign(dc));
|
||||
if (dr !== 0) return (hr + Math.sign(dr)) * cols + hc;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Attempt to extend the active path into `next`. Returns true if the path
|
||||
// changed. Sets this.drawing=false when the link is completed.
|
||||
tryEnter(next) {
|
||||
const k = this.draw.color;
|
||||
const path = this.paths[k];
|
||||
const head = path[path.length - 1];
|
||||
if (!manhattanAdjacent(idxToRC(head, this.board.cols), idxToRC(next, this.board.cols))) return false;
|
||||
|
||||
const ec = this.endpointGrid[next];
|
||||
if (ec === k && next === this.draw.target) {
|
||||
// Completed link.
|
||||
this.owner[next] = k;
|
||||
path.push(next);
|
||||
this.drawing = false;
|
||||
this.redrawPaths();
|
||||
this.pulseCell(next, FLOW[k % FLOW.length].n);
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
this.checkWin();
|
||||
return true;
|
||||
}
|
||||
if (ec >= 0) return false; // another colour's dot, or our own start dot
|
||||
|
||||
// Already part of our own path -> truncate back to it (backtrack / un-cross).
|
||||
const selfIdx = path.indexOf(next);
|
||||
if (selfIdx >= 0) {
|
||||
this.truncateColorTo(k, selfIdx);
|
||||
this.redrawPaths();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Overwriting another colour's pipe: erase it from the touched cell onward.
|
||||
const oc = this.owner[next];
|
||||
if (oc >= 0 && oc !== k) this.cutAt(oc, next);
|
||||
|
||||
this.owner[next] = k;
|
||||
path.push(next);
|
||||
this.redrawPaths();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove `cell` and everything drawn after it from colour `oc`'s path.
|
||||
cutAt(oc, cell) {
|
||||
const path = this.paths[oc];
|
||||
const i = path.indexOf(cell);
|
||||
if (i < 0) return;
|
||||
for (let j = i; j < path.length; j++) this.owner[path[j]] = -1;
|
||||
path.length = i;
|
||||
}
|
||||
|
||||
pulseCell(idx, col) {
|
||||
const { x, y } = this.cellCenter(idx);
|
||||
const ring = this.add.circle(x, y, this.geom.cell * 0.3, col, 0).setStrokeStyle(4, col, 0.9).setDepth(D.hud);
|
||||
this.layer.add(ring);
|
||||
this.tweens.add({
|
||||
targets: ring, scale: 2.4, alpha: 0, duration: 460, ease: 'Cubic.easeOut',
|
||||
onComplete: () => ring.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
onPointerUp() {
|
||||
if (!this.inPlay()) return;
|
||||
this.drawing = false;
|
||||
this.draw = null;
|
||||
this.checkWin();
|
||||
}
|
||||
|
||||
clearAllPaths() {
|
||||
if (!this.inPlay()) return;
|
||||
for (let k = 0; k < this.paths.length; k++) this.resetColor(k);
|
||||
this.drawing = false; this.draw = null;
|
||||
this.redrawPaths();
|
||||
playSound(this, SFX.CARD_SHUFFLE);
|
||||
}
|
||||
|
||||
useHint() {
|
||||
if (!this.inPlay() || !this.solution) return;
|
||||
// Fill in the first colour whose current path doesn't match its solution.
|
||||
for (let k = 0; k < this.solution.length; k++) {
|
||||
const sol = this.solution[k];
|
||||
const cur = this.paths[k];
|
||||
const same = cur.length === sol.length && cur.every((idx, i) => idx === sol[i][0] * this.board.cols + sol[i][1]);
|
||||
if (same) continue;
|
||||
this.resetColor(k);
|
||||
const cells = sol.map((rc) => rc[0] * this.board.cols + rc[1]);
|
||||
for (const cell of cells) {
|
||||
const oc = this.owner[cell];
|
||||
if (oc >= 0 && oc !== k) this.cutAt(oc, cell);
|
||||
this.owner[cell] = k;
|
||||
}
|
||||
this.paths[k] = cells;
|
||||
this.redrawPaths();
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this.checkWin();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
checkWin() {
|
||||
if (this.overlayUp || !this.board) return;
|
||||
const asRC = this.paths.map((p) => p.map((idx) => idxToRC(idx, this.board.cols)));
|
||||
if (isSolved(this.board, asRC)) {
|
||||
this.drawing = false; this.draw = null;
|
||||
if (this.onWin) this.onWin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function idxToRC(idx, cols) { return [Math.floor(idx / cols), idx % cols]; }
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
// Dot Link — pure puzzle engine (no Phaser). Shared by the Phaser scene, the
|
||||
// offline bank generator (server/scripts/genDotLink.js) and the verifier.
|
||||
//
|
||||
// "Dot Link" is a Flow-Free / Numberlink puzzle: every colour has two endpoint
|
||||
// dots; connect each pair with a non-crossing path so that EVERY cell of the
|
||||
// grid is covered exactly once.
|
||||
//
|
||||
// Board model:
|
||||
// { rows, cols, endpoints: [ { color, a:[r,c], b:[r,c] }, ... ] }
|
||||
// A solution assigns every cell to exactly one colour such that each colour
|
||||
// forms a single simple path between its two endpoints and no cell is empty.
|
||||
|
||||
// ── Seeded RNG (mulberry32) ──────────────────────────────────────────────────
|
||||
export function makeRng(seed) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// Hash an arbitrary string (e.g. a date "2026-06-14") to a uint32 seed so that
|
||||
// everyone playing on the same day gets the same daily boards.
|
||||
export function dateSeed(str) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
// Local YYYY-MM-DD for a Date (defaults to now).
|
||||
export function localDateString(d = new Date()) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// ── Grid helpers ─────────────────────────────────────────────────────────────
|
||||
function buildNeighbors(rows, cols) {
|
||||
const nbr = new Array(rows * cols);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const list = [];
|
||||
if (r > 0) list.push((r - 1) * cols + c);
|
||||
if (r < rows - 1) list.push((r + 1) * cols + c);
|
||||
if (c > 0) list.push(r * cols + (c - 1));
|
||||
if (c < cols - 1) list.push(r * cols + (c + 1));
|
||||
nbr[r * cols + c] = list;
|
||||
}
|
||||
}
|
||||
return nbr;
|
||||
}
|
||||
|
||||
export function manhattanAdjacent(a, b) {
|
||||
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) === 1;
|
||||
}
|
||||
|
||||
// ── Solver / solution counter ────────────────────────────────────────────────
|
||||
// Grows each colour's path from endpoint a toward endpoint b. Counts solutions
|
||||
// up to `countLimit` (default 2 — enough to decide uniqueness). Aborts and
|
||||
// reports `aborted:true` if it exceeds `maxNodes` search nodes.
|
||||
//
|
||||
// Returns { count, aborted, solvable }.
|
||||
export function solve(board, opts = {}) {
|
||||
const { rows, cols } = board;
|
||||
const N = rows * cols;
|
||||
const colors = board.endpoints.length;
|
||||
const countLimit = opts.countLimit ?? 2;
|
||||
const maxNodes = opts.maxNodes ?? 400000;
|
||||
const nbr = buildNeighbors(rows, cols);
|
||||
const idx = (rc) => rc[0] * cols + rc[1];
|
||||
|
||||
const grid = new Int16Array(N).fill(-1); // cell -> colour or -1 empty
|
||||
const headAt = new Int16Array(N).fill(-1); // cell -> colour whose active head sits here
|
||||
const goalAt = new Int16Array(N).fill(-1); // cell -> colour whose goal sits here (fixed)
|
||||
const heads = new Int32Array(colors);
|
||||
const goals = new Int32Array(colors);
|
||||
const connected = new Uint8Array(colors);
|
||||
|
||||
for (let k = 0; k < colors; k++) {
|
||||
const e = board.endpoints[k];
|
||||
const a = idx(e.a);
|
||||
const b = idx(e.b);
|
||||
if (grid[a] !== -1 || grid[b] !== -1) {
|
||||
return { count: 0, aborted: false, solvable: false }; // overlapping endpoints
|
||||
}
|
||||
grid[a] = k; grid[b] = k;
|
||||
heads[k] = a; goals[k] = b;
|
||||
headAt[a] = k;
|
||||
goalAt[b] = k;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
let nodes = 0;
|
||||
let aborted = false;
|
||||
|
||||
// Reusable BFS buffers (stamp-based to avoid per-call clears).
|
||||
const reachStamp = new Int32Array(N); // empty cell reachable by some head
|
||||
const seenStamp = new Int32Array(N);
|
||||
const queue = new Int32Array(N);
|
||||
let stamp = 0;
|
||||
|
||||
// Two safe, strong prunes (never reject a real solution):
|
||||
// (1) Degree: every empty cell needs >=2 open connections (empty neighbour,
|
||||
// active head, or open goal endpoint) since it must be an interior path
|
||||
// cell of degree 2.
|
||||
// (2) Reachability: each unconnected colour's head must still reach its goal
|
||||
// through empty cells, AND every empty cell must be reachable from some
|
||||
// head through empty cells (or it can never be filled).
|
||||
function pruneOk() {
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (grid[i] !== -1) continue;
|
||||
let deg = 0;
|
||||
const ns = nbr[i];
|
||||
for (let j = 0; j < ns.length; j++) {
|
||||
const nb = ns[j];
|
||||
if (grid[nb] === -1) { deg++; if (deg >= 2) break; continue; }
|
||||
const h = headAt[nb];
|
||||
if (h !== -1 && !connected[h]) { deg++; if (deg >= 2) break; continue; }
|
||||
const g = goalAt[nb];
|
||||
if (g !== -1 && !connected[g]) { deg++; if (deg >= 2) break; }
|
||||
}
|
||||
if (deg < 2) return false;
|
||||
}
|
||||
|
||||
const reachTag = ++stamp;
|
||||
for (let k = 0; k < colors; k++) {
|
||||
if (connected[k]) continue;
|
||||
const head = heads[k];
|
||||
const goal = goals[k];
|
||||
const seenTag = ++stamp;
|
||||
let qh = 0; let qt = 0;
|
||||
let reachedGoal = false;
|
||||
const hns = nbr[head];
|
||||
for (let j = 0; j < hns.length; j++) {
|
||||
const nb = hns[j];
|
||||
if (nb === goal) { reachedGoal = true; }
|
||||
else if (grid[nb] === -1 && seenStamp[nb] !== seenTag) {
|
||||
seenStamp[nb] = seenTag; reachStamp[nb] = reachTag; queue[qt++] = nb;
|
||||
}
|
||||
}
|
||||
while (qh < qt) {
|
||||
const cur = queue[qh++];
|
||||
const cns = nbr[cur];
|
||||
for (let j = 0; j < cns.length; j++) {
|
||||
const nb = cns[j];
|
||||
if (nb === goal) { reachedGoal = true; continue; }
|
||||
if (grid[nb] === -1 && seenStamp[nb] !== seenTag) {
|
||||
seenStamp[nb] = seenTag; reachStamp[nb] = reachTag; queue[qt++] = nb;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!reachedGoal) return false;
|
||||
}
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (grid[i] === -1 && reachStamp[i] !== reachTag) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function dfs() {
|
||||
nodes++;
|
||||
if (nodes > maxNodes) { aborted = true; return; }
|
||||
|
||||
// Most-constrained colour to extend (fewest moves). Bail if any unconnected
|
||||
// colour has no move.
|
||||
let allConnected = true;
|
||||
let bestK = -1;
|
||||
let bestMoves = null;
|
||||
let bestLen = Infinity;
|
||||
for (let k = 0; k < colors; k++) {
|
||||
if (connected[k]) continue;
|
||||
allConnected = false;
|
||||
const h = heads[k];
|
||||
const g = goals[k];
|
||||
const moves = [];
|
||||
const ns = nbr[h];
|
||||
for (let j = 0; j < ns.length; j++) {
|
||||
const nb = ns[j];
|
||||
if (nb === g) moves.push(nb);
|
||||
else if (grid[nb] === -1) moves.push(nb);
|
||||
}
|
||||
if (moves.length === 0) return; // stuck colour
|
||||
if (moves.length < bestLen) { bestLen = moves.length; bestK = k; bestMoves = moves; }
|
||||
}
|
||||
|
||||
if (allConnected) {
|
||||
for (let i = 0; i < N; i++) if (grid[i] === -1) return; // unfilled -> dead
|
||||
count++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pruneOk()) return;
|
||||
|
||||
const k = bestK;
|
||||
const h = heads[k];
|
||||
const g = goals[k];
|
||||
for (let m = 0; m < bestMoves.length; m++) {
|
||||
const n = bestMoves[m];
|
||||
if (n === g) {
|
||||
// Connect: head reaches its goal.
|
||||
headAt[h] = -1;
|
||||
connected[k] = 1;
|
||||
dfs();
|
||||
connected[k] = 0;
|
||||
headAt[h] = k;
|
||||
} else {
|
||||
grid[n] = k; headAt[h] = -1; headAt[n] = k; heads[k] = n;
|
||||
dfs();
|
||||
heads[k] = h; headAt[n] = -1; headAt[h] = k; grid[n] = -1;
|
||||
}
|
||||
if (aborted || count >= countLimit) return;
|
||||
}
|
||||
}
|
||||
|
||||
dfs();
|
||||
return { count, aborted, solvable: count > 0 };
|
||||
}
|
||||
|
||||
// ── Random board generation ──────────────────────────────────────────────────
|
||||
// Generate a random Hamiltonian path over the whole grid via the "backbite"
|
||||
// algorithm, then cut it into `colors` contiguous segments. Each segment's two
|
||||
// ends become a colour's endpoints — guaranteeing a full-cover solution exists.
|
||||
function randomHamiltonian(rows, cols, rng) {
|
||||
const N = rows * cols;
|
||||
const nbr = buildNeighbors(rows, cols);
|
||||
const path = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
if (r % 2 === 0) for (let c = 0; c < cols; c++) path.push(r * cols + c);
|
||||
else for (let c = cols - 1; c >= 0; c--) path.push(r * cols + c);
|
||||
}
|
||||
const pos = new Int32Array(N);
|
||||
for (let i = 0; i < N; i++) pos[path[i]] = i;
|
||||
|
||||
const reverse = (lo, hi) => {
|
||||
while (lo < hi) {
|
||||
const a = path[lo], b = path[hi];
|
||||
path[lo] = b; path[hi] = a;
|
||||
pos[b] = lo; pos[a] = hi;
|
||||
lo++; hi--;
|
||||
}
|
||||
};
|
||||
|
||||
const iters = Math.max(2000, N * 30);
|
||||
for (let it = 0; it < iters; it++) {
|
||||
const L = path.length;
|
||||
if (rng() < 0.5) {
|
||||
// Backbite the tail.
|
||||
const t = path[L - 1];
|
||||
const cand = nbr[t];
|
||||
const m = cand[Math.floor(rng() * cand.length)];
|
||||
const k = pos[m];
|
||||
if (k < L - 2) reverse(k + 1, L - 1);
|
||||
} else {
|
||||
// Backbite the head.
|
||||
const hcell = path[0];
|
||||
const cand = nbr[hcell];
|
||||
const m = cand[Math.floor(rng() * cand.length)];
|
||||
const k = pos[m];
|
||||
if (k > 1) reverse(0, k - 1);
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// Cut the Hamiltonian path into `colors` contiguous segments. Returns both the
|
||||
// puzzle (endpoints) and the reference `solution` (each colour's full cell list)
|
||||
// — the partition is itself a valid full-cover solution.
|
||||
function cutIntoColors(ham, colors, rows, cols, rng) {
|
||||
const N = ham.length;
|
||||
if (colors > Math.floor(N / 2)) return null; // need >=2 cells per colour
|
||||
const lens = new Array(colors).fill(2);
|
||||
let rem = N - 2 * colors;
|
||||
while (rem > 0) { lens[Math.floor(rng() * colors)]++; rem--; }
|
||||
const endpoints = [];
|
||||
const solution = [];
|
||||
let p = 0;
|
||||
for (let k = 0; k < colors; k++) {
|
||||
const cells = [];
|
||||
for (let j = 0; j < lens[k]; j++) {
|
||||
const cell = ham[p + j];
|
||||
cells.push([Math.floor(cell / cols), cell % cols]);
|
||||
}
|
||||
endpoints.push({ color: k, a: cells[0], b: cells[cells.length - 1] });
|
||||
solution.push(cells);
|
||||
p += lens[k];
|
||||
}
|
||||
return { board: { rows, cols, endpoints }, solution };
|
||||
}
|
||||
|
||||
// Produce a board for the given size/colour count. Always returns a valid,
|
||||
// solvable, full-cover board plus its reference solution. When `uniqueCheck` is
|
||||
// set (cheap only on small grids) it prefers a board the search solver proves
|
||||
// uniquely solvable, falling back to the first candidate otherwise.
|
||||
// Deterministic for a given rng sequence. Returns { board, solution, unique }.
|
||||
export function generateBoard(rows, cols, colors, rng, opts = {}) {
|
||||
const cells = rows * cols;
|
||||
const uniqueCheck = opts.uniqueCheck ?? (cells <= 64);
|
||||
const tries = uniqueCheck ? (opts.tries ?? 24) : 1;
|
||||
const maxNodes = opts.maxNodes ?? 200000;
|
||||
let fallback = null;
|
||||
for (let t = 0; t < tries; t++) {
|
||||
const ham = randomHamiltonian(rows, cols, rng);
|
||||
const cut = cutIntoColors(ham, colors, rows, cols, rng);
|
||||
if (!cut) return null;
|
||||
if (!fallback) fallback = cut;
|
||||
if (!uniqueCheck) break;
|
||||
const res = solve(cut.board, { countLimit: 2, maxNodes });
|
||||
if (!res.aborted && res.count === 1) {
|
||||
return { board: cut.board, solution: cut.solution, unique: true };
|
||||
}
|
||||
}
|
||||
return { board: fallback.board, solution: fallback.solution, unique: false };
|
||||
}
|
||||
|
||||
// ── Win check for live play ──────────────────────────────────────────────────
|
||||
// `paths` maps colour index -> array of [r,c] cells the player has drawn for
|
||||
// that colour (endpoints included). Returns true when every colour links its
|
||||
// two endpoints with a contiguous simple path, every cell is covered exactly
|
||||
// once, and nothing overlaps.
|
||||
export function isSolved(board, paths) {
|
||||
const { rows, cols } = board;
|
||||
const owner = new Int16Array(rows * cols).fill(-1);
|
||||
for (let k = 0; k < board.endpoints.length; k++) {
|
||||
const path = paths[k];
|
||||
if (!path || path.length < 2) return false;
|
||||
const e = board.endpoints[k];
|
||||
const first = path[0];
|
||||
const last = path[path.length - 1];
|
||||
const endsOk =
|
||||
(first[0] === e.a[0] && first[1] === e.a[1] && last[0] === e.b[0] && last[1] === e.b[1]) ||
|
||||
(first[0] === e.b[0] && first[1] === e.b[1] && last[0] === e.a[0] && last[1] === e.a[1]);
|
||||
if (!endsOk) return false;
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const cell = path[i][0] * cols + path[i][1];
|
||||
if (owner[cell] !== -1) return false; // overlap
|
||||
owner[cell] = k;
|
||||
if (i > 0 && !manhattanAdjacent(path[i - 1], path[i])) return false; // broken
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < owner.length; i++) if (owner[i] === -1) return false; // unfilled
|
||||
return true;
|
||||
}
|
||||
|
|
@ -17,17 +17,17 @@
|
|||
export const SPELLS = {
|
||||
// ── Knight — skulls and raw damage ─────────────────────────────────────────
|
||||
shieldBash: {
|
||||
name: 'Shield Bash', cost: { red: 4 },
|
||||
name: 'Shield Bash', cost: { red: 4 }, icon: 'shield',
|
||||
effects: [{ kind: 'damage', amount: 3 }],
|
||||
desc: 'Slam your shield into the enemy for 3 damage.',
|
||||
},
|
||||
rallyingCry: {
|
||||
name: 'Rallying Cry', cost: { yellow: 6 },
|
||||
name: 'Rallying Cry', cost: { yellow: 6 }, icon: 'banner',
|
||||
effects: [{ kind: 'heal', amount: 6 }],
|
||||
desc: 'Steel your resolve and recover 6 life.',
|
||||
},
|
||||
cleave: {
|
||||
name: 'Cleave', cost: { red: 8, yellow: 4 },
|
||||
name: 'Cleave', cost: { red: 8, yellow: 4 }, icon: 'swords',
|
||||
effects: [{ kind: 'damage', amount: 8 }],
|
||||
desc: 'A mighty two-handed blow for 8 damage.',
|
||||
},
|
||||
|
|
@ -37,14 +37,14 @@ export const SPELLS = {
|
|||
desc: 'Forge 4 random gems into skulls.',
|
||||
},
|
||||
crusadersWrath: {
|
||||
name: "Crusader's Wrath", cost: { red: 12, yellow: 8 },
|
||||
name: "Crusader's Wrath", cost: { red: 12, yellow: 8 }, icon: 'swords',
|
||||
effects: [{ kind: 'buffSkullDamage', amount: 2 }, { kind: 'damage', amount: 8 }],
|
||||
desc: 'Deal 8 damage; your skull matches deal +2 for the rest of the battle.',
|
||||
},
|
||||
|
||||
// ── Sorcerer — big mana, big bursts ────────────────────────────────────────
|
||||
spark: {
|
||||
name: 'Spark', cost: { blue: 3 },
|
||||
name: 'Spark', cost: { blue: 3 }, icon: 'bolt',
|
||||
effects: [{ kind: 'damage', amount: 3 }],
|
||||
desc: 'A crackle of arcane energy for 3 damage.',
|
||||
},
|
||||
|
|
@ -54,7 +54,7 @@ export const SPELLS = {
|
|||
desc: 'Destroy a random column — you collect all its mana.',
|
||||
},
|
||||
fireball: {
|
||||
name: 'Fireball', cost: { red: 12 },
|
||||
name: 'Fireball', cost: { red: 12 }, icon: 'flame',
|
||||
effects: [{ kind: 'damage', amount: 13 }],
|
||||
desc: 'A roaring blast of flame for 13 damage.',
|
||||
},
|
||||
|
|
@ -64,7 +64,7 @@ export const SPELLS = {
|
|||
desc: 'Turn every yellow gem on the board blue.',
|
||||
},
|
||||
meteorStorm: {
|
||||
name: 'Meteor Storm', cost: { red: 14, blue: 10 },
|
||||
name: 'Meteor Storm', cost: { red: 14, blue: 10 }, icon: 'meteor',
|
||||
effects: [
|
||||
{ kind: 'destroyGems', selector: { mode: 'random', count: 8 } },
|
||||
{ kind: 'damage', amount: 6 },
|
||||
|
|
@ -74,12 +74,12 @@ export const SPELLS = {
|
|||
|
||||
// ── Druid — healing and board control ──────────────────────────────────────
|
||||
regrowth: {
|
||||
name: 'Regrowth', cost: { green: 5 },
|
||||
name: 'Regrowth', cost: { green: 5 }, icon: 'leaf',
|
||||
effects: [{ kind: 'heal', amount: 8 }],
|
||||
desc: 'Soothing vines restore 8 life.',
|
||||
},
|
||||
thornLash: {
|
||||
name: 'Thorn Lash', cost: { green: 6 },
|
||||
name: 'Thorn Lash', cost: { green: 6 }, icon: 'thorn',
|
||||
effects: [{ kind: 'damage', amount: 5 }],
|
||||
desc: 'A whip of thorns for 5 damage.',
|
||||
},
|
||||
|
|
@ -94,7 +94,7 @@ export const SPELLS = {
|
|||
desc: 'Bloom 5 random gems into green mana.',
|
||||
},
|
||||
naturesBalance: {
|
||||
name: "Nature's Balance", cost: { green: 12, yellow: 8 },
|
||||
name: "Nature's Balance", cost: { green: 12, yellow: 8 }, icon: 'leaf',
|
||||
effects: [
|
||||
{ kind: 'heal', amount: 10 },
|
||||
{ kind: 'destroyGems', selector: { mode: 'skulls', harmless: true } },
|
||||
|
|
@ -104,7 +104,7 @@ export const SPELLS = {
|
|||
|
||||
// ── Assassin — debuffs and theft ───────────────────────────────────────────
|
||||
poisonDart: {
|
||||
name: 'Poison Dart', cost: { green: 4 },
|
||||
name: 'Poison Dart', cost: { green: 4 }, icon: 'poison',
|
||||
effects: [
|
||||
{ kind: 'damage', amount: 3 },
|
||||
{ kind: 'drainMana', amount: 3, color: 'largest' },
|
||||
|
|
@ -117,7 +117,7 @@ export const SPELLS = {
|
|||
desc: 'Steal 6 mana from the enemy\'s deepest pool.',
|
||||
},
|
||||
backstab: {
|
||||
name: 'Backstab', cost: { green: 8, blue: 4 },
|
||||
name: 'Backstab', cost: { green: 8, blue: 4 }, icon: 'dagger',
|
||||
effects: [{ kind: 'damage', amount: 9 }],
|
||||
desc: 'Strike from the shadows for 9 damage.',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ const GEM_HEX = { red: '#e04444', green: '#2ecc71', blue: '#3f8efc', yellow: '#f
|
|||
|
||||
const PANEL_X = [320, GAME_WIDTH - 320]; // player left, enemy right
|
||||
|
||||
const D = { felt: -2, frame: -1, grid: 0, cells: 5, fx: 12, ui: 30, overlay: 60, overlayUI: 62 };
|
||||
const D = { felt: -2, frame: -1, grid: 0, cells: 5, fx: 12, ui: 30, strike: 50, overlay: 60, overlayUI: 62 };
|
||||
const CENTER_X = GAME_WIDTH / 2; // 960
|
||||
const CENTER_Y = GAME_HEIGHT / 2; // 540
|
||||
const HP_Y = 376;
|
||||
const PORTRAIT_Y = 240;
|
||||
const REPLAY_DELAY = {
|
||||
swap: 240, clear: 340, fall: 180, refill: 230,
|
||||
spell: 750, damage: 380, heal: 380, mana: 380, buff: 380, stun: 550,
|
||||
|
|
@ -49,6 +53,7 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
this.match = null;
|
||||
this.overlayUp = false;
|
||||
this.playerClass = null;
|
||||
this.activeSpellId = null;
|
||||
}
|
||||
|
||||
async create() {
|
||||
|
|
@ -180,6 +185,129 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
g.strokeRoundedRect(3, 3, CELL - 6, CELL - 6, 16);
|
||||
g.generateTexture('jq-select', CELL, CELL);
|
||||
g.destroy();
|
||||
|
||||
this.makeIconTextures();
|
||||
}
|
||||
|
||||
// ── Procedural spell-icon + particle textures (for combat animations) ────────
|
||||
|
||||
makeIconTextures() {
|
||||
if (this.textures.exists('jq-icon-flame')) return;
|
||||
const S = 96;
|
||||
const STEEL = 0xd8dee8;
|
||||
const ORANGE = 0xe98b2a;
|
||||
|
||||
const icon = (key, draw) => {
|
||||
const g = this.make.graphics({ add: false });
|
||||
draw(g);
|
||||
g.generateTexture(`jq-icon-${key}`, S, S);
|
||||
g.destroy();
|
||||
};
|
||||
|
||||
icon('shield', (g) => {
|
||||
g.fillStyle(GEM_INT.red, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(48, 14); g.lineTo(80, 26); g.lineTo(76, 56);
|
||||
g.lineTo(48, 84); g.lineTo(20, 56); g.lineTo(16, 26);
|
||||
g.closePath(); g.fillPath();
|
||||
g.lineStyle(4, 0xffffff, 0.6); g.strokePath();
|
||||
g.fillStyle(0xffffff, 0.85); g.fillCircle(48, 46, 8);
|
||||
});
|
||||
|
||||
icon('swords', (g) => {
|
||||
g.lineStyle(11, STEEL, 1);
|
||||
g.lineBetween(20, 80, 76, 16);
|
||||
g.lineBetween(76, 80, 20, 16);
|
||||
g.lineStyle(8, GEM_INT.red, 1);
|
||||
g.lineBetween(12, 84, 30, 66);
|
||||
g.lineBetween(84, 84, 66, 66);
|
||||
});
|
||||
|
||||
icon('bolt', (g) => {
|
||||
g.fillStyle(GEM_INT.yellow, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(54, 12); g.lineTo(30, 52); g.lineTo(46, 52);
|
||||
g.lineTo(40, 84); g.lineTo(70, 40); g.lineTo(52, 40);
|
||||
g.closePath(); g.fillPath();
|
||||
g.lineStyle(3, 0xffffff, 0.7); g.strokePath();
|
||||
});
|
||||
|
||||
icon('flame', (g) => {
|
||||
g.fillStyle(ORANGE, 1);
|
||||
g.fillCircle(48, 58, 24);
|
||||
g.fillTriangle(24, 58, 72, 58, 48, 8);
|
||||
g.fillStyle(GEM_INT.yellow, 1);
|
||||
g.fillCircle(48, 60, 13);
|
||||
g.fillTriangle(35, 60, 61, 60, 48, 28);
|
||||
});
|
||||
|
||||
icon('meteor', (g) => {
|
||||
g.fillStyle(ORANGE, 0.9);
|
||||
g.fillTriangle(46, 44, 10, 10, 32, 52);
|
||||
g.fillStyle(GEM_INT.yellow, 0.85);
|
||||
g.fillTriangle(46, 42, 20, 16, 32, 46);
|
||||
g.fillStyle(0x8a6a55, 1);
|
||||
g.fillCircle(58, 58, 20);
|
||||
g.fillStyle(0x6f5343, 1);
|
||||
g.fillCircle(52, 52, 5); g.fillCircle(64, 62, 4);
|
||||
});
|
||||
|
||||
icon('thorn', (g) => {
|
||||
g.lineStyle(6, GEM_INT.green, 1);
|
||||
g.lineBetween(18, 82, 80, 16);
|
||||
g.fillStyle(GEM_INT.green, 1);
|
||||
g.fillTriangle(42, 56, 30, 50, 46, 42);
|
||||
g.fillTriangle(58, 40, 46, 32, 62, 26);
|
||||
g.fillTriangle(34, 64, 50, 62, 44, 76);
|
||||
});
|
||||
|
||||
icon('poison', (g) => {
|
||||
g.fillStyle(GEM_INT.green, 1);
|
||||
g.fillCircle(48, 58, 22);
|
||||
g.fillTriangle(28, 50, 68, 50, 48, 12);
|
||||
g.fillStyle(0xffffff, 0.5); g.fillCircle(40, 54, 5);
|
||||
g.fillStyle(0x0a2a14, 0.5);
|
||||
g.fillCircle(54, 64, 4); g.fillCircle(45, 70, 3);
|
||||
});
|
||||
|
||||
icon('dagger', (g) => {
|
||||
g.fillStyle(STEEL, 1);
|
||||
g.fillTriangle(48, 8, 40, 60, 56, 60);
|
||||
g.fillStyle(GEM_INT.red, 1);
|
||||
g.fillRect(33, 58, 30, 7);
|
||||
g.fillStyle(0x7a4a26, 1);
|
||||
g.fillRect(43, 65, 10, 21);
|
||||
g.fillStyle(GEM_INT.red, 1);
|
||||
g.fillCircle(48, 88, 5);
|
||||
});
|
||||
|
||||
icon('banner', (g) => {
|
||||
g.lineStyle(5, 0x9a7b3a, 1);
|
||||
g.lineBetween(30, 12, 30, 86);
|
||||
g.fillStyle(GEM_INT.yellow, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(33, 16); g.lineTo(78, 22); g.lineTo(70, 38);
|
||||
g.lineTo(78, 54); g.lineTo(33, 48);
|
||||
g.closePath(); g.fillPath();
|
||||
g.lineStyle(3, 0xffffff, 0.5); g.strokePath();
|
||||
});
|
||||
|
||||
icon('leaf', (g) => {
|
||||
g.fillStyle(GEM_INT.green, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(48, 14); g.lineTo(74, 50); g.lineTo(48, 86); g.lineTo(22, 50);
|
||||
g.closePath(); g.fillPath();
|
||||
g.lineStyle(3, 0x0a3a18, 0.8);
|
||||
g.lineBetween(48, 18, 48, 82);
|
||||
g.lineBetween(48, 40, 64, 34); g.lineBetween(48, 40, 32, 34);
|
||||
g.lineBetween(48, 58, 64, 64); g.lineBetween(48, 58, 32, 64);
|
||||
});
|
||||
|
||||
const sp = this.make.graphics({ add: false });
|
||||
sp.fillStyle(0xffffff, 0.35); sp.fillCircle(8, 8, 8);
|
||||
sp.fillStyle(0xffffff, 1); sp.fillCircle(8, 8, 4);
|
||||
sp.generateTexture('jq-spark', 16, 16);
|
||||
sp.destroy();
|
||||
}
|
||||
|
||||
textureFor(cell) {
|
||||
|
|
@ -893,6 +1021,154 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
return { x: x / cells.length, y: y / cells.length };
|
||||
}
|
||||
|
||||
// ── Combat animations ────────────────────────────────────────────────────────
|
||||
// Each returns the rough total duration (ms); processEvent returns that so the
|
||||
// replay queue holds the next event until the sequence finishes.
|
||||
|
||||
iconForSpell(spellId) {
|
||||
const key = SPELLS[spellId]?.icon ?? 'swords';
|
||||
return `jq-icon-${key}`;
|
||||
}
|
||||
|
||||
// White flash over a player's HP bar (used as the attack/heal lands).
|
||||
flashHpBar(idx, color = 0xffffff) {
|
||||
const flash = this.add.rectangle(PANEL_X[idx], HP_Y, 392, 36, color, 0.9).setDepth(D.strike);
|
||||
this.layer.add(flash);
|
||||
this.tweens.add({
|
||||
targets: flash, alpha: 0, duration: 150, yoyo: true, repeat: 1,
|
||||
onComplete: () => flash.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
// Shared tail: a "damage line" sized to the amount flies from center to the
|
||||
// target's HP bar, then flashes it and drains the HP in sync.
|
||||
strikeHpBar(targetIdx, amount, players, color = GEM_INT.red) {
|
||||
const lineW = Phaser.Math.Clamp(amount * 14, 36, 320);
|
||||
const line = this.add.rectangle(CENTER_X, CENTER_Y, lineW, 18, color)
|
||||
.setStrokeStyle(2, 0xffffff, 0.8).setScale(0.2, 1).setDepth(D.strike);
|
||||
this.layer.add(line);
|
||||
this.tweens.add({
|
||||
targets: line, scaleX: 1, duration: 180, ease: 'Back.easeOut',
|
||||
onComplete: () => {
|
||||
this.tweens.add({
|
||||
targets: line, x: PANEL_X[targetIdx], y: HP_Y, duration: 600, ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
line.destroy();
|
||||
if (!this.scene.isActive()) return;
|
||||
playSound(this, SFX.SWORD_SLICE);
|
||||
this.flashHpBar(targetIdx);
|
||||
this.updateHpBar(targetIdx, players, { tween: true });
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Skull match: matched skulls converge to center, merge into a big skull with
|
||||
// the total damage, then morph into the damage line and strike the foe.
|
||||
animateSkullStrike(e) {
|
||||
const skullGroups = (e.groups ?? []).filter((g) => g.cls === 'skull');
|
||||
const cells = skullGroups.flatMap((g) => g.cells);
|
||||
const totalDmg = skullGroups.reduce((s, g) => s + (g.damage || 0), 0);
|
||||
const targetIdx = 1 - e.actor;
|
||||
|
||||
for (const cell of cells) {
|
||||
const { x, y } = this.cellXY(cell.r, cell.c);
|
||||
const img = this.add.image(x, y, 'jq-skull').setDepth(D.strike);
|
||||
this.layer.add(img);
|
||||
this.tweens.add({
|
||||
targets: img, x: CENTER_X, y: CENTER_Y, scale: 1.15, duration: 480, ease: 'Cubic.easeIn',
|
||||
onComplete: () => img.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
this.time.delayedCall(480, () => {
|
||||
if (!this.scene.isActive()) return;
|
||||
const merged = this.add.image(CENTER_X, CENTER_Y, 'jq-skull').setScale(0.6).setDepth(D.strike);
|
||||
const label = this.add.text(CENTER_X, CENTER_Y + 6, `-${totalDmg}`, {
|
||||
fontFamily: 'Righteous', fontSize: '46px', color: GEM_HEX.red, stroke: '#000000', strokeThickness: 6,
|
||||
}).setOrigin(0.5).setScale(0.5).setDepth(D.strike + 1);
|
||||
this.layer.add([merged, label]);
|
||||
playSound(this, SFX.SWORD_HIT);
|
||||
this.tweens.add({ targets: merged, scale: 2.4, duration: 420, ease: 'Back.easeOut' });
|
||||
this.tweens.add({ targets: label, scale: 1.2, duration: 420, ease: 'Back.easeOut' });
|
||||
|
||||
this.time.delayedCall(560, () => {
|
||||
this.tweens.add({
|
||||
targets: [merged, label], alpha: 0, scale: '*=0.6', duration: 180,
|
||||
onComplete: () => { merged.destroy(); label.destroy(); },
|
||||
});
|
||||
if (!this.scene.isActive()) return;
|
||||
this.strikeHpBar(targetIdx, totalDmg, e.players, GEM_INT.red);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Damage spell: class/damage-type icon flies from caster portrait to center,
|
||||
// pulses, then morphs into the damage line and strikes the foe.
|
||||
animateSpellDamage(spellId, casterIdx, amount, players) {
|
||||
const icon = this.add.image(PANEL_X[casterIdx], PORTRAIT_Y, this.iconForSpell(spellId))
|
||||
.setScale(0.5).setDepth(D.strike);
|
||||
this.layer.add(icon);
|
||||
this.tweens.add({
|
||||
targets: icon, x: CENTER_X, y: CENTER_Y, scale: 2.2, duration: 700, ease: 'Cubic.easeOut',
|
||||
onComplete: () => {
|
||||
if (!this.scene.isActive()) { icon.destroy(); return; }
|
||||
playSound(this, SFX.SWORD_HIT);
|
||||
this.tweens.add({
|
||||
targets: icon, scale: 2.7, duration: 260, yoyo: true, ease: 'Sine.easeInOut',
|
||||
onComplete: () => {
|
||||
this.tweens.add({ targets: icon, alpha: 0, scale: '*=0.7', duration: 180, onComplete: () => icon.destroy() });
|
||||
if (!this.scene.isActive()) return;
|
||||
this.strikeHpBar(1 - casterIdx, amount, players, GEM_INT.red);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Healing spell: icon grows at center, flies back to the caster's portrait and
|
||||
// bursts a radiating particle effect while their HP fills up.
|
||||
animateSpellHeal(spellId, casterIdx, amount, players) {
|
||||
const icon = this.add.image(CENTER_X, CENTER_Y, this.iconForSpell(spellId))
|
||||
.setScale(0.4).setDepth(D.strike);
|
||||
this.layer.add(icon);
|
||||
this.tweens.add({
|
||||
targets: icon, scale: 2.2, duration: 700, ease: 'Back.easeOut',
|
||||
onComplete: () => {
|
||||
if (!this.scene.isActive()) { icon.destroy(); return; }
|
||||
this.tweens.add({
|
||||
targets: icon, x: PANEL_X[casterIdx], y: PORTRAIT_Y, scale: 0.7, duration: 600, ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
icon.destroy();
|
||||
if (!this.scene.isActive()) return;
|
||||
playSound(this, SFX.CARD_SHOW);
|
||||
this.radiateHeal(PANEL_X[casterIdx], PORTRAIT_Y);
|
||||
this.flashHpBar(casterIdx, 0x9be7b4);
|
||||
this.updateHpBar(casterIdx, players, { tween: true });
|
||||
this.floatText(PANEL_X[casterIdx], 300, `+${amount}`, '#9be7b4', PANEL_X[casterIdx], HP_Y);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Expanding ring + particle burst around a portrait (heal feedback).
|
||||
radiateHeal(x, y) {
|
||||
const ring = this.add.circle(x, y, 62, 0x2ecc71, 0)
|
||||
.setStrokeStyle(5, 0x9be7b4, 0.9).setDepth(D.strike);
|
||||
this.layer.add(ring);
|
||||
this.tweens.add({ targets: ring, scale: 1.8, alpha: 0, duration: 650, ease: 'Cubic.easeOut', onComplete: () => ring.destroy() });
|
||||
try {
|
||||
const em = this.add.particles(x, y, 'jq-spark', {
|
||||
speed: { min: 120, max: 260 }, angle: { min: 0, max: 360 }, lifespan: 650,
|
||||
scale: { start: 1.1, end: 0 }, quantity: 26, tint: 0x9be7b4, blendMode: 'ADD', emitting: false,
|
||||
}).setDepth(D.overlay); // scene-root emitter, renders above the board container
|
||||
em.explode(26);
|
||||
this.time.delayedCall(800, () => { try { em.destroy(); } catch (_) { /* scene torn down */ } });
|
||||
} catch (_) { /* particles optional */ }
|
||||
}
|
||||
|
||||
processEvent(e) {
|
||||
switch (e.type) {
|
||||
case 'swap':
|
||||
|
|
@ -900,15 +1176,13 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
this.flashCells([e.a, e.b], 0xffffff);
|
||||
break;
|
||||
case 'clear': {
|
||||
const hasSkullMatch = (e.groups ?? []).some((grp) => grp.cls === 'skull' && grp.cells.length >= 3);
|
||||
playSound(this, hasSkullMatch ? SFX.SWORD_HIT : (SFX.MASTERMIND_MATCH ?? SFX.CARD_SHOW));
|
||||
const hasSkull = (e.groups ?? []).some((grp) => grp.cls === 'skull');
|
||||
playSound(this, hasSkull ? SFX.SWORD_HIT : (SFX.MASTERMIND_MATCH ?? SFX.CARD_SHOW));
|
||||
const actorPanel = PANEL_X[e.actor];
|
||||
const foePanel = PANEL_X[1 - e.actor];
|
||||
for (const grp of e.groups ?? []) {
|
||||
const { x, y } = this.groupCentroid(grp.cells);
|
||||
if (grp.cls === 'skull') {
|
||||
this.flashCells(grp.cells, GEM_INT.red);
|
||||
this.floatText(x, y, `-${grp.damage}`, GEM_HEX.red, foePanel, 376);
|
||||
} else {
|
||||
this.flashCells(grp.cells, GEM_INT[grp.cls]);
|
||||
this.floatText(x, y, `+${grp.mana}`, GEM_HEX[grp.cls], actorPanel, 480);
|
||||
|
|
@ -919,6 +1193,13 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
this.oppPortrait?.playEmotion(e.actor === 0 ? 'upset' : 'happy');
|
||||
}
|
||||
this.renderBoardCells(e.board);
|
||||
if (hasSkull) {
|
||||
// Mana updates now; HP drains at the strike climax inside the animation.
|
||||
this.updateManaFrom(e);
|
||||
this.animateSkullStrike(e);
|
||||
this.refreshSpellButtons();
|
||||
return 2200;
|
||||
}
|
||||
this.updateMetersFrom(e);
|
||||
break;
|
||||
}
|
||||
|
|
@ -928,6 +1209,7 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
break;
|
||||
case 'spell': {
|
||||
playSound(this, SFX.CARD_SHOW);
|
||||
this.activeSpellId = e.spellId; // consumed by the next damage/heal event
|
||||
const caster = e.caster === 0 ? 'YOU' : this.opponent.name.toUpperCase();
|
||||
this.showCallout(`${caster}: ${e.name}!`, e.caster === 0 ? '#9be7b4' : '#ff8a8a');
|
||||
this.oppPortrait?.playEmotion(e.caster === 0 ? 'upset' : 'happy');
|
||||
|
|
@ -936,14 +1218,15 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
break;
|
||||
}
|
||||
case 'damage':
|
||||
playSound(this, SFX.SWORD_SLICE);
|
||||
this.floatText(PANEL_X[e.target], 320, `-${e.amount}`, GEM_HEX.red, PANEL_X[e.target], 376);
|
||||
this.updateMetersFrom(e);
|
||||
break;
|
||||
this.updateManaFrom(e);
|
||||
this.animateSpellDamage(this.activeSpellId, 1 - e.target, e.amount, e.players);
|
||||
this.refreshSpellButtons();
|
||||
return 2400;
|
||||
case 'heal':
|
||||
this.floatText(PANEL_X[e.target], 320, `+${e.amount}`, '#9be7b4', PANEL_X[e.target], 376);
|
||||
this.updateMetersFrom(e);
|
||||
break;
|
||||
this.updateManaFrom(e);
|
||||
this.animateSpellHeal(this.activeSpellId, e.target, e.amount, e.players);
|
||||
this.refreshSpellButtons();
|
||||
return 2400;
|
||||
case 'mana':
|
||||
this.floatText(PANEL_X[e.target], 480, `-${e.amount} ${e.color}`, GEM_HEX[e.color] ?? '#ffffff', PANEL_X[e.target], 520);
|
||||
this.updateMetersFrom(e);
|
||||
|
|
@ -1026,17 +1309,44 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
const players = e?.players ?? this.match?.players;
|
||||
if (!players || !this.hpBars) return;
|
||||
for (const i of [0, 1]) {
|
||||
const p = players[i];
|
||||
const frac = Math.max(0, Math.min(1, p.hp / p.maxHp));
|
||||
this.hpBars[i].fill.width = this.hpBars[i].w * frac;
|
||||
this.hpBars[i].fill.setFillStyle(frac > 0.5 ? 0x2ecc71 : frac > 0.25 ? 0xf1c40f : 0xe04444);
|
||||
const buff = p.status?.skullBuff > 0 ? ` ⚔+${p.status.skullBuff}` : '';
|
||||
this.hpTexts[i].setText(`${p.hp} / ${p.maxHp}${buff}`);
|
||||
for (const color of MANA_COLORS) {
|
||||
const bar = this.manaBars[i][color];
|
||||
bar.fill.width = bar.w * Math.max(0, Math.min(1, p.mana[color] / MANA_CAP));
|
||||
this.manaTexts[i][color].setText(`${p.mana[color]}/${MANA_CAP}`);
|
||||
}
|
||||
this.updateHpBar(i, players);
|
||||
this.updateManaBar(i, players);
|
||||
}
|
||||
}
|
||||
|
||||
// Mana bars only — used by animated events so mana reacts immediately while
|
||||
// the HP change is deferred to the strike/heal climax.
|
||||
updateManaFrom(e) {
|
||||
const players = e?.players ?? this.match?.players;
|
||||
if (!players || !this.manaBars) return;
|
||||
for (const i of [0, 1]) this.updateManaBar(i, players);
|
||||
}
|
||||
|
||||
updateManaBar(i, players) {
|
||||
const p = players[i];
|
||||
for (const color of MANA_COLORS) {
|
||||
const bar = this.manaBars[i][color];
|
||||
bar.fill.width = bar.w * Math.max(0, Math.min(1, p.mana[color] / MANA_CAP));
|
||||
this.manaTexts[i][color].setText(`${p.mana[color]}/${MANA_CAP}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the HP bar to the snapshot value. When `tween` is true the fill width
|
||||
// eases from its current on-screen value to the new one (the climax of an
|
||||
// attack/heal animation); otherwise it snaps.
|
||||
updateHpBar(i, players, { tween = false } = {}) {
|
||||
if (!this.hpBars) return;
|
||||
const p = players[i];
|
||||
const frac = Math.max(0, Math.min(1, p.hp / p.maxHp));
|
||||
const w = this.hpBars[i].w * frac;
|
||||
const color = frac > 0.5 ? 0x2ecc71 : frac > 0.25 ? 0xf1c40f : 0xe04444;
|
||||
this.hpBars[i].fill.setFillStyle(color);
|
||||
const buff = p.status?.skullBuff > 0 ? ` ⚔+${p.status.skullBuff}` : '';
|
||||
this.hpTexts[i].setText(`${p.hp} / ${p.maxHp}${buff}`);
|
||||
if (tween) {
|
||||
this.tweens.add({ targets: this.hpBars[i].fill, width: w, duration: 360, ease: 'Cubic.easeOut' });
|
||||
} else {
|
||||
this.hpBars[i].fill.width = w;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -396,20 +396,25 @@ function resolveBoard(match, actorIdx, push) {
|
|||
|
||||
// Turn bookkeeping shared by swaps and spell casts. Guarantees the board has a
|
||||
// legal move before control returns (reshuffling if needed).
|
||||
function finishAction(match, extraTurn, push) {
|
||||
function finishAction(match, extraTurn, push, { passTurn = true } = {}) {
|
||||
if (match.over) {
|
||||
push('gameOver', { winner: match.winner });
|
||||
return;
|
||||
}
|
||||
if (extraTurn) {
|
||||
push('extraTurn', {});
|
||||
} else {
|
||||
match.turn = 1 - match.turn;
|
||||
const next = match.players[match.turn];
|
||||
if (next.status.stunned) {
|
||||
next.status.stunned = false;
|
||||
push('skipTurn', { skipped: match.turn });
|
||||
// Spells keep control (passTurn = false, Puzzle Quest rule): the caster may
|
||||
// cast again or make a gem move — only a swap (or an exhausted extra turn)
|
||||
// hands the turn to the opponent.
|
||||
if (passTurn) {
|
||||
if (extraTurn) {
|
||||
push('extraTurn', {});
|
||||
} else {
|
||||
match.turn = 1 - match.turn;
|
||||
const next = match.players[match.turn];
|
||||
if (next.status.stunned) {
|
||||
next.status.stunned = false;
|
||||
push('skipTurn', { skipped: match.turn });
|
||||
match.turn = 1 - match.turn;
|
||||
}
|
||||
}
|
||||
}
|
||||
// AI lookahead clones (match.sim) skip the move guard — the cost isn't
|
||||
|
|
@ -594,11 +599,11 @@ export function castSpell(match, spellId) {
|
|||
if (moves.length) push('fall', { moves });
|
||||
const filled = refill(match);
|
||||
if (filled.length) push('refill', { cells: filled });
|
||||
// Cascades triggered by the spell credit the caster (Puzzle Quest rule),
|
||||
// but spells never grant an extra turn.
|
||||
// Cascades triggered by the spell credit the caster (Puzzle Quest rule).
|
||||
resolveBoard(match, casterIdx, push);
|
||||
}
|
||||
finishAction(match, false, push);
|
||||
// Casting does not end the turn — the caster retains control (see finishAction).
|
||||
finishAction(match, false, push, { passTurn: false });
|
||||
return { legal: true, events };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ each rival on the ladder to unlock the next.
|
|||
- Matching **colored gems** (red, green, blue, yellow) fills your mana pools,
|
||||
up to 25 of each color.
|
||||
- Spend mana on your class's **spells** — healing, fireballs, stuns, mana
|
||||
theft, board-warping magic, and more. Casting a spell takes your turn.
|
||||
theft, board-warping magic, and more. Casting a spell **doesn't end your
|
||||
turn**: cast as many as you can afford, then make a gem move to pass play.
|
||||
- **Wildcard gems** match any color and **multiply** the mana from the run
|
||||
they complete (×2 or ×3).
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js';
|
|||
import SlotsGame from './games/slots/SlotsGame.js';
|
||||
import CribbageGame from './games/cribbage/CribbageGame.js';
|
||||
import CanastaGame from './games/canasta/CanastaGame.js';
|
||||
import DotLinkGame from './games/dotlink/DotLinkGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -163,6 +164,7 @@ const config = {
|
|||
SlotsGame,
|
||||
CribbageGame,
|
||||
CanastaGame,
|
||||
DotLinkGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('blockfighter', '/data/blockfighter.json');
|
||||
this.load.json('jewelquest', '/data/jewelquest.json');
|
||||
this.load.json('zuma', '/data/zuma.json');
|
||||
this.load.json('dotlink', '/data/dotlink.json');
|
||||
|
||||
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
|
||||
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
|
||||
|
|
|
|||
|
|
@ -90,3 +90,4 @@ registerGame({ slug: 'minimotorways', name: 'Mini Motorways', category: 'logic',
|
|||
registerGame({ slug: 'slots', name: 'Slot Machines', category: 'casino', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 63 });
|
||||
registerGame({ slug: 'cribbage', name: 'Cribbage', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 64 });
|
||||
registerGame({ slug: 'canasta', name: 'Canasta', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 65 });
|
||||
registerGame({ slug: 'dotlink', name: 'Dot Link', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 66 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
// Offline generator for Dot Link puzzles.
|
||||
//
|
||||
// Builds a smooth easy->legendary curve of 100 Flow-Free boards by growing a
|
||||
// random Hamiltonian path over each grid (backbite) and cutting it into colour
|
||||
// segments, then preferring boards the solver proves uniquely solvable. Writes
|
||||
// ordered levels to public/data/dotlink.json.
|
||||
//
|
||||
// Usage:
|
||||
// node server/scripts/genDotLink.js [seed] [outFile]
|
||||
//
|
||||
// Deterministic: same seed -> same bank.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { makeRng, generateBoard, isSolved } from '../../public/src/games/dotlink/DotLinkLogic.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_FILE = process.argv[3]
|
||||
? path.resolve(process.argv[3])
|
||||
: path.join(__dirname, '../../public/data/dotlink.json');
|
||||
const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0xd07117e5;
|
||||
|
||||
// Difficulty curve: grid size grows, colour count grows. counts sum to 100.
|
||||
const TIERS = [
|
||||
{ count: 12, rows: 5, cols: 5, colorsMin: 4, colorsMax: 5 },
|
||||
{ count: 14, rows: 6, cols: 6, colorsMin: 5, colorsMax: 6 },
|
||||
{ count: 14, rows: 7, cols: 7, colorsMin: 6, colorsMax: 7 },
|
||||
{ count: 14, rows: 8, cols: 8, colorsMin: 7, colorsMax: 8 },
|
||||
{ count: 14, rows: 9, cols: 9, colorsMin: 8, colorsMax: 9 },
|
||||
{ count: 12, rows: 10, cols: 10, colorsMin: 9, colorsMax: 10 },
|
||||
{ count: 12, rows: 11, cols: 11, colorsMin: 10, colorsMax: 11 },
|
||||
{ count: 8, rows: 11, cols: 11, colorsMin: 12, colorsMax: 12 },
|
||||
];
|
||||
|
||||
function canonKey(board) {
|
||||
return board.endpoints
|
||||
.map((e) => {
|
||||
const a = e.a[0] * board.cols + e.a[1];
|
||||
const b = e.b[0] * board.cols + e.b[1];
|
||||
return a < b ? `${a}-${b}` : `${b}-${a}`;
|
||||
})
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
const rng = makeRng(SEED);
|
||||
console.log(`[dotlink] generating with seed 0x${SEED.toString(16)}…`);
|
||||
|
||||
const levels = [];
|
||||
const seen = new Set();
|
||||
let uniqueCount = 0;
|
||||
const startedAt = Date.now();
|
||||
|
||||
for (const tier of TIERS) {
|
||||
for (let i = 0; i < tier.count; i++) {
|
||||
const span = tier.colorsMax - tier.colorsMin;
|
||||
const colors = tier.count > 1
|
||||
? tier.colorsMin + Math.round((i / (tier.count - 1)) * span)
|
||||
: tier.colorsMin;
|
||||
|
||||
// generateBoard always returns a valid (solvable) board with its reference
|
||||
// solution; prefer one that is uniquely solvable (only checked on small
|
||||
// grids) and not a duplicate of an earlier level.
|
||||
let chosen = null;
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
const g = generateBoard(tier.rows, tier.cols, colors, rng, { tries: 24, maxNodes: 200000 });
|
||||
if (!g) continue;
|
||||
const dup = seen.has(canonKey(g.board));
|
||||
if (!chosen || (!dup && (g.unique || !chosen.unique))) chosen = g;
|
||||
if (g.unique && !dup) break;
|
||||
}
|
||||
if (!chosen) {
|
||||
console.error(`[dotlink] FAILED to generate a ${tier.rows}x${tier.cols}/${colors} board`);
|
||||
process.exit(1);
|
||||
}
|
||||
seen.add(canonKey(chosen.board));
|
||||
if (chosen.unique) uniqueCount++;
|
||||
|
||||
levels.push({
|
||||
level: levels.length + 1,
|
||||
rows: chosen.board.rows,
|
||||
cols: chosen.board.cols,
|
||||
colors,
|
||||
unique: !!chosen.unique,
|
||||
endpoints: chosen.board.endpoints,
|
||||
solution: chosen.solution,
|
||||
});
|
||||
process.stdout.write(`\r[dotlink] built ${levels.length}/100 (unique ${uniqueCount}) `);
|
||||
}
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
|
||||
// Sanity pass: every level's stored reference solution must validate.
|
||||
let unsolvable = 0;
|
||||
for (const lv of levels) {
|
||||
if (!isSolved(lv, lv.solution)) unsolvable++;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
seed: SEED,
|
||||
count: levels.length,
|
||||
levels,
|
||||
};
|
||||
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
|
||||
|
||||
const secs = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
console.log(`[dotlink] wrote ${levels.length} levels (${uniqueCount} unique, ${unsolvable} unsolvable) in ${secs}s -> ${OUT_FILE}`);
|
||||
if (unsolvable > 0) process.exit(1);
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// Verifier for Dot Link (Node only — no browser).
|
||||
//
|
||||
// Asserts every level in public/data/dotlink.json is solvable with a full-cover
|
||||
// solution, reports how many are uniquely solvable, and confirms the daily
|
||||
// board generator is deterministic for a fixed seed.
|
||||
//
|
||||
// Usage: node server/scripts/verifyDotLink.js
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
makeRng, dateSeed, generateBoard, solve, isSolved,
|
||||
} from '../../public/src/games/dotlink/DotLinkLogic.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FILE = path.join(__dirname, '../../public/data/dotlink.json');
|
||||
|
||||
let failures = 0;
|
||||
const fail = (msg) => { console.error(` ✗ ${msg}`); failures++; };
|
||||
|
||||
// ── Bank checks ──────────────────────────────────────────────────────────────
|
||||
const raw = JSON.parse(fs.readFileSync(FILE, 'utf8'));
|
||||
const levels = raw.levels ?? [];
|
||||
console.log(`[verify] ${FILE}`);
|
||||
console.log(`[verify] ${levels.length} levels (seed 0x${(raw.seed >>> 0).toString(16)})`);
|
||||
|
||||
if (levels.length !== 100) fail(`expected 100 levels, found ${levels.length}`);
|
||||
|
||||
let unique = 0;
|
||||
let prevCells = 0;
|
||||
for (const lv of levels) {
|
||||
const cells = lv.rows * lv.cols;
|
||||
// Endpoints in-bounds and distinct.
|
||||
const used = new Set();
|
||||
for (const e of lv.endpoints) {
|
||||
for (const p of [e.a, e.b]) {
|
||||
if (p[0] < 0 || p[0] >= lv.rows || p[1] < 0 || p[1] >= lv.cols) fail(`L${lv.level}: endpoint out of bounds`);
|
||||
const key = p[0] * lv.cols + p[1];
|
||||
if (used.has(key)) fail(`L${lv.level}: endpoint overlap`);
|
||||
used.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Solvable: the stored reference solution must be a valid full-cover solution
|
||||
// whose colour endpoints match the puzzle's.
|
||||
if (!lv.solution || lv.solution.length !== lv.endpoints.length) {
|
||||
fail(`L${lv.level}: missing/mismatched solution`);
|
||||
} else if (!isSolved(lv, lv.solution)) {
|
||||
fail(`L${lv.level}: stored solution does not validate`);
|
||||
}
|
||||
|
||||
// Uniqueness (best-effort; only cheap on small grids).
|
||||
if (cells <= 64) {
|
||||
const two = solve(lv, { countLimit: 2, maxNodes: 600000 });
|
||||
if (!two.aborted && two.count === 1) unique++;
|
||||
}
|
||||
|
||||
// Difficulty should be non-decreasing in grid size.
|
||||
if (cells < prevCells) fail(`L${lv.level}: grid shrank vs previous level`);
|
||||
prevCells = cells;
|
||||
}
|
||||
console.log(`[verify] all ${levels.length} reference solutions validate; ${unique} small boards provably unique`);
|
||||
|
||||
// isSolved must reject an obviously incomplete board.
|
||||
if (isSolved(levels[0], levels[0].endpoints.map((e) => [e.a]))) {
|
||||
fail('isSolved accepted an incomplete board');
|
||||
}
|
||||
|
||||
// ── Daily determinism ────────────────────────────────────────────────────────
|
||||
function dailyBoards(dateStr) {
|
||||
const STAGES = [
|
||||
{ rows: 5, cols: 5, colors: 4 },
|
||||
{ rows: 7, cols: 7, colors: 6 },
|
||||
{ rows: 8, cols: 8, colors: 8 },
|
||||
{ rows: 10, cols: 10, colors: 9 },
|
||||
{ rows: 11, cols: 11, colors: 11 },
|
||||
];
|
||||
const base = dateSeed(dateStr);
|
||||
return STAGES.map((s, i) => {
|
||||
const rng = makeRng((base ^ Math.imul(0x9e3779b9, i + 1)) >>> 0);
|
||||
return generateBoard(s.rows, s.cols, s.colors, rng);
|
||||
});
|
||||
}
|
||||
|
||||
const day = '2026-06-14';
|
||||
const a = dailyBoards(day);
|
||||
const b = dailyBoards(day);
|
||||
if (a.some((x) => !x)) fail('daily generation returned a null board');
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) fail('daily boards are not deterministic for the same date');
|
||||
const c = dailyBoards('2026-06-15');
|
||||
if (JSON.stringify(a.map((g) => g.board)) === JSON.stringify(c.map((g) => g.board))) {
|
||||
fail('different dates produced identical daily boards');
|
||||
}
|
||||
for (const g of a) {
|
||||
if (g && !isSolved(g.board, g.solution)) fail('a daily board solution does not validate');
|
||||
}
|
||||
console.log('[verify] daily boards: deterministic per date, distinct across dates, solvable');
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n[verify] FAILED with ${failures} problem(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n[verify] All Dot Link checks passed.');
|
||||
|
|
@ -251,16 +251,30 @@ console.log('Fixtures:');
|
|||
|
||||
console.log('Spell fixtures:');
|
||||
{
|
||||
// Damage spell: cost deducted, hp reduced, turn passes.
|
||||
// Damage spell: cost deducted, hp reduced, turn is KEPT (Puzzle Quest rule).
|
||||
const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'] });
|
||||
m.players[0].mana.red = 4;
|
||||
const res = castSpell(m, 'shieldBash');
|
||||
check('shieldBash legal', res.legal);
|
||||
check('shieldBash deals 3', m.players[1].hp === 47);
|
||||
check('mana deducted', m.players[0].mana.red === 0);
|
||||
check('casting ends the turn', m.turn === 1);
|
||||
check('casting keeps the turn', m.turn === 0);
|
||||
check('spellsCast recorded', m.players[0].stats.spellsCast === 1);
|
||||
}
|
||||
{
|
||||
// Spells chain within a turn: cast twice, then a gem swap ends the turn.
|
||||
const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']],
|
||||
{ classes: ['knight', 'druid'] });
|
||||
m.players[0].mana.red = 8;
|
||||
check('first cast legal', castSpell(m, 'shieldBash').legal);
|
||||
check("still the caster's turn after one spell", m.turn === 0);
|
||||
check('second cast legal (same turn)', castSpell(m, 'shieldBash').legal);
|
||||
check('both casts landed (50 - 3 - 3)', m.players[1].hp === 44);
|
||||
check("still the caster's turn after two spells", m.turn === 0);
|
||||
stubRng(m, ['yellow', 'blue']);
|
||||
const swap = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
|
||||
check('a gem swap ends the turn', swap.legal && m.turn === 1);
|
||||
}
|
||||
{
|
||||
// Unaffordable cast rejected without deduction.
|
||||
const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'] });
|
||||
|
|
@ -321,13 +335,21 @@ console.log('Spell fixtures:');
|
|||
check('board refilled after column destroy', fullBoard(m.board));
|
||||
}
|
||||
{
|
||||
// Stun: opponent's next turn is skipped.
|
||||
const m = fixtureMatch(BASE, [], { classes: ['druid', 'knight'] });
|
||||
// Stun is pending: a spell doesn't end the turn, so the opponent's skip is
|
||||
// applied when the caster's turn actually ends (a gem swap).
|
||||
const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']],
|
||||
{ classes: ['druid', 'knight'] });
|
||||
m.players[0].mana.green = 9;
|
||||
const res = castSpell(m, 'entangle');
|
||||
check('casting entangle keeps the caster in control', m.turn === 0);
|
||||
check('opponent marked stunned (pending)', m.players[1].status.stunned === true);
|
||||
check('no skipTurn emitted on the cast', !res.events.some((e) => e.type === 'skipTurn'));
|
||||
stubRng(m, ['yellow', 'blue']);
|
||||
const swap = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
|
||||
check('turn-ending swap is legal', swap.legal);
|
||||
check('stun returns the turn to the caster', m.turn === 0);
|
||||
check('stun flag cleared after the skip', m.players[1].status.stunned === false);
|
||||
check('skipTurn event emitted', res.events.some((e) => e.type === 'skipTurn'));
|
||||
check('skipTurn event emitted on turn end', swap.events.some((e) => e.type === 'skipTurn'));
|
||||
}
|
||||
{
|
||||
// Transform-all + spell-triggered cascade credits caster, never grants
|
||||
|
|
@ -353,7 +375,7 @@ console.log('Spell fixtures:');
|
|||
check('transmute converts exactly the 2 yellows', tf && tf.cells.length === 2);
|
||||
check('transform event board has no yellow', tf && countCells(tf.board, (x) => x.type === 'yellow') === 0);
|
||||
check('spell cascade credits caster 4 blue', m.players[0].mana.blue === 4, `got ${m.players[0].mana.blue}`);
|
||||
check('spell 4-run does NOT grant extra turn', m.turn === 1);
|
||||
check('spell keeps the turn (cascade 4-run grants no extra turn)', m.turn === 0);
|
||||
}
|
||||
{
|
||||
// Harmless skull destruction deals no damage.
|
||||
|
|
@ -468,7 +490,9 @@ function playGame({ skills, classes, seed, hp = [50, 50], invariants = false })
|
|||
createAI({ skill: skills[1], seed: seed * 13 + 5 }),
|
||||
];
|
||||
let turns = 0;
|
||||
const MAX_TURNS = 300;
|
||||
// Spells no longer end the turn, so a turn can span several actions — raise
|
||||
// the per-game action cap accordingly.
|
||||
const MAX_TURNS = 600;
|
||||
while (!match.over && turns < MAX_TURNS) {
|
||||
const pIdx = match.turn;
|
||||
const action = chooseAction(ais[pIdx], match, pIdx);
|
||||
|
|
|
|||
Loading…
Reference in New Issue