feat(js): m09 input, app, runtime, browser, main, index.html and smoke test

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-28 23:53:09 +01:00
co-authored by Claude Sonnet 5.5
parent 584f1d4825
commit 8028bb2fc0
16 changed files with 622 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
import { Action } from './model.js'
import { update } from './game.js'
import { newGame } from './spawn.js'
import { view } from './view.js'
import { map as mapIO, randomSeed, drawScene } from './io.js'
import { keyDowns, interval, map, filterMap, merge, scan, startWith } from './stream.js'
import { keyToAction } from './input.js'
export const TICK_MS = 150
// 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))
// 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))
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))
+36
View File
@@ -0,0 +1,36 @@
// Browser glue: turns a canvas and a window into a Platform. Impure by nature; not part of the exercises' logic.
const PREVENT = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '])
export const browserPlatform = (canvas, win) => {
const ctx = canvas.getContext('2d')
// Keys that scroll the page should only steer the game.
const onKey = (cb) => (e) => {
if (PREVENT.has(e.key)) e.preventDefault()
cb(e.key)
}
return {
fillRect: (x, y, w, h, color) => {
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
},
fillText: (x, y, text, color, size, align) => {
ctx.fillStyle = color
ctx.font = `${size}px monospace`
ctx.textAlign = align
ctx.fillText(text, x, y)
},
randomSeed: () => crypto.getRandomValues(new Uint32Array(1))[0],
// t is the number of milliseconds since the first frame.
startClock: (onTime) => {
const frame = (start) => (now) => {
const t0 = start ?? now
onTime(now - t0)
win.requestAnimationFrame(frame(t0))
}
win.requestAnimationFrame(frame(undefined))
},
onKeyDown: (cb) => win.addEventListener('keydown', onKey(cb)),
onKeyUp: (cb) => win.addEventListener('keyup', onKey(cb)),
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Action } from './model.js'
// Key name (as in KeyboardEvent.key) -> direction. A lookup table keeps keyToAction a one-liner.
const directions = {
ArrowUp: 'Up', w: 'Up', W: 'Up',
ArrowDown: 'Down', s: 'Down', S: 'Down',
ArrowLeft: 'Left', a: 'Left', A: 'Left',
ArrowRight: 'Right', d: 'Right', D: 'Right',
}
export const keyToAction = (key) =>
Object.hasOwn(directions, key) ? Action.Turn(directions[key]) : null
+5
View File
@@ -0,0 +1,5 @@
import { runApp } from './runtime.js'
import { browserPlatform } from './browser.js'
import { main } from './app.js'
runApp(main, browserPlatform(document.getElementById('game'), window))
+31
View File
@@ -0,0 +1,31 @@
// The single impure place in the course (with browser.js and main.js): everything else only builds values.
// Here we interpret IO descriptions against a Platform and hold the one mutable variable, the current stream.
// Platform = { fillRect, fillText, randomSeed, startClock, onKeyDown, onKeyUp }
// Perform one op on the platform and return its result.
const perform = (op, platform) => {
if (op.tag === 'RandomSeed') return platform.randomSeed()
const s = op.shape
return s.tag === 'Rect'
? platform.fillRect(s.x, s.y, s.w, s.h, s.color)
: platform.fillText(s.x, s.y, s.text, s.color, s.size, s.align)
}
// A loop, not recursion: a scene is hundreds of ops and the stack must not grow with them.
export const runIO = (io, platform) => {
let current = io
while (current.tag === 'Effect') current = current.k(perform(current.op, platform))
return current.value
}
export const runApp = (main, platform) => {
let stream = runIO(main, platform)
const feed = (input) => {
const [ios, next] = stream.step(input)
stream = next
ios.forEach((io) => runIO(io, platform))
}
platform.onKeyDown((key) => feed({ tag: 'KeyDown', key }))
platform.onKeyUp((key) => feed({ tag: 'KeyUp', key }))
platform.startClock((t) => feed({ tag: 'Time', t }))
}