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:
co-authored by
Claude Sonnet 5.5
parent
25141ce86d
commit
fd7c5f2acf
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-1
@@ -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<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') })
|
||||
|
||||
+14
-2
@@ -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')
|
||||
|
||||
|
||||
+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`.
|
||||
* `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')
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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