Skip to content

Commit a0e1f1f

Browse files
rickboyclaude
andcommitted
v1.2.0 - mobile support, sound effects, Playwright tests
- Touch controls (D-pad + action strip) for mobile browsers - Responsive layout scales map/sidebar to narrow viewports - Touch controls hidden on desktop; press t to toggle or auto-show on true mobile - Shopkeeper passive until attacked - Playwright E2E tests: navigation, gameplay, inventory, endgame, mobile (31 tests) - GitHub Actions CI runs tests before deploying to Pages - Mute indicator in sidebar - Changelog screen (V key) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2d8ee7f commit a0e1f1f

35 files changed

Lines changed: 1572 additions & 14 deletions

File tree

‎.github/workflows/static.yml‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,41 @@ concurrency:
1515
cancel-in-progress: false
1616

1717
jobs:
18+
test:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v6
23+
24+
- name: Setup Node
25+
uses: actions/setup-node@v6
26+
with:
27+
node-version: 24
28+
cache: "yarn"
29+
30+
- name: Install dependencies
31+
run: yarn install --frozen-lockfile
32+
33+
- name: Cache Playwright browsers
34+
uses: actions/cache@v4
35+
id: playwright-cache
36+
with:
37+
path: ~/.cache/ms-playwright
38+
key: playwright-${{ hashFiles('**/yarn.lock') }}
39+
40+
- name: Install Playwright browsers
41+
if: steps.playwright-cache.outputs.cache-hit != 'true'
42+
run: npx playwright install --with-deps chromium
43+
44+
- name: Install Playwright deps (cached)
45+
if: steps.playwright-cache.outputs.cache-hit == 'true'
46+
run: npx playwright install-deps chromium
47+
48+
- name: Run Playwright tests
49+
run: yarn test
50+
1851
deploy:
52+
needs: test
1953
environment:
2054
name: github-pages
2155
url: ${{ steps.deployment.outputs.page_url }}

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ dist/
33
.env
44
.env.local
55
*.local
6+
playwright-report/
7+
test-results/

‎index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
77
<title>webhack</title>
88
</head>
99
<body>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-05-15
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
## Context
2+
3+
WebHack runs in the browser with all input handled via a single `keydown` listener in `useKeyboard` (App.tsx). The game map is rendered as a fixed CSS grid of monospace spans. The sidebar sits to the right. There are no touch handlers and no responsive breakpoints. On mobile, the map overflows, the sidebar is unusable, and no input is possible without a keyboard.
4+
5+
The game state and dispatch interface (`useGame`) are untouched — touch controls simply call `dispatch` and `SoundEngine.play` the same way keyboard handlers do.
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
- Touch input covers all gameplay actions reachable from the keyboard during `playing` phase: 8-directional movement, wait, pickup, inventory, stairs, mute
11+
- Swipe gestures (4-cardinal + 4-diagonal) as an alternative to D-pad taps
12+
- Context-sensitive action buttons change per phase (e.g., confirm/cancel in targeting, close in inventory)
13+
- Inventory screen items are tappable (no need to type the slot letter)
14+
- Menu screens (main menu, char select, death, victory, high scores, changelog) are fully tappable — existing click handlers already cover most of this
15+
- Responsive layout works at 320px minimum width
16+
- Desktop keyboard experience unchanged
17+
18+
**Non-Goals:**
19+
- Native app or PWA packaging
20+
- Pinch-to-zoom map
21+
- Custom virtual keyboard for text entry (player names are hardcoded per class)
22+
- Gamepad/controller support
23+
24+
## Decisions
25+
26+
### 1. On-screen controls as a React component, not injected CSS
27+
**Decision**: New `TouchControls` component rendered inside `PlayingLayout`, visible only during touch-relevant phases.
28+
**Rationale**: Co-located with game UI, gets `dispatch` and `phase` as props, easy to conditionally render. No global event soup.
29+
**Alternative considered**: CSS-only overlay with pointer events — can't call dispatch, doesn't work.
30+
31+
### 2. D-pad layout: fixed 3×3 grid with centre gap
32+
**Decision**: 3×3 button grid, 8 directional buttons, centre empty. Rendered as position-fixed overlay at bottom-centre of screen.
33+
**Rationale**: Familiar gamepad-style layout. Fixed positioning means it stays in place regardless of map scroll.
34+
**Alternative considered**: Floating buttons scattered around edges — harder to hit consistently.
35+
36+
### 3. Swipe detection on the map canvas, not the whole document
37+
**Decision**: Attach `touchstart`/`touchend` listeners to the map container div via React `onTouchStart`/`onTouchEnd` props. Threshold: 30px, timeout: 400ms.
38+
**Rationale**: Avoids interfering with scroll on menu screens. Map area is the natural swipe target.
39+
**Alternative considered**: Document-level swipe listener — conflicts with browser scroll and overlays.
40+
41+
### 4. Action button strip above D-pad
42+
**Decision**: Row of labelled icon buttons above the D-pad for: Wait (`.`), Pickup (`,`), Inventory (`i`), Stairs down (`>`), Stairs up (`<`). Secondary strip for: Mute (`M`), Help (`?`).
43+
**Rationale**: Most common actions need one-tap access. Less common actions (drop, equip, throw, cast) accessible via inventory screen once open.
44+
**Alternative considered**: Radial menu on long-press — complex to implement, poor discoverability.
45+
46+
### 5. Inventory tap-to-select
47+
**Decision**: `InventoryPanel` renders each item as a tappable row. On tap, dispatch the appropriate action based on current `awaitingSecondKey` mode, or open a contextual action sheet (equip/use/drop) if no mode is active.
48+
**Rationale**: Letter-key slot system is the blocker for mobile inventory. Tap rows eliminate it without changing the underlying dispatch actions.
49+
**Alternative considered**: Expose slot letters as on-screen keyboard — too much screen space.
50+
51+
### 6. Responsive layout: sidebar below map on narrow screens
52+
**Decision**: CSS media query at 600px. Above: current side-by-side layout. Below: map on top, sidebar as compact horizontal strip below, touch controls overlay on top of everything.
53+
**Rationale**: Map needs maximum width on mobile. Sidebar info is secondary; compacting it frees vertical space.
54+
**Alternative considered**: Collapsible sidebar drawer — adds complexity and a tap to access critical info like HP.
55+
56+
### 7. Map tile font scaling
57+
**Decision**: Use `vw`-based font size on the map grid at narrow viewports so tiles fill the screen width.
58+
**Rationale**: Fixed `font-size: 14px` makes the 80-column map 1120px wide — wider than any phone. Scaling to viewport makes it playable.
59+
**Alternative considered**: Horizontal scroll — poor UX on touch, easy to lose position.
60+
61+
## Risks / Trade-offs
62+
63+
- **Map legibility at small sizes** → At 320px wide, 80 columns = 4px per tile. Roguelikes on phones are inherently small. Mitigation: use the smallest readable monospace size (~7-8px) and accept some squinting; the map is still functional.
64+
- **Touch controls overlap map content** → D-pad sits over bottom portion of map. Mitigation: reserve bottom ~160px of map area with padding so dungeon content isn't permanently hidden.
65+
- **Diagonal swipe detection is imprecise** → Human swipes are rarely perfectly diagonal. Mitigation: use 22.5° angle buckets (8 directions in 360°) for swipe classification.
66+
- **Inventory action sheet adds a tap** → Mobile inventory is slower (tap item → tap action) vs desktop (letter key). Acceptable trade-off; no good alternative.
67+
- **iOS Safari `touchend` quirks** → iOS sometimes fires `click` after `touchend`, causing double-dispatch. Mitigation: call `e.preventDefault()` on touchend handlers.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
## Why
2+
3+
WebHack is currently keyboard-only, making it unplayable on mobile browsers where no physical keyboard is present. Adding touch controls expands the audience to mobile players without breaking the existing desktop experience.
4+
5+
## What Changes
6+
7+
- Add on-screen virtual D-pad and action button overlay for touch input
8+
- Add swipe gesture recognition as an alternative movement input
9+
- Make the game layout responsive so the map and sidebar render usably on small screens
10+
- Inventory and menu screens must be tappable (letter-key selection replaced with tap-to-select)
11+
- All existing keyboard shortcuts remain fully functional
12+
13+
## Capabilities
14+
15+
### New Capabilities
16+
17+
- `touch-controls`: On-screen directional pad, action buttons, and swipe gesture input that translate to the same game actions as keyboard keys. Includes contextual button sets (movement in playing phase, confirm/cancel in menus).
18+
- `responsive-layout`: CSS layout that adapts the game map, sidebar, and overlays to small viewport sizes (≥320px wide). Map tiles scale to fit; sidebar collapses or moves below map on narrow screens.
19+
20+
### Modified Capabilities
21+
22+
- none
23+
24+
## Impact
25+
26+
- `src/App.tsx` — touch event wiring, context-sensitive button rendering
27+
- `src/components/` — new TouchControls component, updates to Sidebar and screen components for tap interaction
28+
- `src/index.css` — responsive breakpoints, touch overlay styles, tap-target sizing
29+
- `src/hooks/useGame.ts` — no changes required (dispatch interface unchanged)
30+
- No new npm dependencies — use native pointer/touch events
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Viewport meta tag
4+
The HTML document SHALL include a viewport meta tag that disables user scaling and sets initial scale to 1.0, preventing unwanted browser zoom on the game UI.
5+
6+
#### Scenario: Viewport tag present
7+
- **WHEN** page loads on a mobile browser
8+
- **THEN** the viewport SHALL be set to device width with no user-scalable zoom
9+
10+
### Requirement: Responsive game layout
11+
The game layout SHALL adapt to the viewport width. At viewports wider than 600px the existing side-by-side layout (map left, sidebar right) SHALL be preserved. At viewports 600px and below the map SHALL occupy full width and the sidebar SHALL render as a compact horizontal strip below the map.
12+
13+
#### Scenario: Wide layout preserved on desktop
14+
- **WHEN** viewport width is greater than 600px
15+
- **THEN** map and sidebar render side by side as currently
16+
17+
#### Scenario: Narrow layout on mobile
18+
- **WHEN** viewport width is 600px or less
19+
- **THEN** map occupies full viewport width and sidebar renders below map as a horizontal strip
20+
21+
#### Scenario: Sidebar horizontal strip shows key stats
22+
- **WHEN** sidebar renders in horizontal strip mode
23+
- **THEN** it SHALL display at minimum: HP, dungeon level, hunger state, and mute indicator
24+
25+
### Requirement: Map tile font scaling on narrow viewports
26+
The game map font size SHALL scale so that all 80 columns fit within the viewport width on screens narrower than 800px. The minimum font size SHALL be 7px.
27+
28+
#### Scenario: Map fits on 375px phone
29+
- **WHEN** viewport width is 375px
30+
- **THEN** all 80 map columns SHALL be visible without horizontal scrolling
31+
32+
#### Scenario: Font size floor
33+
- **WHEN** viewport is very narrow (less than 320px)
34+
- **THEN** font size SHALL NOT drop below 7px
35+
36+
### Requirement: Touch target minimum size
37+
All interactive buttons in the touch controls overlay SHALL have a minimum tap target size of 44×44px to meet mobile usability standards.
38+
39+
#### Scenario: D-pad buttons are tappable
40+
- **WHEN** D-pad is rendered on a 375px wide screen
41+
- **THEN** each directional button SHALL have a touch target of at least 44×44px
42+
43+
#### Scenario: Action buttons are tappable
44+
- **WHEN** action strip is rendered
45+
- **THEN** each action button SHALL have a touch target of at least 44×44px
46+
47+
### Requirement: Touch controls do not permanently obscure map content
48+
The touch control overlay SHALL be positioned such that the active dungeon area above it remains fully visible. The map container SHALL reserve vertical space at the bottom equal to the height of the controls overlay.
49+
50+
#### Scenario: Map content above controls is unobscured
51+
- **WHEN** touch controls are rendered
52+
- **THEN** dungeon tiles in the upper portion of the map SHALL be fully visible and not hidden behind controls
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
## ADDED Requirements
2+
3+
### Requirement: On-screen directional pad
4+
The system SHALL render a 3×3 D-pad overlay during the `playing`, `targeting`, and `awaitingItemKey` phases. The eight directional buttons SHALL dispatch the same `MOVE` actions as keyboard movement keys. The centre cell SHALL be empty.
5+
6+
#### Scenario: D-pad tap moves player
7+
- **WHEN** user taps a directional button on the D-pad during `playing` phase
8+
- **THEN** player moves one tile in the corresponding direction, identical to pressing the matching keyboard key
9+
10+
#### Scenario: D-pad not shown on menu screens
11+
- **WHEN** game phase is `mainMenu`, `charSelect`, `dead`, `victory`, `highScores`, or `changelog`
12+
- **THEN** D-pad overlay SHALL NOT be rendered
13+
14+
#### Scenario: D-pad visible during targeting
15+
- **WHEN** game phase is `targeting`
16+
- **THEN** D-pad SHALL be rendered and tapping a direction moves the targeting cursor
17+
18+
### Requirement: Action button strip
19+
The system SHALL render a row of labelled action buttons above the D-pad for the most common gameplay actions. The strip SHALL include: Wait, Pickup, Inventory, Stairs Down, Stairs Up, Mute, Help.
20+
21+
#### Scenario: Wait button
22+
- **WHEN** user taps the Wait button
23+
- **THEN** system dispatches `WAIT` action, identical to pressing `.`
24+
25+
#### Scenario: Pickup button
26+
- **WHEN** user taps the Pickup button
27+
- **THEN** system dispatches `PICKUP` action, identical to pressing `,`
28+
29+
#### Scenario: Inventory button
30+
- **WHEN** user taps the Inventory button
31+
- **THEN** system dispatches `OPEN_INVENTORY`, identical to pressing `i`
32+
33+
#### Scenario: Stairs Down button
34+
- **WHEN** user taps the Stairs Down button
35+
- **THEN** system dispatches `STAIRS` with direction `down`, identical to pressing `>`
36+
37+
#### Scenario: Stairs Up button
38+
- **WHEN** user taps the Stairs Up button
39+
- **THEN** system dispatches `STAIRS` with direction `up`, identical to pressing `<`
40+
41+
#### Scenario: Mute button
42+
- **WHEN** user taps the Mute button
43+
- **THEN** mute state toggles, identical to pressing `M`
44+
45+
#### Scenario: Help button
46+
- **WHEN** user taps the Help button
47+
- **THEN** help overlay opens, identical to pressing `?`
48+
49+
### Requirement: Swipe gesture movement
50+
The system SHALL detect swipe gestures on the game map area and translate them to directional movement in `playing` phase. Swipes SHALL support all 8 directions using 45° angle buckets. Minimum swipe distance SHALL be 30px. Maximum swipe duration SHALL be 400ms.
51+
52+
#### Scenario: Cardinal swipe moves player
53+
- **WHEN** user swipes right on the map area with distance ≥30px within 400ms
54+
- **THEN** player moves east, identical to pressing `l` or arrow right
55+
56+
#### Scenario: Diagonal swipe moves player
57+
- **WHEN** user swipes diagonally (e.g. up-right) on the map area
58+
- **THEN** player moves in the matching diagonal direction (NE)
59+
60+
#### Scenario: Slow swipe ignored
61+
- **WHEN** user drags finger across map but takes longer than 400ms
62+
- **THEN** no movement is dispatched
63+
64+
#### Scenario: Short touch ignored
65+
- **WHEN** user taps map without moving finger 30px
66+
- **THEN** no movement is dispatched (tap is not a swipe)
67+
68+
### Requirement: Inventory tap-to-select
69+
The system SHALL make each inventory item row tappable. When no second-key action is pending, tapping an item SHALL present an action sheet with context-appropriate options (Equip/Unequip, Use, Drop). When a second-key action is pending (equip, use, drop, cast), tapping an item SHALL immediately dispatch that action for the tapped item.
70+
71+
#### Scenario: Tap item with no pending action shows action sheet
72+
- **WHEN** user taps an inventory item and no `awaitingSecondKey` mode is active
73+
- **THEN** an action sheet appears with relevant options for that item type
74+
75+
#### Scenario: Tap item during equip mode dispatches equip
76+
- **WHEN** `awaitingSecondKey` is `equip` and user taps an inventory item
77+
- **THEN** system dispatches `EQUIP` for that item and closes await mode
78+
79+
#### Scenario: Tap item during drop mode dispatches drop
80+
- **WHEN** `awaitingSecondKey` is `drop` and user taps an inventory item
81+
- **THEN** system dispatches `DROP` for that item and closes await mode
82+
83+
#### Scenario: Tap item during use mode dispatches use
84+
- **WHEN** `awaitingSecondKey` is `use` and user taps an inventory item
85+
- **THEN** system dispatches `USE` for that item and closes await mode
86+
87+
### Requirement: Touch controls prevent default browser behaviour
88+
All touch event handlers on game controls SHALL call `preventDefault()` to suppress browser scroll, zoom, and double-tap zoom on the game UI.
89+
90+
#### Scenario: No scroll on D-pad touch
91+
- **WHEN** user touches the D-pad or action buttons
92+
- **THEN** page SHALL NOT scroll and browser SHALL NOT zoom
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
## 1. Viewport and Base CSS
2+
3+
- [x] 1.1 Add `<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">` to `index.html`
4+
- [x] 1.2 Add `touch-action: none` and `-webkit-tap-highlight-color: transparent` to `#game-root` in `index.css`
5+
- [x] 1.3 Add responsive breakpoint in `index.css` at 600px: map full-width, sidebar becomes horizontal strip below map
6+
- [x] 1.4 Add map tile font scaling in `index.css` using `vw` units at narrow viewports so 80 columns fit without horizontal scroll (min 7px)
7+
- [x] 1.5 Reserve bottom padding on map container equal to touch controls overlay height so dungeon content is not permanently hidden
8+
9+
## 2. Responsive Sidebar
10+
11+
- [x] 2.1 In `src/components/Sidebar.tsx` add horizontal strip layout variant for narrow viewports (CSS class or media query): shows HP, depth, hunger, mute indicator in one row
12+
- [x] 2.2 Verify sidebar horizontal strip renders key stats and does not overflow at 375px width
13+
14+
## 3. TouchControls Component
15+
16+
- [x] 3.1 Create `src/components/TouchControls.tsx` with a 3×3 D-pad grid (8 directional buttons, centre empty)
17+
- [x] 3.2 Each D-pad button dispatches `MOVE` with the correct direction on `touchend` with `preventDefault()`
18+
- [x] 3.3 D-pad buttons have minimum 44×44px touch targets
19+
- [x] 3.4 Add action button strip above D-pad: Wait, Pickup, Inventory, Stairs Down, Stairs Up
20+
- [x] 3.5 Add secondary action buttons: Mute (toggles mute + updates local state), Help (dispatches `SET_PHASE help`)
21+
- [x] 3.6 Position TouchControls as `position: fixed` at bottom-centre of screen
22+
- [x] 3.7 Render TouchControls only during `playing`, `targeting`, and `awaitingItemKey` phases
23+
- [x] 3.8 During `targeting` phase, D-pad dispatches `SET_TARGET` (move cursor) instead of `MOVE`
24+
25+
## 4. Swipe Gesture Detection
26+
27+
- [x] 4.1 In `src/components/Map.tsx` (or a `useSwipe` hook) attach `onTouchStart` and `onTouchEnd` to the map container div
28+
- [x] 4.2 On `touchend`, compute dx/dy from start; if distance ≥30px and duration ≤400ms, classify direction using 45° angle buckets (8 directions)
29+
- [x] 4.3 Dispatch `MOVE` with the classified direction during `playing` phase; call `preventDefault()` on both touch events
30+
- [x] 4.4 Ignore swipes during non-playing phases (no dispatch)
31+
32+
## 5. Inventory Tap-to-Select
33+
34+
- [x] 5.1 In `src/components/InventoryPanel.tsx` make each item row tappable (add `onClick` handler)
35+
- [x] 5.2 When `awaitingSecondKey` mode is active (equip/drop/use), tapping an item dispatches the pending action for that item and clears await mode
36+
- [x] 5.3 When no await mode is active, tapping an item opens a contextual action sheet (small inline div or modal) with relevant options: Equip/Unequip, Use, Drop — each dispatching the correct action on tap
37+
- [x] 5.4 Action sheet closes on tap-outside or Escape
38+
39+
## 6. Wire TouchControls into App
40+
41+
- [x] 6.1 Import and render `TouchControls` in `PlayingLayout` in `src/App.tsx`, passing `state`, `dispatch`, `muted`, and `setMuted` props
42+
- [x] 6.2 Pass `phase` and `awaitingSecondKey` to `TouchControls` so it can render context-correct buttons and D-pad behaviour
43+
44+
## 7. Verify
45+
46+
- [ ] 7.1 Test on Chrome mobile emulator at 375×667px: map fits, D-pad works, action buttons work
47+
- [ ] 7.2 Test on real iOS Safari: no double-dispatch, no scroll bleed, no zoom on tap
48+
- [ ] 7.3 Test on real Android Chrome: same checks
49+
- [ ] 7.4 Verify keyboard input still works on desktop after changes
50+
- [x] 7.5 Confirm `npm run build` passes with no TypeScript errors
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-05-16

0 commit comments

Comments
 (0)