feat(js): m11 pong capstone (model, rules, view, app, page) and polling smoke test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
fd7c5f2acf
commit
1d1d918805
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"function": "pongKey",
|
||||
"cases": [
|
||||
{
|
||||
"name": "ArrowUp-down-presses-up",
|
||||
"args": [
|
||||
true,
|
||||
"ArrowUp"
|
||||
],
|
||||
"expected": {
|
||||
"tag": "Press",
|
||||
"key": "Up"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ArrowUp-up-releases-up",
|
||||
"args": [
|
||||
false,
|
||||
"ArrowUp"
|
||||
],
|
||||
"expected": {
|
||||
"tag": "Release",
|
||||
"key": "Up"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "w-down-presses-up",
|
||||
"args": [
|
||||
true,
|
||||
"w"
|
||||
],
|
||||
"expected": {
|
||||
"tag": "Press",
|
||||
"key": "Up"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "w-up-releases-up",
|
||||
"args": [
|
||||
false,
|
||||
"w"
|
||||
],
|
||||
"expected": {
|
||||
"tag": "Release",
|
||||
"key": "Up"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ArrowDown-down-presses-down",
|
||||
"args": [
|
||||
true,
|
||||
"ArrowDown"
|
||||
],
|
||||
"expected": {
|
||||
"tag": "Press",
|
||||
"key": "Down"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ArrowDown-up-releases-down",
|
||||
"args": [
|
||||
false,
|
||||
"ArrowDown"
|
||||
],
|
||||
"expected": {
|
||||
"tag": "Release",
|
||||
"key": "Down"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "s-down-presses-down",
|
||||
"args": [
|
||||
true,
|
||||
"s"
|
||||
],
|
||||
"expected": {
|
||||
"tag": "Press",
|
||||
"key": "Down"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "s-up-releases-down",
|
||||
"args": [
|
||||
false,
|
||||
"s"
|
||||
],
|
||||
"expected": {
|
||||
"tag": "Release",
|
||||
"key": "Down"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "r-down-restarts",
|
||||
"args": [
|
||||
true,
|
||||
"r"
|
||||
],
|
||||
"expected": {
|
||||
"tag": "Restart"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "r-up-does-nothing",
|
||||
"args": [
|
||||
false,
|
||||
"r"
|
||||
],
|
||||
"expected": null
|
||||
},
|
||||
{
|
||||
"name": "other-key-down-is-ignored",
|
||||
"args": [
|
||||
true,
|
||||
"x"
|
||||
],
|
||||
"expected": null
|
||||
},
|
||||
{
|
||||
"name": "other-key-up-is-ignored",
|
||||
"args": [
|
||||
false,
|
||||
"x"
|
||||
],
|
||||
"expected": null
|
||||
},
|
||||
{
|
||||
"name": "space-is-ignored",
|
||||
"args": [
|
||||
true,
|
||||
" "
|
||||
],
|
||||
"expected": null
|
||||
},
|
||||
{
|
||||
"name": "constructor-is-not-a-key",
|
||||
"args": [
|
||||
true,
|
||||
"constructor"
|
||||
],
|
||||
"expected": null
|
||||
},
|
||||
{
|
||||
"name": "toString-is-not-a-key",
|
||||
"args": [
|
||||
false,
|
||||
"toString"
|
||||
],
|
||||
"expected": null
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
||||
{
|
||||
"function": "pongView",
|
||||
"cases": [
|
||||
{
|
||||
"name": "new-match",
|
||||
"args": [
|
||||
{
|
||||
"ball": {
|
||||
"x": 40,
|
||||
"y": 25
|
||||
},
|
||||
"vel": {
|
||||
"dx": 1,
|
||||
"dy": 1
|
||||
},
|
||||
"player": 21,
|
||||
"cpu": 21,
|
||||
"held": {
|
||||
"up": false,
|
||||
"down": false
|
||||
},
|
||||
"score": {
|
||||
"player": 0,
|
||||
"cpu": 0
|
||||
},
|
||||
"status": "Playing",
|
||||
"seed": 0,
|
||||
"frame": 0
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 640,
|
||||
"h": 400,
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 16,
|
||||
"y": 168,
|
||||
"w": 8,
|
||||
"h": 64,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 616,
|
||||
"y": 168,
|
||||
"w": 8,
|
||||
"h": 64,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 320,
|
||||
"y": 200,
|
||||
"w": 8,
|
||||
"h": 8,
|
||||
"color": "#ffeb3b"
|
||||
},
|
||||
{
|
||||
"tag": "Text",
|
||||
"x": 320,
|
||||
"y": 30,
|
||||
"text": "0 0",
|
||||
"color": "#ffffff",
|
||||
"size": 24,
|
||||
"align": "center"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "paddles-and-ball-move-with-the-state",
|
||||
"args": [
|
||||
{
|
||||
"ball": {
|
||||
"x": 10,
|
||||
"y": 5
|
||||
},
|
||||
"vel": {
|
||||
"dx": 1,
|
||||
"dy": 1
|
||||
},
|
||||
"player": 0,
|
||||
"cpu": 42,
|
||||
"held": {
|
||||
"up": false,
|
||||
"down": false
|
||||
},
|
||||
"score": {
|
||||
"player": 3,
|
||||
"cpu": 2
|
||||
},
|
||||
"status": "Playing",
|
||||
"seed": 0,
|
||||
"frame": 0
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 640,
|
||||
"h": 400,
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 16,
|
||||
"y": 0,
|
||||
"w": 8,
|
||||
"h": 64,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 616,
|
||||
"y": 336,
|
||||
"w": 8,
|
||||
"h": 64,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 80,
|
||||
"y": 40,
|
||||
"w": 8,
|
||||
"h": 8,
|
||||
"color": "#ffeb3b"
|
||||
},
|
||||
{
|
||||
"tag": "Text",
|
||||
"x": 320,
|
||||
"y": 30,
|
||||
"text": "3 2",
|
||||
"color": "#ffffff",
|
||||
"size": 24,
|
||||
"align": "center"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "player-wins",
|
||||
"args": [
|
||||
{
|
||||
"ball": {
|
||||
"x": 40,
|
||||
"y": 25
|
||||
},
|
||||
"vel": {
|
||||
"dx": 1,
|
||||
"dy": 1
|
||||
},
|
||||
"player": 21,
|
||||
"cpu": 21,
|
||||
"held": {
|
||||
"up": false,
|
||||
"down": false
|
||||
},
|
||||
"score": {
|
||||
"player": 5,
|
||||
"cpu": 3
|
||||
},
|
||||
"status": "Over",
|
||||
"seed": 0,
|
||||
"frame": 0
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 640,
|
||||
"h": 400,
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 16,
|
||||
"y": 168,
|
||||
"w": 8,
|
||||
"h": 64,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 616,
|
||||
"y": 168,
|
||||
"w": 8,
|
||||
"h": 64,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 320,
|
||||
"y": 200,
|
||||
"w": 8,
|
||||
"h": 8,
|
||||
"color": "#ffeb3b"
|
||||
},
|
||||
{
|
||||
"tag": "Text",
|
||||
"x": 320,
|
||||
"y": 30,
|
||||
"text": "5 3",
|
||||
"color": "#ffffff",
|
||||
"size": 24,
|
||||
"align": "center"
|
||||
},
|
||||
{
|
||||
"tag": "Text",
|
||||
"x": 320,
|
||||
"y": 200,
|
||||
"text": "You Win!",
|
||||
"color": "#ffffff",
|
||||
"size": 32,
|
||||
"align": "center"
|
||||
},
|
||||
{
|
||||
"tag": "Text",
|
||||
"x": 320,
|
||||
"y": 230,
|
||||
"text": "Press R to restart",
|
||||
"color": "#ffffff",
|
||||
"size": 16,
|
||||
"align": "center"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cpu-wins",
|
||||
"args": [
|
||||
{
|
||||
"ball": {
|
||||
"x": 40,
|
||||
"y": 25
|
||||
},
|
||||
"vel": {
|
||||
"dx": 1,
|
||||
"dy": 1
|
||||
},
|
||||
"player": 21,
|
||||
"cpu": 21,
|
||||
"held": {
|
||||
"up": false,
|
||||
"down": false
|
||||
},
|
||||
"score": {
|
||||
"player": 2,
|
||||
"cpu": 5
|
||||
},
|
||||
"status": "Over",
|
||||
"seed": 0,
|
||||
"frame": 0
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 640,
|
||||
"h": 400,
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 16,
|
||||
"y": 168,
|
||||
"w": 8,
|
||||
"h": 64,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 616,
|
||||
"y": 168,
|
||||
"w": 8,
|
||||
"h": 64,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"tag": "Rect",
|
||||
"x": 320,
|
||||
"y": 200,
|
||||
"w": 8,
|
||||
"h": 8,
|
||||
"color": "#ffeb3b"
|
||||
},
|
||||
{
|
||||
"tag": "Text",
|
||||
"x": 320,
|
||||
"y": 30,
|
||||
"text": "2 5",
|
||||
"color": "#ffffff",
|
||||
"size": 24,
|
||||
"align": "center"
|
||||
},
|
||||
{
|
||||
"tag": "Text",
|
||||
"x": 320,
|
||||
"y": 200,
|
||||
"text": "CPU Wins",
|
||||
"color": "#ffffff",
|
||||
"size": 32,
|
||||
"align": "center"
|
||||
},
|
||||
{
|
||||
"tag": "Text",
|
||||
"x": 320,
|
||||
"y": 230,
|
||||
"text": "Press R to restart",
|
||||
"color": "#ffffff",
|
||||
"size": 16,
|
||||
"align": "center"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pure Pong</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 12px; background: #1b1b1b; color: #ddd; font: 14px system-ui, sans-serif; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
canvas { background: #111111; border: 2px solid #333; }
|
||||
p { margin: 0; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Pure Pong</h1>
|
||||
<canvas id="game" width="640" height="400"></canvas>
|
||||
<p>Up/Down arrows (or W/S) move your paddle. First to 5 wins. R restarts.</p>
|
||||
<script type="module" src="src/pong/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
// m11 pong app: the same wiring as app.js (streams in, drawings out), reusing the course's modules.
|
||||
import { initialPong, PongAction } from './model.js'
|
||||
import { pongUpdate } from './rules.js'
|
||||
import { pongView } from './view.js'
|
||||
import { map as mapIO, randomSeed, drawScene } from '../io.js'
|
||||
import { keyDowns, keyUps, interval, map, filterMap, merge, scan, startWith } from '../stream.js'
|
||||
|
||||
export const FRAME_MS_PONG = 30
|
||||
|
||||
const keys = {
|
||||
ArrowUp: 'Up', w: 'Up',
|
||||
ArrowDown: 'Down', s: 'Down',
|
||||
}
|
||||
|
||||
// hasOwn, not `key in keys`: "constructor" and "toString" are inherited, not keys we mapped.
|
||||
export const pongKey = (isDown, key) =>
|
||||
Object.hasOwn(keys, key) ? (isDown ? PongAction.Press : PongAction.Release)(keys[key])
|
||||
: key === 'r' && isDown ? PongAction.Restart
|
||||
: null
|
||||
|
||||
export const pongActions = merge(
|
||||
map(interval(FRAME_MS_PONG), () => PongAction.Frame),
|
||||
merge(
|
||||
filterMap(keyDowns, (key) => pongKey(true, key)),
|
||||
filterMap(keyUps, (key) => pongKey(false, key))))
|
||||
|
||||
export const pongStates = (seed, actions) =>
|
||||
startWith(scan(actions, pongUpdate, initialPong(seed)), initialPong(seed))
|
||||
|
||||
export const pongMain = mapIO(randomSeed, (seed) =>
|
||||
map(pongStates(seed, pongActions), (state) => drawScene(pongView(state))))
|
||||
@@ -0,0 +1,5 @@
|
||||
import { runApp } from '../runtime.js'
|
||||
import { browserPlatform } from '../browser.js'
|
||||
import { pongMain } from './app.js'
|
||||
|
||||
runApp(pongMain, browserPlatform(document.getElementById('game'), window))
|
||||
@@ -0,0 +1,38 @@
|
||||
// m11 pong model: the same ideas as the snake model, for a different game.
|
||||
|
||||
/** @typedef {{ball: {x: number, y: number}, vel: {dx: number, dy: number}, player: number, cpu: number,
|
||||
* held: {up: boolean, down: boolean}, score: {player: number, cpu: number},
|
||||
* status: 'Playing'|'Over', seed: number, frame: number}} PongState */
|
||||
|
||||
// The court, in cells. `player` and `cpu` are the y of the top cell of each paddle.
|
||||
export const W = 80
|
||||
export const H = 50
|
||||
/** Pixels per cell. */
|
||||
export const PCELL = 8
|
||||
/** Paddle height in cells. */
|
||||
export const PADDLE = 8
|
||||
/** Column of the player's paddle (left) and the cpu's paddle (right). */
|
||||
export const PLAYER_X = 2
|
||||
export const CPU_X = 77
|
||||
/** Points needed to win the match. */
|
||||
export const WIN = 5
|
||||
|
||||
export const initialPong = (seed) => ({
|
||||
ball: { x: 40, y: 25 },
|
||||
vel: { dx: 1, dy: 1 },
|
||||
player: 21,
|
||||
cpu: 21,
|
||||
held: { up: false, down: false },
|
||||
score: { player: 0, cpu: 0 },
|
||||
status: 'Playing',
|
||||
seed,
|
||||
frame: 0,
|
||||
})
|
||||
|
||||
/** Everything that can happen in a match, as data. */
|
||||
export const PongAction = {
|
||||
Press: (key) => ({ tag: 'Press', key }),
|
||||
Release: (key) => ({ tag: 'Release', key }),
|
||||
Frame: { tag: 'Frame' },
|
||||
Restart: { tag: 'Restart' },
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// m11 pong rules: one pure `pongUpdate`, built from small steps that each return a new piece of state.
|
||||
import { H, W, PADDLE, PLAYER_X, CPU_X, WIN, initialPong } from './model.js'
|
||||
import { nextUint32 } from '../random.js'
|
||||
|
||||
export const clamp = (lo, hi, x) => Math.min(hi, Math.max(lo, x))
|
||||
|
||||
const clampPaddle = (y) => clamp(0, H - PADDLE, y)
|
||||
|
||||
const movePlayer = (state) =>
|
||||
clampPaddle(state.player + (state.held.down ? 1 : 0) - (state.held.up ? 1 : 0))
|
||||
|
||||
// The cpu chases the middle of its paddle toward the ball, one cell every other frame.
|
||||
const moveCpu = (state, frame) =>
|
||||
frame % 2 === 0 ? clampPaddle(state.cpu + Math.sign(state.ball.y - PADDLE / 2 - state.cpu)) : state.cpu
|
||||
|
||||
const hits = (paddle, y) => paddle <= y && y < paddle + PADDLE
|
||||
|
||||
// Where the ball goes next, given the paddles' new positions. `scorer` says who got the point, if anyone.
|
||||
const moveBall = (state, player, cpu) => {
|
||||
const { ball, vel } = state
|
||||
const wall = ball.y + vel.dy < 0 || ball.y + vel.dy >= H
|
||||
const dy = wall ? -vel.dy : vel.dy
|
||||
const ny = ball.y + dy
|
||||
const nx = ball.x + vel.dx
|
||||
const bounce =
|
||||
(nx === PLAYER_X && vel.dx < 0 && hits(player, ny)) ||
|
||||
(nx === CPU_X && vel.dx > 0 && hits(cpu, ny))
|
||||
return {
|
||||
ball: { x: bounce ? ball.x : nx, y: ny },
|
||||
vel: { dx: bounce ? -vel.dx : vel.dx, dy },
|
||||
scorer: nx < 0 ? 'cpu' : nx >= W ? 'player' : null,
|
||||
}
|
||||
}
|
||||
|
||||
// After a point the ball restarts in the middle, heading toward whoever conceded; the seed picks up or down.
|
||||
const serve = (state, scorer) => {
|
||||
const [r, seed] = nextUint32(state.seed)
|
||||
const score = { ...state.score, [scorer]: state.score[scorer] + 1 }
|
||||
return {
|
||||
...state,
|
||||
ball: { x: 40, y: 25 },
|
||||
vel: { dx: scorer === 'cpu' ? -1 : 1, dy: r % 2 === 0 ? 1 : -1 },
|
||||
score,
|
||||
seed,
|
||||
status: score[scorer] >= WIN ? 'Over' : 'Playing',
|
||||
}
|
||||
}
|
||||
|
||||
const frame = (state) => {
|
||||
if (state.status !== 'Playing') return state
|
||||
const n = state.frame + 1
|
||||
const player = movePlayer(state)
|
||||
const cpu = moveCpu(state, n)
|
||||
const { scorer, ...moved } = moveBall(state, player, cpu)
|
||||
const next = { ...state, ...moved, player, cpu, frame: n }
|
||||
return scorer === null ? next : serve(next, scorer)
|
||||
}
|
||||
|
||||
export const pongUpdate = (state, action) => {
|
||||
switch (action.tag) {
|
||||
case 'Press': return { ...state, held: { ...state.held, [action.key.toLowerCase()]: true } }
|
||||
case 'Release': return { ...state, held: { ...state.held, [action.key.toLowerCase()]: false } }
|
||||
case 'Restart': return initialPong(state.seed)
|
||||
case 'Frame': return frame(state)
|
||||
default: return state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// m11 pong view: the same Scene shapes as the snake view, so the same drawScene and runtime draw it.
|
||||
import { PCELL, PADDLE, PLAYER_X, CPU_X } from './model.js'
|
||||
|
||||
const rect = (x, y, w, h, color) => ({ tag: 'Rect', x, y, w, h, color })
|
||||
const text = (x, y, str, size) => ({ tag: 'Text', x, y, text: str, color: '#ffffff', size, align: 'center' })
|
||||
|
||||
const paddle = (column, y) => rect(column * PCELL, y * PCELL, PCELL, PADDLE * PCELL, '#ffffff')
|
||||
|
||||
const overlay = (state) =>
|
||||
state.status === 'Over'
|
||||
? [
|
||||
text(320, 200, state.score.player > state.score.cpu ? 'You Win!' : 'CPU Wins', 32),
|
||||
text(320, 230, 'Press R to restart', 16),
|
||||
]
|
||||
: []
|
||||
|
||||
export const pongView = (state) => [
|
||||
rect(0, 0, 640, 400, '#111111'),
|
||||
paddle(PLAYER_X, state.player),
|
||||
paddle(CPU_X, state.cpu),
|
||||
rect(state.ball.x * PCELL, state.ball.y * PCELL, PCELL, PCELL, '#ffeb3b'),
|
||||
text(320, 30, `${state.score.player} ${state.score.cpu}`, 24),
|
||||
...overlay(state),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
import { todo } from '../todo.js'
|
||||
import { pure } from '../io.js'
|
||||
|
||||
/** Milliseconds between frames of the match. */
|
||||
export const FRAME_MS_PONG = 30
|
||||
|
||||
/**
|
||||
* Translate a key event into a `PongAction`, or `null`. `isDown` is true for a key press, false for a release.
|
||||
* `ArrowUp`/`w` -> `Press('Up')` / `Release('Up')`, `ArrowDown`/`s` -> `Press('Down')` / `Release('Down')`,
|
||||
* `r` -> `Restart` (on press only; releasing `r` does nothing). Any other key -> `null`.
|
||||
*/
|
||||
export const pongKey = (isDown, key) => todo('m11')
|
||||
|
||||
/**
|
||||
* Everything that can happen in a match: `Frame` every FRAME_MS_PONG (from `interval`), merged with the
|
||||
* `pongKey(true, key)` of every key press and the `pongKey(false, key)` of every key release.
|
||||
*/
|
||||
export const pongActions = { step: () => todo('m11') }
|
||||
|
||||
/** Fold the actions into a stream of states: start from `initialPong(seed)`, `pongUpdate` for each action, emit the initial state first. */
|
||||
export const pongStates = (seed, actions) => todo('m11')
|
||||
|
||||
/** The whole program as a value, like `main`: ask for a seed, turn the stream of states into a stream of drawings of `pongView`. */
|
||||
export const pongMain = pure({ step: () => todo('m11') })
|
||||
@@ -0,0 +1,5 @@
|
||||
import { runApp } from '../runtime.js'
|
||||
import { browserPlatform } from '../browser.js'
|
||||
import { pongMain } from './app.js'
|
||||
|
||||
runApp(pongMain, browserPlatform(document.getElementById('game'), window))
|
||||
@@ -0,0 +1,36 @@
|
||||
import { todo } from '../todo.js'
|
||||
|
||||
/** @typedef {{ball: {x: number, y: number}, vel: {dx: number, dy: number}, player: number, cpu: number,
|
||||
* held: {up: boolean, down: boolean}, score: {player: number, cpu: number},
|
||||
* status: 'Playing'|'Over', seed: number, frame: number}} PongState */
|
||||
|
||||
// The court, in cells. `player` and `cpu` are the y of the top cell of each paddle.
|
||||
export const W = 80
|
||||
export const H = 50
|
||||
/** Pixels per cell. */
|
||||
export const PCELL = 8
|
||||
/** Paddle height in cells. */
|
||||
export const PADDLE = 8
|
||||
/** Column of the player's paddle (left) and the cpu's paddle (right). */
|
||||
export const PLAYER_X = 2
|
||||
export const CPU_X = 77
|
||||
/** Points needed to win the match. */
|
||||
export const WIN = 5
|
||||
|
||||
/**
|
||||
* A fresh match: ball at (40,25) moving (dx 1, dy 1), both paddles at y 21, no keys held,
|
||||
* score 0 to 0, status 'Playing', the given seed, frame 0.
|
||||
*/
|
||||
export const initialPong = (seed) => todo('m11')
|
||||
|
||||
/**
|
||||
* Everything that can happen in a match, as data:
|
||||
* `Press(key)` / `Release(key)` for key 'Up' or 'Down' (`{tag, key}`), and `Frame` / `Restart` (`{tag}`).
|
||||
* The getters only exist so the stub compiles; replace them with plain values.
|
||||
*/
|
||||
export const PongAction = {
|
||||
Press: (key) => todo('m11'),
|
||||
Release: (key) => todo('m11'),
|
||||
get Frame() { return todo('m11') },
|
||||
get Restart() { return todo('m11') },
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { todo } from '../todo.js'
|
||||
|
||||
/** Keep `x` inside `[lo, hi]`. */
|
||||
export const clamp = (lo, hi, x) => todo('m11')
|
||||
|
||||
/**
|
||||
* The one function that says how a match changes: `(state, action) -> state`.
|
||||
*
|
||||
* - `Press(key)` / `Release(key)`: set `held.up` or `held.down` (even when the match is over).
|
||||
* - `Restart`: `initialPong(state.seed)`.
|
||||
* - `Frame`: unchanged unless status is 'Playing'. Otherwise, in this order:
|
||||
* 1. `frame + 1`.
|
||||
* 2. The player moves one cell down for `held.down` and one up for `held.up` (both cancel out),
|
||||
* kept within `[0, H - PADDLE]`.
|
||||
* 3. When the new `frame` is even, the cpu moves one cell toward `ball.y - PADDLE / 2` (it stays put when it is
|
||||
* already there), kept within `[0, H - PADDLE]`. It looks at the ball before the ball moves.
|
||||
* 4. The ball moves vertically: `ny = y + dy`; if `ny < 0 || ny >= H` then `dy` flips and `ny = y + dy`.
|
||||
* 5. Horizontally: `nx = x + dx`. It bounces (`dx` flips, `nx = x`) when `nx === PLAYER_X`, `dx < 0` and
|
||||
* `player <= ny < player + PADDLE` (using the player's new position), or when `nx === CPU_X`, `dx > 0` and
|
||||
* `cpu <= ny < cpu + PADDLE`.
|
||||
* 6. If `nx < 0` the cpu scores; if `nx >= W` the player scores. A point serves a new ball: position (40,25),
|
||||
* `dx` toward the side that conceded (cpu scored -> -1, player scored -> +1), `dy = 1` when
|
||||
* `nextUint32(seed)` is even and `-1` otherwise, and the seed advances.
|
||||
* 7. A score of `WIN` ends the match: status 'Over'.
|
||||
*/
|
||||
export const pongUpdate = (state, action) => todo('m11')
|
||||
@@ -0,0 +1,11 @@
|
||||
import { todo } from '../todo.js'
|
||||
|
||||
/**
|
||||
* Describe the match as shapes, drawn in order (all text is '#ffffff', align 'center'):
|
||||
* background Rect(0,0,640,400,'#111111'); player paddle Rect(PLAYER_X*8, player*8, 8, 64, '#ffffff'), then the
|
||||
* cpu paddle the same way at CPU_X; ball Rect(x*8, y*8, 8, 8, '#ffeb3b');
|
||||
* score Text(320, 30, '<player> <cpu>' (three spaces), size 24).
|
||||
* When Over: Text(320, 200, 'You Win!' if the player has more points, else 'CPU Wins', size 32) and
|
||||
* Text(320, 230, 'Press R to restart', size 16).
|
||||
*/
|
||||
export const pongView = (state) => todo('m11')
|
||||
@@ -0,0 +1,84 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { W, H, PCELL, PADDLE, PLAYER_X, CPU_X, WIN, initialPong, PongAction } from '../src/pong/model.js'
|
||||
import { pongUpdate, clamp } from '../src/pong/rules.js'
|
||||
import { pongView } from '../src/pong/view.js'
|
||||
import { pongKey, FRAME_MS_PONG, pongActions, pongStates, pongMain } from '../src/pong/app.js'
|
||||
import { run } from '../src/stream.js'
|
||||
import { trace } from '../src/io.js'
|
||||
import { loadFixture, freeze } from './helpers.js'
|
||||
|
||||
const fixtureTests = (name, f) => {
|
||||
const fx = loadFixture(name)
|
||||
fx.cases.forEach((c) =>
|
||||
test(`m11 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected)))
|
||||
}
|
||||
|
||||
fixtureTests('m11-pong-update', pongUpdate)
|
||||
fixtureTests('m11-pong-view', pongView)
|
||||
fixtureTests('m11-pong-key', pongKey)
|
||||
|
||||
const time = (t) => ({ tag: 'Time', t })
|
||||
const down = (key) => ({ tag: 'KeyDown', key })
|
||||
const up = (key) => ({ tag: 'KeyUp', key })
|
||||
|
||||
test('m11 the court is 80 by 50 cells of 8 pixels, with 8-cell paddles', () =>
|
||||
assert.deepStrictEqual([W, H, PCELL, PADDLE, PLAYER_X, CPU_X, WIN], [80, 50, 8, 8, 2, 77, 5]))
|
||||
|
||||
test('m11 initialPong puts the ball in the middle, both paddles centred, and nobody ahead', () =>
|
||||
assert.deepStrictEqual(initialPong(5), {
|
||||
ball: { x: 40, y: 25 }, vel: { dx: 1, dy: 1 }, player: 21, cpu: 21,
|
||||
held: { up: false, down: false }, score: { player: 0, cpu: 0 }, status: 'Playing', seed: 5, frame: 0,
|
||||
}))
|
||||
|
||||
test('m11 PongAction builds Press and Release with a key, and has Frame and Restart as plain values', () =>
|
||||
assert.deepStrictEqual(
|
||||
[PongAction.Press('Up'), PongAction.Release('Down'), PongAction.Frame, PongAction.Restart],
|
||||
[{ tag: 'Press', key: 'Up' }, { tag: 'Release', key: 'Down' }, { tag: 'Frame' }, { tag: 'Restart' }]))
|
||||
|
||||
test('m11 clamp keeps a number inside [lo, hi]', () =>
|
||||
assert.deepStrictEqual([clamp(0, 42, -1), clamp(0, 42, 17), clamp(0, 42, 43)], [0, 17, 42]))
|
||||
|
||||
test('m11 pongUpdate does not modify the state it is given', () => {
|
||||
const s = freeze(initialPong(0))
|
||||
assert.doesNotThrow(() => pongUpdate(s, PongAction.Frame))
|
||||
})
|
||||
|
||||
// 3000 frames with no one touching the keys: the ball must always stay on the court and the match must end.
|
||||
test('m11 an idle match stays on the court and ends once someone reaches five', () => {
|
||||
const states = Array.from({ length: 3000 }).reduce(
|
||||
(acc) => [...acc, pongUpdate(acc[acc.length - 1], PongAction.Frame)], [initialPong(3)])
|
||||
assert.ok(states.every((s) => s.ball.y >= 0 && s.ball.y < H && s.ball.x >= 0 && s.ball.x < W))
|
||||
assert.ok(states.every((s) => s.player >= 0 && s.player <= H - PADDLE && s.cpu >= 0 && s.cpu <= H - PADDLE))
|
||||
const last = states[states.length - 1]
|
||||
assert.equal(last.status, 'Over')
|
||||
assert.equal(Math.max(last.score.player, last.score.cpu), WIN)
|
||||
})
|
||||
|
||||
test('m11 the clock runs every 30 ms', () => assert.equal(FRAME_MS_PONG, 30))
|
||||
|
||||
test('m11 pongActions turns the clock into Frames and keys into Press, Release and Restart', () =>
|
||||
assert.deepStrictEqual(
|
||||
run(pongActions, freeze([time(30), down('ArrowUp'), up('ArrowUp'), down('s'), down('r'), up('r'), down('x'), time(90)])),
|
||||
[PongAction.Frame, PongAction.Press('Up'), PongAction.Release('Up'), PongAction.Press('Down'), PongAction.Restart,
|
||||
PongAction.Frame, PongAction.Frame]))
|
||||
|
||||
test('m11 pongStates starts with the new match, then yields one state per action', () => {
|
||||
const out = run(pongStates(42, pongActions), freeze([time(30), down('w'), time(60)]))
|
||||
assert.equal(out.length, 4)
|
||||
assert.deepStrictEqual(out[0], initialPong(42))
|
||||
assert.deepStrictEqual(out.map((s) => s.frame), [0, 1, 1, 2])
|
||||
assert.deepStrictEqual(out.map((s) => s.held.up), [false, false, true, true])
|
||||
assert.deepStrictEqual(out[3].player, 20)
|
||||
})
|
||||
|
||||
test('m11 pongMain asks for a seed, draws the new match first, and then the updated one', () => {
|
||||
const { ops, result: stream } = trace(pongMain, 42)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])
|
||||
const [first, next] = stream.step(time(0))
|
||||
const [second] = next.step(time(30))
|
||||
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), pongView(initialPong(42)))
|
||||
assert.deepStrictEqual(
|
||||
trace(second[0], 0).ops.map((o) => o.shape),
|
||||
pongView(pongUpdate(initialPong(42), PongAction.Frame)))
|
||||
})
|
||||
+15
-3
@@ -47,6 +47,7 @@ try {
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject })
|
||||
const errors = []
|
||||
let loaded = false
|
||||
const pending = new Map()
|
||||
let id = 0
|
||||
const send = (method, params = {}) => new Promise((resolve) => {
|
||||
@@ -56,6 +57,7 @@ try {
|
||||
ws.onmessage = ({ data }) => {
|
||||
const m = JSON.parse(data)
|
||||
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? m.error); pending.delete(m.id) }
|
||||
else if (m.method === 'Page.loadEventFired') loaded = true
|
||||
else if (m.method === 'Runtime.exceptionThrown') {
|
||||
const d = m.params.exceptionDetails
|
||||
errors.push(`exception: ${d.exception?.description ?? d.text}`)
|
||||
@@ -69,7 +71,7 @@ try {
|
||||
await send('Log.enable')
|
||||
await send('Page.enable')
|
||||
await send('Page.navigate', { url })
|
||||
await sleep(1000)
|
||||
for (let i = 0; i < 100 && !loaded; i++) await sleep(100) // wait for the page load event (up to 10s)
|
||||
|
||||
// Does the canvas hold at least one pixel that is not the #111111 background (and not transparent)?
|
||||
const check = `(() => {
|
||||
@@ -80,8 +82,18 @@ try {
|
||||
if (d[i + 3] !== 0 && (d[i] !== 0x11 || d[i + 1] !== 0x11 || d[i + 2] !== 0x11)) return 'painted'
|
||||
return 'blank'
|
||||
})()`
|
||||
const res = await send('Runtime.evaluate', { expression: check, returnByValue: true })
|
||||
const state = res.result?.value ?? `evaluation failed: ${JSON.stringify(res)}`
|
||||
// Poll instead of sleeping a fixed time: the first frame is drawn on the first animation frame after load.
|
||||
const probe = async () => {
|
||||
const res = await send('Runtime.evaluate', { expression: check, returnByValue: true })
|
||||
return res.result?.value ?? `evaluation failed: ${JSON.stringify(res)}`
|
||||
}
|
||||
const poll = async (left) => {
|
||||
const state = await probe()
|
||||
if (state === 'painted' || left === 0) return state
|
||||
await sleep(200)
|
||||
return poll(left - 1)
|
||||
}
|
||||
const state = await poll(25) // every 200 ms, up to 5 s
|
||||
|
||||
const problems = [...(state === 'painted' ? [] : [`canvas: ${state}`]), ...errors]
|
||||
clearTimeout(timer)
|
||||
|
||||
Reference in New Issue
Block a user