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;
|
if (spr.dying) continue;
|
||||||
const pos = this.cellCenter(r, c);
|
const pos = this.cellCenter(r, c);
|
||||||
spr.x = pos.x;
|
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);
|
spr.setDepth(D.panels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,7 @@ function updateFallStates(board) {
|
||||||
const p = board[r][c];
|
const p = board[r][c];
|
||||||
if (p && p.state === 'falling' && supported(board, r, c)) {
|
if (p && p.state === 'falling' && supported(board, r, c)) {
|
||||||
p.state = 'idle';
|
p.state = 'idle';
|
||||||
|
delete p._prevRow; // landed — clear animation origin
|
||||||
landed.push({ r, c, id: p.id });
|
landed.push({ r, c, id: p.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -334,6 +335,7 @@ function applyFallStep(board) {
|
||||||
for (let r = board.length - 2; r >= 0; r--) {
|
for (let r = board.length - 2; r >= 0; r--) {
|
||||||
const p = board[r][c];
|
const p = board[r][c];
|
||||||
if (p && p.state === 'falling' && board[r + 1][c] === null) {
|
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 + 1][c] = p;
|
||||||
board[r][c] = null;
|
board[r][c] = null;
|
||||||
moves.push({ col: c, fromR: r, toR: r + 1, id: p.id });
|
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
|
// re-evaluate landings after the move
|
||||||
const landed2 = updateFallStates(board);
|
const landed2 = updateFallStates(board);
|
||||||
for (const l of landed2) events.push({ type: 'land', ...l });
|
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 {
|
} else {
|
||||||
state.fallTick = 0;
|
state.fallTick = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Match detection over settled idle panels.
|
// 3. Match detection over settled idle panels (skip during settling frame).
|
||||||
const groups = findMatchGroups(board);
|
if (!state._justLanded) {
|
||||||
|
const groups = findMatchGroups(board);
|
||||||
if (groups.length) {
|
if (groups.length) {
|
||||||
let hadChainMember = false;
|
let hadChainMember = false;
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
|
|
@ -450,16 +459,20 @@ export function step(state, rng = Math.random) {
|
||||||
state.score += tickScore;
|
state.score += tickScore;
|
||||||
events.push({ type: 'clear', cells: allCells, groups: groups.map((g) => g.size), combo: biggestCombo, chain: state.chain, 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.
|
// 4. Wave end: board settled with no clears / falls / new matches → reset chain.
|
||||||
const settled = state.clears.length === 0 && !anyFalling(board);
|
// (skip during settling frame — chain may still continue)
|
||||||
if (settled) {
|
if (!state._justLanded) {
|
||||||
if (state.chain > 0) {
|
const settled = state.clears.length === 0 && !anyFalling(board);
|
||||||
if (state.chain > 1) events.push({ type: 'chainEnd', chain: state.chain });
|
if (settled) {
|
||||||
state.chain = 0;
|
if (state.chain > 0) {
|
||||||
state.combo = 0;
|
if (state.chain > 1) events.push({ type: 'chainEnd', chain: state.chain });
|
||||||
// clear lingering chain flags
|
state.chain = 0;
|
||||||
for (const row of board) for (const p of row) if (p) p.chain = false;
|
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 (!inDanger && state.danger) { state.danger = false; events.push({ type: 'danger', on: false }); }
|
||||||
|
|
||||||
if (!state.over && !state.won) {
|
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 (state.mode === 'puzzle') {
|
||||||
if (isBoardEmpty(board)) { state.won = true; events.push({ type: 'win' }); }
|
if (isBoardEmpty(board)) { state.won = true; events.push({ type: 'win' }); }
|
||||||
else if (idleSettled && state.movesLeft <= 0) { state.over = true; events.push({ type: 'gameOver' }); }
|
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) ─────────────────────────────────────
|
// ── Query helpers (renderer / verifier) ─────────────────────────────────────
|
||||||
export function isSettled(state) {
|
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) {
|
export function remainingTargets(state) {
|
||||||
|
|
|
||||||