956 lines
34 KiB
JavaScript
956 lines
34 KiB
JavaScript
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));
|
||
}
|
||
}
|
||
}
|