diff --git a/src/games/gootower/GooTowerGame.js b/src/games/gootower/GooTowerGame.js index d3743c8..5c0ebbd 100644 --- a/src/games/gootower/GooTowerGame.js +++ b/src/games/gootower/GooTowerGame.js @@ -68,6 +68,7 @@ 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)); @@ -82,6 +83,31 @@ const lerpColor = (a, b, t) => { 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'); } @@ -110,6 +136,8 @@ export default class GooTowerGame extends Phaser.Scene { 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() { @@ -145,12 +173,33 @@ export default class GooTowerGame extends Phaser.Scene { } drawBackground() { + this.bgImage = null; if (this.textures.exists('gootower-bg')) { - this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'gootower-bg') - .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.sky); + 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 = []; @@ -328,6 +377,7 @@ export default class GooTowerGame extends Phaser.Scene { 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; @@ -486,6 +536,7 @@ export default class GooTowerGame extends Phaser.Scene { applyView() { this.clampView(); + this.updateBackgroundParallax(); if (!this.board) return; this.board.setScale(this.view.scale); 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), ); 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 @@ -774,21 +839,42 @@ export default class GooTowerGame extends Phaser.Scene { g.strokeCircle(x, y, b.r + 3); } - // Eyes. Pupils lean into the direction of motion; loose goo has two eyes, - // structural goo one, which is a cheap way to read the pile at a glance. + // 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 dx = sp > 0.02 ? (vx / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0; - const dy = sp > 0.02 ? (vy / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0; + 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.fillCircle(ex, ey, er); + g.fillEllipse(ex, ey, er * 2, er * 2 * eyeScaleY); g.fillStyle(0x101018, 1); - g.fillCircle(ex + dx, ey + dy, pr); + g.fillEllipse(ex + dx, ey + dy * eyeScaleY, pr * 2, pr * 2 * eyeScaleY); } } diff --git a/src/games/gootower/GooTowerLogic.js b/src/games/gootower/GooTowerLogic.js index 45c4eba..9aea939 100644 --- a/src/games/gootower/GooTowerLogic.js +++ b/src/games/gootower/GooTowerLogic.js @@ -983,11 +983,14 @@ export function placeBall(state, ball, x, y, events = null) { // The nearest ball the player is allowed to pick up: // -// 1. Loose (unattached) balls within reach — preferred so you can grab crawlers -// without accidentally detaching structural balls. -// 2. Attached detachable balls ONLY when clicked directly on them (center-to- -// center distance ≤ ball radius). A glancing pick near the edge does NOT -// detach them — this prevents the "oops I ripped off my ivy" problem. +// 1. Attached detachable balls, but ONLY when clicked directly on them +// (center-to-center distance ≤ ball radius) — a direct click reads as +// "detach this one", even if a loose ball happens to be sitting right +// next to it (e.g. a crawler that wandered up against the structure). +// 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. 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; } } } - // Prefer a loose ball; only return an attached one if clicked directly on it - // and there's no loose ball closer. - if (bestLoose) return bestLoose; - return bestDirect; + // A direct hit on a detachable structural ball wins even over a nearby + // loose ball; only fall back to the loose ball when the click wasn't + // squarely on the attached one. + if (bestDirect) return bestDirect; + return bestLoose; } export function beginDrag(state, ball, events = null) {