fertig-classic-games/src/games/gootower/GooTowerGame.js

1648 lines
65 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 = 0x9c7a52; // lighter, warmer dirt fill
const DIRT_RIM = 0x5c4327; // thick accent stroke on sides/bottom
const DIRT_STROKE_W = 9;
const DIRT_FLECK_DARK = 0x7a5c3c;
const DIRT_FLECK_LIGHT = 0xb99268;
const DIRT_TEX_STEP = 22; // grid spacing for the dirt-fleck texture pass
const GRASS = 0x5fae3b;
const GRASS_DARK = 0x3f7d28;
const GRASS_INSET = DIRT_STROKE_W; // keeps the whole accent stroke visible; grass starts past it
const GRASS_DEPTH = 15; // how far the patchy band reaches down into the dirt, beyond the inset
const GRASS_SEG = 20; // approx width of one grass patch, in px
// Spikes -- gunmetal plate with faceted steel teeth stamped on every outward
// edge. SPIKE_LIGHT_DIR is a fixed "sun" used to pick which facet of each
// tooth reads as lit vs shadowed, regardless of which way the edge faces.
const SPIKE_BASE = 0x3a4250;
const SPIKE_BASE_DARK = 0x1f232b;
const SPIKE_BASE_EDGE_HI = 0x788597;
const SPIKE_BASE_EDGE_LO = 0x13151a;
const SPIKE_TOOTH_LIGHT = 0xcdd9e6;
const SPIKE_TOOTH_DARK = 0x4a5262;
const SPIKE_RIVET = 0x14161b;
const SPIKE_RIVET_HI = 0x9aa5b4;
const SPIKE_RUST = 0x8a5a3a;
const SPIKE_GLINT = 0xf3fbff;
const SPIKE_LIGHT_DIR = { x: -0.55, y: -0.83 };
const SPIKE_TOOTH_LEN = 20;
const SPIKE_TOOTH_STEP = 24;
// Fire/lava -- layered heat gradient (crust rim -> white-hot core) with an
// animated surface: drifting flow streaks and rising/popping bubbles.
const LAVA_CRUST = 0x2a1410;
const LAVA_CRUST_EDGE = 0x140a08;
const LAVA_OUTER = 0x8a2410;
const LAVA_MID = 0xd4642a;
const LAVA_HOT = 0xf59a3c;
const LAVA_CORE = 0xffe9a8;
const LAVA_FLOW_LINE = 0xffb459;
const LAVA_BUBBLE = 0xffd98a;
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);
};
// Deterministic spatial hash -> [0,1). Used for grass-patch variation so a
// terrain piece looks the same on every redraw (e.g. after a blast opens a
// destructible rock) instead of re-rolling every frame.
const hash01 = (n) => {
const s = Math.sin(n * 12.9898) * 43758.5453;
return s - Math.floor(s);
};
// Splits a terrain polygon into edges tagged with their true outward normal
// (found via the centroid, so it works regardless of winding order) and
// whether that edge faces up -- i.e. is exposed ground a grass patch belongs
// on, as opposed to a side or the underside. Despite the historical name,
// every edge is returned (not just top-facing ones): callers that only want
// the top filter on `.top` themselves; spike teeth and lava crust want all of
// them.
function topFacingEdges(poly) {
const n = poly.length;
let cx = 0, cy = 0;
for (const [x, y] of poly) { cx += x; cy += y; }
cx /= n; cy /= n;
const edges = [];
for (let i = 0; i < n; i += 1) {
const [ax, ay] = poly[i];
const [bx, by] = poly[(i + 1) % n];
const ex = bx - ax, ey = by - ay;
const len = Math.hypot(ex, ey) || 1;
let nx = -ey / len, ny = ex / len;
const mx = (ax + bx) / 2, my = (ay + by) / 2;
if ((mx - cx) * nx + (my - cy) * ny < 0) { nx = -nx; ny = -ny; }
edges.push({ ax, ay, bx, by, nx, ny, top: ny < -0.5 });
}
return edges;
}
function polyCentroid(poly) {
let cx = 0, cy = 0;
for (const [x, y] of poly) { cx += x; cy += y; }
return { x: cx / poly.length, y: cy / poly.length };
}
// Shrinks a polygon toward a center point by factor f (1 = unchanged, 0 =
// collapsed to the point). Used to fake a soft radial "hot core" gradient on
// lava by layering a few of these, each hotter and more shrunk than the last,
// instead of one flat fill.
function scalePoly(poly, cx, cy, f) {
return poly.map(([x, y]) => [cx + (x - cx) * f, cy + (y - cy) * f]);
}
// The outward-facing unit normal of edge p0->p1, chosen to point away from
// `insidePt` -- used to shade a tooth's two facets against a fixed light
// direction regardless of which way the tooth itself is pointing.
function edgeNormalAway(p0, p1, insidePt) {
const ex = p1.x - p0.x, ey = p1.y - p0.y;
const len = Math.hypot(ex, ey) || 1;
let nx = -ey / len, ny = ex / len;
const mx = (p0.x + p1.x) / 2, my = (p0.y + p1.y) / 2;
if ((insidePt.x - mx) * nx + (insidePt.y - my) * ny > 0) { nx = -nx; ny = -ny; }
return { x: nx, y: ny };
}
// Standard ray-cast point-in-polygon test, used to clip the dirt-fleck
// texture to a terrain piece's actual shape rather than its bounding box --
// most pieces are simple rectangles/trapezoids, but this keeps flecks from
// spilling past the edge on the handful that aren't.
function pointInPoly(px, py, poly) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i];
const [xj, yj] = poly[j];
const cross = ((yi > py) !== (yj > py)) &&
(px < ((xj - xi) * (py - yi)) / (yj - yi) + xi);
if (cross) inside = !inside;
}
return inside;
}
// 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;
this.suckFX = []; // short-lived cosmetic tails for balls the pipe just collected
this.pipeImg = null; // set in setupPipeSprite() when gootower-pipe art is loaded
this.pipeGlow = null; // graphics layer painted OVER pipeImg for the open/close pulse
this.gearSprites = []; // [{ gear, img }], set in setupGearSprites()
}
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.suckFX = [];
this.pipeImg = null;
this.pipeGlow = null;
this.gearSprites = [];
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. Gear
// art sits between terrain and dyn so it paints under strands/balls, same
// as the procedural drawGears() call used to (drawn first inside dyn);
// pipe art sits after dyn so it still paints over balls/suck-tendrils,
// same as the old drawPipe() call being last inside dyn.
this.board = this.track(this.add.container(0, 0).setDepth(D.terrain));
this.terrainG = this.add.graphics();
this.board.add(this.terrainG);
this.setupGearSprites();
this.dyn = this.add.graphics();
this.board.add(this.dyn);
this.setupPipeSprite();
// Painted after pipeImg so the open/close pulse (drawPipeGlow) shows over
// the opaque sprite instead of being covered by it.
this.pipeGlow = this.add.graphics();
this.board.add(this.pipeGlow);
this.ghost = this.add.graphics();
this.board.add(this.ghost);
this.view = { ...VIEW_HOME };
this.applyView();
this.drawTerrain();
this.buildHud();
if (DEBUG_ZOOM) this.createDebugZoomText();
this.startIntroAnim();
}
// One Image per gear, in place of the old drawGears() procedural redraw.
// The gear's polygon still gets rebuilt every physics tick in
// GooTowerLogic.js (collision needs it) -- this only replaces the RENDER
// side, rotating a static sprite instead of re-triangulating teeth each
// frame. Falls back to the procedural draw when no art is loaded.
setupGearSprites() {
this.gearSprites = [];
if (!this.textures.exists('gootower-gear')) return;
for (const t of this.state.terrain) {
if (!t.gear) continue;
const img = this.add.image(t.gear.cx, t.gear.cy, 'gootower-gear');
img.setDisplaySize(t.gear.r * 2, t.gear.r * 2);
img.setRotation(t.gear.angle);
this.board.add(img);
this.gearSprites.push({ gear: t.gear, img });
}
}
// Static pipe art in place of the old drawPipe() procedural body+opening.
// Anchored bottom-center (setOrigin(0.5, 1)) because that's where the
// procedural version's (x, y) sat -- the body extended upward from there.
// The open/closed pulse is still drawn procedurally each frame (see
// drawPipeGlow) since state.pipe.open can flip either way mid-level.
setupPipeSprite() {
this.pipeImg = null;
const p = this.state.pipe;
if (!p || !this.textures.exists('gootower-pipe')) return;
const img = this.add.image(p.x, p.y, 'gootower-pipe').setOrigin(0.5, 1);
img.setDisplaySize(p.r * 1.7, p.r * 1.5);
this.board.add(img);
this.pipeImg = img;
}
// 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);
this.drawDirtTexture(g, t);
// Thick stroke stays fully visible on every edge, including the top;
// grass is inset past it (see GRASS_INSET) rather than painted over
// it, so the accented outline reads as a border under the turf.
g.lineStyle(DIRT_STROKE_W, DIRT_RIM, 1);
g.strokePoints(pts, true);
for (const e of topFacingEdges(t.poly)) {
if (e.top) this.drawGrassEdge(g, e);
}
} else if (t.kind === 'spike') {
this.drawSpikeBase(g, t);
this.drawTeeth(g, t);
this.drawMetalGrit(g, t);
} else if (t.kind === 'fire') {
this.drawLavaBase(g, t);
}
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);
}
// A patchy strip of grass along one top-facing edge of a dirt polygon:
// short segments of varying depth and two-tone green, with occasional bare
// gaps, so it reads as uneven turf rather than a painted racing stripe.
// The whole band starts GRASS_INSET past the true edge so the full accent
// stroke stays visible as a border between the open air and the turf.
drawGrassEdge(g, e) {
const dx = e.bx - e.ax, dy = e.by - e.ay;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const inX = -e.nx, inY = -e.ny; // inward, i.e. down into the dirt
// Inset start point: the band runs parallel to the true edge, offset
// inward by GRASS_INSET, for its full original length (dx, dy).
const bax = e.ax + inX * GRASS_INSET, bay = e.ay + inY * GRASS_INSET;
const seed = e.ax * 0.131 + e.ay * 0.277 + e.bx * 0.071 + e.by * 0.193;
const nSeg = Math.max(1, Math.round(len / GRASS_SEG));
for (let i = 0; i < nSeg; i += 1) {
const s = seed + i * 7.919;
if (hash01(s) < 0.12) continue; // bare dirt gap -- keeps the band patchy
const t0 = i / nSeg, t1 = (i + 1) / nSeg;
const d0 = GRASS_DEPTH * (0.5 + 0.6 * hash01(s + 1.7));
const d1 = GRASS_DEPTH * (0.5 + 0.6 * hash01(s + 4.4));
const ax0 = bax + dx * t0, ay0 = bay + dy * t0;
const ax1 = bax + dx * t1, ay1 = bay + dy * t1;
g.fillStyle(hash01(s + 2.6) < 0.5 ? GRASS : GRASS_DARK, 1);
g.fillPoints([
{ x: ax0, y: ay0 },
{ x: ax1, y: ay1 },
{ x: ax1 + inX * d1, y: ay1 + inY * d1 },
{ x: ax0 + inX * d0, y: ay0 + inY * d0 },
], true);
}
}
// Subtle mottled speckling across the dirt fill, clipped to the polygon's
// actual shape (not just its bounding box) so it doesn't spill past
// non-rectangular edges. Purely a texture pass -- deterministic per piece
// so it doesn't shimmer on redraw (e.g. when a blast opens a neighbor).
drawDirtTexture(g, t) {
const bb = t.bounds;
for (let gx = bb.minX; gx < bb.maxX; gx += DIRT_TEX_STEP) {
for (let gy = bb.minY; gy < bb.maxY; gy += DIRT_TEX_STEP) {
const cellSeed = gx * 0.041 + gy * 0.077;
if (hash01(cellSeed) < 0.55) continue; // sparse -- a hint of texture, not noise
const jx = gx + (hash01(cellSeed + 1.1) - 0.5) * DIRT_TEX_STEP * 0.8;
const jy = gy + (hash01(cellSeed + 2.3) - 0.5) * DIRT_TEX_STEP * 0.8;
if (!pointInPoly(jx, jy, t.poly)) continue;
const dark = hash01(cellSeed + 3.7) < 0.5;
const rad = 2 + hash01(cellSeed + 4.9) * 2.5;
g.fillStyle(dark ? DIRT_FLECK_DARK : DIRT_FLECK_LIGHT, dark ? 0.35 : 0.28);
g.fillCircle(jx, jy, rad);
}
}
}
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);
}
}
// Gunmetal plate under the teeth: a dark base, a soft highlight blob
// pulled toward the fixed light direction so it doesn't read as flat, and
// a bright/dark rim stroke on whichever edges face toward/away from that
// light -- the same trick drawTeeth uses per-tooth, applied to the plate.
drawSpikeBase(g, t) {
const pts = t.poly.map(([x, y]) => ({ x, y }));
const c = polyCentroid(t.poly);
g.fillStyle(SPIKE_BASE_DARK, 1);
g.fillPoints(pts, true);
const hiPts = scalePoly(
t.poly, c.x + SPIKE_LIGHT_DIR.x * 14, c.y + SPIKE_LIGHT_DIR.y * 14, 0.62,
).map(([x, y]) => ({ x, y }));
g.fillStyle(SPIKE_BASE, 0.55);
g.fillPoints(hiPts, true);
for (const e of topFacingEdges(t.poly)) {
const lit = e.nx * SPIKE_LIGHT_DIR.x + e.ny * SPIKE_LIGHT_DIR.y;
if (lit > 0.3) { g.lineStyle(2, SPIKE_BASE_EDGE_HI, 0.8); g.lineBetween(e.ax, e.ay, e.bx, e.by); }
else if (lit < -0.3) { g.lineStyle(2, SPIKE_BASE_EDGE_LO, 0.8); g.lineBetween(e.ax, e.ay, e.bx, e.by); }
}
}
// Spikes read as spikes because of the teeth, not the fill colour. Unlike
// the old version, teeth are stamped along EVERY outward edge (via
// topFacingEdges' true per-edge normal), not just horizontal ones, so a
// block bristles on every side that faces open air. Each tooth is split
// into two facets -- (p0, mid, tip) and (mid, p1, tip) -- shaded against a
// fixed light direction via edgeNormalAway, so it reads as a faceted steel
// point instead of a flat silhouette regardless of which way it points.
// Tip world positions are cached on the piece for drawSpikeTwinkle.
drawTeeth(g, t) {
const tips = [];
for (const e of topFacingEdges(t.poly)) {
const dx = e.bx - e.ax, dy = e.by - e.ay;
const len = Math.hypot(dx, dy);
if (len < 1) continue;
const n = Math.max(1, Math.round(len / SPIKE_TOOTH_STEP));
for (let k = 0; k < n; k += 1) {
const p0 = { x: e.ax + dx * (k / n), y: e.ay + dy * (k / n) };
const p1 = { x: e.ax + dx * ((k + 1) / n), y: e.ay + dy * ((k + 1) / n) };
const mid = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
const tip = { x: mid.x + e.nx * SPIKE_TOOTH_LEN, y: mid.y + e.ny * SPIKE_TOOTH_LEN };
tips.push({ x: tip.x, y: tip.y, seed: hash01(tip.x * 0.13 + tip.y * 0.29) });
const n1 = edgeNormalAway(p0, tip, mid);
const n2 = edgeNormalAway(tip, p1, mid);
const lit1 = n1.x * SPIKE_LIGHT_DIR.x + n1.y * SPIKE_LIGHT_DIR.y;
const lit2 = n2.x * SPIKE_LIGHT_DIR.x + n2.y * SPIKE_LIGHT_DIR.y;
g.fillStyle(lit1 >= lit2 ? SPIKE_TOOTH_LIGHT : SPIKE_TOOTH_DARK, 1);
g.fillTriangle(p0.x, p0.y, mid.x, mid.y, tip.x, tip.y);
g.fillStyle(lit1 >= lit2 ? SPIKE_TOOTH_DARK : SPIKE_TOOTH_LIGHT, 1);
g.fillTriangle(mid.x, mid.y, p1.x, p1.y, tip.x, tip.y);
g.lineStyle(1, SPIKE_BASE_EDGE_LO, 0.5);
g.lineBetween(p0.x, p0.y, p1.x, p1.y);
// A rivet stamped into the plate at most tooth bases, inset along
// the inward normal so it sits on the metal, not floating past it.
if (hash01(mid.x * 0.07 + mid.y * 0.11) > 0.4) {
const rx = mid.x - e.nx * 7, ry = mid.y - e.ny * 7;
g.fillStyle(SPIKE_RIVET, 1);
g.fillCircle(rx, ry, 2.4);
g.fillStyle(SPIKE_RIVET_HI, 0.8);
g.fillCircle(rx - 0.6, ry - 0.6, 0.9);
}
}
}
t._teethTips = tips;
}
// Rust speckling + fine scratches, reusing the dirt-fleck clipping trick
// (drawDirtTexture) so grit stays inside the plate's actual silhouette.
drawMetalGrit(g, t) {
const bb = t.bounds;
const STEP = 26;
for (let gx = bb.minX; gx < bb.maxX; gx += STEP) {
for (let gy = bb.minY; gy < bb.maxY; gy += STEP) {
const seed = gx * 0.053 + gy * 0.091;
if (hash01(seed) < 0.72) continue;
const jx = gx + (hash01(seed + 1.3) - 0.5) * STEP * 0.8;
const jy = gy + (hash01(seed + 2.7) - 0.5) * STEP * 0.8;
if (!pointInPoly(jx, jy, t.poly)) continue;
if (hash01(seed + 3.1) < 0.5) {
g.fillStyle(SPIKE_RUST, 0.3);
g.fillCircle(jx, jy, 1.5 + hash01(seed + 4.2) * 1.8);
} else {
const ang = hash01(seed + 5.5) * Math.PI;
const len = 4 + hash01(seed + 6.6) * 5;
g.lineStyle(1, SPIKE_TOOTH_LIGHT, 0.12);
g.lineBetween(
jx - Math.cos(ang) * len / 2, jy - Math.sin(ang) * len / 2,
jx + Math.cos(ang) * len / 2, jy + Math.sin(ang) * len / 2,
);
}
}
}
}
// Molten pool: a layered heat gradient (crust rim -> white-hot core) faked
// with a few polygons shrunk toward the centroid via scalePoly instead of a
// real radial gradient, topped with jagged "broken crust" chunks along
// every edge with glowing seams between them. Also seeds the flow-lane and
// bubble-slot data drawLavaFlow animates every frame.
drawLavaBase(g, t) {
const pts = t.poly.map(([x, y]) => ({ x, y }));
const c = polyCentroid(t.poly);
g.fillStyle(LAVA_CRUST, 1);
g.fillPoints(pts, true);
const layers = [
{ f: 0.86, color: LAVA_OUTER },
{ f: 0.62, color: LAVA_MID },
{ f: 0.36, color: LAVA_HOT },
{ f: 0.16, color: LAVA_CORE },
];
for (const layer of layers) {
const lp = scalePoly(t.poly, c.x, c.y, layer.f).map(([x, y]) => ({ x, y }));
g.fillStyle(layer.color, 1);
g.fillPoints(lp, true);
}
for (const e of topFacingEdges(t.poly)) {
const dx = e.bx - e.ax, dy = e.by - e.ay;
const len = Math.hypot(dx, dy);
if (len < 1) continue;
const seed = e.ax * 0.11 + e.ay * 0.19 + e.bx * 0.07 + e.by * 0.23;
const n = Math.max(1, Math.round(len / 30));
for (let k = 0; k < n; k += 1) {
const s = seed + k * 5.31;
if (hash01(s + 2.2) < 0.2) continue; // occasional gap: a wider crack of bare lava
const depth = 8 + hash01(s) * 10;
const p0 = { x: e.ax + dx * (k / n), y: e.ay + dy * (k / n) };
const p1 = { x: e.ax + dx * ((k + 1) / n), y: e.ay + dy * ((k + 1) / n) };
const q0 = { x: p0.x - e.nx * depth, y: p0.y - e.ny * depth };
const q1 = { x: p1.x - e.nx * depth, y: p1.y - e.ny * depth };
g.fillStyle(LAVA_CRUST, 0.9);
g.fillPoints([p0, p1, q1, q0], true);
g.lineStyle(1.5, LAVA_FLOW_LINE, 0.55);
g.lineBetween(p0.x, p0.y, q0.x, q0.y);
}
}
const bb = t.bounds;
const laneCount = Math.max(2, Math.round((bb.maxX - bb.minX) / 40));
const lanes = [];
for (let i = 0; i < laneCount; i += 1) {
const s = i * 3.7 + bb.minX * 0.01;
lanes.push({
y: bb.minY + (bb.maxY - bb.minY) * (0.25 + 0.5 * hash01(s)),
speed: 14 + hash01(s + 1) * 10,
amp: 3 + hash01(s + 2) * 4,
freq: 0.02 + hash01(s + 3) * 0.02,
phase: hash01(s + 4) * Math.PI * 2,
dir: hash01(s + 5) < 0.5 ? 1 : -1,
});
}
t._lavaLanes = lanes;
const area = (bb.maxX - bb.minX) * (bb.maxY - bb.minY);
const bubbleCount = Math.max(2, Math.round(area / 4500));
const bubbles = [];
for (let i = 0; i < bubbleCount; i += 1) {
const s = i * 5.3 + bb.minY * 0.017;
bubbles.push({
x: bb.minX + (bb.maxX - bb.minX) * hash01(s),
y: bb.minY + (bb.maxY - bb.minY) * (0.3 + 0.5 * hash01(s + 1)),
period: 1.4 + hash01(s + 2) * 1.8,
offset: hash01(s + 3) * 10,
maxR: 3 + hash01(s + 4) * 4,
});
}
t._lavaBubbles = bubbles;
}
// A handful of tooth tips catch a bright glint at a time, cycling on
// desynced per-tip timers via `time` (the sim clock) -- reads as light
// twinkling off steel points rather than a static texture.
drawSpikeTwinkle(g, t, time) {
const tips = t._teethTips;
if (!tips) return;
for (const tip of tips) {
const phase = (time * 0.35 + tip.seed * 6.28) % (Math.PI * 2);
const bright = Math.pow(Math.max(0, Math.sin(phase)), 10); // sharp, brief peak
if (bright < 0.05) continue;
g.fillStyle(SPIKE_GLINT, bright * 0.9);
g.fillCircle(tip.x, tip.y, 2 + bright * 1.5);
}
}
// Drifting wavy flow-streaks (per-lane sine offset scrolling over time) and
// rising/popping bubbles, animated each frame from the slots drawLavaBase
// seeded once per piece.
drawLavaFlow(g, t, time) {
const lanes = t._lavaLanes;
const bb = t.bounds;
if (lanes) {
for (const lane of lanes) {
const linePts = [];
const steps = 10;
for (let i = 0; i <= steps; i += 1) {
const x = bb.minX + (bb.maxX - bb.minX) * (i / steps);
const y = lane.y + Math.sin(x * lane.freq + time * lane.speed * lane.dir + lane.phase) * lane.amp;
if (pointInPoly(x, y, t.poly)) linePts.push({ x, y });
}
if (linePts.length > 1) {
g.lineStyle(2, LAVA_FLOW_LINE, 0.4);
g.strokePoints(linePts, false);
}
}
}
const bubbles = t._lavaBubbles;
if (bubbles) {
for (const b of bubbles) {
const phase = ((time + b.offset) % b.period) / b.period;
let r, a;
if (phase < 0.7) { r = b.maxR * (phase / 0.7); a = 0.8; }
else { r = b.maxR * (1 - (phase - 0.7) / 0.3); a = 0.8 * (1 - (phase - 0.7) / 0.3); }
if (r <= 0.3) continue;
g.fillStyle(LAVA_BUBBLE, a);
g.fillCircle(b.x, b.y - r * 0.3, r);
g.lineStyle(1, LAVA_CORE, a * 0.7);
g.strokeCircle(b.x, b.y - r * 0.3, r);
}
}
}
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);
if (this.suckFX.length) {
for (const fx of this.suckFX) fx.t += dt;
this.suckFX = this.suckFX.filter((fx) => fx.t < fx.dur);
}
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);
// The sim deletes the ball this same tick; spawn a short cosmetic
// tail continuing the pre-collect pull tendril so it reads as being
// swallowed rather than popping out of existence.
const b = this.state.balls[e.ball];
const pipe = this.state.pipe;
if (b && pipe) {
const ddx = pipe.x - b.x;
const ddy = pipe.y - b.y;
const dlen = Math.hypot(ddx, ddy) || 1;
this.suckFX.push({
x: b.x, y: b.y, dirX: ddx / dlen, dirY: ddy / dlen,
r: b.r, color: GOO_COLOR[b.type] ?? GOO_COLOR.common,
t: 0, dur: 0.18,
});
}
}
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();
if (this.gearSprites.length) {
for (const gs of this.gearSprites) gs.img.setRotation(gs.gear.angle);
} else {
this.drawGears(g);
}
// Spike glints and lava flow/bubbles animate every frame off the sim
// clock, layered on top of the static plate/pool fill drawn in terrainG.
for (const tr of st.terrain) {
if (tr.kind === 'spike') this.drawSpikeTwinkle(g, tr, st.clock);
else if (tr.kind === 'fire') this.drawLavaFlow(g, tr, st.clock);
}
// 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.drawSuckFX(g);
if (this.pipeImg) {
this.pipeImg.setTint(this.state.pipe.open ? 0xffffff : 0x9a9a9a);
this.pipeGlow.clear();
this.drawPipeGlow(this.pipeGlow);
} else {
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);
// A walking ball nearing an open pipe grows a tapering tendril reaching
// toward the mouth, so the last stretch of its walk visibly pulls it in
// rather than the sim's instant collect reading as a random disappearance.
// Drawn UNDER the body circles below so it merges seamlessly into the ball.
const pipe = this.state.pipe;
if (b.walking && pipe && pipe.open) {
const pdx = pipe.x - x;
const pdy = pipe.y - y;
const pdist = Math.hypot(pdx, pdy);
const PULL_RANGE = b.r * 16;
if (pdist < PULL_RANGE) {
const pullAmt = Math.pow(1 - pdist / PULL_RANGE, 1.3);
const inv = pdist > 0.001 ? 1 / pdist : 0;
const pullX = pdx * inv;
const pullY = pdy * inv;
this.drawPullTendril(g, x, y, pullX, pullY, b.r, pullAmt, base);
}
}
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);
}
}
// A tapering blob stretching from (x, y) toward unit direction (dirX, dirY),
// built the same way strands are (a perpendicular-offset polygon) rather
// than a canvas rotate/scale, so it works for any pull direction without
// having to un-rotate the body's fixed-light-source highlight. `amt` in
// [0,1] drives both how far it reaches and how much it thins toward the
// tip; `alpha` lets the post-collect fade dim it out as it finishes.
drawPullTendril(g, x, y, dirX, dirY, r, amt, color, alpha = 1) {
const len = r * (0.5 + 3.2 * amt);
const nx = -dirY;
const ny = dirX;
const baseW = r * 2 * (1 - 0.2 * amt);
const tipW = r * 2 * Math.max(0.05, 0.5 - 0.45 * amt);
const steps = 6;
const verts = [];
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const w = baseW + (tipW - baseW) * t;
const cx = x + dirX * len * t;
const cy = y + dirY * len * t;
verts.push({ x: cx + nx * (w / 2), y: cy + ny * (w / 2) });
}
for (let i = steps; i >= 0; i -= 1) {
const t = i / steps;
const w = baseW + (tipW - baseW) * t;
const cx = x + dirX * len * t;
const cy = y + dirY * len * t;
verts.push({ x: cx - nx * (w / 2), y: cy - ny * (w / 2) });
}
g.fillStyle(color, alpha);
g.fillPoints(verts, true);
}
// Short-lived cosmetic tail spawned when a ball is instantly collected by
// the pipe (see handleEvents 'collect'). The sim has already deleted the
// ball by the time this plays, so it stands in for a few frames to turn
// that hard cut into a continuation of the pre-collect pull tendril above,
// shrinking and reaching further in as it fades.
drawSuckFX(g) {
if (!this.suckFX.length) return;
for (const fx of this.suckFX) {
const t = Math.min(1, fx.t / fx.dur);
const fade = 1 - t;
if (fade <= 0.02) continue;
this.drawPullTendril(g, fx.x, fx.y, fx.dirX, fx.dirY, fx.r * fade, 0.6 + 0.4 * t, fx.color, fade);
}
}
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)));
}
}
// Just the pulsing "open" ring, used over pipe art (see setupPipeSprite)
// instead of the full procedural body+opening drawPipe() draws. Kept
// separate from the sprite because state.pipe.open can flip either way
// mid-level (a structure can lose its connection to the pipe), so this
// still needs a per-frame check rather than a one-time sprite swap.
drawPipeGlow(g) {
const p = this.state.pipe;
if (!p || !p.open) return;
const pulse = 0.35 + 0.25 * Math.sin(this.state.clock * 6);
g.lineStyle(3, PIPE_RIM, pulse);
g.strokeCircle(p.x, p.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;
},
});
});
},
});
}
}