feat(gootower): add edge-pan camera control, intro animation, and debug zoom

- Add edge-based camera panning when zoomed in (50px threshold from screen edges)
- Add cinematic level intro animation: zoom to pipe, pause, then pan to initial structure
- Block user input during intro animation to prevent interference
- Add optional debug zoom level display (toggled via DEBUG_ZOOM constant)
- Clean up intro animation and debug text on level reset
This commit is contained in:
Brian Fertig 2026-07-26 18:43:17 -06:00
parent f1c4289f0f
commit b6e7d932cd
1 changed files with 133 additions and 10 deletions

View File

@ -26,6 +26,9 @@ 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 = {
@ -64,6 +67,7 @@ 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 shade = (color, f) => {
const ch = (s) => Math.min(255, Math.round(((color >> s) & 0xff) * f));
@ -103,6 +107,8 @@ export default class GooTowerGame extends Phaser.Scene {
this.finished = false;
this.view = { ...VIEW_HOME };
this.panFrom = null;
this.introAnim = null; // intro animation state
this.debugZoomText = null;
}
async create() {
@ -158,6 +164,8 @@ export default class GooTowerGame extends Phaser.Scene {
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; }
@ -335,6 +343,8 @@ export default class GooTowerGame extends Phaser.Scene {
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.
@ -518,14 +528,14 @@ export default class GooTowerGame extends Phaser.Scene {
this.input.on('pointerup', (p) => this.onUp(p));
this.input.on('wheel', (p, over, dx, dy) => {
if (this.viewMode !== 'play' || !this.state || this.overlayUp) return;
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', () => this.resetView());
this.input.keyboard?.on('keydown-HOME', () => this.resetView());
this.input.keyboard?.on('keydown-R', () => { if (this.viewMode === 'play') this.startLevel(this.level); });
this.input.keyboard?.on('keydown-ESC', () => { if (this.viewMode === 'play') this.exitLevel(); });
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
@ -539,7 +549,7 @@ export default class GooTowerGame extends Phaser.Scene {
}
onDown(p) {
if (this.viewMode !== 'play' || this.overlayUp || !this.state) return;
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()) {
@ -556,10 +566,12 @@ export default class GooTowerGame extends Phaser.Scene {
}
onMove(p) {
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();
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;
@ -592,6 +604,28 @@ export default class GooTowerGame extends Phaser.Scene {
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();
}
@ -840,4 +874,93 @@ export default class GooTowerGame extends Phaser.Scene {
}
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;
},
});
});
},
});
}
}