diff --git a/bmad/_bmad-output/implementation-artifacts/3-1-tile-slide-animation.md b/bmad/_bmad-output/implementation-artifacts/3-1-tile-slide-animation.md new file mode 100644 index 0000000..0d22ea4 --- /dev/null +++ b/bmad/_bmad-output/implementation-artifacts/3-1-tile-slide-animation.md @@ -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) diff --git a/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml b/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml index 2968320..edfaf91 100644 --- a/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -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 diff --git a/bmad/src/App.svelte b/bmad/src/App.svelte index aa44dc4..fda9434 100644 --- a/bmad/src/App.svelte +++ b/bmad/src/App.svelte @@ -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); } diff --git a/bmad/src/app.css b/bmad/src/app.css index e7cc9f6..898980c 100644 --- a/bmad/src/app.css +++ b/bmad/src/app.css @@ -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; + } +} diff --git a/bmad/src/components/Grid.svelte b/bmad/src/components/Grid.svelte index eefda66..7bc2eee 100644 --- a/bmad/src/components/Grid.svelte +++ b/bmad/src/components/Grid.svelte @@ -36,7 +36,7 @@ {/each} - {#each tiles as tile (tile.id || `${tile.row}-${tile.col}`)} - + {#each tiles as tile (tile.id)} + {/each} diff --git a/bmad/src/components/Tile.svelte b/bmad/src/components/Tile.svelte index cfc57a1..f2ecd5b 100644 --- a/bmad/src/components/Tile.svelte +++ b/bmad/src/components/Tile.svelte @@ -1,7 +1,7 @@