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
This commit is contained in:
parent
f5b0c545f4
commit
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;
|
||||
}
|
||||
|
|
@ -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.');
|
||||
Loading…
Reference in New Issue