feat: add tile slide animation with identity tracking (Story 3.1)

Tiles now slide smoothly to new positions with 100ms CSS transitions.
New tile-tracker.js module manages tile identity across moves, enabling
Svelte to reuse DOM nodes for CSS transition animation. Input queuing
prevents moves during animation. Supports prefers-reduced-motion.
59 tests passing (10 new tile-tracker tests).
This commit is contained in:
2026-04-13 22:52:28 +07:00
parent 29b7204687
commit deebbbeb13
8 changed files with 521 additions and 24 deletions
@@ -0,0 +1,166 @@
# Story 3.1: Tile Slide Animation
Status: done
## Story
As a player,
I want tiles to slide smoothly to their new positions,
so that the game feels responsive and I can visually track tile movement.
## Acceptance Criteria
1. After a valid move, each tile animates from its old position to its new position using CSS `transform: translate` with a 100ms ease-in-out transition
2. All movable tiles animate simultaneously
3. The animation runs at 60fps (GPU-accelerated CSS transition)
4. During an animation, input is queued and executes after the current animation completes (isAnimating flag)
5. When `prefers-reduced-motion` is enabled, tiles appear instantly at new positions with no transition
## Tasks / Subtasks
- [x] Task 1: Create tile-tracker.js module (AC: #1)
- [x] Create `src/lib/tile-tracker.js` with tile identity management
- [x] `createTilesFromGrid(grid)` — assigns unique IDs to each non-zero cell
- [x] `computeTilesAfterMove(prevTiles, prevGrid, newGrid, direction)` — tracks tile movements through a move, preserving IDs for tiles that slide/merge, assigning new IDs for spawned tiles
- [x] Track merged tiles with `isMerged` flag and spawned tiles with `isNew` flag (for Stories 3.2, 3.3)
- [x] `resetTracker()` — resets ID counter (for New Game)
- [x] Task 2: Integrate tile tracker into App.svelte (AC: #1)
- [x] Replace raw grid extraction with tile tracker
- [x] Call `createTilesFromGrid` on init and New Game
- [x] Call `computeTilesAfterMove` after each move
- [x] Pass tile objects (with id, value, row, col) to Grid
- [x] Task 3: Add CSS transition to Tile.svelte (AC: #1, #2, #3)
- [x] Add `transition: transform 100ms ease-in-out` to tile style
- [x] Tiles already use `transform: translate()` for positioning — transition animates position changes automatically
- [x] Task 4: Add animation state and input queuing (AC: #4)
- [x] Add `isAnimating` flag in App.svelte
- [x] Add `queuedDirection` to store pending input during animation
- [x] On move: set isAnimating=true, after 100ms timeout clear flag and process queued input
- [x] Block moves in handleKeydown when isAnimating is true (queue instead)
- [x] Task 5: Support prefers-reduced-motion (AC: #5)
- [x] Add CSS media query `@media (prefers-reduced-motion: reduce)` that sets `transition: none`
- [x] When reduced motion: skip isAnimating delay (set flag immediately)
- [x] Task 6: Update Grid.svelte keying (AC: #1)
- [x] Change tile key from `tile.row-tile.col` to `tile.id` for stable DOM identity
- [x] Task 7: Write tests and verify (AC: all)
- [x] Write tests for tile-tracker.js: ID assignment, ID persistence across moves, new tile detection, merge detection
- [x] Run full test suite — all 59 tests pass (38 game-logic + 11 storage + 10 tile-tracker)
## Dev Notes
### Architecture Compliance
- **tile-tracker.js** — pure JS module in `src/lib/`, zero Svelte imports
- **Game logic unchanged** — `game-logic.js` stays pure, tile tracking is a presentation concern
- **Props-down pattern** — App.svelte passes tile objects to Grid/Tile via props
- **CSS-only animation** — no JS animation library, no requestAnimationFrame for tile movement
### Tile Tracking Algorithm
The tracker must simulate the move to map old tile IDs to new positions:
1. Build a position map from `prevTiles`: `{row}-{col}` → tile object
2. For the given direction, process each row/column:
- Extract non-zero tiles in order (matching game-logic's slideRow)
- Simulate mergeRow to determine which pairs merge
- Assign new positions: slid tiles keep their ID, merged pairs → leading tile's ID survives with `isMerged: true`
3. Compare moved grid with `newGrid` to find the spawned tile (the one cell in newGrid that differs from the moved-but-not-spawned grid)
4. Spawned tile gets a new ID with `isNew: true`
Direction normalization must match game-logic.js exactly:
- LEFT: process rows left-to-right as-is
- RIGHT: reverse rows → process → reverse back
- UP: transpose → process → transpose back
- DOWN: transpose + reverse → process → reverse + transpose back
### Input Queuing Pattern
```javascript
let isAnimating = $state(false);
let queuedDirection = $state(null);
function handleMove(direction) {
if (isAnimating) { queuedDirection = direction; return; }
// ... execute move
isAnimating = true;
setTimeout(() => {
isAnimating = false;
if (queuedDirection) {
const next = queuedDirection;
queuedDirection = null;
handleMove(next);
}
}, 100);
}
```
Note: Using setTimeout instead of transitionend for reliability — transitionend can miss if no tile actually moved. 100ms matches the transition duration.
### Prefers-Reduced-Motion
Add to `src/app.css`:
```css
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0s !important; }
}
```
When reduced motion is active, set animation timeout to 0ms.
### Previous Story Intelligence
From Epic 1-2:
- Tile.svelte uses `transform: translate(x, y)` for positioning — already GPU-accelerated
- Grid.svelte keys tiles by `tile.id || ${tile.row}-${tile.col}` — needs to use `tile.id` only
- App.svelte uses `$derived.by()` for tile extraction — will change to tile tracker
- GAP=15, CELL_SIZE=106.25 constants in Tile.svelte and Grid.svelte
- 49 tests passing (38 game-logic + 11 storage)
### Critical Anti-Patterns (DO NOT)
- DO NOT modify game-logic.js — tile tracking is a presentation concern
- DO NOT use JavaScript animation (requestAnimationFrame) for tile sliding — CSS transitions only
- DO NOT use transitionend events for animation completion — use setTimeout (more reliable)
- DO NOT add animation-related fields to the canonical game state shape
- DO NOT block the main thread during animation — use async scheduling
### References
- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.1]
- [Source: _bmad-output/planning-artifacts/architecture.md#Frontend Architecture - Animation Approach]
- [Source: _bmad-output/planning-artifacts/architecture.md#Implementation Patterns - Process Patterns]
## Dev Agent Record
### Agent Model Used
Claude Opus 4.6 (1M context)
### Debug Log References
### Completion Notes List
- Created tile-tracker.js: manages tile identity across moves via simulateLine algorithm
- Tile IDs persist through slides and merges; leading-edge tile's ID survives merges
- Spawned tiles detected by diffing moved grid vs final grid, assigned new IDs with isNew=true
- App.svelte refactored: tiles now managed as $state (not $derived), updated via tracker after each move
- Tile.svelte: added `transition: transform 100ms ease-in-out` for GPU-accelerated sliding
- Input queuing: isAnimating flag + queuedDirection, 100ms setTimeout for animation window
- prefers-reduced-motion: CSS override + JS detection skips animation delay
- Grid.svelte: keying changed from position-based to tile.id for stable DOM identity
- 10 new tile-tracker tests, 59 total tests passing
### File List
- src/lib/tile-tracker.js (new — tile identity and movement tracking)
- src/lib/tile-tracker.test.js (new — 10 tests)
- src/App.svelte (modified — tile tracker integration, animation state, input queuing)
- src/components/Tile.svelte (modified — CSS transition, isNew/isMerged props)
- src/components/Grid.svelte (modified — tile.id keying, pass isNew/isMerged)
- src/app.css (modified — prefers-reduced-motion media query)
@@ -35,7 +35,7 @@
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
generated: 2026-04-13
last_updated: 2026-04-13T22:44:00
last_updated: 2026-04-13T22:47:00
project: try-bmad
project_key: NOKEY
tracking_system: file-system
@@ -60,8 +60,8 @@ development_status:
epic-2-retrospective: optional
# Epic 3: Smooth Animations & Game Feel
epic-3: backlog
3-1-tile-slide-animation: backlog
epic-3: in-progress
3-1-tile-slide-animation: done
3-2-tile-spawn-pop-animation: backlog
3-3-tile-merge-bounce-animation: backlog
3-4-score-float-animation: backlog
+40 -18
View File
@@ -6,10 +6,20 @@
import { GRID_SIZE } from './lib/constants.js';
import { getDirectionFromKey } from './lib/input-handler.js';
import { saveGameState, loadGameState, saveBestScore, loadBestScore, clearGameState } from './lib/storage.js';
import { createTilesFromGrid, computeTilesAfterMove, resetTracker } from './lib/tile-tracker.js';
const SLIDE_DURATION = 100;
const savedState = loadGameState();
let gameState = $state(savedState || initGame());
let bestScore = $state(Math.max(loadBestScore(), savedState?.score || 0));
let tiles = $state(createTilesFromGrid(gameState.grid));
let isAnimating = $state(false);
let queuedDirection = $state(null);
let reducedMotion = $state(false);
if (typeof window !== 'undefined') {
reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
$effect(() => {
saveGameState(gameState);
@@ -22,28 +32,40 @@
saveBestScore(bestScore);
});
let tiles = $derived.by(() => {
const result = [];
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
const value = gameState.grid[row][col];
if (value !== 0) {
result.push({ value, row, col });
}
}
}
return result;
});
let overlayType = $derived.by(() => {
if (gameState.won && !gameState.keepPlaying) return 'win';
if (isGameOver(gameState)) return 'gameover';
return null;
});
function executeMove(direction) {
if (overlayType === 'gameover') return;
const prevGrid = gameState.grid;
const newState = move(gameState, direction);
if (newState === gameState) return;
tiles = computeTilesAfterMove(tiles, prevGrid, newState.grid, direction);
gameState = newState;
if (reducedMotion) return;
isAnimating = true;
setTimeout(() => {
isAnimating = false;
if (queuedDirection) {
const next = queuedDirection;
queuedDirection = null;
executeMove(next);
}
}, SLIDE_DURATION);
}
function handleNewGame() {
clearGameState();
resetTracker();
gameState = initGame();
tiles = createTilesFromGrid(gameState.grid);
}
function handleKeepGoing() {
@@ -55,12 +77,12 @@
if (!direction) return;
event.preventDefault();
if (overlayType === 'gameover') return;
const newState = move(gameState, direction);
if (newState !== gameState) {
gameState = newState;
if (isAnimating) {
queuedDirection = direction;
return;
}
executeMove(direction);
}
</script>
+7
View File
@@ -21,3 +21,10 @@ body {
font-family: 'Clear Sans', 'Helvetica Neue', Arial, sans-serif;
margin: 0;
}
@media (prefers-reduced-motion: reduce) {
* {
transition-duration: 0s !important;
animation-duration: 0s !important;
}
}
+2 -2
View File
@@ -36,7 +36,7 @@
{/each}
<!-- Tiles -->
{#each tiles as tile (tile.id || `${tile.row}-${tile.col}`)}
<Tile value={tile.value} row={tile.row} col={tile.col} />
{#each tiles as tile (tile.id)}
<Tile value={tile.value} row={tile.row} col={tile.col} isNew={tile.isNew} isMerged={tile.isMerged} />
{/each}
</div>
+2 -1
View File
@@ -1,7 +1,7 @@
<script>
import { TILE_COLORS } from '../lib/constants.js';
let { value, row, col } = $props();
let { value, row, col, isNew = false, isMerged = false } = $props();
let colors = $derived(TILE_COLORS[value] || TILE_COLORS.super);
@@ -18,6 +18,7 @@
width: {CELL_SIZE}px;
height: {CELL_SIZE}px;
transform: translate({x}px, {y}px);
transition: transform 100ms ease-in-out;
background: {colors.bg};
color: {colors.text};
font-size: 55px;
+127
View File
@@ -0,0 +1,127 @@
import { GRID_SIZE, DIRECTIONS } from './constants.js';
let nextId = 1;
export function resetTracker() {
nextId = 1;
}
/** Create tile objects with unique IDs from a grid */
export function createTilesFromGrid(grid) {
const tiles = [];
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
if (grid[row][col] !== 0) {
tiles.push({ id: nextId++, value: grid[row][col], row, col, isNew: true, isMerged: false });
}
}
}
return tiles;
}
/**
* Track tile movements through a move.
* Simulates the slide/merge to map old tile IDs to new positions.
* Returns new tile array with preserved IDs for moved tiles, new IDs for spawned tiles.
*/
export function computeTilesAfterMove(prevTiles, prevGrid, newGrid, direction) {
// Build position → tile lookup from previous tiles
const posMap = new Map();
for (const tile of prevTiles) {
posMap.set(`${tile.row}-${tile.col}`, tile);
}
// Simulate the move to track tile ID mappings
const resultTiles = [];
const processLine = (cells) => simulateLine(cells, posMap, resultTiles);
if (direction === DIRECTIONS.LEFT) {
for (let row = 0; row < GRID_SIZE; row++) {
const cells = [];
for (let col = 0; col < GRID_SIZE; col++) cells.push({ row, col });
processLine(cells);
}
} else if (direction === DIRECTIONS.RIGHT) {
for (let row = 0; row < GRID_SIZE; row++) {
const cells = [];
for (let col = GRID_SIZE - 1; col >= 0; col--) cells.push({ row, col });
processLine(cells);
}
} else if (direction === DIRECTIONS.UP) {
for (let col = 0; col < GRID_SIZE; col++) {
const cells = [];
for (let row = 0; row < GRID_SIZE; row++) cells.push({ row, col });
processLine(cells);
}
} else if (direction === DIRECTIONS.DOWN) {
for (let col = 0; col < GRID_SIZE; col++) {
const cells = [];
for (let row = GRID_SIZE - 1; row >= 0; row--) cells.push({ row, col });
processLine(cells);
}
}
// Find the spawned tile: cell in newGrid that has a value but no resultTile at that position
const resultPosSet = new Set(resultTiles.map(t => `${t.row}-${t.col}`));
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
if (newGrid[row][col] !== 0 && !resultPosSet.has(`${row}-${col}`)) {
resultTiles.push({
id: nextId++,
value: newGrid[row][col],
row,
col,
isNew: true,
isMerged: false,
});
}
}
}
return resultTiles;
}
/**
* Simulate slide+merge for one line of cells (in movement direction order).
* Matches game-logic.js: slide → merge → pad.
*/
function simulateLine(cells, posMap, resultTiles) {
// Gather non-zero tiles in order (slide)
const lineTiles = [];
for (const { row, col } of cells) {
const tile = posMap.get(`${row}-${col}`);
if (tile) lineTiles.push(tile);
}
// Merge adjacent equal tiles (leading-edge order, once-per-move)
let outIdx = 0;
let i = 0;
while (i < lineTiles.length) {
if (i + 1 < lineTiles.length && lineTiles[i].value === lineTiles[i + 1].value) {
// Merge: leading tile survives with doubled value
const target = cells[outIdx];
resultTiles.push({
id: lineTiles[i].id,
value: lineTiles[i].value * 2,
row: target.row,
col: target.col,
isNew: false,
isMerged: true,
});
i += 2;
} else {
// Slide: tile moves to new position
const target = cells[outIdx];
resultTiles.push({
id: lineTiles[i].id,
value: lineTiles[i].value,
row: target.row,
col: target.col,
isNew: false,
isMerged: false,
});
i += 1;
}
outIdx++;
}
}
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTilesFromGrid, computeTilesAfterMove, resetTracker } from './tile-tracker.js';
import { DIRECTIONS } from './constants.js';
import { move, initGame } from './game-logic.js';
beforeEach(() => {
resetTracker();
});
describe('createTilesFromGrid', () => {
it('assigns unique IDs to each non-zero cell', () => {
const grid = [
[2, 0, 0, 0],
[0, 4, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
];
const tiles = createTilesFromGrid(grid);
expect(tiles).toHaveLength(2);
expect(tiles[0]).toMatchObject({ value: 2, row: 0, col: 0 });
expect(tiles[1]).toMatchObject({ value: 4, row: 1, col: 1 });
expect(tiles[0].id).not.toBe(tiles[1].id);
});
it('marks all tiles as isNew', () => {
const grid = [[2, 4, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]];
const tiles = createTilesFromGrid(grid);
expect(tiles.every(t => t.isNew)).toBe(true);
});
it('returns empty array for empty grid', () => {
const grid = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]];
expect(createTilesFromGrid(grid)).toHaveLength(0);
});
});
describe('computeTilesAfterMove', () => {
it('preserves tile IDs when sliding left', () => {
const prevGrid = [
[0, 0, 0, 2],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
];
const prevTiles = createTilesFromGrid(prevGrid);
const originalId = prevTiles[0].id;
const state = { grid: prevGrid, score: 0, won: false, keepPlaying: false };
const newState = move(state, DIRECTIONS.LEFT);
const newTiles = computeTilesAfterMove(prevTiles, prevGrid, newState.grid, DIRECTIONS.LEFT);
const movedTile = newTiles.find(t => t.id === originalId);
expect(movedTile).toBeDefined();
expect(movedTile.row).toBe(0);
expect(movedTile.col).toBe(0);
expect(movedTile.value).toBe(2);
expect(movedTile.isNew).toBe(false);
});
it('marks merged tiles with isMerged flag', () => {
const prevGrid = [
[2, 2, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
];
const prevTiles = createTilesFromGrid(prevGrid);
const leadingId = prevTiles[0].id;
const state = { grid: prevGrid, score: 0, won: false, keepPlaying: false };
const newState = move(state, DIRECTIONS.LEFT);
const newTiles = computeTilesAfterMove(prevTiles, prevGrid, newState.grid, DIRECTIONS.LEFT);
const mergedTile = newTiles.find(t => t.id === leadingId);
expect(mergedTile).toBeDefined();
expect(mergedTile.value).toBe(4);
expect(mergedTile.isMerged).toBe(true);
});
it('creates new ID for spawned tile', () => {
const prevGrid = [
[0, 0, 0, 2],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
];
const prevTiles = createTilesFromGrid(prevGrid);
const prevIds = new Set(prevTiles.map(t => t.id));
const state = { grid: prevGrid, score: 0, won: false, keepPlaying: false };
const newState = move(state, DIRECTIONS.LEFT);
const newTiles = computeTilesAfterMove(prevTiles, prevGrid, newState.grid, DIRECTIONS.LEFT);
const newTile = newTiles.find(t => !prevIds.has(t.id));
expect(newTile).toBeDefined();
expect(newTile.isNew).toBe(true);
});
it('handles RIGHT direction correctly', () => {
const prevGrid = [
[2, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
];
const prevTiles = createTilesFromGrid(prevGrid);
const originalId = prevTiles[0].id;
const state = { grid: prevGrid, score: 0, won: false, keepPlaying: false };
const newState = move(state, DIRECTIONS.RIGHT);
const newTiles = computeTilesAfterMove(prevTiles, prevGrid, newState.grid, DIRECTIONS.RIGHT);
const movedTile = newTiles.find(t => t.id === originalId);
expect(movedTile).toBeDefined();
expect(movedTile.col).toBe(3);
});
it('handles UP direction correctly', () => {
const prevGrid = [
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[2, 0, 0, 0],
];
const prevTiles = createTilesFromGrid(prevGrid);
const originalId = prevTiles[0].id;
const state = { grid: prevGrid, score: 0, won: false, keepPlaying: false };
const newState = move(state, DIRECTIONS.UP);
const newTiles = computeTilesAfterMove(prevTiles, prevGrid, newState.grid, DIRECTIONS.UP);
const movedTile = newTiles.find(t => t.id === originalId);
expect(movedTile).toBeDefined();
expect(movedTile.row).toBe(0);
});
it('handles DOWN direction correctly', () => {
const prevGrid = [
[2, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
];
const prevTiles = createTilesFromGrid(prevGrid);
const originalId = prevTiles[0].id;
const state = { grid: prevGrid, score: 0, won: false, keepPlaying: false };
const newState = move(state, DIRECTIONS.DOWN);
const newTiles = computeTilesAfterMove(prevTiles, prevGrid, newState.grid, DIRECTIONS.DOWN);
const movedTile = newTiles.find(t => t.id === originalId);
expect(movedTile).toBeDefined();
expect(movedTile.row).toBe(3);
});
it('handles chain merge [2,2,2,2] → [4,4] with correct IDs', () => {
const prevGrid = [
[2, 2, 2, 2],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
];
const prevTiles = createTilesFromGrid(prevGrid);
const state = { grid: prevGrid, score: 0, won: false, keepPlaying: false };
const newState = move(state, DIRECTIONS.LEFT);
const newTiles = computeTilesAfterMove(prevTiles, prevGrid, newState.grid, DIRECTIONS.LEFT);
const mergedTiles = newTiles.filter(t => t.isMerged);
expect(mergedTiles).toHaveLength(2);
expect(mergedTiles[0].value).toBe(4);
expect(mergedTiles[1].value).toBe(4);
});
});