// m05 game: one `update` function turns every action into a new state; replay is a fold over actions. // m10 adds pause, restart, a frame counter that makes the snake speed up as the score grows, and a turn queue. import { turn } from './rules.js' import { opposite } from './model.js' import { tick, newGame } from './spawn.js' /** @import { Direction, Action, GameState } from './types' */ /** * What `update` does with `Turn(dir)` from Module 10 on. With no turn pending (`nextDir == dir`) it is the Module 3 * `turn`. With a turn pending, a key that is neither `nextDir` nor its opposite is remembered in `queued` * (replacing an earlier one) and taken on the step after; any other key changes nothing. * @type {(state: GameState, dir: Direction) => GameState} */ export const steer = (state, dir) => { if (state.status !== 'Playing') return state if (state.nextDir === state.dir) return turn(state, dir) return dir !== state.nextDir && dir !== opposite(state.nextDir) ? { ...state, queued: dir } : state } // After a step: a game still in play takes the queued turn, if there is one. It is always a legal turn, because the // step just made `dir` equal to the old `nextDir`, and `queued` is neither that nor its opposite. const takeQueued = (state) => state.status === 'Playing' && state.queued !== null ? { ...state, nextDir: state.queued, queued: null } : state // One game step as `update` takes it: `tick`, then the queued turn. const step = (state) => takeQueued(tick(state)) /** How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2. * @type {(score: number) => number} */ export const movesEvery = (score) => Math.max(2, 8 - Math.floor(score / 3)) // One animation frame. `frame` counts the frames since the snake last moved; when it reaches // `movesEvery(score)` the count starts again and the game takes a step. Counting from the last move // (not from the start of the game) keeps every step exactly `movesEvery` frames long, also right after a speed-up. const frame = (state) => { if (state.status !== 'Playing') return state const n = state.frame + 1 return n >= movesEvery(state.score) ? step({ ...state, frame: 0 }) : { ...state, frame: n } } // Pause and resume only make sense in those two statuses; a finished game stays finished. const togglePause = (state) => state.status === 'Playing' ? { ...state, status: 'Paused' } : state.status === 'Paused' ? { ...state, status: 'Playing' } : state /** @type {(state: GameState, action: Action) => GameState} */ export const update = (state, action) => { switch (action.tag) { case 'Tick': return step(state) case 'Turn': return steer(state, action.dir) case 'TogglePause': return togglePause(state) case 'Restart': return newGame(state.seed) case 'Frame': return frame(state) default: return state } } /** Fold the actions over the state, left to right. * @type {(state: GameState, actions: Action[]) => GameState} */ export const replay = (state, actions) => actions.reduce(update, state) /** Like replay, but keeps each intermediate state. Recursion instead of mutation: no array is ever pushed to. * @type {(state: GameState, actions: Action[]) => GameState[]} */ export const history = (state, actions) => actions.length === 0 ? [state] : [state, ...history(update(state, actions[0]), actions.slice(1))]