Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 2b224ff62b feat(jewelquest): spell chaining and combat animations
- Allow spell chaining within a turn (Puzzle Quest rule): casting a spell
  no longer ends your turn, enabling multiple casts per turn followed by a
  gem swap to pass play.
- Add procedural spell-icon textures (shield, swords, bolt, flame, meteor,
  thorn, poison, dagger, banner, leaf) for visual spell feedback.
- Implement combat animations: skull-match convergence strike, spell damage
  fly-in, spell heal burst with particle ring, HP bar flash, and floating
  damage/heal numbers.
- Refactor HP and mana updates into separate `updateHpBar` and `updateManaBar`
  methods so mana reacts immediately while HP drains at animation climax.
- Defer stun application until the caster's turn actually ends.
- Update tutorial and verification tests to match the new turn-passing rules.
2026-06-14 05:18:45 -06:00
Brian Fertig 59ee3eca0c feat: add Dot Link puzzle game
Introduce Dot Link, a Flow-Free-style single-player puzzle game where players connect matching colored dots to fill the grid. Includes:
- Pure JS puzzle engine with deterministic board generation, DFS solver, and win validation (`DotLinkLogic.js`)
- Phaser 3 UI scene featuring a cyberpunk/synthwave theme, level select, daily runs, hint system, and smooth path-drawing mechanics
- Deterministic bank of 100 hand-tuned puzzles (`dotlink.json`)
- Node scripts for puzzle generation and verification
- Full integration into the app's preload, routing, and game registry
2026-06-14 05:18:03 -06:00
16 changed files with 41724 additions and 56 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

39833
public/data/dotlink.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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]; }

View File

@ -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;
}

View File

@ -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.',
},

View File

@ -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;
}
}

View File

@ -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 };
}

View File

@ -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).

View File

@ -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,
],
};

View File

@ -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,

View File

@ -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');

View File

@ -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 });

View File

@ -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);

View File

@ -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.');

View File

@ -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);