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:
parent
c12371b654
commit
e1b1ab2bab
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue