mirror of
https://github.com/tiennm99/try-bmad.git
synced 2026-08-05 00:26:06 +00:00
feat: add multi-input support and responsive layout (Epic 4, Stories 4.1-4.3)
- Add WASD and Vim hjkl keyboard mappings to input handler - Add touch/swipe input with 10px threshold and dominant-axis detection - Refactor all component dimensions to CSS custom properties - Add responsive breakpoint at 520px (500px→280px container, scaled fonts) - Add 31 new tests (21 input-handler + 10 swipe), 90 total passing
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# Story 4.1: WASD & Vim Keyboard Support
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a desktop player,
|
||||
I want to use WASD or Vim hjkl keys to control the game,
|
||||
so that I can play with my preferred keyboard layout.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Given the game is in playing state, when the player presses W/A/S/D keys, then they map to Up/Left/Down/Right directions respectively and trigger a move
|
||||
2. Given the game is in playing state, when the player presses h/j/k/l keys, then they map to Left/Down/Up/Right directions respectively and trigger a move
|
||||
3. Given the input handler, when any mapped key is pressed, then it produces identical behavior to the corresponding arrow key (same game logic path)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Extend KEY_MAP in input-handler.js (AC: #1, #2, #3)
|
||||
- [x] Add WASD mappings: `w` → UP, `a` → LEFT, `s` → DOWN, `d` → RIGHT
|
||||
- [x] Add Vim mappings: `h` → LEFT, `j` → DOWN, `k` → UP, `l` → RIGHT
|
||||
- [x] Support both lowercase and uppercase keys for Caps Lock/Shift edge cases
|
||||
- [x] Task 2: Add unit tests for new key mappings (AC: #1, #2, #3)
|
||||
- [x] Test all 8 new key mappings return correct DIRECTIONS value
|
||||
- [x] Test uppercase variants (W, A, S, D, H, J, K, L) also map correctly
|
||||
- [x] Test unmapped keys still return null
|
||||
- [x] Task 3: Verify no regressions (AC: #3)
|
||||
- [x] All existing arrow key tests still pass (4 tests)
|
||||
- [x] All 80 tests pass across 4 test files (was 59, now 80 with 21 new input-handler tests)
|
||||
- [x] All key types produce identical behavior via same getDirectionFromKey → executeMove pipeline
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Implementation Details
|
||||
|
||||
This is a minimal change — only `src/lib/input-handler.js` needs modification. The entire input pipeline already works:
|
||||
|
||||
1. `App.svelte:handleKeydown` captures `svelte:window onkeydown`
|
||||
2. Calls `getDirectionFromKey(event.key)` from `input-handler.js`
|
||||
3. If direction returned, calls `executeMove(direction)`
|
||||
4. Animation queuing (`isAnimating` / `queuedDirection`) already handles rapid input
|
||||
|
||||
The ONLY change needed is adding entries to the `KEY_MAP` object in `input-handler.js`. No changes to App.svelte, no changes to game logic, no new files.
|
||||
|
||||
### Current input-handler.js (complete file)
|
||||
|
||||
```javascript
|
||||
import { DIRECTIONS } from './constants.js';
|
||||
|
||||
const KEY_MAP = {
|
||||
ArrowUp: DIRECTIONS.UP,
|
||||
ArrowDown: DIRECTIONS.DOWN,
|
||||
ArrowLeft: DIRECTIONS.LEFT,
|
||||
ArrowRight: DIRECTIONS.RIGHT,
|
||||
};
|
||||
|
||||
export function getDirectionFromKey(key) {
|
||||
return KEY_MAP[key] || null;
|
||||
}
|
||||
```
|
||||
|
||||
### Required Changes
|
||||
|
||||
Add to `KEY_MAP`:
|
||||
```javascript
|
||||
// WASD
|
||||
w: DIRECTIONS.UP,
|
||||
a: DIRECTIONS.LEFT,
|
||||
s: DIRECTIONS.DOWN,
|
||||
d: DIRECTIONS.RIGHT,
|
||||
// Vim
|
||||
h: DIRECTIONS.LEFT,
|
||||
j: DIRECTIONS.DOWN,
|
||||
k: DIRECTIONS.UP,
|
||||
l: DIRECTIONS.RIGHT,
|
||||
```
|
||||
|
||||
**Key casing note:** `KeyboardEvent.key` returns `"w"` (lowercase) for letter keys when no modifier is held, and `"W"` (uppercase) when Shift is held. Add both lowercase and uppercase mappings to handle Caps Lock or Shift edge cases.
|
||||
|
||||
### Architecture Compliance
|
||||
|
||||
- File: `src/lib/input-handler.js` — pure JS module in `src/lib/`, zero Svelte imports ✓
|
||||
- Pattern: extends existing KEY_MAP object, no new exports needed ✓
|
||||
- No new dependencies ✓
|
||||
- Immutable game logic path unchanged — same `getDirectionFromKey → executeMove → move()` pipeline ✓
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
- Test file: `src/lib/input-handler.test.js` (create if it doesn't exist, or add to existing)
|
||||
- Framework: Vitest (already configured)
|
||||
- Test pattern: `expect(getDirectionFromKey('w')).toBe('up')` for each mapping
|
||||
- Run: `npx vitest` — all tests must pass including existing 59+ game-logic tests
|
||||
|
||||
### FRs Covered
|
||||
|
||||
- FR15: WASD key input
|
||||
- FR16: Vim hjkl key input
|
||||
|
||||
### References
|
||||
|
||||
- [Source: _bmad-output/planning-artifacts/epics.md — Epic 4, Story 4.1]
|
||||
- [Source: _bmad-output/planning-artifacts/architecture.md — Input Processing pattern]
|
||||
- [Source: src/lib/input-handler.js — current implementation]
|
||||
- [Source: src/App.svelte:77-88 — handleKeydown wiring]
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.6 (1M context)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
- Extended KEY_MAP in input-handler.js with 16 new entries: WASD (8 keys, upper+lower) + Vim hjkl (8 keys, upper+lower)
|
||||
- Created input-handler.test.js with 21 tests: 4 arrow key, 8 WASD, 8 Vim hjkl, 1 unmapped keys
|
||||
- Red-green-refactor cycle: wrote failing tests first, then added mappings, all 80 tests pass
|
||||
- No changes to App.svelte or game logic — purely additive KEY_MAP extension
|
||||
|
||||
### File List
|
||||
- src/lib/input-handler.js (modified — added WASD and Vim key mappings to KEY_MAP)
|
||||
- src/lib/input-handler.test.js (created — 21 tests for all key mappings)
|
||||
@@ -0,0 +1,123 @@
|
||||
# Story 4.2: Touch & Swipe Input
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a mobile player,
|
||||
I want to swipe on the game board to slide tiles,
|
||||
so that I can play the game naturally on a touchscreen.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Given the player touches the game grid and swipes, when the swipe distance exceeds 10px minimum threshold, then the dominant axis (larger delta between horizontal and vertical) determines the direction and the corresponding move is executed
|
||||
2. Given the player touches and lifts with less than 10px movement, when the touch event completes, then no move is triggered (prevents accidental swipes)
|
||||
3. Given a diagonal swipe, when the touch event completes, then the axis with the larger delta wins (e.g., deltaX=30, deltaY=15 → horizontal → Left or Right based on sign)
|
||||
4. Given an animation is in progress on mobile, when the player swipes again, then the input is queued and executes after the current animation completes (same queuing as keyboard)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Add swipe detection functions to input-handler.js (AC: #1, #2, #3)
|
||||
- [x] Export `getDirectionFromSwipe(startX, startY, endX, endY)` — returns direction string or null
|
||||
- [x] Implement 10px minimum threshold: if both |deltaX| and |deltaY| < 10, return null
|
||||
- [x] Implement dominant-axis detection: compare |deltaX| vs |deltaY|, use larger axis
|
||||
- [x] For horizontal: deltaX < 0 → LEFT, deltaX > 0 → RIGHT
|
||||
- [x] For vertical: deltaY < 0 → UP, deltaY > 0 → DOWN
|
||||
- [x] Task 2: Add unit tests for swipe detection (AC: #1, #2, #3)
|
||||
- [x] Test swipe right (deltaX=50, deltaY=5) → 'right'
|
||||
- [x] Test swipe left (deltaX=-50, deltaY=5) → 'left'
|
||||
- [x] Test swipe up (deltaX=5, deltaY=-50) → 'up'
|
||||
- [x] Test swipe down (deltaX=5, deltaY=50) → 'down'
|
||||
- [x] Test below threshold (deltaX=3, deltaY=5) → null
|
||||
- [x] Test diagonal dominant axis (deltaX=30, deltaY=15) → 'right'
|
||||
- [x] Test exact threshold (deltaX=10, deltaY=0) → 'right'
|
||||
- [x] Test zero movement (0, 0) → null
|
||||
- [x] Task 3: Wire touch events in App.svelte (AC: #1, #4)
|
||||
- [x] Import `getDirectionFromSwipe` from input-handler.js
|
||||
- [x] Add `handleTouchStart` — store start coordinates from event.touches[0]
|
||||
- [x] Add `handleTouchEnd` — compute direction via changedTouches[0], feed into executeMove with same animation queuing
|
||||
- [x] Attach ontouchstart and ontouchend on the game container div
|
||||
- [x] Call `event.preventDefault()` in touchend when a direction is detected to prevent scroll
|
||||
- [x] Task 4: Verify no regressions (AC: #4)
|
||||
- [x] All 90 tests pass (was 80, +10 new swipe tests)
|
||||
- [x] Keyboard input unchanged — same handleKeydown path
|
||||
- [x] Animation queuing works for both keyboard and touch (same isAnimating/queuedDirection path in handleTouchEnd)
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Architecture Pattern
|
||||
|
||||
Per architecture doc, `input-handler.js` translates DOM events to direction strings — no game logic. Touch detection follows the same pattern as keyboard: pure functions that convert raw event data to a direction string.
|
||||
|
||||
The touch wiring in App.svelte follows the same pattern as keyboard:
|
||||
1. `handleTouchStart` captures touch coordinates (like `handleKeydown` captures key)
|
||||
2. `handleTouchEnd` computes direction via `getDirectionFromSwipe()` (like `getDirectionFromKey()`)
|
||||
3. Same `isAnimating` check → queue or `executeMove(direction)` — identical pipeline
|
||||
|
||||
### Current input-handler.js exports
|
||||
|
||||
```javascript
|
||||
export function getDirectionFromKey(key) // existing — returns direction string or null
|
||||
```
|
||||
|
||||
New exports to add:
|
||||
```javascript
|
||||
export function getDirectionFromSwipe(startX, startY, endX, endY) // returns direction string or null
|
||||
```
|
||||
|
||||
### App.svelte Touch Wiring
|
||||
|
||||
Touch handlers go on the game container div (the `<div class="relative">` at line 110):
|
||||
```svelte
|
||||
<div class="relative"
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchend={handleTouchEnd}
|
||||
>
|
||||
```
|
||||
|
||||
`handleTouchStart` and `handleTouchEnd` are defined in App.svelte's `<script>` block (not in input-handler.js) because they need access to component state (`isAnimating`, `queuedDirection`, `executeMove`). Only the pure direction calculation is in input-handler.js.
|
||||
|
||||
### Touch Event Details
|
||||
|
||||
- `TouchEvent.touches[0].clientX/clientY` for start position
|
||||
- `TouchEvent.changedTouches[0].clientX/clientY` for end position (touches array is empty on touchend)
|
||||
- Must use `changedTouches` in touchend, NOT `touches`
|
||||
- `event.preventDefault()` in touchend to prevent page scroll on successful swipe
|
||||
|
||||
### Previous Story Learnings (4.1)
|
||||
|
||||
- input-handler.js pattern works well: pure functions, easy to test with Vitest
|
||||
- Test file already exists at `src/lib/input-handler.test.js` — add new describe blocks for swipe tests
|
||||
- 80 tests currently passing across 4 test files
|
||||
|
||||
### FRs Covered
|
||||
|
||||
- FR17: Touch/swipe input for mobile with 10px minimum threshold and dominant-axis detection
|
||||
|
||||
### References
|
||||
|
||||
- [Source: _bmad-output/planning-artifacts/epics.md — Epic 4, Story 4.2]
|
||||
- [Source: _bmad-output/planning-artifacts/architecture.md — Input Processing pattern]
|
||||
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md — UX-DR17 non-blocking animation, UX-DR18 touch targets]
|
||||
- [Source: src/lib/input-handler.js — current implementation with keyboard mappings]
|
||||
- [Source: src/App.svelte:42-88 — executeMove + handleKeydown + animation queuing]
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.6 (1M context)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
- Added `getDirectionFromSwipe()` to input-handler.js: 10px threshold, dominant-axis detection, returns direction or null
|
||||
- Added 10 unit tests for swipe detection: 4 cardinal, 3 threshold, 3 dominant axis
|
||||
- Wired handleTouchStart/handleTouchEnd in App.svelte on game container div
|
||||
- Uses changedTouches[0] in touchend (not touches), preventDefault on valid swipe
|
||||
- Touch input feeds into same executeMove + animation queuing pipeline as keyboard
|
||||
- All 90 tests pass, zero regressions
|
||||
|
||||
### File List
|
||||
- src/lib/input-handler.js (modified — added getDirectionFromSwipe export)
|
||||
- src/lib/input-handler.test.js (modified — added 10 swipe detection tests)
|
||||
- src/App.svelte (modified — added touch handlers, imported getDirectionFromSwipe)
|
||||
@@ -0,0 +1,188 @@
|
||||
# Story 4.3: Responsive Layout
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a mobile player,
|
||||
I want the game to fit my phone screen,
|
||||
so that the board is fully visible and playable on smaller devices.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Given the viewport width is greater than 520px, when the page renders, then the game container is 500px wide, grid cells are ~106px, grid gap is 15px, grid padding is 15px
|
||||
2. Given the viewport width is 520px or less, when the page renders, then the game container scales to 280px wide, grid cells scale to ~57px, grid gap reduces to 10px, grid padding reduces to 10px (UX-DR10)
|
||||
3. Given the mobile breakpoint is active, when text elements render, then title scales to 45px, tile fonts scale proportionally (1-digit: 30px, 2-digit: 25px, 3-digit: 20px, 4-digit: 14px), score value to 16px, overlay message to 35px (UX-DR11)
|
||||
4. Given buttons on mobile, when they render, then all interactive buttons maintain minimum 44x44px touch target (UX-DR18)
|
||||
5. Given any viewport size, when the game container renders, then it is horizontally centered with max-width: 500px and margin: auto
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Add CSS custom properties and media query to app.css (AC: #1, #2)
|
||||
- [x] Define desktop CSS variables on `:root`: --grid-gap, --cell-size, --container-size, --grid-padding, --title-size, --score-value-size, --overlay-msg-size, --tile-font-1 through --tile-font-5
|
||||
- [x] Add `@media (max-width: 520px)` block overriding all variables for mobile dimensions
|
||||
- [x] Desktop container-size = 500px; Mobile container-size = 280px
|
||||
- [x] Task 2: Update Grid.svelte to use CSS variables (AC: #1, #2)
|
||||
- [x] Removed hardcoded GAP, CELL_SIZE, CONTAINER_SIZE constants
|
||||
- [x] Uses var(--container-size), var(--grid-gap), var(--grid-padding), var(--cell-size)
|
||||
- [x] Empty cell placeholders use calc() with CSS variables for absolute positioning
|
||||
- [x] Task 3: Update Tile.svelte to use CSS variables and responsive font sizes (AC: #1, #2, #3)
|
||||
- [x] Removed hardcoded GAP, CELL_SIZE constants
|
||||
- [x] Uses var(--cell-size) for width/height
|
||||
- [x] Tile position via calc() with var(--cell-size) and var(--grid-gap)
|
||||
- [x] Dynamic font sizing: digitCount derived from String(value).length, maps to var(--tile-font-N)
|
||||
- [x] Desktop: 55/45/35/25/15px; Mobile: 30/25/20/14/10px via CSS variables
|
||||
- [x] Task 4: Update App.svelte for responsive title and layout (AC: #3, #5)
|
||||
- [x] Title uses var(--title-size): desktop 80px, mobile 45px
|
||||
- [x] Container is max-w-[500px] mx-auto (verified, already present)
|
||||
- [x] Task 5: Update ScoreBoard.svelte for responsive font sizes (AC: #3)
|
||||
- [x] Score and best score values use var(--score-value-size): desktop 25px, mobile 16px
|
||||
- [x] Score label stays 13px on both (unchanged)
|
||||
- [x] Task 6: Update GameMessage.svelte for responsive overlay text (AC: #3)
|
||||
- [x] Overlay message uses var(--overlay-msg-size): desktop 60px, mobile 35px
|
||||
- [x] Buttons already have min-h-[44px] for touch targets (AC: #4)
|
||||
- [x] Task 7: Verify all tests pass and no regressions (AC: #1-#5)
|
||||
- [x] All 90 tests pass across 4 test files, zero regressions
|
||||
- [x] Production build succeeds: 22KB gzipped (well under 50KB limit)
|
||||
- [x] Note: Visual verification requires browser testing at different viewport widths
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Implementation Strategy: CSS Custom Properties
|
||||
|
||||
Using CSS custom properties (CSS variables) defined on `:root` with a `@media (max-width: 520px)` override. This is the cleanest approach because:
|
||||
1. Grid and Tile components use the same variables — no prop drilling needed
|
||||
2. CSS handles the breakpoint — no JS resize listeners
|
||||
3. Works with Tailwind — just add to app.css alongside existing `@keyframes`
|
||||
4. Svelte components reference variables in inline styles
|
||||
|
||||
### Current Component Dimensions (Desktop — MUST NOT CHANGE)
|
||||
|
||||
- Grid: `GAP=15`, `CELL_SIZE=106.25`, container = `500px`
|
||||
- Tile: `GAP=15`, `CELL_SIZE=106.25`, `font-size: 55px`
|
||||
- ScoreBoard: label 13px, value 25px
|
||||
- GameMessage: message 60px
|
||||
- App title: 80px
|
||||
|
||||
### Mobile Dimensions (≤520px)
|
||||
|
||||
Per UX-DR10 and UX-DR11:
|
||||
- Grid: `GAP=10`, `CELL_SIZE=57.5`, container = `280px`
|
||||
- Tile fonts: 1-digit 30px, 2-digit 25px, 3-digit 20px, 4-digit 14px
|
||||
- Score value: 16px
|
||||
- Overlay message: 35px
|
||||
- Title: 45px
|
||||
|
||||
### CSS Variables to Define in app.css
|
||||
|
||||
```css
|
||||
:root {
|
||||
--grid-gap: 15px;
|
||||
--grid-padding: 15px;
|
||||
--cell-size: 106.25px;
|
||||
--container-size: 500px;
|
||||
--title-size: 80px;
|
||||
--score-value-size: 25px;
|
||||
--overlay-msg-size: 60px;
|
||||
--tile-font-1: 55px;
|
||||
--tile-font-2: 45px;
|
||||
--tile-font-3: 35px;
|
||||
--tile-font-4: 25px;
|
||||
--tile-font-5: 15px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
:root {
|
||||
--grid-gap: 10px;
|
||||
--grid-padding: 10px;
|
||||
--cell-size: 57.5px;
|
||||
--container-size: 280px;
|
||||
--title-size: 45px;
|
||||
--score-value-size: 16px;
|
||||
--overlay-msg-size: 35px;
|
||||
--tile-font-1: 30px;
|
||||
--tile-font-2: 25px;
|
||||
--tile-font-3: 20px;
|
||||
--tile-font-4: 14px;
|
||||
--tile-font-5: 10px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tile Font Size Logic
|
||||
|
||||
In Tile.svelte, derive font size from digit count:
|
||||
```javascript
|
||||
let digitCount = $derived(String(value).length);
|
||||
let fontSize = $derived(
|
||||
digitCount <= 1 ? 'var(--tile-font-1)' :
|
||||
digitCount === 2 ? 'var(--tile-font-2)' :
|
||||
digitCount === 3 ? 'var(--tile-font-3)' :
|
||||
digitCount === 4 ? 'var(--tile-font-4)' :
|
||||
'var(--tile-font-5)'
|
||||
);
|
||||
```
|
||||
|
||||
### Tile Position with CSS calc()
|
||||
|
||||
Replace hardcoded pixel calculations with:
|
||||
```javascript
|
||||
// In Tile.svelte — use template strings referencing CSS vars
|
||||
let transformStyle = $derived(
|
||||
`translate(calc(${col} * (var(--cell-size) + var(--grid-gap))), calc(${row} * (var(--cell-size) + var(--grid-gap))))`
|
||||
);
|
||||
```
|
||||
|
||||
### Grid Empty Cells
|
||||
|
||||
Same pattern — use `calc()` with CSS variables for positioning empty cell placeholders.
|
||||
|
||||
### Previous Story Learnings
|
||||
|
||||
- Story 4.1/4.2: input-handler changes were clean, no component layout impact
|
||||
- 90 tests passing across 4 test files
|
||||
- Grid.svelte and Tile.svelte both duplicate GAP/CELL_SIZE constants — this story consolidates them into CSS variables
|
||||
|
||||
### Architecture Compliance
|
||||
|
||||
- CSS variables in app.css — consistent with existing @keyframes location
|
||||
- No new JS libraries or resize listeners needed
|
||||
- Props-down pattern preserved — components still receive data via props
|
||||
- Tailwind utilities still used for flex/layout — CSS variables only for dimension values
|
||||
|
||||
### FRs Covered
|
||||
|
||||
- FR28: Responsive layout (520px breakpoint)
|
||||
|
||||
### References
|
||||
|
||||
- [Source: _bmad-output/planning-artifacts/epics.md — Epic 4, Story 4.3]
|
||||
- [Source: _bmad-output/planning-artifacts/architecture.md — Frontend Architecture, responsive at 520px]
|
||||
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md — UX-DR10, UX-DR11, UX-DR18]
|
||||
- [Source: src/components/Grid.svelte — current hardcoded dimensions]
|
||||
- [Source: src/components/Tile.svelte — current hardcoded dimensions and font]
|
||||
- [Source: src/app.css — existing @keyframes and media query location]
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.6 (1M context)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
- Defined 13 CSS custom properties on :root with @media (max-width: 520px) override in app.css
|
||||
- Replaced all hardcoded pixel dimensions in Grid.svelte and Tile.svelte with CSS var() + calc()
|
||||
- Added dynamic tile font sizing in Tile.svelte based on digit count (1-5+ digits)
|
||||
- Updated App.svelte title, ScoreBoard.svelte score values, GameMessage.svelte message text to use CSS variables
|
||||
- All responsive values match UX-DR10 (dimensions) and UX-DR11 (font scaling) exactly
|
||||
- All 90 tests pass, production build 22KB gzipped
|
||||
- Note: Cannot run browser visual verification in this environment — requires manual testing at ≤520px
|
||||
|
||||
### File List
|
||||
- src/app.css (modified — added :root CSS variables and @media responsive override)
|
||||
- src/components/Grid.svelte (modified — replaced hardcoded dims with CSS variables)
|
||||
- src/components/Tile.svelte (modified — CSS variables for dims, dynamic font sizing)
|
||||
- src/App.svelte (modified — responsive title size via CSS variable)
|
||||
- src/components/ScoreBoard.svelte (modified — responsive score value font size)
|
||||
- src/components/GameMessage.svelte (modified — responsive overlay message font size)
|
||||
@@ -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:55:00
|
||||
last_updated: 2026-04-14
|
||||
project: try-bmad
|
||||
project_key: NOKEY
|
||||
tracking_system: file-system
|
||||
@@ -69,10 +69,10 @@ development_status:
|
||||
epic-3-retrospective: optional
|
||||
|
||||
# Epic 4: Mobile & Multi-Input Support
|
||||
epic-4: backlog
|
||||
4-1-wasd-and-vim-keyboard-support: backlog
|
||||
4-2-touch-and-swipe-input: backlog
|
||||
4-3-responsive-layout: backlog
|
||||
epic-4: done
|
||||
4-1-wasd-and-vim-keyboard-support: done
|
||||
4-2-touch-and-swipe-input: done
|
||||
4-3-responsive-layout: done
|
||||
epic-4-retrospective: optional
|
||||
|
||||
# Epic 5: Visual Polish & Deployment
|
||||
|
||||
+26
-3
@@ -4,7 +4,7 @@
|
||||
import GameMessage from './components/GameMessage.svelte';
|
||||
import { initGame, move, isGameOver } from './lib/game-logic.js';
|
||||
import { GRID_SIZE } from './lib/constants.js';
|
||||
import { getDirectionFromKey } from './lib/input-handler.js';
|
||||
import { getDirectionFromKey, getDirectionFromSwipe } from './lib/input-handler.js';
|
||||
import { saveGameState, loadGameState, saveBestScore, loadBestScore, clearGameState } from './lib/storage.js';
|
||||
import { createTilesFromGrid, computeTilesAfterMove, resetTracker } from './lib/tile-tracker.js';
|
||||
|
||||
@@ -74,6 +74,29 @@
|
||||
gameState = { ...gameState, keepPlaying: true, won: true };
|
||||
}
|
||||
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
|
||||
function handleTouchStart(event) {
|
||||
touchStartX = event.touches[0].clientX;
|
||||
touchStartY = event.touches[0].clientY;
|
||||
}
|
||||
|
||||
function handleTouchEnd(event) {
|
||||
const endX = event.changedTouches[0].clientX;
|
||||
const endY = event.changedTouches[0].clientY;
|
||||
const direction = getDirectionFromSwipe(touchStartX, touchStartY, endX, endY);
|
||||
if (!direction) return;
|
||||
event.preventDefault();
|
||||
|
||||
if (isAnimating) {
|
||||
queuedDirection = direction;
|
||||
return;
|
||||
}
|
||||
|
||||
executeMove(direction);
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
const direction = getDirectionFromKey(event.key);
|
||||
if (!direction) return;
|
||||
@@ -93,7 +116,7 @@
|
||||
<main class="max-w-[500px] mx-auto px-2 pt-6" role="application">
|
||||
<header>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h1 class="text-[80px] font-bold leading-none" style="color: #776e65;">2048</h1>
|
||||
<h1 class="font-bold leading-none" style="color: #776e65; font-size: var(--title-size);">2048</h1>
|
||||
<ScoreBoard score={gameState.score} {bestScore} {scoreDelta} />
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -107,7 +130,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="relative">
|
||||
<div class="relative" ontouchstart={handleTouchStart} ontouchend={handleTouchEnd}>
|
||||
<Grid {tiles} />
|
||||
<GameMessage type={overlayType} onKeepGoing={handleKeepGoing} onNewGame={handleNewGame} />
|
||||
</div>
|
||||
|
||||
+32
@@ -16,6 +16,38 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--grid-gap: 15px;
|
||||
--grid-padding: 15px;
|
||||
--cell-size: 106.25px;
|
||||
--container-size: 500px;
|
||||
--title-size: 80px;
|
||||
--score-value-size: 25px;
|
||||
--overlay-msg-size: 60px;
|
||||
--tile-font-1: 55px;
|
||||
--tile-font-2: 45px;
|
||||
--tile-font-3: 35px;
|
||||
--tile-font-4: 25px;
|
||||
--tile-font-5: 15px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
:root {
|
||||
--grid-gap: 10px;
|
||||
--grid-padding: 10px;
|
||||
--cell-size: 57.5px;
|
||||
--container-size: 280px;
|
||||
--title-size: 45px;
|
||||
--score-value-size: 16px;
|
||||
--overlay-msg-size: 35px;
|
||||
--tile-font-1: 30px;
|
||||
--tile-font-2: 25px;
|
||||
--tile-font-3: 20px;
|
||||
--tile-font-4: 14px;
|
||||
--tile-font-5: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: #faf8ef;
|
||||
font-family: 'Clear Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
aria-modal="true"
|
||||
aria-label={message}
|
||||
>
|
||||
<p class="text-[60px] font-bold mb-4" style="color: #776e65;">{message}</p>
|
||||
<p class="font-bold mb-4" style="color: #776e65; font-size: var(--overlay-msg-size);">{message}</p>
|
||||
{#if type === 'win'}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -3,19 +3,15 @@
|
||||
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;
|
||||
width: var(--container-size);
|
||||
height: var(--container-size);
|
||||
background: #bbada0;
|
||||
padding: {GAP}px;
|
||||
padding: var(--grid-padding);
|
||||
"
|
||||
role="grid"
|
||||
aria-label="Game board"
|
||||
@@ -27,9 +23,9 @@
|
||||
<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);
|
||||
width: var(--cell-size);
|
||||
height: var(--cell-size);
|
||||
transform: translate(calc({col} * (var(--cell-size) + var(--grid-gap))), calc({row} * (var(--cell-size) + var(--grid-gap))));
|
||||
background: #cdc1b4;
|
||||
"
|
||||
></div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex flex-col items-center rounded-[3px] px-6 py-2 min-w-[80px]" style="background: #bbada0;">
|
||||
<span class="uppercase text-[13px] font-bold" style="color: #eee4da;">Score</span>
|
||||
<span class="text-[25px] font-bold" style="color: #f9f6f2;" aria-live="polite">{score}</span>
|
||||
<span class="font-bold" style="color: #f9f6f2; font-size: var(--score-value-size);" aria-live="polite">{score}</span>
|
||||
{#each floats as float (float.id)}
|
||||
<span
|
||||
class="absolute font-bold text-[18px] pointer-events-none"
|
||||
@@ -30,6 +30,6 @@
|
||||
</div>
|
||||
<div class="flex flex-col items-center rounded-[3px] px-6 py-2 min-w-[80px]" style="background: #bbada0;">
|
||||
<span class="uppercase text-[13px] font-bold" style="color: #eee4da;">Best</span>
|
||||
<span class="text-[25px] font-bold" style="color: #f9f6f2;">{bestScore}</span>
|
||||
<span class="font-bold" style="color: #f9f6f2; font-size: var(--score-value-size);">{bestScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
|
||||
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));
|
||||
let digitCount = $derived(String(value).length);
|
||||
let fontSize = $derived(
|
||||
digitCount <= 1 ? 'var(--tile-font-1)' :
|
||||
digitCount === 2 ? 'var(--tile-font-2)' :
|
||||
digitCount === 3 ? 'var(--tile-font-3)' :
|
||||
digitCount === 4 ? 'var(--tile-font-4)' :
|
||||
'var(--tile-font-5)'
|
||||
);
|
||||
|
||||
let animation = $derived(
|
||||
isNew ? 'tile-pop 200ms ease-in-out' :
|
||||
@@ -21,14 +24,14 @@
|
||||
<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);
|
||||
width: var(--cell-size);
|
||||
height: var(--cell-size);
|
||||
transform: translate(calc({col} * (var(--cell-size) + var(--grid-gap))), calc({row} * (var(--cell-size) + var(--grid-gap))));
|
||||
transition: transform 100ms ease-in-out;
|
||||
animation: {animation};
|
||||
background: {colors.bg};
|
||||
color: {colors.text};
|
||||
font-size: 55px;
|
||||
font-size: {fontSize};
|
||||
"
|
||||
role="gridcell"
|
||||
aria-label="Tile: {value}"
|
||||
|
||||
@@ -5,8 +5,45 @@ const KEY_MAP = {
|
||||
ArrowDown: DIRECTIONS.DOWN,
|
||||
ArrowLeft: DIRECTIONS.LEFT,
|
||||
ArrowRight: DIRECTIONS.RIGHT,
|
||||
// WASD
|
||||
w: DIRECTIONS.UP,
|
||||
W: DIRECTIONS.UP,
|
||||
a: DIRECTIONS.LEFT,
|
||||
A: DIRECTIONS.LEFT,
|
||||
s: DIRECTIONS.DOWN,
|
||||
S: DIRECTIONS.DOWN,
|
||||
d: DIRECTIONS.RIGHT,
|
||||
D: DIRECTIONS.RIGHT,
|
||||
// Vim hjkl
|
||||
h: DIRECTIONS.LEFT,
|
||||
H: DIRECTIONS.LEFT,
|
||||
j: DIRECTIONS.DOWN,
|
||||
J: DIRECTIONS.DOWN,
|
||||
k: DIRECTIONS.UP,
|
||||
K: DIRECTIONS.UP,
|
||||
l: DIRECTIONS.RIGHT,
|
||||
L: DIRECTIONS.RIGHT,
|
||||
};
|
||||
|
||||
export function getDirectionFromKey(key) {
|
||||
return KEY_MAP[key] || null;
|
||||
}
|
||||
|
||||
const SWIPE_THRESHOLD = 10;
|
||||
|
||||
export function getDirectionFromSwipe(startX, startY, endX, endY) {
|
||||
const deltaX = endX - startX;
|
||||
const deltaY = endY - startY;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (absDeltaX < SWIPE_THRESHOLD && absDeltaY < SWIPE_THRESHOLD) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (absDeltaX >= absDeltaY) {
|
||||
return deltaX < 0 ? DIRECTIONS.LEFT : DIRECTIONS.RIGHT;
|
||||
} else {
|
||||
return deltaY < 0 ? DIRECTIONS.UP : DIRECTIONS.DOWN;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getDirectionFromKey, getDirectionFromSwipe } from './input-handler.js';
|
||||
|
||||
describe('getDirectionFromKey', () => {
|
||||
// Existing arrow key mappings
|
||||
describe('arrow keys', () => {
|
||||
it('maps ArrowUp to up', () => {
|
||||
expect(getDirectionFromKey('ArrowUp')).toBe('up');
|
||||
});
|
||||
it('maps ArrowDown to down', () => {
|
||||
expect(getDirectionFromKey('ArrowDown')).toBe('down');
|
||||
});
|
||||
it('maps ArrowLeft to left', () => {
|
||||
expect(getDirectionFromKey('ArrowLeft')).toBe('left');
|
||||
});
|
||||
it('maps ArrowRight to right', () => {
|
||||
expect(getDirectionFromKey('ArrowRight')).toBe('right');
|
||||
});
|
||||
});
|
||||
|
||||
// WASD mappings
|
||||
describe('WASD keys', () => {
|
||||
it('maps w to up', () => {
|
||||
expect(getDirectionFromKey('w')).toBe('up');
|
||||
});
|
||||
it('maps a to left', () => {
|
||||
expect(getDirectionFromKey('a')).toBe('left');
|
||||
});
|
||||
it('maps s to down', () => {
|
||||
expect(getDirectionFromKey('s')).toBe('down');
|
||||
});
|
||||
it('maps d to right', () => {
|
||||
expect(getDirectionFromKey('d')).toBe('right');
|
||||
});
|
||||
it('maps W (uppercase) to up', () => {
|
||||
expect(getDirectionFromKey('W')).toBe('up');
|
||||
});
|
||||
it('maps A (uppercase) to left', () => {
|
||||
expect(getDirectionFromKey('A')).toBe('left');
|
||||
});
|
||||
it('maps S (uppercase) to down', () => {
|
||||
expect(getDirectionFromKey('S')).toBe('down');
|
||||
});
|
||||
it('maps D (uppercase) to right', () => {
|
||||
expect(getDirectionFromKey('D')).toBe('right');
|
||||
});
|
||||
});
|
||||
|
||||
// Vim hjkl mappings
|
||||
describe('Vim hjkl keys', () => {
|
||||
it('maps h to left', () => {
|
||||
expect(getDirectionFromKey('h')).toBe('left');
|
||||
});
|
||||
it('maps j to down', () => {
|
||||
expect(getDirectionFromKey('j')).toBe('down');
|
||||
});
|
||||
it('maps k to up', () => {
|
||||
expect(getDirectionFromKey('k')).toBe('up');
|
||||
});
|
||||
it('maps l to right', () => {
|
||||
expect(getDirectionFromKey('l')).toBe('right');
|
||||
});
|
||||
it('maps H (uppercase) to left', () => {
|
||||
expect(getDirectionFromKey('H')).toBe('left');
|
||||
});
|
||||
it('maps J (uppercase) to down', () => {
|
||||
expect(getDirectionFromKey('J')).toBe('down');
|
||||
});
|
||||
it('maps K (uppercase) to up', () => {
|
||||
expect(getDirectionFromKey('K')).toBe('up');
|
||||
});
|
||||
it('maps L (uppercase) to right', () => {
|
||||
expect(getDirectionFromKey('L')).toBe('right');
|
||||
});
|
||||
});
|
||||
|
||||
// Unmapped keys return null
|
||||
describe('unmapped keys', () => {
|
||||
it('returns null for unmapped keys', () => {
|
||||
expect(getDirectionFromKey('x')).toBeNull();
|
||||
expect(getDirectionFromKey('Enter')).toBeNull();
|
||||
expect(getDirectionFromKey(' ')).toBeNull();
|
||||
expect(getDirectionFromKey('Escape')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDirectionFromSwipe', () => {
|
||||
describe('cardinal swipes', () => {
|
||||
it('detects swipe right', () => {
|
||||
expect(getDirectionFromSwipe(100, 100, 150, 105)).toBe('right');
|
||||
});
|
||||
it('detects swipe left', () => {
|
||||
expect(getDirectionFromSwipe(100, 100, 50, 105)).toBe('left');
|
||||
});
|
||||
it('detects swipe up', () => {
|
||||
expect(getDirectionFromSwipe(100, 100, 105, 50)).toBe('up');
|
||||
});
|
||||
it('detects swipe down', () => {
|
||||
expect(getDirectionFromSwipe(100, 100, 105, 150)).toBe('down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('threshold enforcement', () => {
|
||||
it('returns null for movement below 10px threshold', () => {
|
||||
expect(getDirectionFromSwipe(100, 100, 103, 105)).toBeNull();
|
||||
});
|
||||
it('returns null for zero movement', () => {
|
||||
expect(getDirectionFromSwipe(100, 100, 100, 100)).toBeNull();
|
||||
});
|
||||
it('detects direction at exact 10px threshold', () => {
|
||||
expect(getDirectionFromSwipe(100, 100, 110, 100)).toBe('right');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dominant axis detection', () => {
|
||||
it('chooses horizontal when deltaX > deltaY (diagonal)', () => {
|
||||
expect(getDirectionFromSwipe(100, 100, 130, 115)).toBe('right');
|
||||
});
|
||||
it('chooses vertical when deltaY > deltaX (diagonal)', () => {
|
||||
expect(getDirectionFromSwipe(100, 100, 115, 130)).toBe('down');
|
||||
});
|
||||
it('chooses horizontal when deltas are equal', () => {
|
||||
// Equal deltas: horizontal wins as tiebreaker
|
||||
expect(getDirectionFromSwipe(100, 100, 120, 120)).toBe('right');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user