@@ -5,6 +5,7 @@ import { Card, Button, ButtonGroup, Badge } from "react-bootstrap";
55/**
66 * <SnakeCard /> — drop this inside any React-Bootstrap layout.
77 * Arrow keys / WASD to move. Space = pause. R = reset.
8+ * On phones: swipe on the board or use the D-pad to steer.
89 */
910export default function SnakeCard ( ) {
1011 // Tunables
@@ -28,6 +29,10 @@ export default function SnakeCard() {
2829 const snakeRef = useRef ( [ ] ) ;
2930 const foodRef = useRef ( { x : 5 , y : 5 } ) ;
3031
32+ // Touch swipe tracking
33+ const touchStartRef = useRef ( { x : 0 , y : 0 , active : false } ) ;
34+ const SWIPE_THRESH = 24 ; // px movement needed to register a swipe
35+
3136 const randomEmptyCell = useCallback ( ( ) => {
3237 const taken = new Set ( snakeRef . current . map ( p => `${ p . x } ,${ p . y } ` ) ) ;
3338 let x , y ;
@@ -96,7 +101,6 @@ export default function SnakeCard() {
96101 snake . forEach ( ( p , idx ) => {
97102 const r = idx === 0 ? 3 : 2 ;
98103 ctx . beginPath ( ) ;
99- // roundRect is widely supported in modern browsers
100104 ctx . roundRect ( p . x * TILE + 1 , p . y * TILE + 1 , TILE - 2 , TILE - 2 , r ) ;
101105 ctx . fill ( ) ;
102106 } ) ;
@@ -181,6 +185,64 @@ export default function SnakeCard() {
181185 return ( ) => window . removeEventListener ( "keydown" , onKey ) ;
182186 } , [ draw , resetGame ] ) ;
183187
188+ // Touch (swipe) controls on the canvas
189+ useEffect ( ( ) => {
190+ const canvas = canvasRef . current ;
191+ if ( ! canvas ) return ;
192+
193+ const onTouchStart = ( e ) => {
194+ if ( ! e . touches || e . touches . length === 0 ) return ;
195+ const t = e . touches [ 0 ] ;
196+ touchStartRef . current = { x : t . clientX , y : t . clientY , active : true } ;
197+ // prevent scroll/bounce
198+ e . preventDefault ( ) ;
199+ } ;
200+
201+ const onTouchMove = ( e ) => {
202+ // prevent page scroll while swiping on the board
203+ if ( touchStartRef . current . active ) e . preventDefault ( ) ;
204+ } ;
205+
206+ const onTouchEnd = ( e ) => {
207+ const start = touchStartRef . current ;
208+ if ( ! start . active ) return ;
209+
210+ // last touch point (or changedTouches[0])
211+ const t = ( e . changedTouches && e . changedTouches [ 0 ] ) || null ;
212+ if ( ! t ) { touchStartRef . current . active = false ; return ; }
213+
214+ const dx = t . clientX - start . x ;
215+ const dy = t . clientY - start . y ;
216+
217+ // Ignore micro-movements
218+ if ( Math . max ( Math . abs ( dx ) , Math . abs ( dy ) ) >= SWIPE_THRESH ) {
219+ const cur = dirRef . current ;
220+ // Horizontal swipe
221+ if ( Math . abs ( dx ) > Math . abs ( dy ) ) {
222+ const next = dx > 0 ? { x : 1 , y : 0 } : { x : - 1 , y : 0 } ;
223+ if ( ! ( next . x === - cur . x && next . y === - cur . y ) ) pendingDirRef . current = next ;
224+ } else {
225+ const next = dy > 0 ? { x : 0 , y : 1 } : { x : 0 , y : - 1 } ;
226+ if ( ! ( next . x === - cur . x && next . y === - cur . y ) ) pendingDirRef . current = next ;
227+ }
228+ }
229+
230+ touchStartRef . current . active = false ;
231+ e . preventDefault ( ) ;
232+ } ;
233+
234+ // Use non-passive to allow preventDefault
235+ canvas . addEventListener ( "touchstart" , onTouchStart , { passive : false } ) ;
236+ canvas . addEventListener ( "touchmove" , onTouchMove , { passive : false } ) ;
237+ canvas . addEventListener ( "touchend" , onTouchEnd , { passive : false } ) ;
238+
239+ return ( ) => {
240+ canvas . removeEventListener ( "touchstart" , onTouchStart ) ;
241+ canvas . removeEventListener ( "touchmove" , onTouchMove ) ;
242+ canvas . removeEventListener ( "touchend" , onTouchEnd ) ;
243+ } ;
244+ } , [ ] ) ;
245+
184246 // Loop start/stop
185247 useEffect ( ( ) => {
186248 if ( running ) {
@@ -206,6 +268,14 @@ export default function SnakeCard() {
206268 stepMsRef . current = ms ;
207269 } ;
208270
271+ // helper for D-pad taps
272+ const nudgeDir = ( next ) => {
273+ const cur = dirRef . current ;
274+ if ( ! ( next . x === - cur . x && next . y === - cur . y ) ) {
275+ pendingDirRef . current = next ;
276+ }
277+ } ;
278+
209279 return (
210280 < Card className = "h-100 shadow-sm" >
211281 < Card . Header className = "d-flex justify-content-between align-items-center" >
@@ -215,12 +285,18 @@ export default function SnakeCard() {
215285 < Badge bg = "success" title = "Best on this browser" > High Score!: { high } </ Badge >
216286 </ div >
217287 </ Card . Header >
288+
218289 < Card . Body className = "d-flex flex-column" >
219- < div className = "ratio ratio-1x1 border rounded" style = { { overflow : "hidden" } } >
290+ { /* Board */ }
291+ < div
292+ className = "ratio ratio-1x1 border rounded"
293+ style = { { overflow : "hidden" , touchAction : "none" } } // critical for mobile swipes
294+ >
220295 < canvas ref = { canvasRef } style = { { width : "100%" , height : "100%" , display : "block" } } />
221296 </ div >
222297
223- < div className = "d-flex justify-content-between align-items-center mt-3 flex-wrap gap-2" >
298+ { /* Desktop controls */ }
299+ < div className = "d-none d-sm-flex justify-content-between align-items-center mt-3 flex-wrap gap-2" >
224300 < ButtonGroup >
225301 < Button variant = { running ? "warning" : "primary" } onClick = { ( ) => setRunning ( p => ! p ) } >
226302 { running ? "Pause (Space)" : "Start (Space)" }
@@ -237,7 +313,62 @@ export default function SnakeCard() {
237313 </ ButtonGroup >
238314 </ div >
239315
240- < small className = "text-muted mt-2" >
316+ { /* Mobile controls (visible on xs only) */ }
317+ < div className = "d-flex d-sm-none flex-column gap-2 mt-3" >
318+ < div className = "d-flex justify-content-center gap-2" >
319+ < Button size = "sm" variant = "primary" onClick = { ( ) => setRunning ( p => ! p ) } >
320+ { running ? "Pause" : "Start" }
321+ </ Button >
322+ < Button size = "sm" variant = "outline-secondary" onClick = { ( ) => { resetGame ( ) ; draw ( ) ; } } >
323+ Reset
324+ </ Button >
325+ < Button size = "sm" variant = "outline-success" onClick = { ( ) => setSpeed ( "Fast" , 80 ) } >
326+ FastMode 🔥
327+ </ Button >
328+ </ div >
329+
330+ { /* D-pad */ }
331+ < div className = "d-flex justify-content-center" >
332+ < div className = "d-grid" style = { { gridTemplateColumns : "56px 56px 56px" , gap : "8px" } } aria-label = "Directional pad" >
333+ < div />
334+ < Button
335+ size = "sm"
336+ className = "fw-bold"
337+ onClick = { ( ) => nudgeDir ( { x : 0 , y : - 1 } ) }
338+ aria-label = "Up"
339+ > ▲</ Button >
340+ < div />
341+ < Button
342+ size = "sm"
343+ className = "fw-bold"
344+ onClick = { ( ) => nudgeDir ( { x : - 1 , y : 0 } ) }
345+ aria-label = "Left"
346+ > ◀</ Button >
347+ < div />
348+ < Button
349+ size = "sm"
350+ className = "fw-bold"
351+ onClick = { ( ) => nudgeDir ( { x : 1 , y : 0 } ) }
352+ aria-label = "Right"
353+ > ▶</ Button >
354+ < div />
355+ < Button
356+ size = "sm"
357+ className = "fw-bold"
358+ onClick = { ( ) => nudgeDir ( { x : 0 , y : 1 } ) }
359+ aria-label = "Down"
360+ > ▼</ Button >
361+ < div />
362+ </ div >
363+ </ div >
364+
365+ < small className = "text-muted text-center" >
366+ Tip: swipe on the board to steer.
367+ </ small >
368+ </ div >
369+
370+ { /* Shared hint (desktop already has separate control strip) */ }
371+ < small className = "text-muted mt-2 d-none d-sm-block" >
241372 Controls: Arrow keys / WASD. Space to pause/resume. R to restart.
242373 </ small >
243374 </ Card . Body >
0 commit comments