diff --git a/fixtures/m09-input.json b/fixtures/m09-input.json index 172037e..dc51609 100644 --- a/fixtures/m09-input.json +++ b/fixtures/m09-input.json @@ -143,16 +143,16 @@ "expected": null }, { - "name": "space-is-ignored (until m10)", + "name": "constructor-is-not-a-key", "args": [ - " " + "constructor" ], "expected": null }, { - "name": "r-is-ignored (until m10)", + "name": "toString-is-not-a-key", "args": [ - "r" + "toString" ], "expected": null } diff --git a/fixtures/m10-input.json b/fixtures/m10-input.json new file mode 100644 index 0000000..867399e --- /dev/null +++ b/fixtures/m10-input.json @@ -0,0 +1,66 @@ +{ + "function": "keyToAction", + "cases": [ + { + "name": "space-toggles-pause", + "args": [ + " " + ], + "expected": { + "tag": "TogglePause" + } + }, + { + "name": "r-restarts", + "args": [ + "r" + ], + "expected": { + "tag": "Restart" + } + }, + { + "name": "R-restarts", + "args": [ + "R" + ], + "expected": { + "tag": "Restart" + } + }, + { + "name": "ArrowUp-still-turns-up", + "args": [ + "ArrowUp" + ], + "expected": { + "tag": "Turn", + "dir": "Up" + } + }, + { + "name": "d-still-turns-right", + "args": [ + "d" + ], + "expected": { + "tag": "Turn", + "dir": "Right" + } + }, + { + "name": "Enter-is-not-bound", + "args": [ + "Enter" + ], + "expected": null + }, + { + "name": "empty-string-is-not-bound", + "args": [ + "" + ], + "expected": null + } + ] +} diff --git a/fixtures/m10-moves-every.json b/fixtures/m10-moves-every.json new file mode 100644 index 0000000..ec42093 --- /dev/null +++ b/fixtures/m10-moves-every.json @@ -0,0 +1,47 @@ +{ + "function": "movesEvery", + "cases": [ + { + "name": "score-0", + "args": [ + 0 + ], + "expected": 8 + }, + { + "name": "score-2", + "args": [ + 2 + ], + "expected": 8 + }, + { + "name": "score-3", + "args": [ + 3 + ], + "expected": 7 + }, + { + "name": "score-17", + "args": [ + 17 + ], + "expected": 3 + }, + { + "name": "score-18", + "args": [ + 18 + ], + "expected": 2 + }, + { + "name": "score-100", + "args": [ + 100 + ], + "expected": 2 + } + ] +} diff --git a/fixtures/m10-update.json b/fixtures/m10-update.json new file mode 100644 index 0000000..a21c9c4 --- /dev/null +++ b/fixtures/m10-update.json @@ -0,0 +1,1106 @@ +{ + "function": "update", + "cases": [ + { + "name": "toggle-pause-pauses-a-playing-game", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 0 + }, + { + "tag": "TogglePause" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Paused", + "frame": 0 + } + }, + { + "name": "toggle-pause-resumes-a-paused-game", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Paused", + "frame": 0 + }, + { + "tag": "TogglePause" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 0 + } + }, + { + "name": "toggle-pause-ignored-when-game-over", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "GameOver", + "frame": 0 + }, + { + "tag": "TogglePause" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "GameOver", + "frame": 0 + } + }, + { + "name": "toggle-pause-ignored-when-won", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Won", + "frame": 0 + }, + { + "tag": "TogglePause" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Won", + "frame": 0 + } + }, + { + "name": "turn-while-paused-is-ignored", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Paused", + "frame": 0 + }, + { + "tag": "Turn", + "dir": "Up" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Paused", + "frame": 0 + } + }, + { + "name": "tick-while-paused-is-ignored", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Paused", + "frame": 0 + }, + { + "tag": "Tick" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Paused", + "frame": 0 + } + }, + { + "name": "restart-from-playing-starts-a-new-game-with-the-same-seed", + "args": [ + { + "width": 20, + "height": 20, + "snake": [ + { + "x": 3, + "y": 3 + } + ], + "dir": "Up", + "nextDir": "Up", + "apple": null, + "seed": 0, + "score": 5, + "status": "Playing", + "frame": 9 + }, + { + "tag": "Restart" + } + ], + "expected": { + "width": 20, + "height": 20, + "snake": [ + { + "x": 10, + "y": 10 + }, + { + "x": 9, + "y": 10 + }, + { + "x": 8, + "y": 10 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 18, + "y": 13 + }, + "seed": 1831565813, + "score": 0, + "status": "Playing", + "frame": 0 + } + }, + { + "name": "restart-from-game-over", + "args": [ + { + "width": 20, + "height": 20, + "snake": [ + { + "x": 10, + "y": 10 + }, + { + "x": 9, + "y": 10 + }, + { + "x": 8, + "y": 10 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 0, + "score": 2, + "status": "GameOver", + "frame": 0 + }, + { + "tag": "Restart" + } + ], + "expected": { + "width": 20, + "height": 20, + "snake": [ + { + "x": 10, + "y": 10 + }, + { + "x": 9, + "y": 10 + }, + { + "x": 8, + "y": 10 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 18, + "y": 13 + }, + "seed": 1831565813, + "score": 0, + "status": "Playing", + "frame": 0 + } + }, + { + "name": "restart-from-won", + "args": [ + { + "width": 20, + "height": 20, + "snake": [ + { + "x": 10, + "y": 10 + }, + { + "x": 9, + "y": 10 + }, + { + "x": 8, + "y": 10 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 0, + "score": 7, + "status": "Won", + "frame": 0 + }, + { + "tag": "Restart" + } + ], + "expected": { + "width": 20, + "height": 20, + "snake": [ + { + "x": 10, + "y": 10 + }, + { + "x": 9, + "y": 10 + }, + { + "x": 8, + "y": 10 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 18, + "y": 13 + }, + "seed": 1831565813, + "score": 0, + "status": "Playing", + "frame": 0 + } + }, + { + "name": "restart-from-paused", + "args": [ + { + "width": 20, + "height": 20, + "snake": [ + { + "x": 10, + "y": 10 + }, + { + "x": 9, + "y": 10 + }, + { + "x": 8, + "y": 10 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 0, + "score": 0, + "status": "Paused", + "frame": 0 + }, + { + "tag": "Restart" + } + ], + "expected": { + "width": 20, + "height": 20, + "snake": [ + { + "x": 10, + "y": 10 + }, + { + "x": 9, + "y": 10 + }, + { + "x": 8, + "y": 10 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 18, + "y": 13 + }, + "seed": 1831565813, + "score": 0, + "status": "Playing", + "frame": 0 + } + }, + { + "name": "frame-below-threshold-only-counts-the-frame", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 1 + } + }, + { + "name": "frame-just-before-threshold-only-counts-the-frame", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 6 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 7 + } + }, + { + "name": "frame-at-threshold-ticks-the-game", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 7 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 8 + } + }, + { + "name": "frame-at-second-multiple-of-threshold-ticks-again", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 15 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 16 + } + }, + { + "name": "frame-threshold-shrinks-as-score-grows", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 3, + "status": "Playing", + "frame": 6 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 3, + "status": "Playing", + "frame": 7 + } + }, + { + "name": "frame-after-threshold-shrinks-does-not-tick", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 3, + "status": "Playing", + "frame": 7 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 3, + "status": "Playing", + "frame": 8 + } + }, + { + "name": "frame-at-top-speed-ticks-every-second-frame", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 18, + "status": "Playing", + "frame": 1 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 18, + "status": "Playing", + "frame": 2 + } + }, + { + "name": "frame-at-top-speed-odd-frame-does-not-tick", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 18, + "status": "Playing", + "frame": 2 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 18, + "status": "Playing", + "frame": 3 + } + }, + { + "name": "frame-tick-can-end-the-game", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 2, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 7 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 2, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "GameOver", + "frame": 8 + } + }, + { + "name": "frame-when-paused-is-unchanged", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Paused", + "frame": 7 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Paused", + "frame": 7 + } + }, + { + "name": "frame-when-game-over-is-unchanged", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "GameOver", + "frame": 7 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "GameOver", + "frame": 7 + } + }, + { + "name": "frame-when-won-is-unchanged", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Won", + "frame": 7 + }, + { + "tag": "Frame" + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 0, + "score": 0, + "status": "Won", + "frame": 7 + } + } + ] +} diff --git a/js/solutions/app.js b/js/solutions/app.js index 46e3fe2..4e8956a 100644 --- a/js/solutions/app.js +++ b/js/solutions/app.js @@ -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)) diff --git a/js/solutions/game.js b/js/solutions/game.js index 6f9e527..e5fc5f0 100644 --- a/js/solutions/game.js +++ b/js/solutions/game.js @@ -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 } } diff --git a/js/solutions/input.js b/js/solutions/input.js index 711cf09..564d81a 100644 --- a/js/solutions/input.js +++ b/js/solutions/input.js @@ -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 diff --git a/js/src/app.js b/js/src/app.js index 09d81e6..083b177 100644 --- a/js/src/app.js +++ b/js/src/app.js @@ -4,12 +4,21 @@ import { pure } from './io.js' /** Milliseconds between game steps. */ export const TICK_MS = 150 +/** Milliseconds between animation frames (m10). */ +export const FRAME_MS = 25 + /** * Stream of everything that can happen to the game: `Action.Tick` every TICK_MS (from `interval`), * merged with the `Action` for each key press that `keyToAction` recognises (from `keyDowns`). */ export const classicActions = { step: () => todo('m09') } +/** + * (m10) Like `classicActions`, but the clock emits `Action.Frame` every FRAME_MS instead of `Tick` every TICK_MS; + * key presses are translated by `keyToAction` as before (now including pause and restart). + */ +export const speedActions = { step: () => todo('m10') } + /** * Fold a stream of actions into a stream of game states: start from `newGame(seed)`, apply `update` to each action * (scan), and emit the initial state first (startWith) so there is something to draw before anything happens. @@ -21,6 +30,7 @@ export const render = (state) => todo('m09') /** * The whole program as a value, `IO>>`: ask for a random seed, then turn the stream of - * states (built from `classicActions`) into a stream of drawings (`render`). Nothing runs here. + * states (built from `classicActions` in m09, from `speedActions` after m10) into a stream of drawings (`render`). + * Nothing runs here. */ export const main = pure({ step: () => todo('m09') }) diff --git a/js/src/game.js b/js/src/game.js index 5015dd1..17c3ee9 100644 --- a/js/src/game.js +++ b/js/src/game.js @@ -1,9 +1,21 @@ import { todo } from './todo.js' +/** + * How many frames pass between game steps (m10): `max(2, 8 - floor(score / 3))`. + * Score 0-2 -> 8, 3-5 -> 7, ..., 18 and up -> 2. + */ +export const movesEvery = (score) => todo('m10') + /** * The one function that says how the game changes: `(state, action) -> state`. - * `Tick` runs a game step (`tick`), `Turn` asks to change direction (`turn`); - * every other action returns the state unchanged for now. + * + * m05: `Tick` runs a game step (`tick`), `Turn` asks to change direction (`turn`); + * every other action returns the state unchanged. + * + * (m10) `TogglePause` switches Playing <-> Paused and does nothing in any other status. + * (m10) `Restart` gives `newGame(state.seed)`, whatever the status. + * (m10) `Frame` does nothing unless Playing; otherwise it adds 1 to `frame`, and when the new `frame` is a multiple of + * `movesEvery(score)` it also runs a game step (`tick`) on that incremented state. */ export const update = (state, action) => todo('m05') diff --git a/js/src/input.js b/js/src/input.js index 1cf2a37..d3a061b 100644 --- a/js/src/input.js +++ b/js/src/input.js @@ -2,7 +2,8 @@ import { todo } from './todo.js' /** * Translate a keyboard key name (as in `KeyboardEvent.key`) into the game action it triggers, or `null`. - * `ArrowUp`/`w`/`W` -> `Action.Turn('Up')`, `ArrowDown`/`s`/`S` -> Down, `ArrowLeft`/`a`/`A` -> Left, + * m09: `ArrowUp`/`w`/`W` -> `Action.Turn('Up')`, `ArrowDown`/`s`/`S` -> Down, `ArrowLeft`/`a`/`A` -> Left, * `ArrowRight`/`d`/`D` -> Right. Any other key -> `null`. + * (m10) `' '` (Space) -> `Action.TogglePause`, `r`/`R` -> `Action.Restart`. */ export const keyToAction = (key) => todo('m09') diff --git a/js/test/m09-app.test.js b/js/test/m09-app.test.js index 81f9ed0..83e7bc8 100644 --- a/js/test/m09-app.test.js +++ b/js/test/m09-app.test.js @@ -1,8 +1,8 @@ import test from 'node:test' import assert from 'node:assert/strict' import { states, classicActions, render, main, TICK_MS } from '../src/app.js' -import { run } from '../src/stream.js' -import { trace } from '../src/io.js' +import { run, map as mapStream } from '../src/stream.js' +import { trace, map as mapIO, randomSeed } from '../src/io.js' import { view } from '../src/view.js' import { newGame } from '../src/spawn.js' import { Action } from '../src/model.js' @@ -40,8 +40,10 @@ test('m09 main asks for a random seed, then builds the stream from it', () => { assert.deepStrictEqual(trace(outs[0], 0).ops, trace(render(newGame(42)), 0).ops) }) -test('m09 main with seed 42 draws the new game first, then the ticked game after 150 ms', () => { - const { result: stream } = trace(main, 42) +// `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions. +test('m09 the program built from classicActions draws the new game first, then the ticked game after 150 ms', () => { + const classicMain = mapIO(randomSeed, (seed) => mapStream(states(seed, classicActions), render)) + const { result: stream } = trace(classicMain, 42) const [first, next] = stream.step(time(0)) const [second] = next.step(time(150)) assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42))) diff --git a/js/test/m09-runtime.test.js b/js/test/m09-runtime.test.js index 7a8e495..a6a4664 100644 --- a/js/test/m09-runtime.test.js +++ b/js/test/m09-runtime.test.js @@ -2,7 +2,8 @@ import test from 'node:test' import assert from 'node:assert/strict' import { runIO, runApp } from '../src/runtime.js' import { pure, draw, randomSeed, map, sequence } from '../src/io.js' -import { main } from '../src/app.js' +import { states, classicActions, render } from '../src/app.js' +import { map as mapStream } from '../src/stream.js' import { view } from '../src/view.js' import { newGame, tick } from '../src/spawn.js' @@ -24,6 +25,9 @@ const fakePlatform = (seed = 42) => { } } +// The m09 program: ticks every 150 ms. (The shipped `main` uses speedActions after m10, so these tests build their own.) +const main = map(randomSeed, (seed) => mapStream(states(seed, classicActions), render)) + // What the platform should have been asked to draw for a scene. const expectedCalls = (scene) => scene.map((s) => diff --git a/js/test/m10-extensions.test.js b/js/test/m10-extensions.test.js new file mode 100644 index 0000000..1e7d8f4 --- /dev/null +++ b/js/test/m10-extensions.test.js @@ -0,0 +1,51 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { update, movesEvery } from '../src/game.js' +import { keyToAction } from '../src/input.js' +import { speedActions, main, FRAME_MS } from '../src/app.js' +import { run } from '../src/stream.js' +import { trace } from '../src/io.js' +import { view } from '../src/view.js' +import { newGame } from '../src/spawn.js' +import { Action } from '../src/model.js' +import { loadFixture, freeze } from './helpers.js' + +const fixtureTests = (name, f) => { + const fx = loadFixture(name) + fx.cases.forEach((c) => + test(`m10 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected))) +} + +fixtureTests('m10-update', update) +fixtureTests('m10-moves-every', movesEvery) +fixtureTests('m10-input', keyToAction) + +const time = (t) => ({ tag: 'Time', t }) + +test('m10 the animation clock runs every 25 ms', () => assert.equal(FRAME_MS, 25)) + +test('m10 speedActions turns the clock into Frame actions every 25 ms', () => + assert.deepStrictEqual( + run(speedActions, freeze([25, 50, 75, 100, 125, 150, 175, 200].map(time))), + Array.from({ length: 8 }, () => Action.Frame))) + +test('m10 speedActions still turns keys into actions, including pause and restart', () => + assert.deepStrictEqual( + run(speedActions, freeze([{ tag: 'KeyDown', key: ' ' }, { tag: 'KeyDown', key: 'r' }, { tag: 'KeyDown', key: 'ArrowUp' }])), + [Action.TogglePause, Action.Restart, Action.Turn('Up')])) + +test('m10 main (seed 42) draws a moved snake after eight 25 ms frames', () => { + const { ops, result: stream } = trace(main, 42) + assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }]) + const [first, afterFirst] = stream.step(time(0)) + assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42))) + const [drawings] = [25, 50, 75, 100, 125, 150, 175, 200].reduce( + ([outs, s], t) => { + const [out, next] = s.step(time(t)) + return [[...outs, ...out], next] + }, + [[], afterFirst]) + assert.equal(drawings.length, 8) + const moved = { ...newGame(42), snake: newGame(42).snake.map((p) => ({ x: p.x + 1, y: p.y })), frame: 8 } + assert.deepStrictEqual(trace(drawings[7], 0).ops.map((o) => o.shape), view(moved)) +})