1648 lines
65 KiB
JavaScript
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;
|
|
},
|
|
});
|
|
});
|
|
},
|
|
});
|
|
}
|
|
}
|