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