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_OK = 0x7fe38a;
|
||||||
const GHOST_NO = 0xff6b6b;
|
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
|
// One colour per goo type. Everything is drawn procedurally; there is no art
|
||||||
// dependency (see docs/gootower-build-plan.md).
|
// dependency (see docs/gootower-build-plan.md).
|
||||||
const GOO_COLOR = {
|
const GOO_COLOR = {
|
||||||
|
|
@ -64,6 +67,7 @@ const ZOOM_MIN = 0.45;
|
||||||
const ZOOM_MAX = 9;
|
const ZOOM_MAX = 9;
|
||||||
const ZOOM_STEP = 1.15;
|
const ZOOM_STEP = 1.15;
|
||||||
const VIEW_KEEP = 0.35; // fraction of the viewport that must stay over the board
|
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 shade = (color, f) => {
|
||||||
const ch = (s) => Math.min(255, Math.round(((color >> s) & 0xff) * 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.finished = false;
|
||||||
this.view = { ...VIEW_HOME };
|
this.view = { ...VIEW_HOME };
|
||||||
this.panFrom = null;
|
this.panFrom = null;
|
||||||
|
this.introAnim = null; // intro animation state
|
||||||
|
this.debugZoomText = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create() {
|
async create() {
|
||||||
|
|
@ -158,6 +164,8 @@ export default class GooTowerGame extends Phaser.Scene {
|
||||||
this.panFrom = null;
|
this.panFrom = null;
|
||||||
this.needText = null;
|
this.needText = null;
|
||||||
this.hudText = 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; }
|
track(obj) { this.boardObjs.push(obj); return obj; }
|
||||||
|
|
@ -335,6 +343,8 @@ export default class GooTowerGame extends Phaser.Scene {
|
||||||
this.applyView();
|
this.applyView();
|
||||||
this.drawTerrain();
|
this.drawTerrain();
|
||||||
this.buildHud();
|
this.buildHud();
|
||||||
|
if (DEBUG_ZOOM) this.createDebugZoomText();
|
||||||
|
this.startIntroAnim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redrawn on demand, not just once: a blast can delete destructible terrain.
|
// 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('pointerup', (p) => this.onUp(p));
|
||||||
|
|
||||||
this.input.on('wheel', (p, over, dx, dy) => {
|
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;
|
if (dy === 0) return;
|
||||||
this.zoomAt(p.x, p.y, dy < 0 ? ZOOM_STEP : 1 / ZOOM_STEP);
|
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-ZERO', () => { if (this.viewMode === 'play' && !this.introAnim) this.resetView(); });
|
||||||
this.input.keyboard?.on('keydown-HOME', () => 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.startLevel(this.level); });
|
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.exitLevel(); });
|
this.input.keyboard?.on('keydown-ESC', () => { if (this.viewMode === 'play' && !this.introAnim) this.exitLevel(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Screen <-> world. The board container holds the forward transform; these
|
// Screen <-> world. The board container holds the forward transform; these
|
||||||
|
|
@ -539,7 +549,7 @@ export default class GooTowerGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
onDown(p) {
|
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
|
// Right-drag pans. Zoomed in, you need a way to get around, and the left
|
||||||
// button is already spoken for by dragging goo.
|
// button is already spoken for by dragging goo.
|
||||||
if (p.rightButtonDown()) {
|
if (p.rightButtonDown()) {
|
||||||
|
|
@ -556,10 +566,12 @@ export default class GooTowerGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
onMove(p) {
|
onMove(p) {
|
||||||
if (this.panFrom) {
|
if (this.panFrom || this.introAnim) {
|
||||||
this.view.ox = this.panFrom.ox + (p.x - this.panFrom.x);
|
if (this.panFrom) {
|
||||||
this.view.oy = this.panFrom.oy + (p.y - this.panFrom.y);
|
this.view.ox = this.panFrom.ox + (p.x - this.panFrom.x);
|
||||||
this.applyView();
|
this.view.oy = this.panFrom.oy + (p.y - this.panFrom.y);
|
||||||
|
this.applyView();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.held || !this.state) return;
|
if (!this.held || !this.state) return;
|
||||||
|
|
@ -592,6 +604,28 @@ export default class GooTowerGame extends Phaser.Scene {
|
||||||
this.drawDynamic();
|
this.drawDynamic();
|
||||||
this.refreshHud();
|
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();
|
if (!this.finished && isWon(this.state)) this.finishLevel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -840,4 +874,93 @@ export default class GooTowerGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
this.boardObjs = keep;
|
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