feat(tetrisattack): add Panel de Pon-style tile clear animation and update assets

- Implement staggered tile pop animation: face overlay flashes on each tile,
  then shatters into four quadrants that fly diagonally while shrinking/fading
- Play 8-bit card sound per tile, 8-bit action/win sounds for 4+/5+ tile clears
- Adjust character speed levels in tetrisattack.json (Jerry-1, Michael-2,
  Steve-2, Victor-3, Klaxon-4)
- Replace cursor movement SFX with click sound
- Update advancewars-units and tetrisattack-panels sprite sheets and PSDs
- Add sfx-click and sfx-casino-win-8bit to preload and sound registry
This commit is contained in:
Brian Fertig 2026-07-22 18:40:26 -06:00
parent 962466a0a3
commit a17c5975d8
9 changed files with 121 additions and 27 deletions

BIN
assets/fx/click.mp3 Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -24,7 +24,7 @@
"characterId": "jerry",
"spriteIndex": 10,
"name": "Jerry",
"speedLevel": 2,
"speedLevel": 1,
"startRows": 6,
"clearLineRows": 3,
"intro": "Well now, y'all know I came to play some'a them games!",
@ -35,7 +35,7 @@
"characterId": "michael",
"spriteIndex": 15,
"name": "Michael",
"speedLevel": 3,
"speedLevel": 2,
"startRows": 7,
"clearLineRows": 3,
"intro": "Hey mon! I been waitin' ta play on da Fertig Games — let's go mon!",
@ -46,7 +46,7 @@
"characterId": "steve",
"spriteIndex": 17,
"name": "Steve",
"speedLevel": 4,
"speedLevel": 2,
"startRows": 7,
"clearLineRows": 2,
"intro": "I traveled WAY too far across the galaxy for this. Don't waste my time.",
@ -57,7 +57,7 @@
"characterId": "victor",
"spriteIndex": 2,
"name": "Victor",
"speedLevel": 5,
"speedLevel": 3,
"startRows": 8,
"clearLineRows": 2,
"intro": "Patience. Every panel falls exactly where the ancients intended.",
@ -68,7 +68,7 @@
"characterId": "klaxon",
"spriteIndex": 29,
"name": "Klaxon",
"speedLevel": 6,
"speedLevel": 4,
"startRows": 8,
"clearLineRows": 2,
"intro": "MISSION OBJECTIVE: victory. All other priorities rescinded.",

View File

@ -22,6 +22,16 @@ const BOARD_TOP = 116;
const STEP_MS = 1000 / 60; // logic tick rate
const POSE_HOLD_MS = 5000; // how long a reaction pose holds before resting
// Clear-burst animation (Panel de Pon style): tiles pop one-by-one; a face
// flashes over each, then the tile+face shatter into four quadrants that fly
// diagonally outward while shrinking + fading. Whole burst reads at ~1s and
// fits inside the engine's clear freeze window (CLEAR_TICKS ≈ 767ms).
const FACE_FRAME = 6; // frame 6 of tetrisattack-panels = the pop face
const STAGGER_MS = 70; // gap between consecutive tile pops
const FACE_MS = 150; // face overlay appears + holds before the split
const SPLIT_MS = 300; // quadrants fly out / shrink / fade
const TRAVEL = 0.45 * CELL; // how far each quadrant flies from tile center
export const D = { bg: -10, boardBg: 0, panels: 5, incoming: 4, cursor: 12, fx: 16, hud: 24, portrait: 20, overlay: 60, overlayUI: 62 };
// Bright 16-bit panel palette: fill, light facet, dark rim.
@ -247,6 +257,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
handleEvents(events) {
for (const e of events) {
if (e.type === 'clear') this.onClear(e);
else if (e.type === 'pop') this.onPop(e);
else if (e.type === 'rowShift') playSound(this, SFX.EIGHTBIT_MOVE);
else if (e.type === 'clearLineAppear') this.onClearLineAppear();
else if (e.type === 'danger') this.onDanger(e.on);
@ -257,30 +268,31 @@ export default class TetrisAttackGame extends Phaser.Scene {
}
onClear(e) {
// flash + pop the cleared sprites (positions carried on the event's cells)
const dur = TUNING.CLEAR_TICKS * STEP_MS;
// Panel de Pon pop: tiles burst one-by-one in reading order (staggered), each
// flashing a face then shattering into four fly-away quadrants (see popTile).
const ordered = [...(e.cells ?? [])]
.sort((a, b) => (a.r - b.r) || (a.c - b.c))
.map((cell) => ({ cell, spr: this.sprites.get(cell.id) }))
.filter(({ spr }) => spr);
const biggest = e.combo ?? 3;
// 3-tile matches only get the per-tile 8bit-card pop. 4-tile matches add a
// single 8bit-action once every tile has finished exploding; 5+ tile matches
// add 8bit-win instead, once the whole 8bit-card set has played out.
let popped = 0;
const total = ordered.length;
const onTileExplode = () => {
popped++;
if (popped < total) return;
if (biggest === 4) playSound(this, SFX.EIGHTBIT_ACTION);
else if (biggest >= 5) playSound(this, SFX.EIGHTBIT_WIN);
};
let cx = 0, cy = 0, n = 0;
for (const cell of (e.cells ?? [])) {
const spr = this.sprites.get(cell.id);
if (!spr) continue;
// Keep it in the map but flag it dying: the panel stays on the board (state
// 'clearing') until it pops, and renderBoard skips dying sprites — so this
// prevents a duplicate sprite being spawned for the same cell.
spr.dying = true;
ordered.forEach(({ cell, spr }, i) => {
const pos = this.cellCenter(cell.r, cell.c, false);
cx += pos.x; cy += pos.y; n++;
this.tweens.add({ targets: spr, alpha: 0.35, duration: 90, yoyo: true, repeat: 2 });
this.tweens.add({
targets: spr, scale: 0, angle: 40, delay: dur * 0.55, duration: dur * 0.45,
ease: 'Back.easeIn', onComplete: () => { spr.destroy(); this.sprites.delete(cell.id); },
});
}
// sfx scales with size
const biggest = e.combo ?? 3;
if (e.chain >= 2) playSound(this, SFX.GEM_CHAIN);
else if (biggest >= 5) playSound(this, SFX.GEM_MATCH_5);
else if (biggest >= 4) playSound(this, SFX.GEM_MATCH_4);
else playSound(this, SFX.GEM_MATCH_1);
this.popTile(spr, cell, i * STAGGER_MS, onTileExplode);
});
if (n && (e.chain >= 2 || biggest >= 4)) {
const label = e.chain >= 2 ? `CHAIN ×${e.chain}` : `COMBO ×${biggest}`;
@ -301,6 +313,85 @@ export default class TetrisAttackGame extends Phaser.Scene {
}
}
// Pop one matched tile: after `delay`, flash the frame-6 face over it (Phase A),
// then swap it for four quadrant fragments that fly diagonally out from the tile
// center while shrinking + fading (Phase B). Fragments live in boardLayer so the
// playfield mask clips any overshoot. `spr` is flagged dying so renderBoard leaves
// it be until we destroy it at the Phase A→B handoff.
popTile(spr, cell, delay, onExplode) {
spr.dying = true;
const { x: cx, y: cy } = this.cellCenter(cell.r, cell.c, false);
const idx = PANEL_COLORS.indexOf(cell.color);
// Phase A — face overlay (spritesheet only; fallback just holds the tile).
let face = null;
if (this.usePanelSheet) {
face = this.add.image(cx, cy, 'tetrisattack-panels', FACE_FRAME).setAlpha(0);
const fs = (CELL - 4) / Math.max(face.width, 1);
face.setScale(fs).setDepth(D.panels + 1);
this.boardLayer.add(face);
this.tweens.add({ targets: face, alpha: 1, scale: fs * 1.1, delay, duration: 80, ease: 'Quad.easeOut' });
}
// Phase B — shatter into four fly-away quadrants.
this.time.delayedCall(delay + FACE_MS, () => {
if (!this.state) return;
// The panel stays on the board (state 'clearing') until the engine pops it,
// so hide (don't destroy) the intact sprite: keeping it in the map, flagged
// dying, stops renderBoard respawning it. onPop destroys it for real.
spr.setVisible(false);
if (face) face.destroy();
playSound(this, SFX.EIGHTBIT_CARD);
onExplode?.();
// Build one cropped corner of the given frame (or the fallback texture),
// centered on its own quadrant center at the container origin.
const mkPiece = (frameArg, sx, sy) => {
let img;
if (this.usePanelSheet) {
img = this.make.image({ x: 0, y: 0, key: 'tetrisattack-panels', frame: frameArg, add: false });
img.setScale((CELL - 4) / Math.max(img.width, 1));
} else {
img = this.make.image({ x: 0, y: 0, key: `ta-panel-${cell.color}`, add: false });
}
const F = img.width;
const qx = sx > 0 ? 1 : 0, qy = sy > 0 ? 1 : 0;
img.setCrop(qx * F / 2, qy * F / 2, F / 2, F / 2);
const S = img.displayWidth;
img.setPosition(-sx * S / 4, -sy * S / 4);
return img;
};
for (const [sx, sy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
const frag = this.add.container(0, 0);
this.boardLayer.add(frag);
frag.setDepth(D.panels + 1);
const tile = mkPiece(idx, sx, sy);
frag.add(tile);
if (this.usePanelSheet) frag.add(mkPiece(FACE_FRAME, sx, sy));
const S = tile.displayWidth;
const startX = cx + sx * S / 4, startY = cy + sy * S / 4;
frag.setPosition(startX, startY);
this.tweens.add({
targets: frag,
x: startX + sx * TRAVEL, y: startY + sy * TRAVEL,
scaleX: 0, scaleY: 0, alpha: 0,
duration: SPLIT_MS, ease: 'Quad.easeIn',
onComplete: () => frag.destroy(),
});
}
});
}
// Engine has removed the popped panels from the board — destroy the hidden
// placeholder sprites the pop animation left behind (see popTile Phase B).
onPop(e) {
for (const cell of (e.cells ?? [])) {
const spr = this.sprites.get(cell.id);
if (spr) { spr.destroy(); this.sprites.delete(cell.id); }
}
}
// The boundary line has slid in under the stack: everything above it is now
// the objective. Announce it over the board.
onClearLineAppear() {
@ -667,7 +758,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
kb.on('keydown', (e) => {
if (!this.playing || !this.state) return;
const dir = dirMap[e.code];
if (dir) { e.preventDefault?.(); moveCursor(this.state, dir); playSound(this, SFX.EIGHTBIT_MOVE); this.renderBoard(); return; }
if (dir) { e.preventDefault?.(); moveCursor(this.state, dir); playSound(this, SFX.CLICK); this.renderBoard(); return; }
if (e.code === 'Space' || e.code === 'KeyZ' || e.code === 'Enter' || e.code === 'KeyX') {
e.preventDefault?.();
const ev = trySwap(this.state);

View File

@ -138,6 +138,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-8bit-activate', 'assets/fx/8bit-activate.mp3');
this.load.audio('sfx-8bit-action', 'assets/fx/8bit-action.mp3');
this.load.audio('sfx-8bit-move', 'assets/fx/8bit-move.mp3');
this.load.audio('sfx-click', 'assets/fx/click.mp3');
this.load.audio('sfx-8bit-jump', 'assets/fx/8bit-jump.mp3');
this.load.audio('sfx-8bit-explode', 'assets/fx/8bit-explode.mp3');
this.load.audio('sfx-8bit-explode2', 'assets/fx/8bit-explode2.mp3');

View File

@ -44,11 +44,13 @@ export const SFX = {
EIGHTBIT_ACTIVATE: 'sfx-8bit-activate',
EIGHTBIT_ACTION: 'sfx-8bit-action',
EIGHTBIT_MOVE: 'sfx-8bit-move',
CLICK: 'sfx-click',
EIGHTBIT_JUMP: 'sfx-8bit-jump',
EIGHTBIT_CARD: 'sfx-card-deal-8bit',
EIGHTBIT_EXPLODE: 'sfx-8bit-explode',
EIGHTBIT_EXPLODE_2: 'sfx-8bit-explode2',
EIGHTBIT_COUNT: 'sfx-8bit-count',
EIGHTBIT_WIN: 'sfx-casino-win-8bit',
SQUISH: 'sfx-squish',
SQUASH: 'sfx-squash',
WOOSH: 'sfx-woosh',