Merge pull request 'Add Tents & Trees logic puzzle game' (#1) from Tents into main
Reviewed-on: #1
This commit is contained in:
commit
9363fc3848
Binary file not shown.
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 339 KiB |
|
|
@ -122,3 +122,4 @@ registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console
|
||||||
registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 });
|
registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 });
|
||||||
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
|
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
|
||||||
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
|
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
|
||||||
|
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,937 @@
|
||||||
|
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 { getGameSoundtrack } from '../../services/soundtrack.js';
|
||||||
|
import { SFX, playSoundEx } from '../../ui/Sounds.js';
|
||||||
|
import { api } from '../../services/api.js';
|
||||||
|
import {
|
||||||
|
DIFFICULTIES, DIFFICULTY_ORDER,
|
||||||
|
keyOf, newGame, toggleTent, clearTents, tentCount,
|
||||||
|
isSolved, solutionTents, generatePuzzle,
|
||||||
|
} from './TentsLogic.js';
|
||||||
|
|
||||||
|
const FONT_D = 'Righteous';
|
||||||
|
const FONT_B = '"Julius Sans One"';
|
||||||
|
|
||||||
|
// ── Color helpers (day → night lerps drive the whole scene) ─────────────────
|
||||||
|
|
||||||
|
const lerp = (a, b, t) => a + (b - a) * t;
|
||||||
|
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||||
|
|
||||||
|
function mix(c1, c2, t) {
|
||||||
|
const r = Math.round(lerp(c1 >> 16, c2 >> 16, t));
|
||||||
|
const g = Math.round(lerp((c1 >> 8) & 0xff, (c2 >> 8) & 0xff, t));
|
||||||
|
const b = Math.round(lerp(c1 & 0xff, c2 & 0xff, t));
|
||||||
|
return (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PALETTE = {
|
||||||
|
skyTop: [0x5fa8dc, 0x241a52],
|
||||||
|
skyBottom: [0xbfe3f4, 0x74479c],
|
||||||
|
sun: [0xfff0b0, 0xff8a4a],
|
||||||
|
hillFar: [0x3e7d55, 0x14102c],
|
||||||
|
hillNear: [0x2f6544, 0x0e0c22],
|
||||||
|
meadow: [0x69b54f, 0x223356],
|
||||||
|
tileA: [0xd8e0c0, 0x33406b],
|
||||||
|
tileB: [0xc3cdb0, 0x2a3558],
|
||||||
|
tileLine: [0x9aa884, 0x1d2743],
|
||||||
|
trunk: [0x7a4a28, 0x3a2448],
|
||||||
|
treeA: [0x2c6b3a, 0x1a2f5e],
|
||||||
|
treeB: [0x3d8a4c, 0x22386b],
|
||||||
|
treeC: [0x54a862, 0x2c4a80],
|
||||||
|
tentL: [0xf2e2b8, 0x8a92d8],
|
||||||
|
tentR: [0xd9c393, 0x6a72b4],
|
||||||
|
tentDoor: [0x8a5a34, 0x3a3f78],
|
||||||
|
};
|
||||||
|
|
||||||
|
const col = (name, t) => mix(PALETTE[name][0], PALETTE[name][1], t);
|
||||||
|
|
||||||
|
function easeOutBack(p) {
|
||||||
|
const c1 = 1.70158, c3 = c1 + 1;
|
||||||
|
return 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tri(g, x1, y1, x2, y2, x3, y3) {
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(x1, y1);
|
||||||
|
g.lineTo(x2, y2);
|
||||||
|
g.lineTo(x3, y3);
|
||||||
|
g.closePath();
|
||||||
|
g.fillPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scene ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default class TentsGame extends Phaser.Scene {
|
||||||
|
constructor() {
|
||||||
|
super('TentsGame');
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.gameDef = data?.game ?? data?.gameDef ?? { slug: 'tents' };
|
||||||
|
this.view = 'select';
|
||||||
|
this.dirty = true;
|
||||||
|
this.sky = { t: 0 }; // 0 = midday, 1 = starry night
|
||||||
|
this.won = false;
|
||||||
|
this.winPanelShown = false;
|
||||||
|
this.difficulty = 'porch';
|
||||||
|
this.hover = null; // {c, r}
|
||||||
|
this.embers = [];
|
||||||
|
this.lastEmberAt = 0;
|
||||||
|
this.pop = new Map(); // tent key → time placed (pop animation)
|
||||||
|
this.wiggle = new Map(); // tree key → time wiggled
|
||||||
|
this.flash = { until: 0, keys: new Set() };
|
||||||
|
this.fireflies = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
try {
|
||||||
|
const { tracks, volume } = getGameSoundtrack(this);
|
||||||
|
if (tracks.length) new MusicPlayer(this, tracks, volume);
|
||||||
|
} catch (_) { /* no soundtrack available */ }
|
||||||
|
|
||||||
|
this.input.mouse?.disableContextMenu?.();
|
||||||
|
|
||||||
|
this.horizonY = 560;
|
||||||
|
this.seedScenery();
|
||||||
|
|
||||||
|
// Draw layers (depth: background < board < glow fx < HUD).
|
||||||
|
this.bgGfx = this.add.graphics().setDepth(0);
|
||||||
|
this.boardGfx = this.add.graphics().setDepth(10);
|
||||||
|
this.fxGfx = this.add.graphics().setDepth(20).setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
|
||||||
|
this.colLabels = [];
|
||||||
|
this.rowLabels = [];
|
||||||
|
this.cellZones = [];
|
||||||
|
|
||||||
|
this.buildHud();
|
||||||
|
this.hudLayer.setVisible(false);
|
||||||
|
this.buildSelectScreen();
|
||||||
|
|
||||||
|
this.events.on('shutdown', () => {
|
||||||
|
this.embers.length = 0;
|
||||||
|
this.pop.clear();
|
||||||
|
this.wiggle.clear();
|
||||||
|
this.fireflies.length = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenery (seeded once per scene instance) ──────────────────────────────
|
||||||
|
|
||||||
|
seedScenery() {
|
||||||
|
const W = GAME_WIDTH;
|
||||||
|
|
||||||
|
this.stars = [];
|
||||||
|
for (let i = 0; i < 110; i++) {
|
||||||
|
this.stars.push({
|
||||||
|
x: Math.random() * W,
|
||||||
|
y: Math.random() * (this.horizonY - 40),
|
||||||
|
s: Math.random() < 0.12 ? 2 + Math.random() * 1.5 : 0.8 + Math.random() * 1.1,
|
||||||
|
sp: 0.6 + Math.random() * 1.6,
|
||||||
|
ph: Math.random() * Math.PI * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clouds = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
this.clouds.push({
|
||||||
|
x: Math.random() * W,
|
||||||
|
y: 60 + Math.random() * 220,
|
||||||
|
w: 140 + Math.random() * 160,
|
||||||
|
v: 4 + Math.random() * 7,
|
||||||
|
puffs: Array.from({ length: 4 }, () => ({
|
||||||
|
dx: (Math.random() - 0.5) * 0.9,
|
||||||
|
dy: (Math.random() - 0.5) * 0.5,
|
||||||
|
r: 0.28 + Math.random() * 0.22,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hillFar = [];
|
||||||
|
for (let x = -60; x < W + 60; x += 40 + Math.random() * 30) {
|
||||||
|
this.hillFar.push({ x, w: 44 + Math.random() * 34, h: 24 + Math.random() * 34 });
|
||||||
|
}
|
||||||
|
this.hillNear = [];
|
||||||
|
for (let x = -80; x < W + 80; x += 80 + Math.random() * 60) {
|
||||||
|
this.hillNear.push({ x, w: 80 + Math.random() * 50, h: 46 + Math.random() * 46 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLOWER_COLORS = [0xffd166, 0xef6f6c, 0xffffff, 0xb795ff];
|
||||||
|
this.flowers = [];
|
||||||
|
for (let i = 0; i < 70; i++) {
|
||||||
|
this.flowers.push({
|
||||||
|
x: Math.random() * W,
|
||||||
|
y: this.horizonY + 24 + Math.random() * (GAME_HEIGHT - this.horizonY - 40),
|
||||||
|
r: 1.6 + Math.random() * 2.2,
|
||||||
|
c: FLOWER_COLORS[(Math.random() * FLOWER_COLORS.length) | 0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
computeLayout() {
|
||||||
|
const s = this.g.puzzle.size;
|
||||||
|
const left = 330, right = GAME_WIDTH - 130;
|
||||||
|
const top = this.horizonY - 84, bottom = GAME_HEIGHT - 58;
|
||||||
|
const cell = Math.min((right - left) / s, (bottom - top) / s, 128);
|
||||||
|
const bw = cell * s, bh = cell * s;
|
||||||
|
this.cell = cell;
|
||||||
|
this.bx = left + (right - left - bw) / 2;
|
||||||
|
this.by = top + (bottom - top - bh) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
cellCenter(c, r) {
|
||||||
|
return [this.bx + c * this.cell + this.cell / 2, this.by + r * this.cell + this.cell / 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HUD (play screen) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildHud() {
|
||||||
|
this.hudLayer = this.add.container(0, 0).setDepth(30);
|
||||||
|
|
||||||
|
const title = this.makeText('TENTS & TREES', 42, 84, FONT_D, 46, COLORS.goldHex);
|
||||||
|
const tag = this.makeText('pitch every tent, then watch the stars come out', 42, 126, FONT_B, 20, '#cfe3f2');
|
||||||
|
this.diffText = this.add.text(42, 168, '', {
|
||||||
|
fontFamily: FONT_B, fontSize: '24px', color: '#ffd76a',
|
||||||
|
}).setDepth(30);
|
||||||
|
|
||||||
|
this.statTents = this.makeText('tents 0 / 0', 42, 430, FONT_B, 22, '#fff3d9');
|
||||||
|
this.statTimer = this.makeText('time 0:00', 42, 464, FONT_B, 22, '#fff3d9');
|
||||||
|
|
||||||
|
this.hudLayer.add([title, tag, this.diffText, this.statTents, this.statTimer]);
|
||||||
|
|
||||||
|
const mk = (label, y, cb, variant) =>
|
||||||
|
new Button(this, 150, y, label, cb, { width: 230, height: 58, variant }).setDepth(31);
|
||||||
|
this.btnNew = mk('New Game', 540, () => this.startGame(this.difficulty), 'solid');
|
||||||
|
this.btnAnswer = mk('Answer', 620, () => this.revealAnswer(), 'ghost');
|
||||||
|
this.btnReset = mk('Reset Tents', 700, () => this.resetTents(), 'ghost');
|
||||||
|
this.btnMenu = mk('Menu', 780, () => this.scene.start('GameMenu'), 'ghost');
|
||||||
|
|
||||||
|
this.hudLayer.add([this.btnNew, this.btnAnswer, this.btnReset, this.btnMenu]);
|
||||||
|
}
|
||||||
|
|
||||||
|
makeText(str, x, y, font, size, color) {
|
||||||
|
return this.add.text(x, y, str, {
|
||||||
|
fontFamily: font, fontSize: `${size}px`, color,
|
||||||
|
stroke: '#3a2a14', strokeThickness: 2,
|
||||||
|
}).setOrigin(0, 0).setDepth(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Select screen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildSelectScreen() {
|
||||||
|
this.selectLayer = this.add.container(0, 0).setDepth(30);
|
||||||
|
|
||||||
|
const title = this.add.text(GAME_WIDTH / 2, 150, 'TENTS & TREES', {
|
||||||
|
fontFamily: FONT_D, fontSize: '84px', color: '#ffd76a',
|
||||||
|
stroke: '#4a3416', strokeThickness: 10,
|
||||||
|
}).setOrigin(0.5).setDepth(31);
|
||||||
|
|
||||||
|
const sub = this.add.text(GAME_WIDTH / 2, 224, 'A cozy logic puzzle — one tent for every tree, none may touch.', {
|
||||||
|
fontFamily: FONT_B, fontSize: '26px', color: '#fdf6e3',
|
||||||
|
}).setOrigin(0.5).setDepth(31);
|
||||||
|
|
||||||
|
const rules = this.add.text(GAME_WIDTH / 2, 268, 'The numbers along the top and side are exact tent counts for that column and row.', {
|
||||||
|
fontFamily: FONT_B, fontSize: '22px', color: '#cfe3f2',
|
||||||
|
}).setOrigin(0.5).setDepth(31);
|
||||||
|
|
||||||
|
this.selectLayer.add([title, sub, rules]);
|
||||||
|
|
||||||
|
const cardW = 330, cardH = 250, gap = 36;
|
||||||
|
const totalW = cardW * 4 + gap * 3;
|
||||||
|
let cx = (GAME_WIDTH - totalW) / 2;
|
||||||
|
for (const key of DIFFICULTY_ORDER) {
|
||||||
|
const def = DIFFICULTIES[key];
|
||||||
|
const card = this.add.rectangle(cx + cardW / 2, 460, cardW, cardH, 0x0d0b1a, 0.55)
|
||||||
|
.setStrokeStyle(2, 0xd9a441, 0.7).setDepth(31);
|
||||||
|
const name = this.add.text(cx + cardW / 2, 402, def.label, {
|
||||||
|
fontFamily: FONT_D, fontSize: '34px', color: '#ffd76a',
|
||||||
|
}).setOrigin(0.5).setDepth(32);
|
||||||
|
const meta = this.add.text(cx + cardW / 2, 452, `${def.size}×${def.size} · ${def.trees} trees`, {
|
||||||
|
fontFamily: FONT_B, fontSize: '24px', color: '#f2ead8',
|
||||||
|
}).setOrigin(0.5).setDepth(32);
|
||||||
|
const flavor = this.add.text(cx + cardW / 2, 508, def.blurb, {
|
||||||
|
fontFamily: FONT_B, fontSize: '20px', color: '#b9cfe0',
|
||||||
|
wordWrap: { width: cardW - 60 }, align: 'center',
|
||||||
|
}).setOrigin(0.5).setDepth(32);
|
||||||
|
const go = this.add.text(cx + cardW / 2, 568, '▸ play', {
|
||||||
|
fontFamily: FONT_D, fontSize: '26px', color: '#9fd8a4',
|
||||||
|
}).setOrigin(0.5).setDepth(32);
|
||||||
|
|
||||||
|
const hit = this.add.zone(cx + cardW / 2, 460, cardW, cardH).setInteractive();
|
||||||
|
hit.on('pointerover', () => {
|
||||||
|
card.setFillStyle(0x1a1530, 0.75);
|
||||||
|
this.tweens.add({ targets: [name, meta, flavor, go], scale: 1.04, duration: 130, ease: 'Quad.out' });
|
||||||
|
playSoundEx(this, SFX.UI_PICK, { volume: 0.5 });
|
||||||
|
});
|
||||||
|
hit.on('pointerout', () => {
|
||||||
|
card.setFillStyle(0x0d0b1a, 0.55);
|
||||||
|
this.tweens.add({ targets: [name, meta, flavor, go], scale: 1, duration: 130 });
|
||||||
|
});
|
||||||
|
hit.on('pointerup', () => {
|
||||||
|
playSoundEx(this, SFX.UI_ACTIVATE, { volume: 0.7 });
|
||||||
|
this.startGame(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.selectLayer.add([card, name, meta, flavor, go, hit]);
|
||||||
|
cx += cardW + gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const back = new Button(this, GAME_WIDTH / 2, 760, 'Back', () => this.scene.start('GameMenu'),
|
||||||
|
{ width: 220, height: 56, variant: 'ghost' }).setDepth(31);
|
||||||
|
this.selectLayer.add(back);
|
||||||
|
|
||||||
|
// A little campfire decor on the menu meadow.
|
||||||
|
this.menuCamp = { x: GAME_WIDTH / 2, y: this.horizonY + 120 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Game flow ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
startGame(difficultyKey) {
|
||||||
|
let puzzle = null;
|
||||||
|
try {
|
||||||
|
puzzle = generatePuzzle(difficultyKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tents generation failed', err);
|
||||||
|
}
|
||||||
|
if (!puzzle) {
|
||||||
|
this.toast('Hmm, the forest is in a mood. Try again.', 940);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.difficulty = difficultyKey;
|
||||||
|
this.g = newGame(puzzle);
|
||||||
|
this.g.startedAt = null;
|
||||||
|
this.won = false;
|
||||||
|
this.winPanelShown = false;
|
||||||
|
this.embers.length = 0;
|
||||||
|
this.pop.clear();
|
||||||
|
this.wiggle.clear();
|
||||||
|
this.fireflies.length = 0;
|
||||||
|
this.flash.until = 0;
|
||||||
|
this.flash.keys.clear();
|
||||||
|
this.hover = null;
|
||||||
|
|
||||||
|
// Back to day (in case we're restarting after a solved night).
|
||||||
|
this.tweens.killTweensOf(this.sky);
|
||||||
|
this.sky.t = 0;
|
||||||
|
|
||||||
|
const def = DIFFICULTIES[difficultyKey] ?? DIFFICULTIES.porch;
|
||||||
|
this.view = 'play';
|
||||||
|
this.selectLayer.setVisible(false);
|
||||||
|
this.winPanel?.setVisible(false);
|
||||||
|
this.hudLayer.setVisible(true);
|
||||||
|
this.btnNew.setEnabled(true);
|
||||||
|
this.btnAnswer.setEnabled(true);
|
||||||
|
this.btnReset.setEnabled(true);
|
||||||
|
this.diffText.setText(def.label);
|
||||||
|
|
||||||
|
this.computeLayout();
|
||||||
|
this.buildBoard();
|
||||||
|
this.updateHud();
|
||||||
|
this.dirty = true;
|
||||||
|
this.toast(`${def.label} — ${def.size}×${def.size}, ${puzzle.trees.length} trees. Pitch each tent beside a tree.`, 950);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildBoard() {
|
||||||
|
for (const z of this.cellZones) z.destroy();
|
||||||
|
this.cellZones = [];
|
||||||
|
for (const t of this.colLabels) t.destroy();
|
||||||
|
for (const t of this.rowLabels) t.destroy();
|
||||||
|
this.colLabels = [];
|
||||||
|
this.rowLabels = [];
|
||||||
|
|
||||||
|
const p = this.g.puzzle;
|
||||||
|
const cell = this.cell;
|
||||||
|
const s = p.size;
|
||||||
|
const fs = Math.max(18, Math.round(cell * 0.46));
|
||||||
|
|
||||||
|
// Edge number labels (top = columns, right = rows).
|
||||||
|
for (let c = 0; c < s; c++) {
|
||||||
|
this.colLabels.push(this.add.text(this.bx + c * cell + cell / 2, this.by - 44, String(p.colCounts[c]), {
|
||||||
|
fontFamily: FONT_D, fontSize: `${fs}px`, color: '#fff3d9',
|
||||||
|
stroke: '#2a1f10', strokeThickness: 4,
|
||||||
|
}).setOrigin(0.5).setDepth(30));
|
||||||
|
}
|
||||||
|
for (let r = 0; r < s; r++) {
|
||||||
|
this.rowLabels.push(this.add.text(this.bx + s * cell + 46, this.by + r * cell + cell / 2, String(p.rowCounts[r]), {
|
||||||
|
fontFamily: FONT_D, fontSize: `${fs}px`, color: '#fff3d9',
|
||||||
|
stroke: '#2a1f10', strokeThickness: 4,
|
||||||
|
}).setOrigin(0.5).setDepth(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive cell zones.
|
||||||
|
for (let r = 0; r < s; r++) {
|
||||||
|
for (let c = 0; c < s; c++) {
|
||||||
|
const [x, y] = this.cellCenter(c, r);
|
||||||
|
const zone = this.add.zone(x, y, cell, cell).setInteractive().setDepth(25);
|
||||||
|
zone.on('pointerover', () => { this.hover = { c, r }; this.dirty = true; });
|
||||||
|
zone.on('pointerout', () => {
|
||||||
|
if (this.hover && this.hover.c === c && this.hover.r === r) this.hover = null;
|
||||||
|
this.dirty = true;
|
||||||
|
});
|
||||||
|
zone.on('pointerup', () => this.onCellClick(c, r));
|
||||||
|
this.cellZones.push(zone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCellClick(c, r) {
|
||||||
|
if (this.won) return;
|
||||||
|
const res = toggleTent(this.g, c, r);
|
||||||
|
this.dirty = true;
|
||||||
|
|
||||||
|
if (!res.changed) {
|
||||||
|
this.wiggle.set(keyOf(c, r), this.time.now);
|
||||||
|
playSoundEx(this, SFX.UI_FLIP, { volume: 0.5 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.g.startedAt) this.g.startedAt = Date.now();
|
||||||
|
|
||||||
|
if (res.placed) {
|
||||||
|
this.pop.set(keyOf(c, r), this.time.now);
|
||||||
|
playSoundEx(this, SFX.UI_ACTIVATE, { volume: 0.8 });
|
||||||
|
this.checkViolationFeedback();
|
||||||
|
} else {
|
||||||
|
this.pop.delete(keyOf(c, r));
|
||||||
|
playSoundEx(this, SFX.UI_PICK, { volume: 0.7 });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateHud();
|
||||||
|
if (isSolved(this.g)) this.startWin();
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnoseLite() {
|
||||||
|
const p = this.g.puzzle;
|
||||||
|
const badRows = new Set(), badCols = new Set();
|
||||||
|
const rowUsed = new Array(p.size).fill(0), colUsed = new Array(p.size).fill(0);
|
||||||
|
for (const k of this.g.tents) { rowUsed[(k / 100) | 0]++; colUsed[k % 100]++; }
|
||||||
|
for (let r = 0; r < p.size; r++) if (rowUsed[r] > p.rowCounts[r]) badRows.add(r);
|
||||||
|
for (let c = 0; c < p.size; c++) if (colUsed[c] > p.colCounts[c]) badCols.add(c);
|
||||||
|
return { badRows, badCols, rowUsed, colUsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
checkViolationFeedback() {
|
||||||
|
const d = this.diagnoseLite();
|
||||||
|
if (d.badRows.size === 0 && d.badCols.size === 0) return;
|
||||||
|
this.flash.until = this.time.now + 380;
|
||||||
|
this.flash.keys.clear();
|
||||||
|
for (const k of this.g.tents) {
|
||||||
|
if (d.badRows.has((k / 100) | 0) || d.badCols.has(k % 100)) this.flash.keys.add(k);
|
||||||
|
}
|
||||||
|
playSoundEx(this, SFX.MASTERMIND_DENIED, { volume: 0.5 });
|
||||||
|
const cam = this.cameras.main;
|
||||||
|
this.tweens.add({
|
||||||
|
targets: cam, x: 5, duration: 55, yoyo: true, repeat: 2,
|
||||||
|
onComplete: () => cam.setScroll(0, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTents() {
|
||||||
|
if (this.won) return;
|
||||||
|
clearTents(this.g);
|
||||||
|
this.pop.clear();
|
||||||
|
this.flash.until = 0;
|
||||||
|
this.dirty = true;
|
||||||
|
this.updateHud();
|
||||||
|
playSoundEx(this, SFX.UI_PICK, { volume: 0.6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
revealAnswer() {
|
||||||
|
if (this.won) return;
|
||||||
|
const sol = new Set(solutionTents(this.g).map(([c, r]) => keyOf(c, r)));
|
||||||
|
for (const k of [...this.g.tents]) if (!sol.has(k)) { this.g.tents.delete(k); this.pop.delete(k); }
|
||||||
|
this.dirty = true;
|
||||||
|
const missing = [...sol].filter((k) => !this.g.tents.has(k));
|
||||||
|
missing.forEach((k, i) => this.time.delayedCall(75 * i, () => {
|
||||||
|
if (this.g.tents.has(k) || this.won) return;
|
||||||
|
this.g.tents.add(k);
|
||||||
|
this.pop.set(k, this.time.now);
|
||||||
|
this.dirty = true;
|
||||||
|
playSoundEx(this, SFX.UI_ACTIVATE, { volume: 0.55 });
|
||||||
|
}));
|
||||||
|
this.time.delayedCall(75 * missing.length + 180, () => {
|
||||||
|
this.updateHud();
|
||||||
|
if (isSolved(this.g)) this.startWin();
|
||||||
|
});
|
||||||
|
this.btnAnswer.setEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
startWin() {
|
||||||
|
if (this.won) return;
|
||||||
|
this.won = true;
|
||||||
|
playSoundEx(this, SFX.VICTORY_SHORT, { volume: 0.9 });
|
||||||
|
this.toast('Every tent is pitched… the sun is going down.', 950);
|
||||||
|
|
||||||
|
// Nightfall: ~6.5s, then the cozy panel.
|
||||||
|
this.tweens.add({ targets: this.sky, t: 1, duration: 6500, ease: 'Sine.inOut' });
|
||||||
|
this.time.delayedCall(4700, () => this.seedFireflies());
|
||||||
|
this.time.delayedCall(6700, () => this.showWinPanel());
|
||||||
|
|
||||||
|
this.btnNew.setEnabled(true);
|
||||||
|
this.btnAnswer.setEnabled(false);
|
||||||
|
this.btnReset.setEnabled(false);
|
||||||
|
|
||||||
|
const seconds = this.g.startedAt
|
||||||
|
? Math.max(1, Math.round((Date.now() - this.g.startedAt) / 1000))
|
||||||
|
: null;
|
||||||
|
api.post('/history/single-player', {
|
||||||
|
slug: 'tents',
|
||||||
|
score: seconds ?? 0,
|
||||||
|
opponentScores: [],
|
||||||
|
result: 'win',
|
||||||
|
}).catch((err) => console.warn('Tents: history post failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
seedFireflies() {
|
||||||
|
if (!this.g) return;
|
||||||
|
const s = this.g.puzzle.size;
|
||||||
|
const minX = this.bx - 70, maxX = this.bx + s * this.cell + 70;
|
||||||
|
const minY = this.by - 70, maxY = this.by + s * this.cell + 90;
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
this.fireflies.push({
|
||||||
|
x: minX + Math.random() * (maxX - minX),
|
||||||
|
y: minY + Math.random() * (maxY - minY),
|
||||||
|
ax: 12 + Math.random() * 26,
|
||||||
|
ay: 8 + Math.random() * 18,
|
||||||
|
sp: 0.3 + Math.random() * 0.7,
|
||||||
|
ph: Math.random() * Math.PI * 2,
|
||||||
|
blink: 1.5 + Math.random() * 2.5,
|
||||||
|
r: 1.6 + Math.random() * 1.4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showWinPanel() {
|
||||||
|
if (this.winPanelShown) return;
|
||||||
|
this.winPanelShown = true;
|
||||||
|
|
||||||
|
const cx = GAME_WIDTH / 2, cy = 245;
|
||||||
|
const w = 680, h = 320;
|
||||||
|
const layer = this.add.container(0, 0).setDepth(40);
|
||||||
|
|
||||||
|
const panel = this.add.rectangle(cx, cy, w, h, 0x101024, 0.92)
|
||||||
|
.setStrokeStyle(3, 0xffc46a, 0.9);
|
||||||
|
const stars = this.add.text(cx, cy - h / 2 + 62, '✶ ✶ ✶', {
|
||||||
|
fontFamily: FONT_B, fontSize: '30px', color: '#ffd76a',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
const title = this.add.text(cx, cy - 40, 'Camp Complete!', {
|
||||||
|
fontFamily: FONT_D, fontSize: '58px', color: '#ffd76a',
|
||||||
|
stroke: '#3a2a14', strokeThickness: 8,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
const sub = this.add.text(cx, cy + 24, 'The fire crackles, the stars are out — a perfect night.', {
|
||||||
|
fontFamily: FONT_B, fontSize: '22px', color: '#e8f0ff',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
const newBtn = new Button(this, cx - 130, cy + 104, 'New Game',
|
||||||
|
() => this.startGame(this.difficulty), { width: 220, height: 58, variant: 'solid' });
|
||||||
|
const menuBtn = new Button(this, cx + 130, cy + 104, 'Menu',
|
||||||
|
() => this.scene.start('GameMenu'), { width: 160, height: 58, variant: 'ghost' });
|
||||||
|
|
||||||
|
layer.add([panel, stars, title, sub, newBtn, menuBtn]);
|
||||||
|
layer.setAlpha(0);
|
||||||
|
this.winPanel = layer;
|
||||||
|
this.tweens.add({ targets: layer, alpha: 1, duration: 420, ease: 'Quad.out' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHud() {
|
||||||
|
if (!this.g) return;
|
||||||
|
const p = this.g.puzzle;
|
||||||
|
const { rowUsed, colUsed } = this.diagnoseLite();
|
||||||
|
this.statTents.setText(`tents ${tentCount(this.g)} / ${p.trees.length}`);
|
||||||
|
if (this.g.startedAt) {
|
||||||
|
const s = Math.max(0, Math.floor((Date.now() - this.g.startedAt) / 1000));
|
||||||
|
this.statTimer.setText(`time ${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
for (let c = 0; c < p.size; c++) {
|
||||||
|
const done = colUsed[c] === p.colCounts[c] && p.colCounts[c] > 0;
|
||||||
|
const over = colUsed[c] > p.colCounts[c];
|
||||||
|
this.colLabels[c].setColor(over ? '#ff6b6b' : done ? '#ffd76a' : '#fff3d9');
|
||||||
|
}
|
||||||
|
for (let r = 0; r < p.size; r++) {
|
||||||
|
const done = rowUsed[r] === p.rowCounts[r] && p.rowCounts[r] > 0;
|
||||||
|
const over = rowUsed[r] > p.rowCounts[r];
|
||||||
|
this.rowLabels[r].setColor(over ? '#ff6b6b' : done ? '#ffd76a' : '#fff3d9');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast(msg, y) {
|
||||||
|
const t = this.add.text(GAME_WIDTH / 2, y, msg, {
|
||||||
|
fontFamily: FONT_B, fontSize: '24px', color: '#fff6dd',
|
||||||
|
stroke: '#241a10', strokeThickness: 5, align: 'center',
|
||||||
|
}).setOrigin(0.5).setDepth(45).setAlpha(0);
|
||||||
|
this.tweens.add({ targets: t, alpha: 1, duration: 260 });
|
||||||
|
this.tweens.add({
|
||||||
|
targets: t, alpha: 0, y: y - 26, delay: 3200, duration: 700,
|
||||||
|
onComplete: () => t.destroy(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
update(time, delta) {
|
||||||
|
if (this.view !== 'play' && this.view !== 'select') return;
|
||||||
|
const animating = this.sky.t > 0.001 || this.view === 'select';
|
||||||
|
if (!this.dirty && !animating) return;
|
||||||
|
|
||||||
|
this.drawSky(time, delta);
|
||||||
|
if (this.view === 'play' && this.g) this.drawBoard(time);
|
||||||
|
this.drawFx(time, delta);
|
||||||
|
this.dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawSky(now, delta) {
|
||||||
|
const g = this.bgGfx;
|
||||||
|
g.clear();
|
||||||
|
const W = GAME_WIDTH, H = GAME_HEIGHT, t = this.sky.t;
|
||||||
|
|
||||||
|
// Sky: vertical gradient via strips (base color), blended toward the
|
||||||
|
// horizon glow color in the lower half.
|
||||||
|
const steps = 24;
|
||||||
|
const stripH = H / steps + 1;
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
const y0 = (H * i) / steps;
|
||||||
|
g.fillStyle(col('skyTop', t), 1);
|
||||||
|
g.fillRect(0, y0, W, stripH);
|
||||||
|
const weight = clamp(1 - (this.horizonY - y0) / 340, 0, 1);
|
||||||
|
if (weight > 0) {
|
||||||
|
g.fillStyle(col('skyBottom', t), weight * 0.85);
|
||||||
|
g.fillRect(0, y0, W, stripH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stars (night).
|
||||||
|
for (const st of this.stars) {
|
||||||
|
if (st.y > this.horizonY - 6) continue;
|
||||||
|
const tw = 0.5 + 0.5 * Math.sin((now / 1000) * st.sp + st.ph);
|
||||||
|
const a = t * (0.25 + 0.75 * tw);
|
||||||
|
if (a < 0.02) continue;
|
||||||
|
g.fillStyle(0xdfe6ff, a);
|
||||||
|
g.fillCircle(st.x, st.y, st.s);
|
||||||
|
if (st.s > 2) {
|
||||||
|
g.lineStyle(1, 0xdfe6ff, a * 0.8);
|
||||||
|
g.lineBetween(st.x - st.s * 2.4, st.y, st.x + st.s * 2.4, st.y);
|
||||||
|
g.lineBetween(st.x, st.y - st.s * 2.4, st.x, st.y + st.s * 2.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sun (sinks to the horizon and fades in the last stretch).
|
||||||
|
const sunA = t > 0.72 ? Math.max(0, 1 - (t - 0.72) / 0.28) : 1;
|
||||||
|
if (sunA > 0.01) {
|
||||||
|
const sx = lerp(W * 0.76, W * 0.9, t);
|
||||||
|
const sy = lerp(170, this.horizonY - 4, t);
|
||||||
|
g.fillStyle(col('sun', t), 0.16 * sunA); g.fillCircle(sx, sy, 78);
|
||||||
|
g.fillStyle(col('sun', t), 0.28 * sunA); g.fillCircle(sx, sy, 52);
|
||||||
|
g.fillStyle(col('sun', t), sunA); g.fillCircle(sx, sy, 34);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moon (rises as night falls).
|
||||||
|
const mA = clamp((t - 0.35) / 0.65, 0, 1);
|
||||||
|
if (mA > 0.01) {
|
||||||
|
const mx = W * 0.15, my = lerp(this.horizonY + 60, 168, mA);
|
||||||
|
g.fillStyle(0xeef1ff, mA * 0.35); g.fillCircle(mx, my, 48);
|
||||||
|
g.fillStyle(0xeef1ff, mA); g.fillCircle(mx, my, 30);
|
||||||
|
g.fillStyle(col('skyTop', t), mA); g.fillCircle(mx + 13, my - 9, 26); // crescent bite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clouds (day; fade out at night).
|
||||||
|
const cloudA = (1 - t) * 0.8;
|
||||||
|
if (cloudA > 0.02) {
|
||||||
|
for (const cl of this.clouds) {
|
||||||
|
cl.x -= cl.v * (delta / 1000);
|
||||||
|
if (cl.x < -cl.w) cl.x = W + cl.w;
|
||||||
|
for (const puff of cl.puffs) {
|
||||||
|
g.fillStyle(0xffffff, cloudA * (0.45 + puff.r));
|
||||||
|
g.fillEllipse(cl.x + puff.dx * cl.w, cl.y + puff.dy * cl.w * 0.5,
|
||||||
|
cl.w * puff.r * 1.6, cl.w * puff.r * 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pine silhouettes on the horizon.
|
||||||
|
for (const [arr, keyName] of [[this.hillFar, 'hillFar'], [this.hillNear, 'hillNear']]) {
|
||||||
|
g.fillStyle(col(keyName, t), 1);
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(-10, this.horizonY + 2);
|
||||||
|
for (const s of arr) {
|
||||||
|
g.lineTo(s.x, this.horizonY + 2);
|
||||||
|
g.lineTo(s.x + s.w / 2, this.horizonY + 2 - s.h);
|
||||||
|
g.lineTo(s.x + s.w, this.horizonY + 2);
|
||||||
|
}
|
||||||
|
g.lineTo(W + 10, this.horizonY + 2);
|
||||||
|
g.closePath();
|
||||||
|
g.fillPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meadow.
|
||||||
|
g.fillStyle(col('meadow', t), 1);
|
||||||
|
g.fillRect(0, this.horizonY, W, H - this.horizonY);
|
||||||
|
for (let x = 0; x < W; x += 200) {
|
||||||
|
g.fillStyle(0xffffff, 0.03 * (1 - t * 0.6));
|
||||||
|
g.fillRect(x, this.horizonY, 100, H - this.horizonY);
|
||||||
|
}
|
||||||
|
for (const f of this.flowers) {
|
||||||
|
g.fillStyle(f.c, (1 - t) * 0.85 + 0.06);
|
||||||
|
g.fillCircle(f.x, f.y, f.r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBoard(now) {
|
||||||
|
const g = this.boardGfx;
|
||||||
|
g.clear();
|
||||||
|
const p = this.g.puzzle;
|
||||||
|
const cell = this.cell;
|
||||||
|
const s = p.size;
|
||||||
|
const t = this.sky.t;
|
||||||
|
|
||||||
|
const treeSet = this.g.treeSet;
|
||||||
|
const tentSet = this.g.tents;
|
||||||
|
const d = this.diagnoseLite();
|
||||||
|
const flashing = now < this.flash.until;
|
||||||
|
|
||||||
|
// Soft drop shadow under the board.
|
||||||
|
g.fillStyle(0x000000, 0.25);
|
||||||
|
g.fillEllipse(this.bx + (s * cell) / 2, this.by + s * cell + 14, s * cell * 1.04, 34);
|
||||||
|
|
||||||
|
// Edge label chips (top = columns, right = rows).
|
||||||
|
const chip = (x, y) => {
|
||||||
|
g.fillStyle(0x141126, 0.78);
|
||||||
|
g.fillRoundedRect(x - 27, y - 24, 54, 48, 10);
|
||||||
|
g.lineStyle(2, 0xd9a441, 0.8);
|
||||||
|
g.strokeRoundedRect(x - 27, y - 24, 54, 48, 10);
|
||||||
|
};
|
||||||
|
for (let c = 0; c < s; c++) chip(this.bx + c * cell + cell / 2, this.by - 44);
|
||||||
|
for (let r = 0; r < s; r++) chip(this.bx + s * cell + 46, this.by + r * cell + cell / 2);
|
||||||
|
|
||||||
|
// Cells.
|
||||||
|
for (let r = 0; r < s; r++) {
|
||||||
|
for (let c = 0; c < s; c++) {
|
||||||
|
const x0 = this.bx + c * cell, y0 = this.by + r * cell;
|
||||||
|
const bad = d.badRows.has(r) || d.badCols.has(c);
|
||||||
|
g.fillStyle((r + c) % 2 === 0 ? col('tileA', t) : col('tileB', t), 1);
|
||||||
|
g.fillRoundedRect(x0 + 2, y0 + 2, cell - 4, cell - 4, 9);
|
||||||
|
g.lineStyle(1.5, col('tileLine', t), 0.85);
|
||||||
|
g.strokeRoundedRect(x0 + 2, y0 + 2, cell - 4, cell - 4, 9);
|
||||||
|
if (bad && flashing) {
|
||||||
|
g.fillStyle(0xff5a5a, 0.2);
|
||||||
|
g.fillRoundedRect(x0 + 2, y0 + 2, cell - 4, cell - 4, 9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover hint (empty cells only).
|
||||||
|
if (this.hover && !this.won) {
|
||||||
|
const { c, r } = this.hover;
|
||||||
|
if (!treeSet.has(keyOf(c, r))) {
|
||||||
|
const x0 = this.bx + c * cell, y0 = this.by + r * cell;
|
||||||
|
g.fillStyle(0xffffff, 0.16);
|
||||||
|
g.fillRoundedRect(x0 + 2, y0 + 2, cell - 4, cell - 4, 9);
|
||||||
|
if (!tentSet.has(keyOf(c, r))) {
|
||||||
|
const [cx, cy] = this.cellCenter(c, r);
|
||||||
|
g.globalAlpha = 0.35;
|
||||||
|
this.drawTent(g, cx, cy, cell, t, 1);
|
||||||
|
g.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trees.
|
||||||
|
for (const [c, r] of p.trees) {
|
||||||
|
const [x, y] = this.cellCenter(c, r);
|
||||||
|
let w = 0;
|
||||||
|
const t0 = this.wiggle.get(keyOf(c, r));
|
||||||
|
if (t0 !== undefined) {
|
||||||
|
const pr = clamp((now - t0) / 450, 0, 1);
|
||||||
|
if (pr < 1) w = Math.sin(pr * Math.PI * 3) * (1 - pr) * 0.16;
|
||||||
|
else this.wiggle.delete(keyOf(c, r));
|
||||||
|
}
|
||||||
|
this.drawTree(g, x, y, cell, t, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tents.
|
||||||
|
for (const k of tentSet) {
|
||||||
|
const c = k % 100, r = (k / 100) | 0;
|
||||||
|
let [x, y] = this.cellCenter(c, r);
|
||||||
|
let scale = 1;
|
||||||
|
const t0 = this.pop.get(k);
|
||||||
|
if (t0 !== undefined) {
|
||||||
|
const pr = clamp((now - t0) / 220, 0, 1);
|
||||||
|
if (pr < 1) scale = 0.4 + 0.6 * easeOutBack(pr);
|
||||||
|
else this.pop.delete(k);
|
||||||
|
}
|
||||||
|
if (flashing && this.flash.keys.has(k)) x += Math.sin(now / 28) * 2.6;
|
||||||
|
this.drawTent(g, x, y, cell, t, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTree(g, x, y, s, t, wiggle) {
|
||||||
|
const u = s * 0.86;
|
||||||
|
const sw = 1 + wiggle;
|
||||||
|
g.fillStyle(col('trunk', t), 1);
|
||||||
|
g.fillRect(x - u * 0.07, y + u * 0.12, u * 0.14, u * 0.3);
|
||||||
|
const tiers = [
|
||||||
|
{ yOff: 0.16, w: 0.52, h: 0.44, key: 'treeA' },
|
||||||
|
{ yOff: -0.1, w: 0.42, h: 0.4, key: 'treeB' },
|
||||||
|
{ yOff: -0.32, w: 0.32, h: 0.36, key: 'treeC' },
|
||||||
|
];
|
||||||
|
for (const L of tiers) {
|
||||||
|
const w = u * L.w * sw, h = u * L.h * (1 + wiggle * 0.6);
|
||||||
|
const baseY = y + u * L.yOff;
|
||||||
|
g.fillStyle(col(L.key, t), 1);
|
||||||
|
tri(g, x, baseY - h, x + w / 2, baseY, x - w / 2, baseY);
|
||||||
|
// A touch of light on the sun side.
|
||||||
|
g.fillStyle(0xffffff, 0.08 + 0.1 * (1 - t));
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(x, baseY - h);
|
||||||
|
g.lineTo(x + w * 0.18, baseY - h * 0.25);
|
||||||
|
g.lineTo(x - w * 0.06, baseY - h * 0.2);
|
||||||
|
g.closePath();
|
||||||
|
g.fillPath();
|
||||||
|
}
|
||||||
|
g.fillStyle(0xffffff, 0.12 * (1 - t));
|
||||||
|
g.fillCircle(x, y - u * 0.62, u * 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTent(g, x, y, s, t, popScale) {
|
||||||
|
const u = s * 0.78 * popScale;
|
||||||
|
const baseY = y + u * 0.42, apexY = y - u * 0.5;
|
||||||
|
const halfW = u * 0.5;
|
||||||
|
g.fillStyle(0x000000, 0.18);
|
||||||
|
g.fillEllipse(x, baseY + 2, u * 1.05, u * 0.2);
|
||||||
|
g.fillStyle(col('tentL', t), 1);
|
||||||
|
tri(g, x, apexY, x - halfW, baseY, x, baseY);
|
||||||
|
g.fillStyle(col('tentR', t), 1);
|
||||||
|
tri(g, x, apexY, x, baseY, x + halfW, baseY);
|
||||||
|
g.fillStyle(col('tentDoor', t), 1);
|
||||||
|
tri(g, x, y - u * 0.16, x - u * 0.17, baseY, x + u * 0.17, baseY);
|
||||||
|
g.lineStyle(Math.max(1, u * 0.025), 0x2a1f10, 0.5);
|
||||||
|
g.lineBetween(x, apexY, x, baseY);
|
||||||
|
g.fillStyle(0x2a1f10, 0.8);
|
||||||
|
g.fillCircle(x, apexY, Math.max(1.6, u * 0.045));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Night FX: campfires, embers, fireflies ────────────────────────────────
|
||||||
|
|
||||||
|
drawFx(now, delta) {
|
||||||
|
const g = this.fxGfx;
|
||||||
|
g.clear();
|
||||||
|
const t = this.sky.t;
|
||||||
|
|
||||||
|
// Menu campfire (always lit — a cozy landing spot).
|
||||||
|
if (this.view === 'select') {
|
||||||
|
this.drawCampfire(g, this.menuCamp.x, this.menuCamp.y, now, 0, 1);
|
||||||
|
this.spawnEmbers(this.menuCamp.x, this.menuCamp.y - 10, now);
|
||||||
|
this.updateAndDrawEmbers(g, now, delta, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campfire intensity ramps in over the last half of dusk.
|
||||||
|
const a = clamp((t - 0.5) / 0.5, 0, 1);
|
||||||
|
if (a <= 0.01) {
|
||||||
|
this.updateAndDrawEmbers(g, now, delta, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.g) {
|
||||||
|
const p = this.g.puzzle;
|
||||||
|
const cell = this.cell;
|
||||||
|
const fires = [];
|
||||||
|
for (let i = 0; i < p.solution.length; i++) {
|
||||||
|
const [c, r] = p.solution[i];
|
||||||
|
const [cx, cy] = this.cellCenter(c, r);
|
||||||
|
const fx = cx + cell * 0.62;
|
||||||
|
const fy = cy + cell * 0.34;
|
||||||
|
fires.push([fx, fy]);
|
||||||
|
this.drawCampfire(g, fx, fy, now, i, a);
|
||||||
|
}
|
||||||
|
// Embers rise from a random fire each tick (shared rate limiter).
|
||||||
|
if (fires.length) {
|
||||||
|
const [ex, ey] = fires[(Math.random() * fires.length) | 0];
|
||||||
|
this.spawnEmbers(ex, ey - 10, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm light pooling around each tent door.
|
||||||
|
for (const [c, r] of p.solution) {
|
||||||
|
const [cx, cy] = this.cellCenter(c, r);
|
||||||
|
g.fillStyle(0xff9e42, 0.1 * a);
|
||||||
|
g.fillCircle(cx, cy + cell * 0.3, cell * 1.15);
|
||||||
|
g.fillStyle(0xffc46a, 0.16 * a);
|
||||||
|
g.fillCircle(cx, cy + cell * 0.3, cell * 0.6);
|
||||||
|
g.fillStyle(0xffe2a0, 0.3 * a);
|
||||||
|
g.fillCircle(cx, cy + cell * 0.32, cell * 0.28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateAndDrawEmbers(g, now, delta, a);
|
||||||
|
|
||||||
|
// Fireflies.
|
||||||
|
if (a > 0.15) {
|
||||||
|
for (const f of this.fireflies) {
|
||||||
|
const bx = f.x + Math.cos((now / 1000) * f.sp + f.ph) * f.ax;
|
||||||
|
const by = f.y + Math.sin((now / 1000) * f.sp * 1.3 + f.ph) * f.ay;
|
||||||
|
const blink = 0.5 + 0.5 * Math.sin((now / 1000) * f.blink + f.ph * 2);
|
||||||
|
const al = a * (0.2 + 0.8 * blink);
|
||||||
|
g.fillStyle(0xb8ff7a, al * 0.25);
|
||||||
|
g.fillCircle(bx, by, f.r * 3.4);
|
||||||
|
g.fillStyle(0xd6ffb0, al);
|
||||||
|
g.fillCircle(bx, by, f.r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCampfire(g, x, y, now, seed, a) {
|
||||||
|
const f = 1 + 0.13 * Math.sin(now / 95 + seed * 2.3) + 0.09 * Math.sin(now / 41 + seed * 5.1);
|
||||||
|
const fl = f * a;
|
||||||
|
g.lineStyle(5, 0x3a2412, 1);
|
||||||
|
g.lineBetween(x - 10, y + 4, x + 10, y - 3);
|
||||||
|
g.lineBetween(x - 10, y - 3, x + 10, y + 4);
|
||||||
|
if (a > 0.05) {
|
||||||
|
g.fillStyle(0xff6b3d, 0.75 * a);
|
||||||
|
tri(g, x - 10 * fl, y, x + 10 * fl, y, x, y - 26 * fl);
|
||||||
|
g.fillStyle(0xff9e42, 0.85 * a);
|
||||||
|
tri(g, x - 6.5 * fl, y, x + 6.5 * fl, y, x, y - 17 * fl);
|
||||||
|
g.fillStyle(0xffd76a, 0.95 * a);
|
||||||
|
tri(g, x - 3.4 * fl, y, x + 3.4 * fl, y, x, y - 9.5 * fl);
|
||||||
|
// Glow (additive layer).
|
||||||
|
g.fillStyle(0xff9e42, 0.12 * a); g.fillCircle(x, y - 8, 34 * fl);
|
||||||
|
g.fillStyle(0xffc46a, 0.2 * a); g.fillCircle(x, y - 8, 20 * fl);
|
||||||
|
g.fillStyle(0xffe9b8, 0.3 * a); g.fillCircle(x, y - 8, 10 * fl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnEmbers(x, y, now) {
|
||||||
|
if (now - this.lastEmberAt < 130 + Math.random() * 120) return;
|
||||||
|
if (this.embers.length > 90) return;
|
||||||
|
this.lastEmberAt = now;
|
||||||
|
this.embers.push({
|
||||||
|
x: x + (Math.random() - 0.5) * 10,
|
||||||
|
y: y + (Math.random() - 0.5) * 6,
|
||||||
|
vx: (Math.random() - 0.5) * 22,
|
||||||
|
vy: -26 - Math.random() * 26,
|
||||||
|
r: 1 + Math.random() * 1.6,
|
||||||
|
life: 0,
|
||||||
|
max: 900 + Math.random() * 900,
|
||||||
|
hot: Math.random(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAndDrawEmbers(g, now, delta, a) {
|
||||||
|
const dt = delta / 1000;
|
||||||
|
for (let i = this.embers.length - 1; i >= 0; i--) {
|
||||||
|
const e = this.embers[i];
|
||||||
|
e.life += delta;
|
||||||
|
if (e.life >= e.max) { this.embers.splice(i, 1); continue; }
|
||||||
|
e.x += e.vx * dt;
|
||||||
|
e.y += e.vy * dt;
|
||||||
|
e.vy -= 7 * dt;
|
||||||
|
e.vx += Math.sin(now / 300 + e.hot * 9) * 8 * dt;
|
||||||
|
const k = 1 - e.life / e.max;
|
||||||
|
const al = k * (a > 0 ? a : 1) * 0.9;
|
||||||
|
g.fillStyle(e.hot > 0.5 ? 0xffd76a : 0xff9e42, al);
|
||||||
|
g.fillCircle(e.x, e.y, e.r * (0.5 + 0.5 * k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,274 @@
|
||||||
|
// Tents & Trees — pure board model for the classic "Tents" logic puzzle.
|
||||||
|
// No Phaser, no DOM — unit-testable in Node (see tools/verifyTents.js).
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// • Trees are given; tents are placed on empty cells.
|
||||||
|
// • Every tent must be orthogonally adjacent to a tree, and every tree
|
||||||
|
// must be paired with exactly one tent (one-to-one).
|
||||||
|
// • No two tents may touch — not even corner to corner.
|
||||||
|
// • The row/column counts on the edges must be met exactly.
|
||||||
|
//
|
||||||
|
// Generation scatters trees, then requires that the pairing + no-touch rules
|
||||||
|
// admit exactly ONE solution (checked by an exhaustive solver capped at two
|
||||||
|
// solutions). The edge counts are derived from that unique placement, which
|
||||||
|
// can only ever shrink the solution set — so uniqueness is guaranteed.
|
||||||
|
|
||||||
|
export const DIFFICULTIES = {
|
||||||
|
porch: { key: 'porch', label: 'Porch', size: 6, trees: 6, blurb: 'A gentle stroll' },
|
||||||
|
clearing: { key: 'clearing', label: 'Clearing', size: 8, trees: 10, blurb: 'A proper camp' },
|
||||||
|
meadow: { key: 'meadow', label: 'Meadow', size: 9, trees: 12, blurb: 'Real bushcraft' },
|
||||||
|
deepwoods: { key: 'deepwoods', label: 'Deep Woods', size: 10, trees: 14, blurb: 'For seasoned scouts' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DIFFICULTY_ORDER = ['porch', 'clearing', 'meadow', 'deepwoods'];
|
||||||
|
|
||||||
|
const DIRS4 = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
||||||
|
const DIRS8 = DIRS4.concat([[1, 1], [1, -1], [-1, 1], [-1, -1]]);
|
||||||
|
|
||||||
|
// Cell keys pack row/col into one integer (boards never exceed 99 cells wide).
|
||||||
|
export const keyOf = (c, r) => r * 100 + c;
|
||||||
|
const colOf = (k) => k % 100;
|
||||||
|
const rowOf = (k) => (k / 100) | 0;
|
||||||
|
|
||||||
|
export function inBounds(c, r, size) {
|
||||||
|
return c >= 0 && c < size && r >= 0 && r < size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Solver ────────────────────────────────────────────────────────────────────
|
||||||
|
// Backtracking over trees (ordered by fewest candidate cells). A "solution" is
|
||||||
|
// a full assignment where every tree has exactly one tent in an empty cell,
|
||||||
|
// and no two tents are adjacent in any of the 8 directions. Row/column
|
||||||
|
// counts (when given) are enforced as first-class constraints — in practice
|
||||||
|
// they are what makes a random tree layout unique. Returns up to `limit`
|
||||||
|
// solutions, each a sorted array of [col, row] tent positions.
|
||||||
|
export function countSolutions(trees, size, { limit = 2, rowCounts = null, colCounts = null } = {}) {
|
||||||
|
const treeSet = new Set(trees.map(([c, r]) => keyOf(c, r)));
|
||||||
|
// A candidate cell must be orthogonally adjacent to EXACTLY one tree — a
|
||||||
|
// tent touching two trees would leave one of them double-claimed (or
|
||||||
|
// strand its partner), so such cells are illegal for every tree.
|
||||||
|
function adjTrees(c, r) {
|
||||||
|
let n = 0;
|
||||||
|
for (const [dc, dr] of DIRS4) {
|
||||||
|
const nc = c + dc, nr = r + dr;
|
||||||
|
if (inBounds(nc, nr, size) && treeSet.has(keyOf(nc, nr))) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
const cands = trees.map(([tc, tr]) => {
|
||||||
|
const out = [];
|
||||||
|
for (const [dc, dr] of DIRS4) {
|
||||||
|
const c = tc + dc, r = tr + dr;
|
||||||
|
if (inBounds(c, r, size) && !treeSet.has(keyOf(c, r)) && adjTrees(c, r) === 1) out.push([c, r]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
if (cands.some((cs) => cs.length === 0)) return []; // a tree with no usable neighbour can never get a tent
|
||||||
|
|
||||||
|
const order = cands.map((cs, i) => i).sort((a, b) => cands[a].length - cands[b].length);
|
||||||
|
const solutions = [];
|
||||||
|
const used = []; // tent keys, in assignment order
|
||||||
|
const usedSet = new Set();
|
||||||
|
const rowUsed = new Array(size).fill(0);
|
||||||
|
const colUsed = new Array(size).fill(0);
|
||||||
|
|
||||||
|
// True once we should stop searching entirely.
|
||||||
|
function stop() { return solutions.length >= limit; }
|
||||||
|
|
||||||
|
function backtrack(i) {
|
||||||
|
if (stop()) return true;
|
||||||
|
if (i === order.length) {
|
||||||
|
solutions.push(
|
||||||
|
used
|
||||||
|
.map((k) => [colOf(k), rowOf(k)])
|
||||||
|
.sort((a, b) => a[1] - b[1] || a[0] - b[0]),
|
||||||
|
);
|
||||||
|
return stop(); // true only if the search must halt (limit reached)
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeIdx = order[i];
|
||||||
|
// A new tent must not sit on, or touch (8-way), any tent already placed.
|
||||||
|
const blocked = new Set(usedSet);
|
||||||
|
for (const k of usedSet) {
|
||||||
|
const c = colOf(k), r = rowOf(k);
|
||||||
|
for (const [dc, dr] of DIRS8) {
|
||||||
|
const nc = c + dc, nr = r + dr;
|
||||||
|
if (inBounds(nc, nr, size)) blocked.add(keyOf(nc, nr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [c, r] of cands[treeIdx]) {
|
||||||
|
const k = keyOf(c, r);
|
||||||
|
if (blocked.has(k)) continue;
|
||||||
|
if (rowCounts && rowUsed[r] >= rowCounts[r]) continue;
|
||||||
|
if (colCounts && colUsed[c] >= colCounts[c]) continue;
|
||||||
|
usedSet.add(k);
|
||||||
|
used.push(k);
|
||||||
|
rowUsed[r]++; colUsed[c]++;
|
||||||
|
const halt = backtrack(i + 1);
|
||||||
|
rowUsed[r]--; colUsed[c]--;
|
||||||
|
usedSet.delete(k);
|
||||||
|
used.pop();
|
||||||
|
if (halt) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
backtrack(0);
|
||||||
|
return solutions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function placeTrees(size, count, rng) {
|
||||||
|
const cells = [];
|
||||||
|
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) cells.push([c, r]);
|
||||||
|
// Partial Fisher–Yates: pull `count` distinct random cells.
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const j = i + Math.floor(rng() * (cells.length - i));
|
||||||
|
const t = cells[i]; cells[i] = cells[j]; cells[j] = t;
|
||||||
|
}
|
||||||
|
const trees = cells.slice(0, count);
|
||||||
|
const treeSet = new Set(trees.map(([c, r]) => keyOf(c, r)));
|
||||||
|
// Every tree needs at least one empty orthogonal neighbour to host a tent.
|
||||||
|
for (const [c, r] of trees) {
|
||||||
|
let free = false;
|
||||||
|
for (const [dc, dr] of DIRS4) {
|
||||||
|
const nc = c + dc, nr = r + dr;
|
||||||
|
if (inBounds(nc, nr, size) && !treeSet.has(keyOf(nc, nr))) { free = true; break; }
|
||||||
|
}
|
||||||
|
if (!free) return null;
|
||||||
|
}
|
||||||
|
return trees;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline: scatter trees → find ANY solution → derive its edge counts →
|
||||||
|
// verify that (trees + counts) admits exactly one solution. The counts do the
|
||||||
|
// heavy lifting for uniqueness (a layout that looks ambiguous on pairing
|
||||||
|
// rules alone is usually pinned down by its row/column sums), so this
|
||||||
|
// converges in a handful of tries.
|
||||||
|
export function generatePuzzle(difficultyKey, rng = Math.random) {
|
||||||
|
const def = DIFFICULTIES[difficultyKey] ?? DIFFICULTIES.porch;
|
||||||
|
for (let attempt = 0; attempt < 1500; attempt++) {
|
||||||
|
const trees = placeTrees(def.size, def.trees, rng);
|
||||||
|
if (!trees) continue;
|
||||||
|
|
||||||
|
const base = countSolutions(trees, def.size, { limit: 1 });
|
||||||
|
if (base.length !== 1) continue; // unsolvable layout
|
||||||
|
const solution = base[0];
|
||||||
|
|
||||||
|
const rowCounts = new Array(def.size).fill(0);
|
||||||
|
const colCounts = new Array(def.size).fill(0);
|
||||||
|
for (const [c, r] of solution) { rowCounts[r] += 1; colCounts[c] += 1; }
|
||||||
|
|
||||||
|
const withCounts = countSolutions(trees, def.size, { limit: 2, rowCounts, colCounts });
|
||||||
|
if (withCounts.length !== 1) continue; // still ambiguous once the counts are given
|
||||||
|
|
||||||
|
return {
|
||||||
|
difficulty: def.key,
|
||||||
|
size: def.size,
|
||||||
|
trees, // array of [col, row]
|
||||||
|
rowCounts, // tents per row
|
||||||
|
colCounts, // tents per column
|
||||||
|
solution, // the unique tent placement, [col, row] pairs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('Tents: could not find a uniquely-solvable tree layout');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Play state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function newGame(puzzle) {
|
||||||
|
return {
|
||||||
|
puzzle,
|
||||||
|
treeSet: new Set(puzzle.trees.map(([c, r]) => keyOf(c, r))),
|
||||||
|
tents: new Set(), // player-placed tent keys
|
||||||
|
firstMoveDone: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTree(g, c, r) {
|
||||||
|
return g.treeSet.has(keyOf(c, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle a tent on an empty cell. Returns { changed, placed } (placed=false
|
||||||
|
// on removal, changed=false on no-op such as clicking a tree).
|
||||||
|
export function toggleTent(g, c, r) {
|
||||||
|
if (g.treeSet.has(keyOf(c, r))) return { changed: false, placed: false };
|
||||||
|
const k = keyOf(c, r);
|
||||||
|
if (g.tents.has(k)) {
|
||||||
|
g.tents.delete(k);
|
||||||
|
return { changed: true, placed: false };
|
||||||
|
}
|
||||||
|
g.tents.add(k);
|
||||||
|
g.firstMoveDone = true;
|
||||||
|
return { changed: true, placed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTents(g) {
|
||||||
|
const had = g.tents.size > 0;
|
||||||
|
g.tents.clear();
|
||||||
|
return had;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tentCount(g) {
|
||||||
|
return g.tents.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight-worthy problems in the current placement:
|
||||||
|
// badTents — tent with no tree beside it, or touching another tent
|
||||||
|
// badTrees — tree claimed by two or more tents
|
||||||
|
// badRows / badCols — row/column already over its count
|
||||||
|
export function diagnose(g) {
|
||||||
|
const size = g.puzzle.size;
|
||||||
|
const badTents = new Set();
|
||||||
|
const badTrees = new Set();
|
||||||
|
const badRows = new Set();
|
||||||
|
const badCols = new Set();
|
||||||
|
|
||||||
|
for (const k of g.tents) {
|
||||||
|
const c = colOf(k), r = rowOf(k);
|
||||||
|
let besideTree = false;
|
||||||
|
for (const [dc, dr] of DIRS4) {
|
||||||
|
const nc = c + dc, nr = r + dr;
|
||||||
|
if (inBounds(nc, nr, size) && g.treeSet.has(keyOf(nc, nr))) { besideTree = true; break; }
|
||||||
|
}
|
||||||
|
if (!besideTree) badTents.add(k);
|
||||||
|
|
||||||
|
let touching = false;
|
||||||
|
for (const [dc, dr] of DIRS8) {
|
||||||
|
const nc = c + dc, nr = r + dr;
|
||||||
|
if (inBounds(nc, nr, size) && g.tents.has(keyOf(nc, nr))) { touching = true; break; }
|
||||||
|
}
|
||||||
|
if (touching) badTents.add(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [tc, tr] of g.puzzle.trees) {
|
||||||
|
let claimed = 0;
|
||||||
|
for (const [dc, dr] of DIRS4) {
|
||||||
|
const nc = tc + dc, nr = tr + dr;
|
||||||
|
if (inBounds(nc, nr, size) && g.tents.has(keyOf(nc, nr))) claimed++;
|
||||||
|
}
|
||||||
|
if (claimed >= 2) badTrees.add(keyOf(tc, tr));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowUsed = new Array(size).fill(0);
|
||||||
|
const colUsed = new Array(size).fill(0);
|
||||||
|
for (const k of g.tents) { rowUsed[rowOf(k)]++; colUsed[colOf(k)]++; }
|
||||||
|
for (let r = 0; r < size; r++) if (rowUsed[r] > g.puzzle.rowCounts[r]) badRows.add(r);
|
||||||
|
for (let c = 0; c < size; c++) if (colUsed[c] > g.puzzle.colCounts[c]) badCols.add(c);
|
||||||
|
|
||||||
|
return { badTents, badTrees, badRows, badCols };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solved: every tent placed, perfectly paired, nothing touching, counts met.
|
||||||
|
export function isSolved(g) {
|
||||||
|
if (g.tents.size !== g.puzzle.trees.length) return false;
|
||||||
|
const d = diagnose(g);
|
||||||
|
return d.badTents.size === 0 && d.badTrees.size === 0
|
||||||
|
&& d.badRows.size === 0 && d.badCols.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unique solution (for the Answer button). Array of [col, row].
|
||||||
|
export function solutionTents(g) {
|
||||||
|
return g.puzzle.solution;
|
||||||
|
}
|
||||||
|
|
@ -108,6 +108,7 @@ import VegaCombatSim from './games/mastervega/VegaCombatSim.js';
|
||||||
import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js';
|
import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js';
|
||||||
import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js';
|
import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js';
|
||||||
import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js';
|
import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js';
|
||||||
|
import TentsGame from './games/tents/TentsGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -229,6 +230,7 @@ const config = {
|
||||||
WolfensteinGame,
|
WolfensteinGame,
|
||||||
WolfensteinEditor,
|
WolfensteinEditor,
|
||||||
PipePuzzleGame,
|
PipePuzzleGame,
|
||||||
|
TentsGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
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', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame' };
|
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', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
const sceneKey = slugDispatch[this.game.slug];
|
const sceneKey = slugDispatch[this.game.slug];
|
||||||
const startData = {
|
const startData = {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
// Node smoke test for TentsGame.js: drives create → select → game → solve → win
|
||||||
|
// against a minimal Phaser stub (node_modules/phaser, gitignored).
|
||||||
|
import TentsGame from '../src/games/tents/TentsGame.js';
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const check = (name, cond, extra = '') => {
|
||||||
|
if (cond) console.log(' ok ', name);
|
||||||
|
else { failures++; console.log(' FAIL', name, extra); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generic "does anything" object for Phaser scene services we don't exercise.
|
||||||
|
function stub() {
|
||||||
|
const store = {};
|
||||||
|
const fn = () => s;
|
||||||
|
const s = new Proxy(fn, {
|
||||||
|
get(_, p) {
|
||||||
|
if (p === 'then') return undefined;
|
||||||
|
if (p === Symbol.toPrimitive) return () => 0;
|
||||||
|
if (p === 'length') return 0;
|
||||||
|
if (p in store) return store[p];
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
set(_, p, v) { store[p] = v; return true; },
|
||||||
|
apply() { return s; },
|
||||||
|
construct() { return s; },
|
||||||
|
has() { return true; },
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = new TentsGame();
|
||||||
|
|
||||||
|
// Wire up scene services.
|
||||||
|
s.add = stub();
|
||||||
|
s.scale = { setZoom() {} };
|
||||||
|
s.input = { mouse: { disableContextMenu() {} } };
|
||||||
|
s.sound = { play() {} };
|
||||||
|
s.cache = { json: { get: () => null } };
|
||||||
|
s.scene = { start() {} };
|
||||||
|
s.events = { on() {}, once() {} };
|
||||||
|
s.cameras = { main: { setScroll() {} } };
|
||||||
|
s.tweens = {
|
||||||
|
tweens: [],
|
||||||
|
add(opts) { this.tweens.push(opts); return { kill() {} }; },
|
||||||
|
killTweensOf() {},
|
||||||
|
};
|
||||||
|
const timers = [];
|
||||||
|
s.time = {
|
||||||
|
now: 0,
|
||||||
|
delayedCall(delay, cb) { timers.push({ at: s.time.now + delay, cb }); return { kill() {} }; },
|
||||||
|
addEvent() { return { off() {} }; },
|
||||||
|
};
|
||||||
|
const flushTimers = () => {
|
||||||
|
const pending = timers.splice(0, timers.length).sort((a, b) => a.at - b.at);
|
||||||
|
for (const t of pending) t.cb();
|
||||||
|
};
|
||||||
|
|
||||||
|
s.init({ game: { slug: 'tents', name: 'Tents & Trees' } });
|
||||||
|
s.create();
|
||||||
|
check('create() completes', true);
|
||||||
|
check('select screen built', Array.isArray(s.selectLayer) === false && s.selectLayer !== undefined);
|
||||||
|
|
||||||
|
// ── Start a game ──
|
||||||
|
s.startGame('porch');
|
||||||
|
check('game state exists', s.g && s.g.puzzle && s.g.tents instanceof Set);
|
||||||
|
check('board layout computed', typeof s.cell === 'number' && s.cell > 20 && s.cell < 140);
|
||||||
|
check('zones built', s.cellZones.length === s.g.puzzle.size ** 2);
|
||||||
|
check('labels built', s.colLabels.length === s.g.puzzle.size && s.rowLabels.length === s.g.puzzle.size);
|
||||||
|
|
||||||
|
const p = s.g.puzzle;
|
||||||
|
check('puzzle unique solution stored', Array.isArray(p.solution) && p.solution.length === p.trees.length);
|
||||||
|
|
||||||
|
// ── Render a frame (day) ──
|
||||||
|
s.time.now = 1000;
|
||||||
|
s.update(1000, 16);
|
||||||
|
check('day frame rendered without throwing', true);
|
||||||
|
|
||||||
|
// ── Toggle every solution cell on (the correct solution) ──
|
||||||
|
const solKeys = new Set(p.solution.map(([c, r]) => (r * 100 + c)));
|
||||||
|
let placed = 0;
|
||||||
|
for (let r = 0; r < p.size; r++) {
|
||||||
|
for (let c = 0; c < p.size; c++) {
|
||||||
|
if (solKeys.has(r * 100 + c)) {
|
||||||
|
s.time.now += 30;
|
||||||
|
s.onCellClick(c, r);
|
||||||
|
placed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('all solution tents placed', s.g.tents.size === p.trees.length && placed === p.trees.length, `tents=${s.g.tents.size} placed=${placed} trees=${p.trees.length}`);
|
||||||
|
check('win triggered', s.won === true);
|
||||||
|
check('sky tween scheduled', s.tweens.tweens.some((t) => t.targets === s.sky));
|
||||||
|
|
||||||
|
// ── Nightfall: animate sky to night and render ──
|
||||||
|
for (let t = 1200; t <= 8000; t += 200) {
|
||||||
|
s.time.now = t;
|
||||||
|
s.update(t, 16);
|
||||||
|
}
|
||||||
|
s.sky.t = 1;
|
||||||
|
s.seedFireflies();
|
||||||
|
s.update(9000, 16);
|
||||||
|
check('night frame with fire/embers/fireflies rendered', true);
|
||||||
|
|
||||||
|
// ── Win panel ──
|
||||||
|
s.showWinPanel();
|
||||||
|
check('win panel shown once', s.winPanelShown === true);
|
||||||
|
s.showWinPanel();
|
||||||
|
check('win panel idempotent', true);
|
||||||
|
|
||||||
|
// ── Restart after a win (back to day, fresh puzzle) ──
|
||||||
|
s.startGame('clearing');
|
||||||
|
check('restart resets sky', s.sky.t === 0);
|
||||||
|
check('restart fresh game', s.g.tents.size === 0 && s.won === false);
|
||||||
|
check('restart new size', s.g.puzzle.size === 8);
|
||||||
|
|
||||||
|
// ── Answer reveal path ──
|
||||||
|
s.revealAnswer();
|
||||||
|
flushTimers();
|
||||||
|
check('answer fills the solution', s.g.tents.size === s.g.puzzle.trees.length, `tents=${s.g.tents.size}`);
|
||||||
|
check('answer path wins', s.won === true);
|
||||||
|
|
||||||
|
// ── Reset path ──
|
||||||
|
s.startGame('meadow');
|
||||||
|
const half = [...s.g.puzzle.solution.slice(0, 4)];
|
||||||
|
for (const [c, r] of half) s.onCellClick(c, r);
|
||||||
|
check('partial placement ok', s.g.tents.size === 4, `tents=${s.g.tents.size}`);
|
||||||
|
s.resetTents();
|
||||||
|
check('reset clears tents', s.g.tents.size === 0);
|
||||||
|
|
||||||
|
// ── All difficulties generate + solve ──
|
||||||
|
for (const key of ['porch', 'clearing', 'meadow', 'deepwoods']) {
|
||||||
|
s.startGame(key);
|
||||||
|
const pp = s.g.puzzle;
|
||||||
|
const sk = new Set(pp.solution.map(([c, r]) => r * 100 + c));
|
||||||
|
for (let r = 0; r < pp.size; r++)
|
||||||
|
for (let c = 0; c < pp.size; c++)
|
||||||
|
if (sk.has(r * 100 + c)) s.onCellClick(c, r);
|
||||||
|
check(`${key}: solve-by-answer wins`, s.won === true && s.g.tents.size === pp.trees.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hover + violation path ──
|
||||||
|
s.startGame('porch');
|
||||||
|
s.hover = { c: 0, r: 0 };
|
||||||
|
s.time.now += 100;
|
||||||
|
s.update(s.time.now, 16);
|
||||||
|
check('hover frame renders', true);
|
||||||
|
// Force an over-count: place tents on solution cells until a row is over.
|
||||||
|
{
|
||||||
|
const pp = s.g.puzzle;
|
||||||
|
// find a row with count 1
|
||||||
|
let row = -1;
|
||||||
|
for (let r = 0; r < pp.size; r++) if (pp.rowCounts[r] === 1) { row = r; break; }
|
||||||
|
if (row >= 0) {
|
||||||
|
const empties = [];
|
||||||
|
for (let c = 0; c < pp.size; c++) {
|
||||||
|
if (!pp.treeSet?.has?.(row * 100 + c) && !s.g.treeSet.has(row * 100 + c)) empties.push(c);
|
||||||
|
}
|
||||||
|
if (empties.length >= 2) {
|
||||||
|
s.onCellClick(empties[0], row);
|
||||||
|
s.onCellClick(empties[1], row); // now over
|
||||||
|
check('violation flash armed', s.flash.until > 0, `flash.until=${s.flash.until}`);
|
||||||
|
s.update(s.time.now + 50, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(failures === 0 ? '\n[smoke] ALL PASSED' : `\n[smoke] ${failures} FAILED`);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
// Verifier for Tents & Trees (Node only — no browser).
|
||||||
|
//
|
||||||
|
// 1. Unit-tests the solver against hand-built boards (unique / multi / no
|
||||||
|
// solution, and the no-touch rule).
|
||||||
|
// 2. Unit-tests the play-state helpers (toggle, diagnose, solve, answer).
|
||||||
|
// 3. Generation soak: produces puzzles for every difficulty and re-verifies
|
||||||
|
// each one independently (validity + uniqueness).
|
||||||
|
//
|
||||||
|
// Usage: node tools/verifyTents.js
|
||||||
|
|
||||||
|
import {
|
||||||
|
DIFFICULTIES, DIFFICULTY_ORDER, countSolutions, generatePuzzle,
|
||||||
|
newGame, toggleTent, diagnose, isSolved, solutionTents, keyOf,
|
||||||
|
} from '../src/games/tents/TentsLogic.js';
|
||||||
|
|
||||||
|
let passes = 0;
|
||||||
|
let failures = 0;
|
||||||
|
function check(name, cond, detail = '') {
|
||||||
|
if (cond) { passes++; console.log(` ok ${name}`); }
|
||||||
|
else { failures++; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Solver unit tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('[verify] solver');
|
||||||
|
|
||||||
|
// Two trees far apart, each with a single candidate: exactly one solution.
|
||||||
|
{
|
||||||
|
// 4×4: trees at (1,1) and (2,2) would share diagonal cells; use (0,0) & (3,3).
|
||||||
|
const trees = [[0, 0], [3, 3]];
|
||||||
|
const sols = countSolutions(trees, 4, { limit: 5 });
|
||||||
|
// (0,0)'s candidates: (1,0),(0,1); (3,3)'s: (2,3),(3,2). None touch → 4 solutions.
|
||||||
|
check('disjoint trees → 4 solutions', sols.length === 4, `got ${sols.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// 4×4: trees at (1,1) and (2,2) — diagonal neighbours. Their candidate sets
|
||||||
|
// overlap in touching cells; tent for one blocks the other's options.
|
||||||
|
const trees = [[1, 1], [2, 2]];
|
||||||
|
const sols = countSolutions(trees, 4, { limit: 5 });
|
||||||
|
// Each tree's candidates: (0,1),(1,0),(1,2) and (2,1),(3,2),(2,3).
|
||||||
|
// Valid pairs must not touch (8-way): (0,1)&(2,3)? dist (2,2) ok → no touch.
|
||||||
|
// Count them all: any combo where neither touches the other.
|
||||||
|
check('diagonal trees → 0 or more, each valid', sols.length >= 0, `got ${sols.length}`);
|
||||||
|
for (const sol of sols) {
|
||||||
|
const [a, b] = sol;
|
||||||
|
const touch = Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1])) <= 1;
|
||||||
|
check(`solution ${JSON.stringify(sol)} tents do not touch`, !touch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Tree in a corner with a tree right beside it: (0,0) & (0,1).
|
||||||
|
// (0,0)'s tent: (1,0) or (0,1)=tree → only (1,0). (0,1)'s tent: (0,0) tree,
|
||||||
|
// (0,2), (1,1). (1,0) touches (1,1) and (0,2)? (1,0)-(0,2): Δ(1,2) no touch.
|
||||||
|
// (1,0)-(1,1): touch. So (0,1)'s tent must be (0,2). One solution.
|
||||||
|
const sols = countSolutions([[0, 0], [0, 1]], 4, { limit: 5 });
|
||||||
|
check('corner pair → exactly 1 solution', sols.length === 1, `got ${sols.length}`);
|
||||||
|
check('corner pair solution', JSON.stringify(sols[0]) === JSON.stringify([[1, 0], [0, 2]]),
|
||||||
|
JSON.stringify(sols[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// A tree walled in by trees (no empty orthogonal neighbour) → no solutions.
|
||||||
|
const trees = [[1, 1], [0, 1], [2, 1], [1, 0]];
|
||||||
|
const sols = countSolutions(trees, 4, { limit: 5 });
|
||||||
|
check('walled-in tree → no solutions', sols.length === 0, `got ${sols.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Three in a row: trees (1,1),(2,1),(3,1) on 5×5.
|
||||||
|
// (1,1) tents: (0,1),(1,0),(1,2). (2,1): (2,0),(2,2). (3,1): (3,0),(3,2),(4,1).
|
||||||
|
// Must be non-touching. (2,1)'s only options (2,0)/(2,2) touch (1,0)/(1,2)
|
||||||
|
// diagonally and (3,0)/(3,2) diagonally → whichever chosen blocks both
|
||||||
|
// neighbours' matching side. Check solver finds the true count.
|
||||||
|
const sols = countSolutions([[1, 1], [2, 1], [3, 1]], 5, { limit: 10 });
|
||||||
|
check('three-in-row solvable', sols.length > 0, `got ${sols.length}`);
|
||||||
|
for (const sol of sols) {
|
||||||
|
let ok = true;
|
||||||
|
for (let i = 0; i < sol.length && ok; i++)
|
||||||
|
for (let j = i + 1; j < sol.length; j++)
|
||||||
|
if (Math.max(Math.abs(sol[i][0] - sol[j][0]), Math.abs(sol[i][1] - sol[j][1])) <= 1) ok = false;
|
||||||
|
check(`3-in-row solution ${JSON.stringify(sol)} non-touching`, ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Play-state unit tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('[verify] play state');
|
||||||
|
|
||||||
|
{
|
||||||
|
// Fixed 4×4 puzzle with a known unique solution.
|
||||||
|
const trees = [[0, 0], [0, 1]];
|
||||||
|
const sols = countSolutions(trees, 4, { limit: 5 });
|
||||||
|
check('fixture has unique solution', sols.length === 1, `got ${sols.length}`);
|
||||||
|
const puzzle = {
|
||||||
|
difficulty: 'test', size: 4, trees,
|
||||||
|
rowCounts: [0, 0, 0, 0].map((_, r) => sols[0].filter(([, rr]) => rr === r).length),
|
||||||
|
colCounts: [0, 0, 0, 0].map((_, c) => sols[0].filter(([cc]) => cc === c).length),
|
||||||
|
solution: sols[0],
|
||||||
|
};
|
||||||
|
const g = newGame(puzzle);
|
||||||
|
|
||||||
|
check('clicking a tree is a no-op', toggleTent(g, 0, 0).changed === false);
|
||||||
|
check('place then remove round-trips',
|
||||||
|
toggleTent(g, 1, 0).placed === true && toggleTent(g, 1, 0).placed === false);
|
||||||
|
|
||||||
|
// Wrong tent (not beside any tree).
|
||||||
|
toggleTent(g, 3, 3);
|
||||||
|
let d = diagnose(g);
|
||||||
|
check('lonely tent flagged', d.badTents.has(keyOf(3, 3)));
|
||||||
|
|
||||||
|
// Two tents touching.
|
||||||
|
toggleTent(g, 1, 0);
|
||||||
|
toggleTent(g, 1, 1);
|
||||||
|
d = diagnose(g);
|
||||||
|
check('touching tents flagged', d.badTents.has(keyOf(1, 0)) && d.badTents.has(keyOf(1, 1)));
|
||||||
|
|
||||||
|
// Over-count a row: row 0 count is 1; add a second tent in row 0.
|
||||||
|
g.tents.add(keyOf(3, 0));
|
||||||
|
d = diagnose(g);
|
||||||
|
check('overfull row flagged', d.badRows.has(0));
|
||||||
|
|
||||||
|
check('not solved while broken', isSolved(g) === false);
|
||||||
|
|
||||||
|
// Solve it exactly.
|
||||||
|
g.tents.clear();
|
||||||
|
for (const [c, r] of puzzle.solution) g.tents.add(keyOf(c, r));
|
||||||
|
d = diagnose(g);
|
||||||
|
check('solution has no violations',
|
||||||
|
d.badTents.size + d.badTrees.size + d.badRows.size + d.badCols.size === 0);
|
||||||
|
check('solution counts as solved', isSolved(g) === true);
|
||||||
|
|
||||||
|
// Two tents on one tree → bad tree.
|
||||||
|
g.tents.add(keyOf(1, 1));
|
||||||
|
d = diagnose(g);
|
||||||
|
check('double-claimed tree flagged', d.badTrees.has(keyOf(0, 1)));
|
||||||
|
check('no longer solved', isSolved(g) === false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generation soak ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('[verify] generation');
|
||||||
|
|
||||||
|
for (const key of DIFFICULTY_ORDER) {
|
||||||
|
const def = DIFFICULTIES[key];
|
||||||
|
const t0 = Date.now();
|
||||||
|
let puzzles = 0;
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const p = generatePuzzle(key);
|
||||||
|
puzzles++;
|
||||||
|
// Independent re-verification.
|
||||||
|
// Independent re-verification: (trees + edge counts) must admit exactly
|
||||||
|
// one solution, and it must be the shipped one.
|
||||||
|
const sols = countSolutions(p.trees, p.size, { limit: 2, rowCounts: p.rowCounts, colCounts: p.colCounts });
|
||||||
|
const okUnique = sols.length === 1 && JSON.stringify(sols[0]) === JSON.stringify(p.solution);
|
||||||
|
check(`${key}: puzzle ${i} unique & matches solution`, okUnique,
|
||||||
|
`sols=${sols.length}`);
|
||||||
|
const d = (function () {
|
||||||
|
const g = newGame(p);
|
||||||
|
for (const [c, r] of p.solution) g.tents.add(keyOf(c, r));
|
||||||
|
return { g, d: diagnose(g) };
|
||||||
|
})();
|
||||||
|
check(`${key}: puzzle ${i} solution is valid`,
|
||||||
|
d.d.badTents.size + d.d.badTrees.size + d.d.badRows.size + d.d.badCols.size === 0
|
||||||
|
&& isSolved(d.g));
|
||||||
|
check(`${key}: puzzle ${i} has ${def.trees} trees`, p.trees.length === def.trees,
|
||||||
|
`got ${p.trees.length}`);
|
||||||
|
check(`${key}: puzzle ${i} counts sum to tree count`,
|
||||||
|
p.rowCounts.reduce((a, b) => a + b, 0) === p.trees.length
|
||||||
|
&& p.colCounts.reduce((a, b) => a + b, 0) === p.trees.length);
|
||||||
|
}
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
console.log(` · ${key}: ${puzzles} puzzles in ${ms} ms (${(ms / 12).toFixed(1)} ms avg)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n[verify] ${passes} passed, ${failures} failed`);
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
Loading…
Reference in New Issue