feat: add game board and tile rendering (Story 1.3)

Grid.svelte renders 4x4 board with empty cell placeholders.
Tile.svelte renders colored numbered tiles with absolute positioning
and ARIA roles. App.svelte owns game state via Svelte 5 $state rune.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 21:48:21 +07:00
co-authored by Claude Opus 4.6
parent 21b7ac468a
commit 51fb0ba35c
5 changed files with 209 additions and 2 deletions
@@ -0,0 +1,113 @@
# Story 1.3: Game Board & Tile Rendering
Status: review
## Story
As a player,
I want to see a 4x4 game grid with colored numbered tiles,
so that I can visually understand the game state.
## Acceptance Criteria
1. A 4x4 grid with `#bbada0` background and 16 empty cell placeholders (`#cdc1b4`) is displayed
2. Grid has `15px` gap and `15px` padding with `6px` border radius
3. Grid container is `500px` wide and centered on the page
4. Each tile displays its numeric value centered in the cell
5. Tile background color matches the 12-tier color system from constants.js TILE_COLORS
6. Text color is `#776e65` for values 2 and 4, `#f9f6f2` for values 8+
7. Font family is Clear Sans with fallback chain
8. Tiles have `role="gridcell"` and `aria-label` with tile value
9. Grid has `role="grid"` and `aria-label="Game board"`
## Tasks / Subtasks
- [x] Task 1: Create Grid component (AC: #1, #2, #3, #9)
- [x] Created `src/components/Grid.svelte` with 4x4 layout, #bbada0 background, 15px gap/padding, 6px radius
- [x] 16 empty cell placeholders with #cdc1b4 background
- [x] `role="grid"` and `aria-label="Game board"` added
- [x] Task 2: Create Tile component (AC: #4, #5, #6, #7, #8)
- [x] Created `src/components/Tile.svelte` with dynamic color lookup from TILE_COLORS
- [x] Absolute positioning via CSS transform based on row/col
- [x] `role="gridcell"` and `aria-label="Tile: {value}"` added
- [x] Task 3: Integrate Grid into App with game state (AC: all)
- [x] App.svelte uses `$state(initGame())` and `$derived` for tile extraction
- [x] Grid receives tiles array, renders 2 initial tiles
- [x] Task 4: Verify rendering and build (AC: all)
- [x] Dev server runs, 38 tests pass, production build succeeds (36KB JS, 11KB CSS)
## Dev Notes
### Architecture Compliance
- **Grid.svelte** — presentational component, receives `tiles` as prop, no state ownership
- **Tile.svelte** — presentational, receives `value`, `row`, `col` as props
- **App.svelte** — owns game state via `$state` rune, passes data down via props
- **No Svelte stores** — props-down pattern only
- **Tile positioning** — use absolute positioning with CSS transform translate based on row/col within the grid
### Tile Positioning Strategy
Grid cells are background-only (empty placeholders). Tiles are positioned absolutely over the grid using `transform: translate(x, y)` where x/y are calculated from col/row * cell size. This enables future animation (Story 3.1) where tiles slide between positions.
Cell size calculation: `(containerWidth - (GRID_SIZE + 1) * gap) / GRID_SIZE`
- Desktop: (500 - 5*15) / 4 = 106.25px per cell
- Position: `transform: translate(${col * (cellSize + gap)}px, ${row * (cellSize + gap)}px)`
### Color Lookup
```javascript
import { TILE_COLORS } from '../lib/constants.js';
const colors = TILE_COLORS[value] || TILE_COLORS.super;
```
### Svelte 5 Runes
Use `$state` for mutable state, NOT old Svelte 4 `let` reactivity:
```javascript
let gameState = $state(initGame());
```
Use `$derived` for computed values:
```javascript
let tiles = $derived(/* extract non-zero tiles from gameState.grid */);
```
### Previous Story Intelligence
From Story 1.2:
- `initGame()` returns `{ grid: number[][], score: 0, won: false, keepPlaying: false }`
- Grid is a 4x4 2D array where 0 = empty cell
- TILE_COLORS has keys: 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, super
- Each color entry: `{ bg: '#hex', text: '#hex' }`
### References
- [Source: _bmad-output/planning-artifacts/architecture.md#Frontend Architecture]
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Color System]
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Component Strategy - Grid.svelte]
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Component Strategy - Tile.svelte]
## Dev Agent Record
### Agent Model Used
Claude Opus 4.6 (1M context)
### Debug Log References
### Completion Notes List
- Grid.svelte: 4x4 layout with absolute positioning for tiles over empty cell placeholders
- Tile.svelte: dynamic color lookup, absolute positioning via transform for future animation support
- App.svelte: Svelte 5 $state rune for game state, $derived for tile extraction
- Cell size: 106.25px with 15px gap, total container ~500px
### File List
- src/components/Grid.svelte (new)
- src/components/Tile.svelte (new)
- src/App.svelte (updated — game state + grid integration)
@@ -46,7 +46,7 @@ development_status:
epic-1: in-progress
1-1-project-scaffold-and-dev-environment: review
1-2-game-logic-module: review
1-3-game-board-and-tile-rendering: backlog
1-3-game-board-and-tile-rendering: review
1-4-game-header-score-display-and-new-game-button: backlog
1-5-keyboard-input-and-interactive-gameplay: backlog
1-6-win-and-game-over-overlays: backlog
+24 -1
View File
@@ -1 +1,24 @@
<h1 class="text-center text-[80px] font-bold" style="color: #776e65;">2048</h1>
<script>
import Grid from './components/Grid.svelte';
import { initGame } from './lib/game-logic.js';
import { GRID_SIZE } from './lib/constants.js';
let gameState = $state(initGame());
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;
});
</script>
<main class="max-w-[500px] mx-auto pt-10">
<Grid {tiles} />
</main>
+42
View File
@@ -0,0 +1,42 @@
<script>
import Tile from './Tile.svelte';
import { GRID_SIZE } from '../lib/constants.js';
let { tiles = [] } = $props();
const GAP = 15;
const CELL_SIZE = 106.25;
const CONTAINER_SIZE = GRID_SIZE * CELL_SIZE + (GRID_SIZE + 1) * GAP;
</script>
<div
class="relative mx-auto rounded-[6px]"
style="
width: {CONTAINER_SIZE}px;
height: {CONTAINER_SIZE}px;
background: #bbada0;
padding: {GAP}px;
"
role="grid"
aria-label="Game board"
>
<!-- Empty cell placeholders -->
{#each Array(GRID_SIZE * GRID_SIZE) as _, i}
{@const row = Math.floor(i / GRID_SIZE)}
{@const col = i % GRID_SIZE}
<div
class="absolute rounded-[3px]"
style="
width: {CELL_SIZE}px;
height: {CELL_SIZE}px;
transform: translate({col * (CELL_SIZE + GAP)}px, {row * (CELL_SIZE + GAP)}px);
background: #cdc1b4;
"
></div>
{/each}
<!-- Tiles -->
{#each tiles as tile (tile.id || `${tile.row}-${tile.col}`)}
<Tile value={tile.value} row={tile.row} col={tile.col} />
{/each}
</div>
+29
View File
@@ -0,0 +1,29 @@
<script>
import { TILE_COLORS } from '../lib/constants.js';
let { value, row, col } = $props();
let colors = $derived(TILE_COLORS[value] || TILE_COLORS.super);
const GAP = 15;
const CELL_SIZE = 106.25;
let x = $derived(col * (CELL_SIZE + GAP));
let y = $derived(row * (CELL_SIZE + GAP));
</script>
<div
class="absolute flex items-center justify-center font-bold rounded-[3px]"
style="
width: {CELL_SIZE}px;
height: {CELL_SIZE}px;
transform: translate({x}px, {y}px);
background: {colors.bg};
color: {colors.text};
font-size: 55px;
"
role="gridcell"
aria-label="Tile: {value}"
>
{value}
</div>