feat(fsharp): m11 pong capstone (model, rules, view, app) with tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 01:00:29 +01:00
co-authored by Claude Opus 5.5
parent 038a375027
commit 1d0b8c3a5c
12 changed files with 524 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
/// m11 pong app: the same wiring as App.fs (streams in, drawings out), reusing the course's modules.
module Snake.Pong.App
open Snake
open Snake.IO
open Snake.Stream
open Snake.Pong.Model
open Snake.Pong.Rules
open Snake.Pong.View
/// Milliseconds between frames of the match.
let FRAME_MS_PONG = 30L
/// Translate a key event into a `PongAction`, if any. `isDown` is true for a key press, false for a release.
/// "ArrowUp"/"w"/"W" -> `Press Paddle.Up` / `Release Paddle.Up`, "ArrowDown"/"s"/"S" -> `Press Paddle.Down` /
/// `Release Paddle.Down`, "r"/"R" -> `Restart` (on press only; releasing r does nothing). Any other key -> `None`.
let pongKey (isDown: bool) (key: string) : PongAction option = failwith "TODO m11"
/// Everything that can happen in a match: `Frame` every FRAME_MS_PONG (from `Stream.interval`), merged with the
/// `pongKey true key` of every key press and the `pongKey false key` of every key release.
/// (The stub is wrapped in `Stream` so the TODO only fires when the stream is stepped.)
let pongActions: Stream<PongAction> = Stream(fun _ -> failwith "TODO m11")
/// Fold the actions into a stream of states: start from `initialPong seed`, `pongUpdate` for each action, emit the initial state first.
let pongStates (seed: uint32) (actions: Stream<PongAction>) : Stream<PongState> = failwith "TODO m11"
/// The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings
/// of `pongView`. A function of `unit`, like `App.main`.
let pongMain () : IO<Stream<IO<unit>>> = failwith "TODO m11"
+68
View File
@@ -0,0 +1,68 @@
/// m11 pong model: the same ideas as the snake model, for a different game.
module Snake.Pong.Model
open Snake.Model
/// The ball's direction of travel, one cell per frame on each axis.
type Velocity = { Dx: int; Dy: int }
/// Which of the player's two keys are being held down right now.
type Held = { Up: bool; Down: bool }
type Score = { Player: int; Cpu: int }
// RequireQualifiedAccess: write `PongStatus.Playing`, `Paddle.Up`, `PongAction.Frame`, so these cases never get
// mixed up with the snake's `Playing`, `Up` and `Frame`.
[<RequireQualifiedAccess>]
type PongStatus =
| Playing
| Over
/// The two keys that move the player's paddle.
[<RequireQualifiedAccess>]
type Paddle =
| Up
| Down
/// A whole match. Positions are in cells: `Ball` is a cell of the 80x50 court; `Player` and `Cpu` are the y of the
/// top cell of each paddle. `Seed` is the unsigned 32-bit random seed used to pick the direction of each serve.
type PongState =
{ Ball: Point
Vel: Velocity
Player: int
Cpu: int
Held: Held
Score: Score
Status: PongStatus
Seed: uint32
Frame: int }
/// Everything that can happen in a match, as data.
[<RequireQualifiedAccess>]
type PongAction =
| Press of Paddle
| Release of Paddle
| Frame
| Restart
// The court, in cells.
let W = 80
let H = 50
/// Pixels per cell.
let PCELL = 8
/// Paddle height in cells.
let PADDLE = 8
/// Column of the player's paddle (left) and of the cpu's paddle (right).
let PLAYER_X = 2
let CPU_X = 77
/// Points needed to win the match.
let 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.
/// (Hint: PongState also has `Player` and `Cpu` fields, so write the score as `{ Score.Player = 0; Cpu = 0 }`.)
let initialPong (seed: uint32) : PongState = failwith "TODO m11"
+29
View File
@@ -0,0 +1,29 @@
/// m11 pong rules: one pure `pongUpdate`.
module Snake.Pong.Rules
open Snake
open Snake.Model
open Snake.Pong.Model
/// Keep `x` inside `[lo, hi]`.
let clamp (lo: int) (hi: int) (x: int) : int = failwith "TODO m11"
/// The one function that says how a match changes: `state -> action -> state`.
///
/// - `Press key` / `Release key`: set `Held.Up` or `Held.Down` to true / false (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 the value of
/// `Random.run seed Random.nextUint32` is even and `-1` otherwise, and the seed advances.
/// 7. A score of `WIN` ends the match: status Over.
let pongUpdate (state: PongState) (action: PongAction) : PongState = failwith "TODO m11"
+13
View File
@@ -0,0 +1,13 @@
/// m11 pong view: the same Scene shapes as the snake view, so the same drawScene and runtime draw it.
module Snake.Pong.View
open Snake.View
open Snake.Pong.Model
/// Describe the match as shapes, drawn in order (all text is "#ffffff", align "center"):
/// background `Rect(0, 0, 640, 400, "#111111")`; the player paddle `Rect(PLAYER_X*8, Player*8, 8, 64, "#ffffff")`,
/// then the cpu paddle the same way at CPU_X; the ball `Rect(x*8, y*8, 8, 8, "#ffeb3b")`;
/// the score `Text(320, 30, "<player> <cpu>" (three spaces), size 24)`.
/// When Over: `Text(320, 200, "You Win!" if the player has more points, else "CPU Wins", size 32)` and
/// `Text(320, 230, "Press R to restart", size 16)`.
let pongView (state: PongState) : Scene = failwith "TODO m11"
+4
View File
@@ -18,5 +18,9 @@
<Compile Include="Input.fs" />
<Compile Include="App.fs" />
<Compile Include="Runtime.fs" />
<Compile Include="Pong/Model.fs" />
<Compile Include="Pong/Rules.fs" />
<Compile Include="Pong/View.fs" />
<Compile Include="Pong/App.fs" />
</ItemGroup>
</Project>