feat(js): m10 extensions (pause, restart, speed-up via Frame) with tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25141ce86d
commit
fd7c5f2acf
13 files changed
+1347
-17
No files matched your search
@@ -143,16 +143,16 @@
|
|||||||
"expected": null
|
"expected": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "space-is-ignored (until m10)",
|
"name": "constructor-is-not-a-key",
|
||||||
"args": [
|
"args": [
|
||||||
" "
|
"constructor"
|
||||||
],
|
],
|
||||||
"expected": null
|
"expected": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "r-is-ignored (until m10)",
|
"name": "toString-is-not-a-key",
|
||||||
"args": [
|
"args": [
|
||||||
"r"
|
"toString"
|
||||||
],
|
],
|
||||||
"expected": null
|
"expected": null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
+7
-1
@@ -7,12 +7,18 @@ import { keyDowns, interval, map, filterMap, merge, scan, startWith } from './st
|
|||||||
import { keyToAction } from './input.js'
|
import { keyToAction } from './input.js'
|
||||||
|
|
||||||
export const TICK_MS = 150
|
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.
|
// Everything that can happen to the game: a Tick every TICK_MS, and a Turn for each steering key.
|
||||||
export const classicActions = merge(
|
export const classicActions = merge(
|
||||||
map(interval(TICK_MS), () => Action.Tick),
|
map(interval(TICK_MS), () => Action.Tick),
|
||||||
filterMap(keyDowns, keyToAction))
|
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.
|
// Actions folded into game states. startWith puts the initial state first so it is drawn right away.
|
||||||
export const states = (seed, actions) =>
|
export const states = (seed, actions) =>
|
||||||
startWith(scan(actions, update, newGame(seed)), newGame(seed))
|
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.
|
// 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.
|
// 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
@@ -1,12 +1,31 @@
|
|||||||
// m05 game: one `update` function turns every action into a new state; replay is a fold over actions.
|
// 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 { 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) => {
|
export const update = (state, action) => {
|
||||||
switch (action.tag) {
|
switch (action.tag) {
|
||||||
case 'Tick': return tick(state)
|
case 'Tick': return tick(state)
|
||||||
case 'Turn': return turn(state, action.dir)
|
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
|
default: return state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,11 @@ const directions = {
|
|||||||
ArrowRight: 'Right', d: 'Right', D: 'Right',
|
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) =>
|
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
|
||||||
+11
-1
@@ -4,12 +4,21 @@ import { pure } from './io.js'
|
|||||||
/** Milliseconds between game steps. */
|
/** Milliseconds between game steps. */
|
||||||
export const TICK_MS = 150
|
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`),
|
* 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`).
|
* merged with the `Action` for each key press that `keyToAction` recognises (from `keyDowns`).
|
||||||
*/
|
*/
|
||||||
export const classicActions = { step: () => todo('m09') }
|
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
|
* 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.
|
* (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<Stream<IO<unit>>>`: ask for a random seed, then turn the stream of
|
* The whole program as a value, `IO<Stream<IO<unit>>>`: 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') })
|
export const main = pure({ step: () => todo('m09') })
|
||||||
+14
-2
@@ -1,9 +1,21 @@
|
|||||||
import { todo } from './todo.js'
|
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`.
|
* 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')
|
export const update = (state, action) => todo('m05')
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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`.
|
* 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`.
|
* `ArrowRight`/`d`/`D` -> Right. Any other key -> `null`.
|
||||||
|
* (m10) `' '` (Space) -> `Action.TogglePause`, `r`/`R` -> `Action.Restart`.
|
||||||
*/
|
*/
|
||||||
export const keyToAction = (key) => todo('m09')
|
export const keyToAction = (key) => todo('m09')
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { states, classicActions, render, main, TICK_MS } from '../src/app.js'
|
import { states, classicActions, render, main, TICK_MS } from '../src/app.js'
|
||||||
import { run } from '../src/stream.js'
|
import { run, map as mapStream } from '../src/stream.js'
|
||||||
import { trace } from '../src/io.js'
|
import { trace, map as mapIO, randomSeed } from '../src/io.js'
|
||||||
import { view } from '../src/view.js'
|
import { view } from '../src/view.js'
|
||||||
import { newGame } from '../src/spawn.js'
|
import { newGame } from '../src/spawn.js'
|
||||||
import { Action } from '../src/model.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)
|
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', () => {
|
// `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions.
|
||||||
const { result: stream } = trace(main, 42)
|
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 [first, next] = stream.step(time(0))
|
||||||
const [second] = next.step(time(150))
|
const [second] = next.step(time(150))
|
||||||
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42)))
|
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42)))
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { runIO, runApp } from '../src/runtime.js'
|
import { runIO, runApp } from '../src/runtime.js'
|
||||||
import { pure, draw, randomSeed, map, sequence } from '../src/io.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 { view } from '../src/view.js'
|
||||||
import { newGame, tick } from '../src/spawn.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.
|
// What the platform should have been asked to draw for a scene.
|
||||||
const expectedCalls = (scene) =>
|
const expectedCalls = (scene) =>
|
||||||
scene.map((s) =>
|
scene.map((s) =>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
Reference in new issue
Block a user