Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig b6e7d932cd 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
2026-07-26 18:43:17 -06:00
Brian Fertig f1c4289f0f feat: enhance Jewel Quest and Shift game UI/UX
- Add custom background image for Jewel Quest menu screens
- Remove redundant title/subtitle text from Jewel Quest class and level select screens
- Reorganize Jewel Quest UI layout with improved positioning and semi-transparent text backgrounds
- Update Jewel Quest level select grid and status text positioning for better visual hierarchy
- Add background backdrop to Shift game "Choose Your Image" title for improved readability
2026-07-26 18:00:47 -06:00
Brian Fertig 9f54556ba9 feat: enhance Shift backgrounds and add commander death SFX for TA
- Add background image support for Shift game menu screens
- Remove redundant title/subtitle text in Shift difficulty select
- Add nuclear explosion SFX for Total Annihilation commander deaths
- Extend _throttledSfx to support optional volume parameter
2026-07-26 16:01:54 -06:00
13 changed files with 203 additions and 52 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@ -85,7 +85,10 @@ export const MANIFEST = {
...Array.from({ length: 6 }, (_, i) =>
image(`bloxorz-bg-${i + 1}`, `assets/images/bloxorz/background-${i + 1}.png`)),
],
jewelquest: [image('bg-jewelquest-battle', 'assets/images/background-jewelquest.png')],
jewelquest: [
image('bg-jewelquest-battle', 'assets/images/background-jewelquest.png'),
image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'),
],
bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')],
dominion: [
// Dominion card art. One 270×390 cell per card (art fills the top ~60%;
@ -156,7 +159,10 @@ export const MANIFEST = {
image('spireclimb-act3', 'assets/images/spireclimb-act3.png'),
(scene) => sheetsFrom(scene, 'spireclimb-artwork', ['cardSheet', 'creatureSheet', 'playerSheets']),
],
shift: (scene) => imagesFrom(scene, 'shift-artwork'),
shift: [
image('shift-background', 'assets/images/background-shift.png'),
(scene) => imagesFrom(scene, 'shift-artwork'),
],
slots: (scene) => imagesFrom(scene, 'slots-artwork'),
dungeonboss: (scene) => sheetsFrom(scene, 'dungeonboss-artwork',
['roomSheet', 'heroSheet', 'spellSheet', 'bossSheet', 'deckBackSheet', 'iconSheet']),
@ -278,7 +284,7 @@ export const MANIFEST = {
{ type: 'audio', key: 'sfx-scifi-launch', path: 'assets/fx/scifi-launch.mp3' },
// TA-specific sfx: machine guns per weapon.sound, rocket variants (random pick between the
// two on fire, see TotalAnnihilationGame._onSimEvent), and unit/vehicle death cues.
...['50cal', 'machinegun', 'rocket-1', 'rocket-2', 'unit-loss', 'vehicle-loss']
...['50cal', 'machinegun', 'rocket-1', 'rocket-2', 'nuclear', 'unit-loss', 'vehicle-loss']
.map((n) => ({ type: 'audio', key: `sfx-ta-${n}`, path: `assets/fx/ta-${n}.mp3` })),
(scene) => musicFrom(scene, 'hacker-music'),
],

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;
},
});
});
},
});
}
}

View File

@ -88,8 +88,12 @@ export default class JewelQuestGame extends Phaser.Scene {
} catch (_) { /* private mode */ }
this.makeTextures();
// Solid felt background for non-battle screens (class select, level select, intro)
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt);
// Background for non-battle screens (class select, level select, intro)
if (this.textures.exists('bg-jewelquest-menu')) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'bg-jewelquest-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.felt);
} else {
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt);
}
this.layer = this.add.container(0, 0);
if (this.playerClass) this.showLevelSelect();
@ -361,14 +365,6 @@ export default class JewelQuestGame extends Phaser.Scene {
this.clearLayer();
const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 80, 'JEWEL QUEST', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 138, 'Choose your champion. Match gems for mana, match skulls for damage, and spend mana on spells.', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add([title, sub]);
const loadout = this.loadout();
const ids = Object.keys(CLASSES);
const CARD_W = 430;
@ -376,7 +372,7 @@ export default class JewelQuestGame extends Phaser.Scene {
const GAP = 26;
const totalW = ids.length * CARD_W + (ids.length - 1) * GAP;
const left = cx - totalW / 2 + CARD_W / 2;
const cy = 540;
const cy = 640;
ids.forEach((id, i) => {
const cls = CLASSES[id];
@ -459,14 +455,6 @@ export default class JewelQuestGame extends Phaser.Scene {
this.clearLayer();
const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 78, 'JEWEL QUEST', {
fontFamily: 'Righteous', fontSize: '60px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 130, 'Match 3+ gems to bank mana, match skulls to deal damage, and cast spells to break your rival.', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add([title, sub]);
if (!this.bank.length) {
const msg = this.add.text(cx, 520, 'No levels found in /data/jewelquest.json', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
@ -486,20 +474,27 @@ export default class JewelQuestGame extends Phaser.Scene {
`Spells ${loadout.spellCount}/5`,
nextMs ? `Next bonus after Level ${nextMs.afterLevel}` : 'All bonuses earned',
].join(' • ');
const prog = this.add.text(cx, 176, `Defeated ${this.levelsCompleted} / ${this.bank.length}`, {
const prog = this.add.text(cx, 376, `Defeated ${this.levelsCompleted} / ${this.bank.length}`, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5);
const loadoutText = this.add.text(cx, 216, strip, {
const loadoutText = this.add.text(cx, 416, strip, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5);
this.layer.add([prog, loadoutText]);
// Semi-transparent background behind the status text
const bgW = Math.max(prog.width, loadoutText.width) + 60;
const bgH = 120;
const bg = this.add.graphics().setDepth(D.ui - 1);
bg.fillStyle(0x000000, 0.65);
bg.fillRoundedRect(cx - bgW / 2, 316, bgW, bgH, 12);
this.layer.add([bg, prog, loadoutText]);
const COLS = 10;
const TILE = 128;
const GAP = 16;
const gridW = COLS * TILE + (COLS - 1) * GAP;
const left = cx - gridW / 2 + TILE / 2;
const top = 330;
const top = 530;
this.bank.forEach((lv, i) => {
const col = i % COLS;
@ -619,7 +614,14 @@ export default class JewelQuestGame extends Phaser.Scene {
const statText = this.add.text(cx, 800, `Skill ${stars} HP ❤ ${lv.hp} Class ${enemyCls}`, {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex,
}).setOrigin(0.5);
this.layer.add([name, bio, tagline, statText]);
// Semi-transparent background behind the opponent info text
const bgW = Math.max(name.width, bio.width, tagline.width, statText.width) + 60;
const bgH = 240;
const bg = this.add.graphics().setDepth(D.ui - 1);
bg.fillStyle(0x000000, 0.65);
bg.fillRoundedRect(cx - bgW / 2, 600, bgW, bgH, 12);
this.layer.add([bg, name, bio, tagline, statText]);
const fight = new Button(this, cx - 130, GAME_HEIGHT - 110, 'FIGHT!', () => this.startBattle(level),
{ width: 240, height: 66, fontSize: 30 });

View File

@ -83,24 +83,26 @@ export default class ShiftGame extends Phaser.Scene {
this.bestText = null;
}
_drawMenuBackground() {
if (this.textures.exists('shift-background')) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'shift-background')
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg);
}
}
// ── Difficulty select ─────────────────────────────────────────────────────────
showDifficultySelect() {
this.view = 'difficulty';
this.overlayUp = false;
this.clearLayer();
this._drawMenuBackground();
const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 160, 'SHIFT', {
fontFamily: 'Righteous', fontSize: '96px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 268, 'Slide tiles into the empty space to restore the image.', {
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const pick = this.add.text(cx, 356, 'Choose a difficulty', {
const pick = this.add.text(cx, 390, 'Choose a difficulty', {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
}).setOrigin(0.5);
this.layer.add([title, sub, pick]);
this.layer.add(pick);
const TILE_W = 430;
const TILE_H = 400;
@ -218,12 +220,18 @@ export default class ShiftGame extends Phaser.Scene {
this.view = 'artwork';
this.overlayUp = false;
this.clearLayer();
this._drawMenuBackground();
const cx = GAME_WIDTH / 2;
const titleBg = this.add.graphics().setDepth(D.overlay - 1);
titleBg.fillStyle(0x000000, 0.55);
titleBg.fillRoundedRect(cx - 310, 75, 620, 90, 12);
titleBg.lineStyle(2, 0xffffff, 0.3);
titleBg.strokeRoundedRect(cx - 310, 75, 620, 90, 12);
const title = this.add.text(cx, 120, 'Choose Your Image', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5);
this.layer.add(title);
this.layer.add([titleBg, title]);
const CARD_W = 310;
const CARD_H = 360;

View File

@ -13,7 +13,7 @@ import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { playSound } from '../../ui/Sounds.js';
import { playSound, playSoundEx } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import { compileRules } from './TARules.js';
import { generateMap, decodeMap } from './TAMapGen.js';
@ -263,8 +263,13 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
if (ev.t === 'weaponFired' && ev.sound) this._throttledSfx(this._pickSound(ev.sound), 90);
if (ev.t === 'impact' && ev.sound) this._throttledSfx(this._pickSound(ev.sound), 90);
if (ev.t === 'unitDestroyed' && !ev.isBuilding) {
const moveClass = this.rules.defById[ev.defId]?.moveClass;
if (moveClass) this._throttledSfx(moveClass === 'foot' ? 'sfx-ta-unit-loss' : 'sfx-ta-vehicle-loss', 120);
const def = this.rules.defById[ev.defId];
if (def?.isCommander) {
// Play the nuclear explosion at full volume for maximum impact
this._throttledSfx('sfx-ta-nuclear', 300, 1);
} else if (def?.moveClass) {
this._throttledSfx(def.moveClass === 'foot' ? 'sfx-ta-unit-loss' : 'sfx-ta-vehicle-loss', 120);
}
}
if (ev.t === 'buildingComplete' && ev.army === this.playerArmy) {
// Terrain under a finished structure changes, so its chunk has to be restamped.
@ -290,12 +295,19 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
}
/** Gate repeated plays of the same sound key so a volley of units firing at once doesn't
* stack a dozen overlapping instances of the same clip. */
_throttledSfx(key, gapMs) {
* stack a dozen overlapping instances of the same clip.
* @param {string} key - Sound key
* @param {number} gapMs - Minimum ms between plays
* @param {number} [volume] - Optional volume 0-1 (defaults to normal via playSound) */
_throttledSfx(key, gapMs, volume) {
const now = this.time.now;
if ((this.lastSfxAt[key] ?? -1e9) + gapMs > now) return;
this.lastSfxAt[key] = now;
playSound(this, key);
if (volume !== undefined) {
playSoundEx(this, key, { volume });
} else {
playSound(this, key);
}
}
_finish(over) {