feat(js): m10 extensions (pause, restart, speed-up via Frame) with tests

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-28 23:56:49 +01:00
co-authored by Claude Sonnet 5.5
parent 25141ce86d
commit fd7c5f2acf
13 changed files with 1347 additions and 17 deletions
+7 -1
View File
@@ -7,12 +7,18 @@ import { keyDowns, interval, map, filterMap, merge, scan, startWith } from './st
import { keyToAction } from './input.js'
export const TICK_MS = 150
export const FRAME_MS = 25
// Everything that can happen to the game: a Tick every TICK_MS, and a Turn for each steering key.
export const classicActions = merge(
map(interval(TICK_MS), () => Action.Tick),
filterMap(keyDowns, keyToAction))
// m10: a Frame every FRAME_MS instead of a Tick; the game itself decides (movesEvery) which frames take a step.
export const speedActions = merge(
map(interval(FRAME_MS), () => Action.Frame),
filterMap(keyDowns, keyToAction))
// Actions folded into game states. startWith puts the initial state first so it is drawn right away.
export const states = (seed, actions) =>
startWith(scan(actions, update, newGame(seed)), newGame(seed))
@@ -21,4 +27,4 @@ export const render = (state) => drawScene(view(state))
// The whole program as a value: get a seed, then turn the stream of states into a stream of drawings.
// Nothing runs here; runtime.js interprets it.
export const main = mapIO(randomSeed, (seed) => map(states(seed, classicActions), render))
export const main = mapIO(randomSeed, (seed) => map(states(seed, speedActions), render))
+21 -2
View File
@@ -1,12 +1,31 @@
// m05 game: one `update` function turns every action into a new state; replay is a fold over actions.
// m10 adds pause, restart and a frame clock that speeds up as the score grows.
import { turn } from './rules.js'
import { tick } from './spawn.js'
import { tick, newGame } from './spawn.js'
/** How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2. */
export const movesEvery = (score) => Math.max(2, 8 - Math.floor(score / 3))
// One animation frame: count it, and every `movesEvery(score)`-th frame run a game step.
const frame = (state) => {
if (state.status !== 'Playing') return state
const counted = { ...state, frame: state.frame + 1 }
return counted.frame % movesEvery(counted.score) === 0 ? tick(counted) : counted
}
// 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
/** Tick and Turn are handled here; other actions leave the state alone (m10 adds more). */
export const update = (state, action) => {
switch (action.tag) {
case 'Tick': return tick(state)
case 'Turn': return turn(state, action.dir)
case 'TogglePause': return togglePause(state)
case 'Restart': return newGame(state.seed)
case 'Frame': return frame(state)
default: return state
}
}
+7 -1
View File
@@ -8,5 +8,11 @@ const directions = {
ArrowRight: 'Right', d: 'Right', D: 'Right',
}
// Keys that are not steering (m10).
const commands = { ' ': Action.TogglePause, r: Action.Restart, R: Action.Restart }
// hasOwn, not `key in table`: "constructor" and "toString" are inherited properties, not keys we mapped.
export const keyToAction = (key) =>
Object.hasOwn(directions, key) ? Action.Turn(directions[key]) : null
Object.hasOwn(directions, key) ? Action.Turn(directions[key])
: Object.hasOwn(commands, key) ? commands[key]
: null