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:
co-authored by
Claude Sonnet 5.5
parent
584f1d4825
commit
8028bb2fc0
@@ -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))
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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 }))
|
||||
}
|
||||
Reference in New Issue
Block a user