mirror of
https://github.com/tiennm99/ai-coding-workflow-labs.git
synced 2026-09-13 08:19:03 +00:00
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:
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user