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 @@ + + +
+ + +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, '