1- import { useEffect , useReducer , useRef } from "react" ;
1+ import { useEffect , useRef , useState } from "react" ;
22import { nextDirection , step , type Cell , type Direction } from "./pathing" ;
33
44export const GRID_SIZE = 8 ;
55const INITIAL_LENGTH = 2 ;
66const DYING_TICKS = 12 ;
7+ const MIN_INTERVAL_MS = 40 ;
8+ const REDUCED_MOTION_FACTOR = 0.5 ;
79
810export type Status = "alive" | "dying" ;
911
@@ -20,8 +22,6 @@ export interface GameOptions {
2022 paused : boolean ;
2123}
2224
23- type Action = { type : "tick" } ;
24-
2525function randomEmptyCell ( snake : Cell [ ] ) : Cell {
2626 const occupied = new Set ( snake . map ( ( c ) => `${ c . x } ,${ c . y } ` ) ) ;
2727 const empty : Cell [ ] = [ ] ;
@@ -41,76 +41,86 @@ function initialState(): GameState {
4141 for ( let i = 0 ; i < INITIAL_LENGTH ; i ++ ) {
4242 snake . unshift ( { x : startX + i , y : startY } ) ;
4343 }
44- const food = randomEmptyCell ( snake ) ;
45- return { snake, food, direction : "right" , status : "alive" , dyingTicks : 0 } ;
44+ return {
45+ snake,
46+ food : randomEmptyCell ( snake ) ,
47+ direction : "right" ,
48+ status : "alive" ,
49+ dyingTicks : 0 ,
50+ } ;
4651}
4752
48- function reducer ( state : GameState , action : Action ) : GameState {
49- switch ( action . type ) {
50- case "tick" : {
51- if ( state . status === "dying" ) {
52- const next = state . dyingTicks + 1 ;
53- return next >= DYING_TICKS
54- ? initialState ( )
55- : { ...state , dyingTicks : next } ;
56- }
53+ function tick ( state : GameState ) : GameState {
54+ if ( state . status === "dying" ) {
55+ const next = state . dyingTicks + 1 ;
56+ return next >= DYING_TICKS
57+ ? initialState ( )
58+ : { ...state , dyingTicks : next } ;
59+ }
5760
58- const { snake, food, direction } = state ;
59- const nextDir = nextDirection ( snake , food , direction , GRID_SIZE , GRID_SIZE ) ;
60- const newHead = step ( snake [ 0 ] , nextDir ) ;
61-
62- const outOfBounds =
63- newHead . x < 0 ||
64- newHead . x >= GRID_SIZE ||
65- newHead . y < 0 ||
66- newHead . y >= GRID_SIZE ;
67- const hitSelf = snake
68- . slice ( 0 , - 1 )
69- . some ( ( c ) => c . x === newHead . x && c . y === newHead . y ) ;
70-
71- if ( outOfBounds || hitSelf ) {
72- return { ...state , status : "dying" , dyingTicks : 0 } ;
73- }
61+ const { snake, food, direction } = state ;
62+ const nextDir = nextDirection ( snake , food , direction , GRID_SIZE , GRID_SIZE ) ;
63+ const head = step ( snake [ 0 ] , nextDir ) ;
7464
75- const ate = newHead . x === food . x && newHead . y === food . y ;
76- const newSnake = ate
77- ? [ newHead , ... snake ]
78- : [ newHead , ... snake . slice ( 0 , - 1 ) ] ;
79- const newFood = ate ? randomEmptyCell ( newSnake ) : food ;
65+ const outOfBounds =
66+ head . x < 0 || head . x >= GRID_SIZE || head . y < 0 || head . y >= GRID_SIZE ;
67+ const hitSelf = snake
68+ . slice ( 0 , - 1 )
69+ . some ( ( c ) => c . x === head . x && c . y === head . y ) ;
8070
81- return { ...state , snake : newSnake , food : newFood , direction : nextDir } ;
82- }
83- default :
84- return state ;
71+ if ( outOfBounds || hitSelf ) {
72+ return { ...state , status : "dying" , dyingTicks : 0 } ;
8573 }
74+
75+ const ate = head . x === food . x && head . y === food . y ;
76+ const newSnake = ate ? [ head , ...snake ] : [ head , ...snake . slice ( 0 , - 1 ) ] ;
77+ const newFood = ate ? randomEmptyCell ( newSnake ) : food ;
78+
79+ return { ...state , snake : newSnake , food : newFood , direction : nextDir } ;
80+ }
81+
82+ function computeInterval ( speed : number , reducedMotion : boolean ) : number {
83+ const effective = reducedMotion ? speed * REDUCED_MOTION_FACTOR : speed ;
84+ return Math . max ( MIN_INTERVAL_MS , 1000 / Math . max ( 1 , effective ) ) ;
8685}
8786
88- export function useSnakeGame ( opts : GameOptions ) : GameState {
89- const { speed, paused } = opts ;
90- const [ state , dispatch ] = useReducer ( reducer , null , initialState ) ;
91- const rafRef = useRef < number | null > ( null ) ;
92- const lastTickRef = useRef < number > ( 0 ) ;
87+ export function useSnakeGame ( { speed, paused } : GameOptions ) : GameState {
88+ const [ state , setState ] = useState < GameState > ( initialState ) ;
89+ const stateRef = useRef ( state ) ;
90+ stateRef . current = state ;
9391
9492 useEffect ( ( ) => {
9593 if ( paused ) return ;
9694
97- const reduced =
98- typeof window !== "undefined" &&
99- window . matchMedia ?.( "(prefers-reduced-motion: reduce)" ) . matches ;
100- const effectiveSpeed = reduced ? speed * 0.5 : speed ;
101- const interval = Math . max ( 40 , 1000 / Math . max ( 1 , effectiveSpeed ) ) ;
95+ const mql =
96+ typeof window !== "undefined" && typeof window . matchMedia === "function"
97+ ? window . matchMedia ( "(prefers-reduced-motion: reduce)" )
98+ : null ;
99+
100+ let interval = computeInterval ( speed , mql ?. matches ?? false ) ;
101+ const onMotionChange = ( ) => {
102+ interval = computeInterval ( speed , mql ?. matches ?? false ) ;
103+ } ;
104+ mql ?. addEventListener ( "change" , onMotionChange ) ;
105+
106+ let rafId = 0 ;
107+ let lastTick = 0 ;
102108
103109 const loop = ( t : number ) => {
104- if ( t - lastTickRef . current >= interval ) {
105- lastTickRef . current = t ;
106- dispatch ( { type : "tick" } ) ;
110+ if ( lastTick === 0 ) lastTick = t ;
111+ if ( t - lastTick >= interval ) {
112+ lastTick = t ;
113+ const next = tick ( stateRef . current ) ;
114+ stateRef . current = next ;
115+ setState ( next ) ;
107116 }
108- rafRef . current = requestAnimationFrame ( loop ) ;
117+ rafId = requestAnimationFrame ( loop ) ;
109118 } ;
110- rafRef . current = requestAnimationFrame ( loop ) ;
119+ rafId = requestAnimationFrame ( loop ) ;
111120
112121 return ( ) => {
113- if ( rafRef . current !== null ) cancelAnimationFrame ( rafRef . current ) ;
122+ cancelAnimationFrame ( rafId ) ;
123+ mql ?. removeEventListener ( "change" , onMotionChange ) ;
114124 } ;
115125 } , [ speed , paused ] ) ;
116126
0 commit comments