diff --git a/src/games/gootower/GooTowerGame.js b/src/games/gootower/GooTowerGame.js index 54747d4..647bc50 100644 --- a/src/games/gootower/GooTowerGame.js +++ b/src/games/gootower/GooTowerGame.js @@ -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; + }, + }); + }); + }, + }); + } }