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:
parent
f1c4289f0f
commit
b6e7d932cd
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue