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:
parent
962466a0a3
commit
a17c5975d8
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 94 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue