feat(tetris-attack): smooth panel fall animation and settling frame

- Track `_prevRow` on falling panels for interpolation during animation
- Add `_justLanded` flag to introduce a settling frame after panels land
- Interpolate panel Y position from previous row for smoother fall effect
- Defer match detection and chain-end logic until after settling frame
- Update assets: new background-menu and main-title images, update existing sprites
This commit is contained in:
Brian Fertig 2026-07-20 19:21:08 -06:00
parent aa97cbe9b4
commit ed2a60fa06
7 changed files with 33 additions and 13 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -343,7 +343,14 @@ export default class TetrisAttackGame extends Phaser.Scene {
if (spr.dying) continue;
const pos = this.cellCenter(r, c);
spr.x = pos.x;
spr.y = pos.y + (p.state === 'falling' ? fp * CELL : 0);
if (p.state === 'falling' && p._prevRow != null) {
// interpolate from previous row to current row for smooth fall animation
const fp = st.fallTick / TUNING.FALL_INTERVAL;
const prevPos = this.cellCenter(p._prevRow, c);
spr.y = prevPos.y + (pos.y - prevPos.y) * fp;
} else {
spr.y = pos.y + (p.state === 'falling' ? fp * CELL : 0);
}
spr.setDepth(D.panels);
}
}

View File

@ -320,6 +320,7 @@ function updateFallStates(board) {
const p = board[r][c];
if (p && p.state === 'falling' && supported(board, r, c)) {
p.state = 'idle';
delete p._prevRow; // landed — clear animation origin
landed.push({ r, c, id: p.id });
}
}
@ -334,6 +335,7 @@ function applyFallStep(board) {
for (let r = board.length - 2; r >= 0; r--) {
const p = board[r][c];
if (p && p.state === 'falling' && board[r + 1][c] === null) {
p._prevRow = r; // remember old row for fall animation
board[r + 1][c] = p;
board[r][c] = null;
moves.push({ col: c, fromR: r, toR: r + 1, id: p.id });
@ -407,13 +409,20 @@ export function step(state, rng = Math.random) {
// re-evaluate landings after the move
const landed2 = updateFallStates(board);
for (const l of landed2) events.push({ type: 'land', ...l });
if (landed2.length) state._justLanded = true;
}
} else if (state._justLanded) {
// one settling frame after last panels land: show final animation frame
// before match detection fires, so the player sees panels arrive in place.
state.fallTick = TUNING.FALL_INTERVAL;
state._justLanded = false;
} else {
state.fallTick = 0;
}
// 3. Match detection over settled idle panels.
const groups = findMatchGroups(board);
// 3. Match detection over settled idle panels (skip during settling frame).
if (!state._justLanded) {
const groups = findMatchGroups(board);
if (groups.length) {
let hadChainMember = false;
for (const g of groups) {
@ -450,16 +459,20 @@ export function step(state, rng = Math.random) {
state.score += tickScore;
events.push({ type: 'clear', cells: allCells, groups: groups.map((g) => g.size), combo: biggestCombo, chain: state.chain, score: tickScore });
}
} // end: if (!state._justLanded)
// 4. Wave end: board settled with no clears / falls / new matches → reset chain.
const settled = state.clears.length === 0 && !anyFalling(board);
if (settled) {
if (state.chain > 0) {
if (state.chain > 1) events.push({ type: 'chainEnd', chain: state.chain });
state.chain = 0;
state.combo = 0;
// clear lingering chain flags
for (const row of board) for (const p of row) if (p) p.chain = false;
// (skip during settling frame — chain may still continue)
if (!state._justLanded) {
const settled = state.clears.length === 0 && !anyFalling(board);
if (settled) {
if (state.chain > 0) {
if (state.chain > 1) events.push({ type: 'chainEnd', chain: state.chain });
state.chain = 0;
state.combo = 0;
// clear lingering chain flags
for (const row of board) for (const p of row) if (p) p.chain = false;
}
}
}
@ -496,7 +509,7 @@ function finishStep(state, events) {
if (!inDanger && state.danger) { state.danger = false; events.push({ type: 'danger', on: false }); }
if (!state.over && !state.won) {
const idleSettled = state.clears.length === 0 && !anyFalling(board);
const idleSettled = state.clears.length === 0 && !anyFalling(board) && !state._justLanded;
if (state.mode === 'puzzle') {
if (isBoardEmpty(board)) { state.won = true; events.push({ type: 'win' }); }
else if (idleSettled && state.movesLeft <= 0) { state.over = true; events.push({ type: 'gameOver' }); }
@ -509,7 +522,7 @@ function finishStep(state, events) {
// ── Query helpers (renderer / verifier) ─────────────────────────────────────
export function isSettled(state) {
return state.clears.length === 0 && !anyFalling(state.board);
return state.clears.length === 0 && !anyFalling(state.board) && !state._justLanded;
}
export function remainingTargets(state) {