From 1d1d918805d7ca98f965d73bf185315bda31eb7b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 28 Oct 2026 23:59:25 +0100 Subject: [PATCH] feat(js): m11 pong capstone (model, rules, view, app, page) and polling smoke test Co-Authored-By: Claude Sonnet 5.5 --- fixtures/m11-pong-key.json | 151 +++ fixtures/m11-pong-update.json | 2226 +++++++++++++++++++++++++++++++++ fixtures/m11-pong-view.json | 325 +++++ js/pong.html | 22 + js/solutions/pong/app.js | 31 + js/solutions/pong/main.js | 5 + js/solutions/pong/model.js | 38 + js/solutions/pong/rules.js | 67 + js/solutions/pong/view.js | 24 + js/src/pong/app.js | 24 + js/src/pong/main.js | 5 + js/src/pong/model.js | 36 + js/src/pong/rules.js | 26 + js/src/pong/view.js | 11 + js/test/m11-pong.test.js | 84 ++ scripts/smoke.mjs | 18 +- 16 files changed, 3090 insertions(+), 3 deletions(-) create mode 100644 fixtures/m11-pong-key.json create mode 100644 fixtures/m11-pong-update.json create mode 100644 fixtures/m11-pong-view.json create mode 100644 js/pong.html create mode 100644 js/solutions/pong/app.js create mode 100644 js/solutions/pong/main.js create mode 100644 js/solutions/pong/model.js create mode 100644 js/solutions/pong/rules.js create mode 100644 js/solutions/pong/view.js create mode 100644 js/src/pong/app.js create mode 100644 js/src/pong/main.js create mode 100644 js/src/pong/model.js create mode 100644 js/src/pong/rules.js create mode 100644 js/src/pong/view.js create mode 100644 js/test/m11-pong.test.js diff --git a/fixtures/m11-pong-key.json b/fixtures/m11-pong-key.json new file mode 100644 index 0000000..df9e7e2 --- /dev/null +++ b/fixtures/m11-pong-key.json @@ -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 + } + ] +} diff --git a/fixtures/m11-pong-update.json b/fixtures/m11-pong-update.json new file mode 100644 index 0000000..89bb71c --- /dev/null +++ b/fixtures/m11-pong-update.json @@ -0,0 +1,2226 @@ +{ + "function": "pongUpdate", + "cases": [ + { + "name": "press-up-holds-up", + "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 + }, + { + "tag": "Press", + "key": "Up" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + } + }, + { + "name": "press-down-holds-down", + "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 + }, + { + "tag": "Press", + "key": "Down" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": true + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + } + }, + { + "name": "press-keeps-the-other-key-held", + "args": [ + { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Press", + "key": "Down" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": true + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + } + }, + { + "name": "release-up", + "args": [ + { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": true + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Release", + "key": "Up" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": true + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + } + }, + { + "name": "release-down", + "args": [ + { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": true + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Release", + "key": "Down" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + } + }, + { + "name": "release-of-a-key-not-held-changes-nothing", + "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 + }, + { + "tag": "Release", + "key": "Up" + } + ], + "expected": { + "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 + } + }, + { + "name": "press-is-recorded-even-when-the-match-is-over", + "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": "Over", + "seed": 0, + "frame": 0 + }, + { + "tag": "Press", + "key": "Up" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Over", + "seed": 0, + "frame": 0 + } + }, + { + "name": "frame-moves-the-ball-and-counts-the-frame", + "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 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 26 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "frame-up-key-moves-the-player-up", + "args": [ + { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 26 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "frame-down-key-moves-the-player-down", + "args": [ + { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": true + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 26 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 22, + "cpu": 21, + "held": { + "up": false, + "down": true + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "frame-both-keys-cancel-out", + "args": [ + { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": true + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 26 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": true + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "player-stops-at-the-top-edge", + "args": [ + { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 0, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 26 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 0, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "player-stops-at-the-bottom-edge-42", + "args": [ + { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 42, + "cpu": 21, + "held": { + "up": false, + "down": true + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 26 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 42, + "cpu": 21, + "held": { + "up": false, + "down": true + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "cpu-does-not-move-on-an-odd-frame", + "args": [ + { + "ball": { + "x": 40, + "y": 40 + }, + "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 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 41 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "cpu-moves-down-toward-the-ball-on-an-even-frame", + "args": [ + { + "ball": { + "x": 40, + "y": 40 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 41 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 22, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 2 + } + }, + { + "name": "cpu-moves-down-again-on-frame-four", + "args": [ + { + "ball": { + "x": 40, + "y": 40 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 3 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 41 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 22, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 4 + } + }, + { + "name": "cpu-moves-up-toward-the-ball", + "args": [ + { + "ball": { + "x": 40, + "y": 5 + }, + "vel": { + "dx": 1, + "dy": -1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 4 + }, + "vel": { + "dx": 1, + "dy": -1 + }, + "player": 21, + "cpu": 20, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 2 + } + }, + { + "name": "cpu-stays-when-centred-on-the-ball", + "args": [ + { + "ball": { + "x": 40, + "y": 29 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 25, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 30 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 25, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 2 + } + }, + { + "name": "cpu-stops-at-the-bottom-edge", + "args": [ + { + "ball": { + "x": 40, + "y": 49 + }, + "vel": { + "dx": 1, + "dy": -1 + }, + "player": 21, + "cpu": 42, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 48 + }, + "vel": { + "dx": 1, + "dy": -1 + }, + "player": 21, + "cpu": 42, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 2 + } + }, + { + "name": "cpu-stops-at-the-top-edge", + "args": [ + { + "ball": { + "x": 40, + "y": 2 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 0, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 3 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 0, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 2 + } + }, + { + "name": "ball-bounces-off-the-top-wall", + "args": [ + { + "ball": { + "x": 40, + "y": 0 + }, + "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 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 1 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "ball-bounces-off-the-bottom-wall", + "args": [ + { + "ball": { + "x": 40, + "y": 49 + }, + "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 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 41, + "y": 48 + }, + "vel": { + "dx": 1, + "dy": -1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "ball-bounces-off-the-player-paddle", + "args": [ + { + "ball": { + "x": 3, + "y": 24 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 3, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "player-paddle-catches-the-ball-at-its-last-row", + "args": [ + { + "ball": { + "x": 3, + "y": 26 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 3, + "y": 27 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "ball-just-above-the-player-paddle-is-missed", + "args": [ + { + "ball": { + "x": 3, + "y": 18 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 2, + "y": 19 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "ball-just-below-the-player-paddle-is-missed", + "args": [ + { + "ball": { + "x": 3, + "y": 27 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 2, + "y": 28 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "ball-moving-away-from-the-player-paddle-does-not-bounce", + "args": [ + { + "ball": { + "x": 1, + "y": 24 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 2, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "paddle-bounce-uses-the-player-position-after-it-moved", + "args": [ + { + "ball": { + "x": 3, + "y": 26 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 20, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 2, + "y": 27 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 19, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "ball-bounces-off-the-cpu-paddle", + "args": [ + { + "ball": { + "x": 76, + "y": 32 + }, + "vel": { + "dx": 1, + "dy": -1 + }, + "player": 21, + "cpu": 30, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 76, + "y": 31 + }, + "vel": { + "dx": -1, + "dy": -1 + }, + "player": 21, + "cpu": 30, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "ball-passing-above-the-cpu-paddle-is-missed", + "args": [ + { + "ball": { + "x": 76, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 30, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 77, + "y": 26 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 30, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "ball-moving-away-from-the-cpu-paddle-does-not-bounce", + "args": [ + { + "ball": { + "x": 78, + "y": 32 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 21, + "cpu": 30, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 77, + "y": 33 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 21, + "cpu": 30, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + } + }, + { + "name": "cpu-moves-before-the-ball-meets-its-paddle", + "args": [ + { + "ball": { + "x": 76, + "y": 36 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 29, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 1 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 76, + "y": 37 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 21, + "cpu": 30, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 2 + } + }, + { + "name": "cpu-scores-when-the-ball-leaves-on-the-left-and-serves-toward-the-player", + "args": [ + { + "ball": { + "x": 0, + "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 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 1 + }, + "status": "Playing", + "seed": 1831565813, + "frame": 1 + } + }, + { + "name": "serve-direction-comes-from-the-seed-odd-value-serves-upward", + "args": [ + { + "ball": { + "x": 0, + "y": 25 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 1, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": -1, + "dy": -1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 1 + }, + "status": "Playing", + "seed": 1831565814, + "frame": 1 + } + }, + { + "name": "player-scores-when-the-ball-leaves-on-the-right-and-serves-toward-the-cpu", + "args": [ + { + "ball": { + "x": 79, + "y": 10 + }, + "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 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 1, + "cpu": 0 + }, + "status": "Playing", + "seed": 1831565813, + "frame": 1 + } + }, + { + "name": "scores-add-up", + "args": [ + { + "ball": { + "x": 0, + "y": 25 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 2, + "cpu": 3 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 2, + "cpu": 4 + }, + "status": "Playing", + "seed": 1831565813, + "frame": 1 + } + }, + { + "name": "player-reaching-five-ends-the-match", + "args": [ + { + "ball": { + "x": 79, + "y": 10 + }, + "vel": { + "dx": 1, + "dy": -1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 4, + "cpu": 2 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 5, + "cpu": 2 + }, + "status": "Over", + "seed": 1831565813, + "frame": 1 + } + }, + { + "name": "cpu-reaching-five-ends-the-match", + "args": [ + { + "ball": { + "x": 0, + "y": 25 + }, + "vel": { + "dx": -1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 1, + "cpu": 4 + }, + "status": "Playing", + "seed": 1, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": -1, + "dy": -1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 1, + "cpu": 5 + }, + "status": "Over", + "seed": 1831565814, + "frame": 1 + } + }, + { + "name": "frame-when-over-changes-nothing", + "args": [ + { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 5, + "cpu": 1 + }, + "status": "Over", + "seed": 0, + "frame": 0 + }, + { + "tag": "Frame" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": true, + "down": false + }, + "score": { + "player": 5, + "cpu": 1 + }, + "status": "Over", + "seed": 0, + "frame": 0 + } + }, + { + "name": "restart-starts-a-new-match-with-the-same-seed", + "args": [ + { + "ball": { + "x": 12, + "y": 9 + }, + "vel": { + "dx": -1, + "dy": -1 + }, + "player": 5, + "cpu": 30, + "held": { + "up": true, + "down": true + }, + "score": { + "player": 3, + "cpu": 2 + }, + "status": "Playing", + "seed": 7, + "frame": 99 + }, + { + "tag": "Restart" + } + ], + "expected": { + "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": 7, + "frame": 0 + } + }, + { + "name": "restart-after-the-match-is-over", + "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": 1831565813, + "frame": 321 + }, + { + "tag": "Restart" + } + ], + "expected": { + "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": 1831565813, + "frame": 0 + } + } + ] +} diff --git a/fixtures/m11-pong-view.json b/fixtures/m11-pong-view.json new file mode 100644 index 0000000..d2e0f1c --- /dev/null +++ b/fixtures/m11-pong-view.json @@ -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" + } + ] + } + ] +} diff --git a/js/pong.html b/js/pong.html new file mode 100644 index 0000000..253a345 --- /dev/null +++ b/js/pong.html @@ -0,0 +1,22 @@ + + + + + + Pure Pong + + + + +

Pure Pong

+ +

Up/Down arrows (or W/S) move your paddle. First to 5 wins. R restarts.

+ + + diff --git a/js/solutions/pong/app.js b/js/solutions/pong/app.js new file mode 100644 index 0000000..0415419 --- /dev/null +++ b/js/solutions/pong/app.js @@ -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)))) diff --git a/js/solutions/pong/main.js b/js/solutions/pong/main.js new file mode 100644 index 0000000..bcd6f45 --- /dev/null +++ b/js/solutions/pong/main.js @@ -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)) diff --git a/js/solutions/pong/model.js b/js/solutions/pong/model.js new file mode 100644 index 0000000..216bfe3 --- /dev/null +++ b/js/solutions/pong/model.js @@ -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' }, +} diff --git a/js/solutions/pong/rules.js b/js/solutions/pong/rules.js new file mode 100644 index 0000000..25d7117 --- /dev/null +++ b/js/solutions/pong/rules.js @@ -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 + } +} diff --git a/js/solutions/pong/view.js b/js/solutions/pong/view.js new file mode 100644 index 0000000..9ff9fc9 --- /dev/null +++ b/js/solutions/pong/view.js @@ -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), +] diff --git a/js/src/pong/app.js b/js/src/pong/app.js new file mode 100644 index 0000000..dd3e177 --- /dev/null +++ b/js/src/pong/app.js @@ -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') }) diff --git a/js/src/pong/main.js b/js/src/pong/main.js new file mode 100644 index 0000000..bcd6f45 --- /dev/null +++ b/js/src/pong/main.js @@ -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)) diff --git a/js/src/pong/model.js b/js/src/pong/model.js new file mode 100644 index 0000000..339caef --- /dev/null +++ b/js/src/pong/model.js @@ -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') }, +} diff --git a/js/src/pong/rules.js b/js/src/pong/rules.js new file mode 100644 index 0000000..65140b3 --- /dev/null +++ b/js/src/pong/rules.js @@ -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') diff --git a/js/src/pong/view.js b/js/src/pong/view.js new file mode 100644 index 0000000..08aee08 --- /dev/null +++ b/js/src/pong/view.js @@ -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, ' ' (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') diff --git a/js/test/m11-pong.test.js b/js/test/m11-pong.test.js new file mode 100644 index 0000000..6f373e5 --- /dev/null +++ b/js/test/m11-pong.test.js @@ -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))) +}) diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index c0f82c3..2415b40 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -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)