fertig-classic-games/src/games/tents/TentsGame.js

956 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) this.music = 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;
// Puzzle-start intro — ducks the soundtrack for its own length.
this.playJingle(SFX.TENTS_INTRO);
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.TENTS_PLACE, { volume: 0.8 });
this.checkViolationFeedback();
} else {
this.pop.delete(keyOf(c, r));
playSoundEx(this, SFX.TENTS_REMOVE, { 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.TENTS_REMOVE, { 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.TENTS_PLACE, { volume: 0.55 });
}));
this.time.delayedCall(75 * missing.length + 180, () => {
this.updateHud();
if (isSolved(this.g)) this.startWin();
});
this.btnAnswer.setEnabled(false);
}
// Duck the soundtrack for a one-shot jingle (puzzle-start intro, win),
// then hand playback back to MusicPlayer when the jingle ends — not a
// fixed delay, since the jingle's own length is what decides when the
// music comes back (same pattern as Zuma's start/lose fanfares).
playJingle(key, volume = 0.9) {
this.music?.pause();
try {
const sfx = this.sound.add(key, { volume });
sfx.once(Phaser.Sound.Events.COMPLETE, () => this.music?.resume());
sfx.play();
} catch (_) {
this.music?.resume();
}
}
startWin() {
if (this.won) return;
this.won = true;
this.playJingle(SFX.TENTS_WIN);
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));
}
}
}