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
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.3 MiB |
|
|
@ -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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,12 +409,19 @@ 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.
|
||||
// 3. Match detection over settled idle panels (skip during settling frame).
|
||||
if (!state._justLanded) {
|
||||
const groups = findMatchGroups(board);
|
||||
if (groups.length) {
|
||||
let hadChainMember = false;
|
||||
|
|
@ -450,8 +459,11 @@ 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.
|
||||
// (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) {
|
||||
|
|
@ -462,6 +474,7 @@ export function step(state, rng = Math.random) {
|
|||
for (const row of board) for (const p of row) if (p) p.chain = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Rise (paused while anything is clearing; disabled in puzzle mode).
|
||||
if (state.mode !== 'puzzle' && state.clears.length === 0) {
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||