1111 lines
41 KiB
JavaScript
1111 lines
41 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 { playSound, SFX } from '../../ui/Sounds.js';
|
|
import { Tooltip } from '../../ui/Tooltip.js';
|
|
import { api } from '../../services/api.js';
|
|
import {
|
|
TUNING, gooType, createState, stepSim,
|
|
pickBallAt, beginDrag, dragTo, endDrag, chooseAttachments, canPlace,
|
|
strandStress, isWon, hasOCD, hazardAt, requiredStrands,
|
|
} from './GooTowerLogic.js';
|
|
|
|
const BG = 0x121a24;
|
|
const SKY_TOP = 0x1b2a3a;
|
|
const SKY_BOT = 0x0d1219;
|
|
const DIRT = 0x2f2a3d;
|
|
const DIRT_RIM = 0x4a4260;
|
|
const SPIKE = 0x7d3550;
|
|
const SPIKE_RIM = 0xd46a8c;
|
|
const FIRE = 0xd4642a;
|
|
const STRAND = 0x1c1626;
|
|
const STRAND_HOT = 0xff5a4a;
|
|
const PIPE_BODY = 0x3d5a6c;
|
|
const PIPE_RIM = 0x8fd0e8;
|
|
const GHOST_OK = 0x7fe38a;
|
|
const GHOST_NO = 0xff6b6b;
|
|
|
|
// Debug mode — set to true to show zoom level in lower-right corner.
|
|
const DEBUG_ZOOM = false;
|
|
|
|
// One colour per goo type. Everything is drawn procedurally; there is no art
|
|
// dependency (see docs/gootower-build-plan.md).
|
|
const GOO_COLOR = {
|
|
common: 0x4a4258,
|
|
ivy: 0x63c132,
|
|
balloon: 0xffd7e6,
|
|
bomb: 0xc8392b,
|
|
block: 0x8a6a4a,
|
|
pokey: 0xb07d18,
|
|
bit: 0x9ecbff,
|
|
skull: 0xe8e2ff,
|
|
anchor: 0x5a4a3a,
|
|
};
|
|
|
|
// Phaser Containers render children in insertion order and IGNORE child depth.
|
|
// The HUD and overlays are root-level objects ordered by these depths; the
|
|
// board's own layers live inside `this.board` and are ordered by insertion.
|
|
const D = {
|
|
sky: -10, terrain: 0, pipe: 4, strand: 8, ball: 10, ghost: 14,
|
|
hud: 30, overlay: 60, overlayUI: 62,
|
|
};
|
|
|
|
// World-space -> screen-space.
|
|
//
|
|
// Everything on the board is drawn at RAW WORLD COORDINATES into `this.board`,
|
|
// a container whose position and scale are the view transform. That is what
|
|
// makes zoom free: no drawing code knows the view exists.
|
|
//
|
|
// Phaser containers ignore child depth and render in insertion order, so the
|
|
// board's layers are added in the order they must paint: terrain, then the
|
|
// per-frame layer, then the drag ghost.
|
|
const WORLD_W = 1600;
|
|
const WORLD_H = 1000;
|
|
const VIEW_HOME = { scale: 1, ox: 160, oy: 70 };
|
|
const ZOOM_MIN = 0.45;
|
|
const ZOOM_MAX = 9;
|
|
const ZOOM_STEP = 1.15;
|
|
const VIEW_KEEP = 0.35; // fraction of the viewport that must stay over the board
|
|
const EDGE_PAN_THRESHOLD = 50; // px from screen edge to trigger panning
|
|
const BG_PARALLAX = 0.15; // fraction of the camera's pan/zoom the background follows, so it reads as farther away
|
|
|
|
const shade = (color, f) => {
|
|
const ch = (s) => Math.min(255, Math.round(((color >> s) & 0xff) * f));
|
|
return (ch(16) << 16) | (ch(8) << 8) | ch(0);
|
|
};
|
|
const lerpColor = (a, b, t) => {
|
|
const ch = (s) => {
|
|
const va = (a >> s) & 0xff;
|
|
const vb = (b >> s) & 0xff;
|
|
return Math.round(va + (vb - va) * t) & 0xff;
|
|
};
|
|
return (ch(16) << 16) | (ch(8) << 8) | ch(0);
|
|
};
|
|
|
|
// Vertical extent of a level's actual content (terrain, pipe, balls) — used to
|
|
// stop the camera ever showing empty space below the floor or above the
|
|
// topmost element, rather than the old fixed-world-height slack. Computed
|
|
// once at level start: balls drift during simulation, and a bound that
|
|
// chased live ball positions would jitter the clamp as the tower settles.
|
|
function levelVerticalBounds(state) {
|
|
let top = Infinity;
|
|
let bottom = -Infinity;
|
|
for (const t of state.terrain) {
|
|
if (t.bounds.minY < top) top = t.bounds.minY;
|
|
if (t.bounds.maxY > bottom) bottom = t.bounds.maxY;
|
|
}
|
|
if (state.pipe) {
|
|
top = Math.min(top, state.pipe.y - state.pipe.r);
|
|
bottom = Math.max(bottom, state.pipe.y + state.pipe.r);
|
|
}
|
|
for (const b of state.balls) {
|
|
if (!b) continue;
|
|
top = Math.min(top, b.y - TUNING.BALL_R);
|
|
bottom = Math.max(bottom, b.y + TUNING.BALL_R);
|
|
}
|
|
if (top === Infinity) { top = 0; bottom = WORLD_H; }
|
|
return { top, bottom };
|
|
}
|
|
|
|
export default class GooTowerGame extends Phaser.Scene {
|
|
constructor() { super('GooTowerGame'); }
|
|
|
|
init(data) {
|
|
this.gameDef = data?.game ?? { slug: 'gootower', name: 'Goo Tower' };
|
|
this.testLevel = data?.testLevel ?? null;
|
|
this.returnToEditor = !!data?.returnToEditor;
|
|
|
|
this.manifest = [];
|
|
this.chapters = [];
|
|
this.levelsCompleted = 0;
|
|
this.canPersist = true;
|
|
this.levelCache = new Map();
|
|
|
|
this.viewMode = 'select';
|
|
this.level = 0;
|
|
this.levelDef = null;
|
|
this.state = null;
|
|
this.accum = 0;
|
|
this.held = null;
|
|
this.wasAttached = false; // true when held ball was detached from structure
|
|
this.boardObjs = [];
|
|
this.overlayUp = false;
|
|
this.finished = false;
|
|
this.view = { ...VIEW_HOME };
|
|
this.panFrom = null;
|
|
this.introAnim = null; // intro animation state
|
|
this.debugZoomText = null;
|
|
this.levelBounds = null; // { top, bottom }, set per level in startLevel()
|
|
this.bgImage = null;
|
|
}
|
|
|
|
async create() {
|
|
try {
|
|
const music = this.cache.json.get('music');
|
|
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
|
} catch (_) { /* optional */ }
|
|
|
|
const raw = this.cache.json.get('gootower-levels');
|
|
this.manifest = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
|
|
this.chapters = raw?.chapters?.length
|
|
? raw.chapters
|
|
: (this.manifest.length ? [{ id: 1, name: 'Levels', blurb: '', from: 1, to: this.manifest.length }] : []);
|
|
|
|
try {
|
|
const res = await api.get('/puzzles/gootower/progress');
|
|
this.levelsCompleted = res?.levelsCompleted ?? 0;
|
|
} catch (_) {
|
|
this.canPersist = false;
|
|
this.levelsCompleted = 0;
|
|
}
|
|
|
|
this.bindInput();
|
|
|
|
if (this.testLevel) this.startLevel(this.testLevel.level ?? 1, this.testLevel);
|
|
else this.showSelect();
|
|
}
|
|
|
|
drawSky() {
|
|
const g = this.add.graphics().setDepth(D.sky);
|
|
g.fillGradientStyle(SKY_TOP, SKY_TOP, SKY_BOT, SKY_BOT, 1);
|
|
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
|
}
|
|
|
|
drawBackground() {
|
|
this.bgImage = null;
|
|
if (this.textures.exists('gootower-bg')) {
|
|
const img = this.track(this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'gootower-bg')
|
|
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.sky));
|
|
// Base scale at rest (view = VIEW_HOME); parallax below multiplies on top
|
|
// of this rather than the raw texture scale, so it stays correct
|
|
// whatever size the source image is.
|
|
this.bgBaseScaleX = img.scaleX;
|
|
this.bgBaseScaleY = img.scaleY;
|
|
this.bgImage = img;
|
|
}
|
|
}
|
|
|
|
// Moves/zooms the background by a fraction of the camera's own pan/zoom, so
|
|
// it reads as a distant layer instead of being glued to the foreground.
|
|
// Driven from applyView() so it stays in sync with drag-pan, wheel-zoom,
|
|
// edge-pan and the level-intro cinematic alike.
|
|
updateBackgroundParallax() {
|
|
const img = this.bgImage;
|
|
if (!img) return;
|
|
const zoomFactor = 1 + (this.view.scale - VIEW_HOME.scale) * BG_PARALLAX;
|
|
const panDX = (this.view.ox - VIEW_HOME.ox) * BG_PARALLAX;
|
|
const panDY = (this.view.oy - VIEW_HOME.oy) * BG_PARALLAX;
|
|
img.setScale(this.bgBaseScaleX * zoomFactor, this.bgBaseScaleY * zoomFactor);
|
|
img.setPosition(GAME_WIDTH / 2 + panDX, GAME_HEIGHT / 2 + panDY);
|
|
}
|
|
|
|
clearBoard() {
|
|
for (const o of this.boardObjs) o.destroy();
|
|
this.boardObjs = [];
|
|
if (this.tooltip) { this.tooltip.destroy?.(); this.tooltip = null; }
|
|
// These were tracked, so they are already destroyed -- but the fields still
|
|
// point at dead objects, and drawGhost()/drawDynamic() would happily call
|
|
// methods on them on the next level.
|
|
this.dyn = null;
|
|
this.ghost = null;
|
|
this.terrainG = null;
|
|
this.board = null;
|
|
this.panFrom = null;
|
|
this.needText = null;
|
|
this.hudText = null;
|
|
if (this.introAnim) { this.tweens.killTweensOf(this.introAnim); this.introAnim = null; }
|
|
if (this.debugZoomText) { this.debugZoomText.destroy(); this.debugZoomText = null; }
|
|
}
|
|
|
|
track(obj) { this.boardObjs.push(obj); return obj; }
|
|
|
|
// ── Level select ──────────────────────────────────────────────────────────
|
|
|
|
// Leaving a level goes back wherever the player came from: the level list
|
|
// normally, or the editor when this is an editor test-play.
|
|
exitLevel() {
|
|
if (this.returnToEditor) this.scene.start('GooTowerEditor', { resume: true });
|
|
else this.showSelect();
|
|
}
|
|
|
|
showSelect() {
|
|
this.viewMode = 'select';
|
|
this.state = null;
|
|
this.held = null;
|
|
this.wasAttached = false;
|
|
this.clearBoard();
|
|
|
|
const cx = GAME_WIDTH / 2;
|
|
this.track(this.add.text(cx, 56, 'Goo Tower', {
|
|
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.hud));
|
|
|
|
this.tooltip = new Tooltip(this);
|
|
|
|
// Five chapters of fifteen. Tiles are compact and evenly spread; the
|
|
// level's name lives in the hover tooltip rather than on the tile, because
|
|
// fifteen across leaves no room for it.
|
|
const TILE_W = 92;
|
|
const TILE_H = 66;
|
|
const STEP = 106;
|
|
let y = 148;
|
|
for (const ch of this.chapters) {
|
|
const levels = this.manifest.filter((m) => m.level >= ch.from && m.level <= ch.to);
|
|
this.track(this.add.text(70, y, ch.name, {
|
|
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
|
|
}).setDepth(D.hud));
|
|
if (ch.blurb) {
|
|
this.track(this.add.text(70, y + 32, ch.blurb, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#8b98a6',
|
|
}).setDepth(D.hud));
|
|
}
|
|
|
|
const rowW = (levels.length - 1) * STEP;
|
|
let x = GAME_WIDTH / 2 - rowW / 2;
|
|
for (const m of levels) {
|
|
const unlocked = m.level <= this.levelsCompleted + 1;
|
|
const cleared = m.level <= this.levelsCompleted;
|
|
this.makeLevelTile(x, y + 70, m, unlocked, cleared, TILE_W, TILE_H);
|
|
x += STEP;
|
|
}
|
|
y += 168;
|
|
}
|
|
|
|
this.track(new Button(this, cx, GAME_HEIGHT - 46, 'Back', () => this.scene.start('GameMenu'),
|
|
{ variant: 'ghost', width: 180, height: 52 }).setDepth(D.hud));
|
|
if (this.levelsCompleted > 0) {
|
|
this.track(new Button(this, 210, GAME_HEIGHT - 46, 'Reset Progress',
|
|
() => this.resetProgress(), { variant: 'ghost', width: 240, height: 52, fontSize: 19 }).setDepth(D.hud));
|
|
}
|
|
}
|
|
|
|
makeLevelTile(x, y, m, unlocked, cleared, w = 92, h = 66) {
|
|
const g = this.add.graphics().setDepth(D.hud);
|
|
const base = cleared ? 0x2b4a3a : unlocked ? 0x2a3547 : 0x1b2029;
|
|
g.fillStyle(base, 1);
|
|
g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 8);
|
|
g.lineStyle(2, cleared ? 0x63c132 : unlocked ? 0x5b7fa8 : 0x2a3038, 1);
|
|
g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 8);
|
|
this.track(g);
|
|
|
|
const best = this.bestFor(m.level);
|
|
this.track(this.add.text(x, y - 10, String(m.level), {
|
|
fontFamily: 'Righteous', fontSize: '26px',
|
|
color: unlocked ? COLORS.textHex : '#455060',
|
|
}).setOrigin(0.5).setDepth(D.hud));
|
|
|
|
if (best.count > 0) {
|
|
this.track(this.add.text(x, y + 18, best.ocd ? `\u2605 ${best.count}` : `${best.count}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '14px',
|
|
color: best.ocd ? '#ffd166' : '#7f8c99',
|
|
}).setOrigin(0.5).setDepth(D.hud));
|
|
} else if (unlocked && m.element) {
|
|
// A pip naming the mechanic this level is built around.
|
|
this.track(this.add.text(x, y + 18, m.element, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#6f7c8a',
|
|
}).setOrigin(0.5).setDepth(D.hud));
|
|
}
|
|
|
|
if (!unlocked) return;
|
|
const hit = this.add.rectangle(x, y, w, h, 0xffffff, 0.001)
|
|
.setInteractive({ useHandCursor: true })
|
|
.setDepth(D.hud + 1);
|
|
hit.on('pointerup', () => this.startLevel(m.level));
|
|
if (this.tooltip) {
|
|
this.tooltip.attachTo(hit, () => ({
|
|
title: `${m.level}. ${m.name}`,
|
|
lines: [
|
|
`Collect ${m.required} to finish`,
|
|
`OCD target: ${m.ocdTarget}`,
|
|
...(best.count ? [`Best: ${best.count}${best.ocd ? ' \u2605 OCD' : ''}`] : []),
|
|
],
|
|
}));
|
|
}
|
|
this.track(hit);
|
|
}
|
|
|
|
bestFor(level) {
|
|
try {
|
|
return {
|
|
count: Number(localStorage.getItem(`gt-best-${level}`) || 0),
|
|
ocd: localStorage.getItem(`gt-ocd-${level}`) === '1',
|
|
};
|
|
} catch (_) { return { count: 0, ocd: false }; }
|
|
}
|
|
|
|
recordBest(level, count, ocd) {
|
|
try {
|
|
if (count > Number(localStorage.getItem(`gt-best-${level}`) || 0)) {
|
|
localStorage.setItem(`gt-best-${level}`, String(count));
|
|
}
|
|
if (ocd) localStorage.setItem(`gt-ocd-${level}`, '1');
|
|
} catch (_) { /* private browsing */ }
|
|
}
|
|
|
|
async resetProgress() {
|
|
try { await api.post('/puzzles/gootower/reset'); } catch (_) { /* offline */ }
|
|
for (const m of this.manifest) {
|
|
try {
|
|
localStorage.removeItem(`gt-best-${m.level}`);
|
|
localStorage.removeItem(`gt-ocd-${m.level}`);
|
|
} catch (_) { /* ignore */ }
|
|
}
|
|
this.levelsCompleted = 0;
|
|
this.showSelect();
|
|
}
|
|
|
|
// ── Loading a level ───────────────────────────────────────────────────────
|
|
|
|
async fetchLevel(level) {
|
|
if (this.levelCache.has(level)) return this.levelCache.get(level);
|
|
const entry = this.manifest.find((m) => m.level === level);
|
|
if (!entry) return null;
|
|
const res = await fetch(`assets/gamedata/gootower/${entry.file}`);
|
|
const json = await res.json();
|
|
this.levelCache.set(level, json);
|
|
return json;
|
|
}
|
|
|
|
async startLevel(level, preloaded = null) {
|
|
const def = preloaded || await this.fetchLevel(level);
|
|
if (!def) { this.showSelect(); return; }
|
|
|
|
this.clearBoard();
|
|
this.drawBackground();
|
|
this.viewMode = 'play';
|
|
this.level = level;
|
|
this.levelDef = def;
|
|
this.state = createState(def, 0x60071e + level);
|
|
this.levelBounds = levelVerticalBounds(this.state);
|
|
this.accum = 0;
|
|
this.held = null;
|
|
this.wasAttached = false;
|
|
this.overlayUp = false;
|
|
this.finished = false;
|
|
|
|
// One container for the whole board; its transform is the view. Children
|
|
// are added in paint order because containers ignore child depth.
|
|
this.board = this.track(this.add.container(0, 0).setDepth(D.terrain));
|
|
this.terrainG = this.add.graphics();
|
|
this.dyn = this.add.graphics();
|
|
this.ghost = this.add.graphics();
|
|
this.board.add([this.terrainG, this.dyn, this.ghost]);
|
|
|
|
this.view = { ...VIEW_HOME };
|
|
this.applyView();
|
|
this.drawTerrain();
|
|
this.buildHud();
|
|
if (DEBUG_ZOOM) this.createDebugZoomText();
|
|
this.startIntroAnim();
|
|
}
|
|
|
|
// Redrawn on demand, not just once: a blast can delete destructible terrain.
|
|
drawTerrain() {
|
|
const g = this.terrainG;
|
|
if (!g) return;
|
|
g.clear();
|
|
for (const t of this.state.terrain) {
|
|
if (t.gear) continue; // gears rotate; they are drawn per-frame instead
|
|
const pts = t.poly.map(([x, y]) => ({ x, y }));
|
|
if (t.kind === 'solid') {
|
|
g.fillStyle(DIRT, 1);
|
|
g.fillPoints(pts, true);
|
|
g.lineStyle(4, DIRT_RIM, 1);
|
|
g.strokePoints(pts, true);
|
|
} else if (t.kind === 'spike') {
|
|
g.fillStyle(SPIKE, 1);
|
|
g.fillPoints(pts, true);
|
|
this.drawTeeth(g, t);
|
|
} else if (t.kind === 'fire') {
|
|
g.fillStyle(FIRE, 0.75);
|
|
g.fillPoints(pts, true);
|
|
}
|
|
if (t.destructible) {
|
|
// Cross-hatch marks rock a blast can open.
|
|
g.lineStyle(1, 0xd9a066, 0.5);
|
|
const bb = t.bounds;
|
|
for (let x = bb.minX; x < bb.maxX + (bb.maxY - bb.minY); x += 16) {
|
|
g.lineBetween(x, bb.minY, x - (bb.maxY - bb.minY), bb.maxY);
|
|
}
|
|
}
|
|
}
|
|
this.drawFans(g);
|
|
}
|
|
|
|
drawFans(g) {
|
|
for (const f of this.state.fans) {
|
|
g.fillStyle(0x8fd0e8, 0.07);
|
|
g.fillRect(f.x, f.y, f.w, f.h);
|
|
g.lineStyle(1, 0x8fd0e8, 0.28);
|
|
g.strokeRect(f.x, f.y, f.w, f.h);
|
|
// Chevrons pointing the way the wind blows.
|
|
const step = 56;
|
|
for (let x = f.x + step / 2; x < f.x + f.w; x += step) {
|
|
for (let y = f.y + step / 2; y < f.y + f.h; y += step) {
|
|
const cx = x;
|
|
const cy = y;
|
|
const ax = f.dx * 12;
|
|
const ay = f.dy * 12;
|
|
g.lineStyle(2, 0x8fd0e8, 0.32);
|
|
g.lineBetween(cx - ax, cy - ay, cx + ax, cy + ay);
|
|
g.lineBetween(cx + ax, cy + ay, cx + ax - ay * 0.5 - ax * 0.5, cy + ay + ax * 0.5 - ay * 0.5);
|
|
g.lineBetween(cx + ax, cy + ay, cx + ax + ay * 0.5 - ax * 0.5, cy + ay - ax * 0.5 - ay * 0.5);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
drawGears(g) {
|
|
for (const t of this.state.terrain) {
|
|
if (!t.gear) continue;
|
|
const pts = t.poly.map(([x, y]) => ({ x, y }));
|
|
g.fillStyle(0x4a4260, 1);
|
|
g.fillPoints(pts, true);
|
|
g.lineStyle(3, 0x8a7fb0, 1);
|
|
g.strokePoints(pts, true);
|
|
const cx = t.gear.cx;
|
|
const cy = t.gear.cy;
|
|
g.fillStyle(0x2a2438, 1);
|
|
g.fillCircle(cx, cy, t.gear.r * 0.3);
|
|
// A spoke, so the rotation is legible.
|
|
g.lineStyle(4, 0x8a7fb0, 0.9);
|
|
g.lineBetween(cx, cy,
|
|
cx + Math.cos(t.gear.angle) * t.gear.r * 0.62,
|
|
cy + Math.sin(t.gear.angle) * t.gear.r * 0.62);
|
|
}
|
|
}
|
|
|
|
// Spikes read as spikes because of the teeth, not the fill colour. Teeth are
|
|
// stamped along whichever edge faces away from the polygon's interior.
|
|
drawTeeth(g, t) {
|
|
const poly = t.poly;
|
|
const cy = poly.reduce((s, p) => s + p[1], 0) / poly.length;
|
|
g.fillStyle(SPIKE_RIM, 1);
|
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
const [ax, ay] = poly[j];
|
|
const [bx, by] = poly[i];
|
|
const mid = (ay + by) / 2;
|
|
if (Math.abs(ay - by) > 4) continue; // only horizontal edges
|
|
const up = mid < cy ? -1 : 1; // point away from the middle
|
|
const len = Math.abs(bx - ax);
|
|
const n = Math.max(2, Math.round(len / 26));
|
|
const step = (bx - ax) / n;
|
|
for (let k = 0; k < n; k += 1) {
|
|
const x0 = ax + step * k;
|
|
const x1 = x0 + step;
|
|
g.fillTriangle(
|
|
x0, mid,
|
|
x1, mid,
|
|
(x0 + x1) / 2, mid + up * 20,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
buildHud() {
|
|
const st = this.state;
|
|
this.hudText = this.track(this.add.text(40, 26, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex,
|
|
}).setDepth(D.hud));
|
|
this.track(this.add.text(40, 58, 'wheel: zoom to cursor · right-drag: pan · 0: reset view', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#5f6b78',
|
|
}).setDepth(D.hud));
|
|
this.tipText = this.track(this.add.text(GAME_WIDTH / 2, 30, this.levelDef.tip || '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '19px', color: '#9aa7b4',
|
|
wordWrap: { width: 760 }, align: 'center',
|
|
}).setOrigin(0.5, 0).setDepth(D.hud));
|
|
|
|
this.track(new Button(this, GAME_WIDTH - 130, 44,
|
|
this.returnToEditor ? 'Editor' : 'Levels', () => this.exitLevel(),
|
|
{ variant: 'ghost', width: 160, height: 48, fontSize: 20 }).setDepth(D.hud));
|
|
this.track(new Button(this, GAME_WIDTH - 310, 44, 'Retry', () => this.startLevel(this.level),
|
|
{ variant: 'ghost', width: 160, height: 48, fontSize: 20 }).setDepth(D.hud));
|
|
if (st) this.refreshHud();
|
|
}
|
|
|
|
refreshHud() {
|
|
const st = this.state;
|
|
if (!this.hudText || !st) return;
|
|
this.hudText.setText(
|
|
`Collected ${st.collected} / ${st.required} OCD ${st.ocdTarget} Goo left ${st.pile.length}`,
|
|
);
|
|
}
|
|
|
|
// ── View: zoom and pan ────────────────────────────────────────────────────
|
|
|
|
applyView() {
|
|
this.clampView();
|
|
this.updateBackgroundParallax();
|
|
if (!this.board) return;
|
|
this.board.setScale(this.view.scale);
|
|
this.board.setPosition(this.view.ox, this.view.oy);
|
|
}
|
|
|
|
// Keep at least VIEW_KEEP of the viewport covered by board, rather than
|
|
// forcing the whole board on screen. Forcing it leaves the clamp fighting the
|
|
// zoom anchor near the edges -- the point under the cursor slides away, which
|
|
// is exactly what zoom-to-cursor is supposed to prevent. With this the anchor
|
|
// is pixel-exact everywhere except when you push past the bounds.
|
|
clampView() {
|
|
const clampAxis = (v, span, viewport) => Math.max(
|
|
viewport * VIEW_KEEP - span,
|
|
Math.min(viewport * (1 - VIEW_KEEP), v),
|
|
);
|
|
this.view.ox = clampAxis(this.view.ox, WORLD_W * this.view.scale, GAME_WIDTH);
|
|
|
|
// Y axis is a hard stop at the level's own content, not fixed world slack:
|
|
// the floor must never scroll off the bottom of the screen, and nothing
|
|
// above the topmost element should ever come into view.
|
|
const { top, bottom } = this.levelBounds || { top: 0, bottom: WORLD_H };
|
|
const scale = this.view.scale;
|
|
if ((bottom - top) * scale <= GAME_HEIGHT) {
|
|
// Content is shorter than the viewport at this zoom -- centre it instead
|
|
// of leaving it free to slide between two clamps that would overlap.
|
|
this.view.oy = GAME_HEIGHT / 2 - ((top + bottom) / 2) * scale;
|
|
} else {
|
|
const oyMax = -top * scale;
|
|
const oyMin = GAME_HEIGHT - bottom * scale;
|
|
this.view.oy = Math.max(oyMin, Math.min(oyMax, this.view.oy));
|
|
}
|
|
}
|
|
|
|
// Zoom about a screen point: the world position under the cursor must stay
|
|
// under the cursor, which is the whole point of zooming to the mouse.
|
|
zoomAt(screenX, screenY, factor) {
|
|
const before = this.toWorld({ x: screenX, y: screenY });
|
|
const next = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.view.scale * factor));
|
|
if (next === this.view.scale) return;
|
|
this.view.scale = next;
|
|
this.view.ox = screenX - before.x * next;
|
|
this.view.oy = screenY - before.y * next;
|
|
this.applyView();
|
|
}
|
|
|
|
resetView() {
|
|
this.view = { ...VIEW_HOME };
|
|
this.applyView();
|
|
}
|
|
|
|
// ── Input ─────────────────────────────────────────────────────────────────
|
|
|
|
bindInput() {
|
|
this.input.mouse?.disableContextMenu();
|
|
this.input.on('pointerdown', (p) => this.onDown(p));
|
|
this.input.on('pointermove', (p) => this.onMove(p));
|
|
this.input.on('pointerup', (p) => this.onUp(p));
|
|
|
|
this.input.on('wheel', (p, over, dx, dy) => {
|
|
if (this.viewMode !== 'play' || !this.state || this.overlayUp || this.introAnim) return;
|
|
if (dy === 0) return;
|
|
this.zoomAt(p.x, p.y, dy < 0 ? ZOOM_STEP : 1 / ZOOM_STEP);
|
|
});
|
|
this.input.keyboard?.on('keydown-ZERO', () => { if (this.viewMode === 'play' && !this.introAnim) this.resetView(); });
|
|
this.input.keyboard?.on('keydown-HOME', () => { if (this.viewMode === 'play' && !this.introAnim) this.resetView(); });
|
|
this.input.keyboard?.on('keydown-R', () => { if (this.viewMode === 'play' && !this.introAnim) this.startLevel(this.level); });
|
|
this.input.keyboard?.on('keydown-ESC', () => { if (this.viewMode === 'play' && !this.introAnim) this.exitLevel(); });
|
|
}
|
|
|
|
// Screen <-> world. The board container holds the forward transform; these
|
|
// are it and its inverse.
|
|
tx(x) { return x * this.view.scale + this.view.ox; }
|
|
|
|
ty(y) { return y * this.view.scale + this.view.oy; }
|
|
|
|
toWorld(p) {
|
|
return { x: (p.x - this.view.ox) / this.view.scale, y: (p.y - this.view.oy) / this.view.scale };
|
|
}
|
|
|
|
onDown(p) {
|
|
if (this.viewMode !== 'play' || this.overlayUp || !this.state || this.introAnim) return;
|
|
// Right-drag pans. Zoomed in, you need a way to get around, and the left
|
|
// button is already spoken for by dragging goo.
|
|
if (p.rightButtonDown()) {
|
|
this.panFrom = { x: p.x, y: p.y, ox: this.view.ox, oy: this.view.oy };
|
|
return;
|
|
}
|
|
const w = this.toWorld(p);
|
|
const ball = pickBallAt(this.state, w.x, w.y, 46);
|
|
if (!ball) return;
|
|
if (beginDrag(this.state, ball, null)) {
|
|
this.held = ball;
|
|
this.wasAttached = ball.wasDetached; // only true for detached balls
|
|
playSound(this, SFX.UI_PICK);
|
|
}
|
|
}
|
|
|
|
onMove(p) {
|
|
if (this.panFrom || this.introAnim) {
|
|
if (this.panFrom) {
|
|
this.view.ox = this.panFrom.ox + (p.x - this.panFrom.x);
|
|
this.view.oy = this.panFrom.oy + (p.y - this.panFrom.y);
|
|
this.applyView();
|
|
}
|
|
return;
|
|
}
|
|
if (!this.held || !this.state) return;
|
|
const w = this.toWorld(p);
|
|
dragTo(this.state, this.held, w.x, w.y);
|
|
}
|
|
|
|
onUp(p) {
|
|
if (this.panFrom) { this.panFrom = null; return; }
|
|
if (!this.held || !this.state) return;
|
|
const w = this.toWorld(p);
|
|
const ball = this.held;
|
|
const wasAttached = this.wasAttached;
|
|
this.held = null;
|
|
this.wasAttached = false;
|
|
const events = [];
|
|
const stuck = endDrag(this.state, ball, w.x, w.y, events, wasAttached);
|
|
playSound(this, stuck ? SFX.UI_PLACE : SFX.SQUISH);
|
|
this.handleEvents(events);
|
|
}
|
|
|
|
// ── Frame ─────────────────────────────────────────────────────────────────
|
|
|
|
update(time, delta) {
|
|
if (this.viewMode !== 'play' || !this.state) return;
|
|
|
|
// Fixed-timestep accumulator; the sim substeps internally at 240Hz.
|
|
const dt = Math.min(delta / 1000, 0.05);
|
|
const events = stepSim(this.state, dt);
|
|
this.handleEvents(events);
|
|
|
|
this.drawDynamic();
|
|
this.refreshHud();
|
|
|
|
// Debug: show current zoom level in lower-right corner.
|
|
if (DEBUG_ZOOM && this.debugZoomText) {
|
|
this.debugZoomText.setText(this.view.scale.toFixed(2) + 'x');
|
|
}
|
|
|
|
// Edge-based camera panning when zoomed in.
|
|
if (this.view.scale > 1 && !this.panFrom && !this.overlayUp && !this.introAnim) {
|
|
const ptr = this.input.activePointer;
|
|
const speed = 6 * this.view.scale; // pan faster when more zoomed in
|
|
if (ptr.x < EDGE_PAN_THRESHOLD) {
|
|
this.view.ox += speed;
|
|
} else if (ptr.x > GAME_WIDTH - EDGE_PAN_THRESHOLD) {
|
|
this.view.ox -= speed;
|
|
}
|
|
if (ptr.y < EDGE_PAN_THRESHOLD) {
|
|
this.view.oy += speed;
|
|
} else if (ptr.y > GAME_HEIGHT - EDGE_PAN_THRESHOLD) {
|
|
this.view.oy -= speed;
|
|
}
|
|
this.applyView();
|
|
}
|
|
|
|
if (!this.finished && isWon(this.state)) this.finishLevel();
|
|
}
|
|
|
|
handleEvents(events) {
|
|
for (const e of events) {
|
|
if (e.type === 'strandBreak') playSound(this, SFX.SQUASH);
|
|
else if (e.type === 'collect') playSound(this, SFX.UI_CHIME);
|
|
else if (e.type === 'ballDie') playSound(this, SFX.SQUISH);
|
|
else if (e.type === 'pipeOpen') playSound(this, SFX.GEM_DROP);
|
|
else if (e.type === 'stick') playSound(this, SFX.PIECE_CLICK);
|
|
else if (e.type === 'ignite') playSound(this, SFX.WOOSH);
|
|
else if (e.type === 'explode') {
|
|
playSound(this, SFX.SQUASH);
|
|
this.cameras.main.shake(180, 0.006);
|
|
}
|
|
// A blast can delete destructible terrain, so the static layer is stale.
|
|
else if (e.type === 'terrainDestroyed') this.drawTerrain();
|
|
}
|
|
}
|
|
|
|
drawDynamic() {
|
|
const st = this.state;
|
|
const g = this.dyn;
|
|
if (!g || !st) return;
|
|
g.clear();
|
|
|
|
this.drawGears(g);
|
|
|
|
// Strands first, under the balls — drawn as pinched goo shapes (thick
|
|
// at the ends, thin in the middle with curved edges).
|
|
for (const s of st.strands) {
|
|
if (s.broken) continue;
|
|
const a = st.balls[s.a];
|
|
const b = st.balls[s.b];
|
|
if (!a || !b || a.dead || b.dead) continue;
|
|
const stress = strandStress(s);
|
|
const color = lerpColor(STRAND, STRAND_HOT, stress);
|
|
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const len = Math.hypot(dx, dy);
|
|
if (len < 1) continue;
|
|
|
|
// Unit direction along the strand and perpendicular to it.
|
|
const ux = dx / len, uy = dy / len;
|
|
const nx = -uy, ny = ux;
|
|
|
|
const baseW = 7 - 3 * stress;
|
|
const endW = baseW;
|
|
const midW = Math.max(1, baseW * 0.2);
|
|
// Perpendicular sag of the centerline at the midpoint — gives the strand
|
|
// a gentle drooping arc.
|
|
const sagAmt = baseW * 0.5;
|
|
|
|
// Width profile: endW at both endpoints, pinched to midW at the center.
|
|
// Zero derivative at t=0 and t=1 so the strand meets each ball tangentially.
|
|
// w(t) = midW + (endW - midW) * (1 - 2·t·(1-t))
|
|
// Sag profile: zero at endpoints, sagAmt at center.
|
|
// s(t) = 4·sagAmt·t·(1-t)
|
|
// Both are quadratics with zero derivative at the endpoints, giving
|
|
// perfectly smooth (tangent-aligned) connections into the balls.
|
|
// The second derivative of the edge offset is negative everywhere,
|
|
// so the edges curve inward — a concave "pinched" look.
|
|
|
|
const samples = Math.max(8, Math.min(20, Math.round(len / 15)));
|
|
const verts = [];
|
|
|
|
for (let i = 0; i <= samples; i++) {
|
|
const t = i / samples;
|
|
const tp = t * (1 - t); // peaks at 0.25 when t = 0.5
|
|
const w = midW + (endW - midW) * (1 - 2 * tp);
|
|
const sg = 4 * sagAmt * tp;
|
|
// Center point (straight line + perpendicular sag)
|
|
const cx = a.x + t * dx + nx * sg;
|
|
const cy = a.y + t * dy + ny * sg;
|
|
// Upper edge
|
|
verts.push({ x: cx + nx * (w / 2), y: cy + ny * (w / 2) });
|
|
}
|
|
// Bottom edge, walking back from end → start.
|
|
for (let i = samples; i >= 0; i--) {
|
|
const t = i / samples;
|
|
const tp = t * (1 - t);
|
|
const w = midW + (endW - midW) * (1 - 2 * tp);
|
|
const sg = 4 * sagAmt * tp;
|
|
const cx = a.x + t * dx + nx * sg;
|
|
const cy = a.y + t * dy + ny * sg;
|
|
verts.push({ x: cx - nx * (w / 2), y: cy - ny * (w / 2) });
|
|
}
|
|
|
|
g.fillStyle(color, 1);
|
|
g.fillPoints(verts, true);
|
|
}
|
|
|
|
// Balls.
|
|
for (const b of st.balls) {
|
|
if (b.dead) continue;
|
|
this.drawBall(g, b);
|
|
}
|
|
|
|
this.drawPipe(g);
|
|
this.drawGhost();
|
|
}
|
|
|
|
drawBall(g, b) {
|
|
const base = GOO_COLOR[b.type] ?? GOO_COLOR.common;
|
|
const x = b.x;
|
|
const y = b.y;
|
|
|
|
// Squash along the direction of travel, so goo reads as soft.
|
|
const vx = b.x - b.px;
|
|
const vy = b.y - b.py;
|
|
const sp = Math.min(1, Math.hypot(vx, vy) / 6);
|
|
|
|
g.fillStyle(shade(base, 0.55), 1);
|
|
g.fillCircle(x, y + 2, b.r);
|
|
g.fillStyle(base, 1);
|
|
g.fillCircle(x, y, b.r);
|
|
g.fillStyle(shade(base, 1.45), 0.5);
|
|
g.fillCircle(x - b.r * 0.3, y - b.r * 0.35, b.r * 0.32);
|
|
|
|
if (b.held) {
|
|
g.lineStyle(3, 0xffffff, 0.9);
|
|
g.strokeCircle(x, y, b.r + 5);
|
|
}
|
|
|
|
// A lit fuse flashes faster as it burns down.
|
|
if (b.fuse != null && !b.exploded) {
|
|
const t = 1 - Math.max(0, b.fuse) / TUNING.FUSE_TIME;
|
|
const flash = 0.45 + 0.55 * Math.abs(Math.sin(this.state.clock * (8 + t * 26)));
|
|
g.fillStyle(0xffd166, flash);
|
|
g.fillCircle(x, y, b.r + 4 + t * 6);
|
|
g.lineStyle(2, 0xff5a4a, flash);
|
|
g.strokeCircle(x, y, b.r + 7 + t * 8);
|
|
}
|
|
|
|
// Pinned goo (anchors, wall-stuck pokey) gets a collar so the player can
|
|
// see what is immovable.
|
|
if (b.pinned && b.attached) {
|
|
g.lineStyle(3, 0xd9a066, 0.85);
|
|
g.strokeCircle(x, y, b.r + 3);
|
|
}
|
|
|
|
// Eyes. Pupils lean into the direction of motion and, at rest, drift in a
|
|
// slow per-ball wander so the pupils read as looking around inside a
|
|
// round head instead of sitting dead-center -- the cheapest way to sell a
|
|
// flat circle as a 3D ball. Loose goo has two eyes, structural goo one,
|
|
// which is also a cheap way to read the pile at a glance. Every ball
|
|
// blinks on its own desynced cycle (seeded off its id) rather than a
|
|
// shared timer, so a pile of goo never blinks in unison.
|
|
const eyes = b.attached ? 1 : 2;
|
|
const er = Math.max(2.6, b.r * 0.30);
|
|
const pr = er * 0.52;
|
|
const seed = b.id * 2.399963; // irrational spacing keeps per-ball cycles desynced
|
|
const wt = this.state.clock * 0.6 + seed;
|
|
const wanderX = Math.sin(wt) * er * 0.3;
|
|
const wanderY = Math.sin(wt * 0.63 + 1.7) * er * 0.2;
|
|
const leanX = sp > 0.02 ? (vx / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0;
|
|
const leanY = sp > 0.02 ? (vy / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0;
|
|
let dx = leanX + wanderX;
|
|
let dy = leanY + wanderY;
|
|
const maxOff = er - pr * 0.9;
|
|
const offLen = Math.hypot(dx, dy);
|
|
if (offLen > maxOff) { dx = (dx / offLen) * maxOff; dy = (dy / offLen) * maxOff; }
|
|
|
|
const blinkPeriod = 2.6 + (b.id % 7) * 0.45;
|
|
const blinkDur = 0.14;
|
|
const bt = (this.state.clock + seed * 3.1) % blinkPeriod;
|
|
const closeAmt = bt < blinkDur ? Math.sin((bt / blinkDur) * Math.PI) : 0;
|
|
const eyeScaleY = Math.max(0.12, 1 - closeAmt);
|
|
|
|
const spread = eyes === 2 ? b.r * 0.38 : 0;
|
|
for (let i = 0; i < eyes; i += 1) {
|
|
const ex = x + (eyes === 2 ? (i === 0 ? -spread : spread) : 0);
|
|
const ey = y - b.r * 0.12;
|
|
g.fillStyle(0xffffff, 1);
|
|
g.fillEllipse(ex, ey, er * 2, er * 2 * eyeScaleY);
|
|
g.fillStyle(0x101018, 1);
|
|
g.fillEllipse(ex + dx, ey + dy * eyeScaleY, pr * 2, pr * 2 * eyeScaleY);
|
|
}
|
|
}
|
|
|
|
drawPipe(g) {
|
|
const p = this.state.pipe;
|
|
if (!p) return;
|
|
const x = p.x;
|
|
const y = p.y;
|
|
const open = p.open;
|
|
|
|
g.fillStyle(PIPE_BODY, 1);
|
|
g.fillRoundedRect(x - p.r * 0.72, y - p.r * 1.5, p.r * 1.44, p.r * 1.5, 8);
|
|
g.fillStyle(open ? PIPE_RIM : shade(PIPE_RIM, 0.45), 1);
|
|
g.fillEllipse(x, y - p.r * 0.35, p.r * 1.7, p.r * 0.62);
|
|
g.fillStyle(0x0a0f14, 1);
|
|
g.fillEllipse(x, y - p.r * 0.35, p.r * 1.24, p.r * 0.4);
|
|
|
|
if (open) {
|
|
const pulse = 0.35 + 0.25 * Math.sin(this.state.clock * 6);
|
|
g.lineStyle(3, PIPE_RIM, pulse);
|
|
g.strokeCircle(x, y - p.r * 0.35, p.r * (1.0 + 0.12 * Math.sin(this.state.clock * 4)));
|
|
}
|
|
}
|
|
|
|
// Live feedback while dragging: exactly which strands would form, and
|
|
// whether the drop is legal at all. Without this the MIN_ANGLE rule is
|
|
// invisible and the player just feels rejected at random.
|
|
drawGhost() {
|
|
const g = this.ghost;
|
|
g.clear();
|
|
const b = this.held;
|
|
if (!b) return;
|
|
|
|
const hazard = hazardAt(this.state, b.x, b.y, b.type);
|
|
const ok = canPlace(this.state, b.x, b.y, b.type);
|
|
const targets = chooseAttachments(this.state, b.x, b.y, b.type);
|
|
// Legal but lethal is still worth a warning -- dropping goo onto spikes is
|
|
// a perfectly valid placement that kills it on contact.
|
|
const color = !ok ? GHOST_NO : hazard ? 0xffb347 : GHOST_OK;
|
|
|
|
// Ghost strokes are UI, not physical: keep them a constant width on screen
|
|
// rather than letting 9x zoom turn them into slabs.
|
|
const px = 1 / this.view.scale;
|
|
for (const id of targets) {
|
|
const t = this.state.balls[id];
|
|
if (!t) continue;
|
|
g.lineStyle(3 * px, color, 0.8);
|
|
g.lineBetween(b.x, b.y, t.x, t.y);
|
|
}
|
|
g.lineStyle(2 * px, color, 0.85);
|
|
g.strokeCircle(b.x, b.y, b.r + 8 * px);
|
|
|
|
if (!ok || hazard) {
|
|
const need = requiredStrands(this.state, b.x, b.y, b.type);
|
|
g.lineStyle(1 * px, color, 0.35);
|
|
g.strokeCircle(b.x, b.y, TUNING.ATTACH_R);
|
|
if (!this.needText) {
|
|
this.needText = this.track(this.add.text(0, 0, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#ff9d9d',
|
|
}).setOrigin(0.5).setDepth(D.ghost));
|
|
}
|
|
const label = !ok
|
|
? (need === 0 ? 'no hold' : `needs ${need}`)
|
|
: (hazard === 'fire' && gooType(b.type).explodes ? 'will light!' : `${hazard}!`);
|
|
this.needText.setPosition(this.tx(b.x), this.ty(b.y) - b.r * this.view.scale - 26)
|
|
.setColor(ok ? '#ffb347' : '#ff9d9d')
|
|
.setText(label)
|
|
.setVisible(true);
|
|
} else if (this.needText) {
|
|
this.needText.setVisible(false);
|
|
}
|
|
}
|
|
|
|
// ── Finish ────────────────────────────────────────────────────────────────
|
|
|
|
async finishLevel() {
|
|
this.finished = true;
|
|
const st = this.state;
|
|
const ocd = hasOCD(st);
|
|
if (!this.returnToEditor) this.recordBest(this.level, st.collected, ocd);
|
|
playSound(this, SFX.VICTORY_SHORT);
|
|
|
|
// An editor test-play is not a real run: it must not advance saved
|
|
// progress or write a match record.
|
|
if (this.canPersist && !this.returnToEditor) {
|
|
try { await api.post('/puzzles/gootower/complete', { level: this.level }); } catch (_) { /* offline */ }
|
|
try {
|
|
await api.post('/history/single-player', {
|
|
game: 'gootower', score: st.collected, won: true,
|
|
});
|
|
} catch (_) { /* offline */ }
|
|
}
|
|
if (!this.returnToEditor && this.level > this.levelsCompleted) this.levelsCompleted = this.level;
|
|
|
|
// The overlay offers "Keep Playing" rather than ending the run outright,
|
|
// because OCD is a separate, higher target and hitting `required` should
|
|
// not shut the door on chasing it.
|
|
this.showWinOverlay();
|
|
}
|
|
|
|
showWinOverlay() {
|
|
if (this.overlayUp) return;
|
|
this.overlayUp = true;
|
|
const st = this.state;
|
|
const cx = GAME_WIDTH / 2;
|
|
const ocd = hasOCD(st);
|
|
|
|
this.track(this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62)
|
|
.setDepth(D.overlay).setInteractive());
|
|
this.track(this.add.text(cx, 330, 'Level Complete', {
|
|
fontFamily: 'Righteous', fontSize: '58px', color: COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.overlayUI));
|
|
this.track(this.add.text(cx, 410, `${st.collected} goo collected (needed ${st.required})`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '26px', color: '#b9c6d2',
|
|
}).setOrigin(0.5).setDepth(D.overlayUI));
|
|
this.track(this.add.text(cx, 456, ocd ? `★ OCD met — ${st.ocdTarget}` : `OCD target: ${st.ocdTarget}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '24px', color: ocd ? '#ffd166' : '#7f8c99',
|
|
}).setOrigin(0.5).setDepth(D.overlayUI));
|
|
|
|
const next = this.returnToEditor ? null : this.manifest.find((m) => m.level === this.level + 1);
|
|
if (next) {
|
|
this.track(new Button(this, cx - 180, 570, 'Next Level', () => this.startLevel(next.level),
|
|
{ width: 250 }).setDepth(D.overlayUI));
|
|
}
|
|
this.track(new Button(this, cx + (next ? 180 : 0), 570,
|
|
this.returnToEditor ? 'Back to Editor' : 'Levels', () => this.exitLevel(),
|
|
{ width: 250, variant: 'ghost' }).setDepth(D.overlayUI));
|
|
this.track(new Button(this, cx, 660, 'Keep Playing', () => {
|
|
this.overlayUp = false;
|
|
this.dismissOverlay();
|
|
}, { width: 250, variant: 'ghost' }).setDepth(D.overlayUI));
|
|
}
|
|
|
|
// "Keep Playing" tears the overlay widgets down but must leave the board
|
|
// itself alone, so the tracked list is filtered rather than cleared.
|
|
dismissOverlay() {
|
|
const keep = [];
|
|
for (const o of this.boardObjs) {
|
|
if (o.depth >= D.overlay) o.destroy();
|
|
else keep.push(o);
|
|
}
|
|
this.boardObjs = keep;
|
|
}
|
|
|
|
// ── Debug helpers ─────────────────────────────────────────────────────────
|
|
|
|
// Debug-only: display the current zoom scale in big yellow numbers at lower-right.
|
|
createDebugZoomText() {
|
|
if (this.debugZoomText) return;
|
|
this.debugZoomText = this.add.text(GAME_WIDTH - 20, GAME_HEIGHT - 20, this.view.scale.toFixed(2) + 'x', {
|
|
fontFamily: 'Righteous',
|
|
fontSize: '42px',
|
|
color: '#ffd166',
|
|
}).setOrigin(1, 1).setDepth(D.overlayUI + 1);
|
|
}
|
|
|
|
// ── Level intro animation ─────────────────────────────────────────────────
|
|
|
|
// Cinematic intro: zoom into the pipe, pause, then pan down to the initial triangle.
|
|
startIntroAnim() {
|
|
const st = this.state;
|
|
if (!st) return;
|
|
|
|
// Find the initial structure triangle — balls that are not in the pile.
|
|
const structIds = st.pile.length < st.balls.length
|
|
? st.balls.filter((b) => !st.pile.includes(b.id)).map((b) => b.id)
|
|
: [];
|
|
if (!structIds.length) return;
|
|
let cx = 0, cy = 0;
|
|
for (const id of structIds) {
|
|
const b = st.balls[id];
|
|
if (b) { cx += b.x; cy += b.y; }
|
|
}
|
|
cx /= structIds.length;
|
|
cy /= structIds.length;
|
|
|
|
// Pipe position (the exit target at the top).
|
|
const pipe = st.pipe;
|
|
const pipeX = pipe ? pipe.x : cx;
|
|
const pipeY = pipe ? pipe.y : cy;
|
|
|
|
// Where the board container should be for the target zoom with pipe centered.
|
|
const targetZoom = 3.5;
|
|
const zoomOx = GAME_WIDTH / 2 - pipeX * targetZoom;
|
|
const zoomOy = GAME_HEIGHT / 2 - pipeY * targetZoom;
|
|
|
|
// Where the board container should be for centering on the pile at 3.5x zoom.
|
|
const panOx = GAME_WIDTH / 2 - cx * targetZoom;
|
|
const panOy = GAME_HEIGHT / 2 - cy * targetZoom;
|
|
|
|
// Animate object that tracks view state.
|
|
const anim = { scale: this.view.scale, ox: this.view.ox, oy: this.view.oy };
|
|
this.introAnim = anim;
|
|
|
|
// Phase 1: zoom in toward the pipe over 1 second.
|
|
this.tweens.add({
|
|
targets: anim,
|
|
scale: targetZoom,
|
|
ox: zoomOx,
|
|
oy: zoomOy,
|
|
duration: 2000,
|
|
ease: 'Cubic.easeInOut',
|
|
onUpdate: () => {
|
|
this.view.scale = anim.scale;
|
|
this.view.ox = anim.ox;
|
|
this.view.oy = anim.oy;
|
|
this.applyView();
|
|
},
|
|
// Phase 2: pause 3 seconds at the zoomed-in view.
|
|
onComplete: () => {
|
|
this.time.delayedCall(1000, () => {
|
|
this.tweens.add({
|
|
targets: anim,
|
|
ox: panOx,
|
|
oy: panOy,
|
|
duration: 3000,
|
|
ease: 'Cubic.easeInOut',
|
|
onUpdate: () => {
|
|
this.view.scale = anim.scale;
|
|
this.view.ox = anim.ox;
|
|
this.view.oy = anim.oy;
|
|
this.applyView();
|
|
},
|
|
onComplete: () => {
|
|
// Animation done — release controls.
|
|
this.introAnim = null;
|
|
},
|
|
});
|
|
});
|
|
},
|
|
});
|
|
}
|
|
}
|