Skip to content

Commit 9d167b8

Browse files
authored
Merge pull request #61 from sam-mfb/mobile
Add mobile touch controls support
2 parents bf90a1d + 0ba1839 commit 9d167b8

16 files changed

+576
-31
lines changed

MOBILE_PLAN.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,6 @@ Based on analysis of the codebase, here's a comprehensive plan for adapting the
170170
- Check for touch capability: `'ontouchstart' in window || navigator.maxTouchPoints > 0`
171171
- **Returns true for phones AND tablets (including iPads)**
172172
- This is the key function for determining default touch control state
173-
- Optional: Export `isSmallScreen()` for UI layout decisions
174-
- Check screen width (e.g., `window.innerWidth < 768`)
175-
- Used for layout optimizations, NOT for enabling/disabling touch controls
176173
- **Default behavior**: Touch controls enabled for ANY touch-capable device, regardless of screen size
177174

178175
**2. Add touch controls state to `appSlice.ts`**

package-lock.json

Lines changed: 9 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
},
2121
"dependencies": {
2222
"@reduxjs/toolkit": "^2.8.2",
23+
"nipplejs": "^0.10.2",
2324
"react": "^19.1.0",
2425
"react-dom": "^19.1.0",
2526
"react-redux": "^9.2.0"

src/game/App.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import VolumeButton from './components/VolumeButton'
88
import FullscreenButton from './components/FullscreenButton'
99
import InGameControlsPanel from './components/InGameControlsPanel'
1010
import { loadLevel } from './levelThunks'
11-
import { startGame, setMode } from './appSlice'
11+
import {
12+
startGame,
13+
setMode,
14+
enableTouchControls,
15+
disableTouchControls
16+
} from './appSlice'
17+
import { isTouchDevice } from './mobile/deviceDetection'
1218
import { setHighScore } from '@/core/highscore'
1319
import { shipSlice } from '@/core/ship'
1420
import { invalidateHighScore } from './gameSlice'
@@ -46,10 +52,25 @@ export const App: React.FC<AppProps> = ({
4652
state => state.game.highScoreEligible
4753
)
4854
const scaleMode = useAppSelector(state => state.app.scaleMode)
55+
const touchControlsOverride = useAppSelector(
56+
state => state.app.touchControlsOverride
57+
)
4958

5059
// Use responsive scale that adapts to viewport size or fixed scale from settings
5160
const { scale, dimensions } = useResponsiveScale(scaleMode)
5261

62+
// Re-evaluate touch controls when override setting changes
63+
useEffect(() => {
64+
const shouldEnableTouchControls =
65+
touchControlsOverride !== null ? touchControlsOverride : isTouchDevice()
66+
67+
if (shouldEnableTouchControls) {
68+
dispatch(enableTouchControls())
69+
} else {
70+
dispatch(disableTouchControls())
71+
}
72+
}, [touchControlsOverride, dispatch])
73+
5374
// Track if we should show the resize hint
5475
const [showResizeHint, setShowResizeHint] = useState(false)
5576

src/game/appMiddleware.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
setVolume,
1414
enableSound,
1515
disableSound,
16+
setTouchControlsOverride,
1617
type CollisionMode,
1718
type ScaleMode
1819
} from './appSlice'
@@ -28,6 +29,7 @@ export type PersistedAppSettings = {
2829
scaleMode: ScaleMode
2930
volume: number
3031
soundOn: boolean
32+
touchControlsOverride: boolean | null
3133
}
3234

3335
/**
@@ -48,7 +50,8 @@ export const appMiddleware: Middleware<{}, RootState> =
4850
setScaleMode.match(action) ||
4951
setVolume.match(action) ||
5052
enableSound.match(action) ||
51-
disableSound.match(action)
53+
disableSound.match(action) ||
54+
setTouchControlsOverride.match(action)
5255
) {
5356
const state = store.getState()
5457
try {
@@ -58,7 +61,8 @@ export const appMiddleware: Middleware<{}, RootState> =
5861
showInGameControls: state.app.showInGameControls,
5962
scaleMode: state.app.scaleMode,
6063
volume: state.app.volume,
61-
soundOn: state.app.soundOn
64+
soundOn: state.app.soundOn,
65+
touchControlsOverride: state.app.touchControlsOverride
6266
}
6367
localStorage.setItem(
6468
APP_SETTINGS_STORAGE_KEY,
@@ -87,7 +91,8 @@ export const loadAppSettings = (): Partial<PersistedAppSettings> => {
8791
showInGameControls: parsed.showInGameControls,
8892
scaleMode: parsed.scaleMode,
8993
volume: parsed.volume,
90-
soundOn: parsed.soundOn
94+
soundOn: parsed.soundOn,
95+
touchControlsOverride: parsed.touchControlsOverride
9196
}
9297
}
9398
} catch (error) {

src/game/appSlice.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export type AppState = {
3131
volume: number
3232
soundOn: boolean
3333

34+
// Touch controls
35+
touchControlsEnabled: boolean
36+
touchControlsOverride: boolean | null // null = auto-detect
37+
3438
// Game flow
3539
mode: GameMode
3640
mostRecentScore: MostRecentScore | null
@@ -51,6 +55,8 @@ const initialState: AppState = {
5155
scaleMode: 'auto', // Default to responsive auto-scaling
5256
volume: 0,
5357
soundOn: true,
58+
touchControlsEnabled: false, // Will be set based on device detection
59+
touchControlsOverride: null, // null = auto-detect based on device
5460
mode: 'start',
5561
mostRecentScore: null,
5662
showSettings: false,
@@ -137,6 +143,22 @@ export const appSlice = createSlice({
137143

138144
setTotalLevels: (state, action: PayloadAction<number>) => {
139145
state.totalLevels = action.payload
146+
},
147+
148+
// Touch controls management
149+
enableTouchControls: state => {
150+
state.touchControlsEnabled = true
151+
},
152+
153+
disableTouchControls: state => {
154+
state.touchControlsEnabled = false
155+
},
156+
157+
setTouchControlsOverride: (
158+
state,
159+
action: PayloadAction<boolean | null>
160+
) => {
161+
state.touchControlsOverride = action.payload
140162
}
141163
}
142164
})
@@ -159,5 +181,8 @@ export const {
159181
toggleSettings,
160182
setFullscreen,
161183
setCurrentGalaxy,
162-
setTotalLevels
184+
setTotalLevels,
185+
enableTouchControls,
186+
disableTouchControls,
187+
setTouchControlsOverride
163188
} = appSlice.actions

src/game/components/GameRenderer.tsx

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useRef } from 'react'
1+
import React, { useEffect, useRef, useState } from 'react'
22
import type { FrameInfo, KeyInfo, MonochromeBitmap } from '@lib/bitmap'
33
import { useAppDispatch, useAppSelector, type RootState } from '../store'
44
import { togglePause, showMap, hideMap, pause, unpause } from '../gameSlice'
@@ -11,6 +11,7 @@ import { getDebug } from '../debug'
1111
import type { SpriteService } from '@/core/sprites'
1212
import { SCENTER } from '@/core/figs'
1313
import { useStore } from 'react-redux'
14+
import { TouchControlsOverlay } from '../mobile/TouchControlsOverlay'
1415

1516
type GameRendererProps = {
1617
renderer: (frame: FrameInfo, controls: ControlMatrix) => MonochromeBitmap
@@ -47,9 +48,27 @@ const GameRenderer: React.FC<GameRendererProps> = ({
4748
const paused = useAppSelector(state => state.game.paused)
4849
const showMapState = useAppSelector(state => state.game.showMap)
4950
const bindings = useAppSelector(state => state.controls.bindings)
51+
const touchControlsEnabled = useAppSelector(
52+
state => state.app.touchControlsEnabled
53+
)
5054
const store = useStore()
5155
const dispatch = useAppDispatch()
5256

57+
// Track touch controls state
58+
const [touchControls, setTouchControls] = useState<ControlMatrix>({
59+
thrust: false,
60+
left: false,
61+
right: false,
62+
fire: false,
63+
shield: false,
64+
selfDestruct: false,
65+
pause: false,
66+
quit: false,
67+
nextLevel: false,
68+
extraLife: false,
69+
map: false
70+
})
71+
5372
useEffect(() => {
5473
const canvas = canvasRef.current
5574
if (!canvas) return
@@ -113,6 +132,25 @@ const GameRenderer: React.FC<GameRendererProps> = ({
113132
keysPressed: keysPressed,
114133
keysReleased: keysReleased
115134
}
135+
// Get keyboard controls
136+
const keyboardControls = getControls(keyInfo, bindings)
137+
138+
// Merge keyboard and touch controls (OR logic for each control)
139+
const mergedControls: ControlMatrix = {
140+
thrust: keyboardControls.thrust || touchControls.thrust,
141+
left: keyboardControls.left || touchControls.left,
142+
right: keyboardControls.right || touchControls.right,
143+
fire: keyboardControls.fire || touchControls.fire,
144+
shield: keyboardControls.shield || touchControls.shield,
145+
selfDestruct:
146+
keyboardControls.selfDestruct || touchControls.selfDestruct,
147+
pause: keyboardControls.pause || touchControls.pause,
148+
quit: keyboardControls.quit || touchControls.quit,
149+
nextLevel: keyboardControls.nextLevel || touchControls.nextLevel,
150+
extraLife: keyboardControls.extraLife || touchControls.extraLife,
151+
map: keyboardControls.map || touchControls.map
152+
}
153+
116154
// If map is showing, use blank controls (all false) to prevent game input
117155
const controls = showMapState
118156
? {
@@ -127,9 +165,9 @@ const GameRenderer: React.FC<GameRendererProps> = ({
127165
nextLevel: false,
128166
extraLife: false,
129167
// need to still detect the map key
130-
map: getControls(keyInfo, bindings).map
168+
map: mergedControls.map
131169
}
132-
: getControls(keyInfo, bindings)
170+
: mergedControls
133171

134172
if (controls.map) {
135173
if (showMapState) {
@@ -298,25 +336,34 @@ const GameRenderer: React.FC<GameRendererProps> = ({
298336
dispatch,
299337
collisionService,
300338
spriteService,
301-
store
339+
store,
340+
touchControls
302341
])
303342

304343
return (
305-
<div style={{ position: 'relative', display: 'inline-block' }}>
306-
<canvas
307-
ref={canvasRef}
308-
width={width * scale}
309-
height={height * scale}
310-
style={{
311-
imageRendering: 'pixelated',
312-
// @ts-ignore - vendor prefixes
313-
WebkitImageRendering: 'pixelated',
314-
MozImageRendering: 'crisp-edges',
315-
display: 'block'
316-
}}
317-
/>
318-
{showMapState && <Map scale={scale} />}
319-
</div>
344+
<>
345+
<div style={{ position: 'relative', display: 'inline-block' }}>
346+
<canvas
347+
ref={canvasRef}
348+
width={width * scale}
349+
height={height * scale}
350+
style={{
351+
imageRendering: 'pixelated',
352+
// @ts-ignore - vendor prefixes
353+
WebkitImageRendering: 'pixelated',
354+
MozImageRendering: 'crisp-edges',
355+
display: 'block'
356+
}}
357+
/>
358+
{showMapState && <Map scale={scale} />}
359+
</div>
360+
{touchControlsEnabled && (
361+
<TouchControlsOverlay
362+
scale={scale}
363+
onControlsChange={setTouchControls}
364+
/>
365+
)}
366+
</>
320367
)
321368
}
322369

src/game/components/InGameControlsPanel.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ type InGameControlsPanelProps = {
1010
const InGameControlsPanel: React.FC<InGameControlsPanelProps> = ({ scale }) => {
1111
const dispatch = useAppDispatch()
1212
const bindings = useAppSelector(state => state.controls.bindings)
13+
const touchControlsEnabled = useAppSelector(
14+
state => state.app.touchControlsEnabled
15+
)
16+
17+
// Hide panel when touch controls are active to save screen space
18+
if (touchControlsEnabled) return null
1319

1420
const panelStyle: React.CSSProperties = {
1521
position: 'absolute',

0 commit comments

Comments
 (0)