From 0e413e310da87251a76bd62a4ed62bdc2f0ff7e9 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 14 Apr 2026 09:27:35 +0700 Subject: [PATCH] feat: add multi-input support and responsive layout (Epic 4, Stories 4.1-4.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../4-1-wasd-and-vim-keyboard-support.md | 121 +++++++++++ .../4-2-touch-and-swipe-input.md | 123 ++++++++++++ .../4-3-responsive-layout.md | 188 ++++++++++++++++++ .../sprint-status.yaml | 10 +- src/App.svelte | 29 ++- src/app.css | 32 +++ src/components/GameMessage.svelte | 2 +- src/components/Grid.svelte | 16 +- src/components/ScoreBoard.svelte | 4 +- src/components/Tile.svelte | 21 +- src/lib/input-handler.js | 37 ++++ src/lib/input-handler.test.js | 128 ++++++++++++ 12 files changed, 681 insertions(+), 30 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/4-1-wasd-and-vim-keyboard-support.md create mode 100644 _bmad-output/implementation-artifacts/4-2-touch-and-swipe-input.md create mode 100644 _bmad-output/implementation-artifacts/4-3-responsive-layout.md create mode 100644 src/lib/input-handler.test.js diff --git a/_bmad-output/implementation-artifacts/4-1-wasd-and-vim-keyboard-support.md b/_bmad-output/implementation-artifacts/4-1-wasd-and-vim-keyboard-support.md new file mode 100644 index 0000000..72b5970 --- /dev/null +++ b/_bmad-output/implementation-artifacts/4-1-wasd-and-vim-keyboard-support.md @@ -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) diff --git a/_bmad-output/implementation-artifacts/4-2-touch-and-swipe-input.md b/_bmad-output/implementation-artifacts/4-2-touch-and-swipe-input.md new file mode 100644 index 0000000..8ca27ad --- /dev/null +++ b/_bmad-output/implementation-artifacts/4-2-touch-and-swipe-input.md @@ -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 `
` at line 110): +```svelte +
+``` + +`handleTouchStart` and `handleTouchEnd` are defined in App.svelte's `
diff --git a/src/components/ScoreBoard.svelte b/src/components/ScoreBoard.svelte index d684cda..ebca622 100644 --- a/src/components/ScoreBoard.svelte +++ b/src/components/ScoreBoard.svelte @@ -18,7 +18,7 @@
Score - {score} + {score} {#each floats as float (float.id)}
Best - {bestScore} + {bestScore}
diff --git a/src/components/Tile.svelte b/src/components/Tile.svelte index 2669f31..b897857 100644 --- a/src/components/Tile.svelte +++ b/src/components/Tile.svelte @@ -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 @@
= absDeltaY) { + return deltaX < 0 ? DIRECTIONS.LEFT : DIRECTIONS.RIGHT; + } else { + return deltaY < 0 ? DIRECTIONS.UP : DIRECTIONS.DOWN; + } +} diff --git a/src/lib/input-handler.test.js b/src/lib/input-handler.test.js new file mode 100644 index 0000000..ec8babf --- /dev/null +++ b/src/lib/input-handler.test.js @@ -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'); + }); + }); +});