feat(gootower): add background parallax, dynamic level bounds, ball eye animations

- Add background parallax layer that follows camera pan/zoom at a reduced
  rate for depth perception
- Replace fixed world-height Y clamping with dynamic level content bounds
  computed from terrain, pipe, and balls
- Add per-ball eye wander and desynchronized blink cycles for livelier
  ball rendering
- Reorder pickBallAt priority so direct clicks on detachable attached balls
  take precedence over loose balls, preventing accidental structure detachment
This commit is contained in:
Brian Fertig 2026-07-30 19:38:37 -06:00
parent c12371b654
commit e1b1ab2bab
2 changed files with 108 additions and 18 deletions

View File

@ -68,6 +68,7 @@ const ZOOM_MAX = 9;
const ZOOM_STEP = 1.15; const ZOOM_STEP = 1.15;
const VIEW_KEEP = 0.35; // fraction of the viewport that must stay over the board 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 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 shade = (color, f) => {
const ch = (s) => Math.min(255, Math.round(((color >> s) & 0xff) * f)); const ch = (s) => Math.min(255, Math.round(((color >> s) & 0xff) * f));
@ -82,6 +83,31 @@ const lerpColor = (a, b, t) => {
return (ch(16) << 16) | (ch(8) << 8) | ch(0); 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 { export default class GooTowerGame extends Phaser.Scene {
constructor() { super('GooTowerGame'); } constructor() { super('GooTowerGame'); }
@ -110,6 +136,8 @@ export default class GooTowerGame extends Phaser.Scene {
this.panFrom = null; this.panFrom = null;
this.introAnim = null; // intro animation state this.introAnim = null; // intro animation state
this.debugZoomText = null; this.debugZoomText = null;
this.levelBounds = null; // { top, bottom }, set per level in startLevel()
this.bgImage = null;
} }
async create() { async create() {
@ -145,12 +173,33 @@ export default class GooTowerGame extends Phaser.Scene {
} }
drawBackground() { drawBackground() {
this.bgImage = null;
if (this.textures.exists('gootower-bg')) { if (this.textures.exists('gootower-bg')) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, '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); .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() { clearBoard() {
for (const o of this.boardObjs) o.destroy(); for (const o of this.boardObjs) o.destroy();
this.boardObjs = []; this.boardObjs = [];
@ -328,6 +377,7 @@ export default class GooTowerGame extends Phaser.Scene {
this.level = level; this.level = level;
this.levelDef = def; this.levelDef = def;
this.state = createState(def, 0x60071e + level); this.state = createState(def, 0x60071e + level);
this.levelBounds = levelVerticalBounds(this.state);
this.accum = 0; this.accum = 0;
this.held = null; this.held = null;
this.wasAttached = false; this.wasAttached = false;
@ -486,6 +536,7 @@ export default class GooTowerGame extends Phaser.Scene {
applyView() { applyView() {
this.clampView(); this.clampView();
this.updateBackgroundParallax();
if (!this.board) return; if (!this.board) return;
this.board.setScale(this.view.scale); this.board.setScale(this.view.scale);
this.board.setPosition(this.view.ox, this.view.oy); this.board.setPosition(this.view.ox, this.view.oy);
@ -502,7 +553,21 @@ export default class GooTowerGame extends Phaser.Scene {
Math.min(viewport * (1 - VIEW_KEEP), v), Math.min(viewport * (1 - VIEW_KEEP), v),
); );
this.view.ox = clampAxis(this.view.ox, WORLD_W * this.view.scale, GAME_WIDTH); this.view.ox = clampAxis(this.view.ox, WORLD_W * this.view.scale, GAME_WIDTH);
this.view.oy = clampAxis(this.view.oy, WORLD_H * this.view.scale, GAME_HEIGHT);
// 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 // Zoom about a screen point: the world position under the cursor must stay
@ -774,21 +839,42 @@ export default class GooTowerGame extends Phaser.Scene {
g.strokeCircle(x, y, b.r + 3); g.strokeCircle(x, y, b.r + 3);
} }
// Eyes. Pupils lean into the direction of motion; loose goo has two eyes, // Eyes. Pupils lean into the direction of motion and, at rest, drift in a
// structural goo one, which is a cheap way to read the pile at a glance. // 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 eyes = b.attached ? 1 : 2;
const er = Math.max(2.6, b.r * 0.30); const er = Math.max(2.6, b.r * 0.30);
const pr = er * 0.52; const pr = er * 0.52;
const dx = sp > 0.02 ? (vx / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0; const seed = b.id * 2.399963; // irrational spacing keeps per-ball cycles desynced
const dy = sp > 0.02 ? (vy / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0; 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; const spread = eyes === 2 ? b.r * 0.38 : 0;
for (let i = 0; i < eyes; i += 1) { for (let i = 0; i < eyes; i += 1) {
const ex = x + (eyes === 2 ? (i === 0 ? -spread : spread) : 0); const ex = x + (eyes === 2 ? (i === 0 ? -spread : spread) : 0);
const ey = y - b.r * 0.12; const ey = y - b.r * 0.12;
g.fillStyle(0xffffff, 1); g.fillStyle(0xffffff, 1);
g.fillCircle(ex, ey, er); g.fillEllipse(ex, ey, er * 2, er * 2 * eyeScaleY);
g.fillStyle(0x101018, 1); g.fillStyle(0x101018, 1);
g.fillCircle(ex + dx, ey + dy, pr); g.fillEllipse(ex + dx, ey + dy * eyeScaleY, pr * 2, pr * 2 * eyeScaleY);
} }
} }

View File

@ -983,11 +983,14 @@ export function placeBall(state, ball, x, y, events = null) {
// The nearest ball the player is allowed to pick up: // The nearest ball the player is allowed to pick up:
// //
// 1. Loose (unattached) balls within reach — preferred so you can grab crawlers // 1. Attached detachable balls, but ONLY when clicked directly on them
// without accidentally detaching structural balls. // (center-to-center distance ≤ ball radius) — a direct click reads as
// 2. Attached detachable balls ONLY when clicked directly on them (center-to- // "detach this one", even if a loose ball happens to be sitting right
// center distance ≤ ball radius). A glancing pick near the edge does NOT // next to it (e.g. a crawler that wandered up against the structure).
// detach them — this prevents the "oops I ripped off my ivy" problem. // 2. Otherwise, the nearest loose (unattached) ball within reach — this is
// what lets you grab a crawler by clicking near it without detaching
// structure, since a glancing click near the edge of an attached ball
// does NOT count as "direct" and falls through to here instead.
// //
// Non-detachable attached balls are never returned. // Non-detachable attached balls are never returned.
export function pickBallAt(state, x, y, reach = 42) { export function pickBallAt(state, x, y, reach = 42) {
@ -1008,10 +1011,11 @@ export function pickBallAt(state, x, y, reach = 42) {
if (d <= reach + b.r && d < bestLooseD) { bestLooseD = d; bestLoose = b; } if (d <= reach + b.r && d < bestLooseD) { bestLooseD = d; bestLoose = b; }
} }
} }
// Prefer a loose ball; only return an attached one if clicked directly on it // A direct hit on a detachable structural ball wins even over a nearby
// and there's no loose ball closer. // loose ball; only fall back to the loose ball when the click wasn't
if (bestLoose) return bestLoose; // squarely on the attached one.
return bestDirect; if (bestDirect) return bestDirect;
return bestLoose;
} }
export function beginDrag(state, ball, events = null) { export function beginDrag(state, ball, events = null) {