From d8296940e5027b45e6cb2eafdca415f091e83198 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 30 Jul 2026 19:54:18 -0600 Subject: [PATCH] Suck into pipe effect for goos --- src/games/gootower/GooTowerGame.js | 93 +++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/games/gootower/GooTowerGame.js b/src/games/gootower/GooTowerGame.js index 5c0ebbd..e2f28ae 100644 --- a/src/games/gootower/GooTowerGame.js +++ b/src/games/gootower/GooTowerGame.js @@ -138,6 +138,7 @@ export default class GooTowerGame extends Phaser.Scene { 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 } async create() { @@ -212,6 +213,7 @@ export default class GooTowerGame extends Phaser.Scene { this.terrainG = null; this.board = null; this.panFrom = null; + this.suckFX = []; this.needText = null; this.hudText = null; if (this.introAnim) { this.tweens.killTweensOf(this.introAnim); this.introAnim = null; } @@ -672,6 +674,11 @@ export default class GooTowerGame extends Phaser.Scene { 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(); @@ -703,7 +710,24 @@ export default class GooTowerGame extends Phaser.Scene { 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 === '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); @@ -796,6 +820,7 @@ export default class GooTowerGame extends Phaser.Scene { this.drawBall(g, b); } + this.drawSuckFX(g); this.drawPipe(g); this.drawGhost(); } @@ -810,6 +835,25 @@ export default class GooTowerGame extends Phaser.Scene { 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 * 7; + if (pdist < PULL_RANGE) { + const pullAmt = Math.pow(1 - pdist / PULL_RANGE, 1.6); + 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); @@ -878,6 +922,53 @@ export default class GooTowerGame extends Phaser.Scene { } } + // 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;