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).
7.3 KiB
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
- After a valid move, each tile animates from its old position to its new position using CSS
transform: translatewith a 100ms ease-in-out transition - All movable tiles animate simultaneously
- The animation runs at 60fps (GPU-accelerated CSS transition)
- During an animation, input is queued and executes after the current animation completes (isAnimating flag)
- When
prefers-reduced-motionis enabled, tiles appear instantly at new positions with no transition
Tasks / Subtasks
-
Task 1: Create tile-tracker.js module (AC: #1)
- Create
src/lib/tile-tracker.jswith tile identity management createTilesFromGrid(grid)— assigns unique IDs to each non-zero cellcomputeTilesAfterMove(prevTiles, prevGrid, newGrid, direction)— tracks tile movements through a move, preserving IDs for tiles that slide/merge, assigning new IDs for spawned tiles- Track merged tiles with
isMergedflag and spawned tiles withisNewflag (for Stories 3.2, 3.3) resetTracker()— resets ID counter (for New Game)
- Create
-
Task 2: Integrate tile tracker into App.svelte (AC: #1)
- Replace raw grid extraction with tile tracker
- Call
createTilesFromGridon init and New Game - Call
computeTilesAfterMoveafter each move - Pass tile objects (with id, value, row, col) to Grid
-
Task 3: Add CSS transition to Tile.svelte (AC: #1, #2, #3)
- Add
transition: transform 100ms ease-in-outto tile style - Tiles already use
transform: translate()for positioning — transition animates position changes automatically
- Add
-
Task 4: Add animation state and input queuing (AC: #4)
- Add
isAnimatingflag in App.svelte - Add
queuedDirectionto store pending input during animation - On move: set isAnimating=true, after 100ms timeout clear flag and process queued input
- Block moves in handleKeydown when isAnimating is true (queue instead)
- Add
-
Task 5: Support prefers-reduced-motion (AC: #5)
- Add CSS media query
@media (prefers-reduced-motion: reduce)that setstransition: none - When reduced motion: skip isAnimating delay (set flag immediately)
- Add CSS media query
-
Task 6: Update Grid.svelte keying (AC: #1)
- Change tile key from
tile.row-tile.coltotile.idfor stable DOM identity
- Change tile key from
-
Task 7: Write tests and verify (AC: all)
- Write tests for tile-tracker.js: ID assignment, ID persistence across moves, new tile detection, merge detection
- 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.jsstays 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:
- Build a position map from
prevTiles:{row}-{col}→ tile object - 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
- Compare moved grid with
newGridto find the spawned tile (the one cell in newGrid that differs from the moved-but-not-spawned grid) - 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
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:
@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 usetile.idonly - 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-outfor 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)