Merge pull request 'Add Tents & Trees logic puzzle game' (#1) from Tents into main

Reviewed-on: #1
This commit is contained in:
brianfertig 2026-08-28 02:54:54 +00:00
commit 9363fc3848
8 changed files with 1561 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 339 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = {

168
tools/smokeTents.js Normal file
View File

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

178
tools/verifyTents.js Normal file
View File

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