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