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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 00:15:33 +01:00
1 parent c6ecb7c013
commit f4b671678e
10 files changed
+479 -1

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
package snake
package pong
import PongModel.initialPong
/** m11 pong app: the same wiring as App.scala (streams in, drawings out), reusing the course's modules. */
object PongApp:
val FRAME_MS_PONG: Long = 30
private def paddleKey(key: String): Option[Paddle] = key match
case "ArrowUp" | "w" | "W" => Some(Paddle.Up)
case "ArrowDown" | "s" | "S" => Some(Paddle.Down)
case _ => None
def pongKey(isDown: Boolean, key: String): Option[PongAction] =
paddleKey(key) match
case Some(p) => Some(if isDown then PongAction.Press(p) else PongAction.Release(p))
case None => if isDown && (key == "r" || key == "R") then Some(PongAction.Restart) else None
lazy val pongActions: Stream[PongAction] =
Stream
.interval(FRAME_MS_PONG)
.map(_ => PongAction.Frame)
.merge(Stream.keyDowns.filterMap(pongKey(true, _)))
.merge(Stream.keyUps.filterMap(pongKey(false, _)))
def pongStates(seed: Long, actions: Stream[PongAction]): Stream[PongState] =
actions.scan(initialPong(seed))(PongRules.pongUpdate).startWith(initialPong(seed))
lazy val pongMain: IO[Stream[IO[Unit]]] =
IO.randomSeed.map(seed => pongStates(seed, pongActions).map(state => IO.drawScene(PongView.pongView(state))))
+73
View File
@@ -0,0 +1,73 @@
package snake
package pong
// m11 pong model: the same ideas as the snake model, for a different game.
/** The ball's direction of travel, one cell per frame on each axis. */
final case class Velocity(dx: Int, dy: Int)
/** Which of the player's two keys are being held down right now. */
final case class Held(up: Boolean, down: Boolean)
final case class Score(player: Int, cpu: Int)
enum PongStatus:
case Playing, Over
/** The two keys that move the player's paddle. */
enum Paddle:
case 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.
*/
final case class PongState(
ball: Point,
vel: Velocity,
player: Int,
cpu: Int,
held: Held,
score: Score,
status: PongStatus,
seed: Long,
frame: Int
)
/** Everything that can happen in a match, as data. */
enum PongAction:
case Press(key: Paddle)
case Release(key: Paddle)
case Frame
case Restart
object PongModel:
// The court, in cells.
val W: Int = 80
val H: Int = 50
/** Pixels per cell. */
val PCELL: Int = 8
/** Paddle height in cells. */
val PADDLE: Int = 8
/** Column of the player's paddle (left) and of the cpu's paddle (right). */
val PLAYER_X: Int = 2
val CPU_X: Int = 77
/** Points needed to win the match. */
val WIN: Int = 5
def initialPong(seed: Long): PongState = PongState(
ball = Point(40, 25),
vel = Velocity(1, 1),
player = 21,
cpu = 21,
held = Held(up = false, down = false),
score = Score(player = 0, cpu = 0),
status = PongStatus.Playing,
seed = seed,
frame = 0
)
+71
View File
@@ -0,0 +1,71 @@
package snake
package pong
import PongModel.*
/** m11 pong rules: one pure `pongUpdate`, built from small steps that each return a new piece of state. */
object PongRules:
def clamp(lo: Int, hi: Int, x: Int): Int = math.min(hi, math.max(lo, x))
private def clampPaddle(y: Int): Int = clamp(0, H - PADDLE, y)
private def movePlayer(state: PongState): Int =
clampPaddle(state.player + (if state.held.down then 1 else 0) - (if state.held.up then 1 else 0))
// The cpu chases the middle of its paddle toward the ball, one cell every other frame.
private def moveCpu(state: PongState, frame: Int): Int =
if frame % 2 == 0 then clampPaddle(state.cpu + (state.ball.y - PADDLE / 2 - state.cpu).sign)
else state.cpu
private def hits(paddle: Int, y: Int): Boolean = paddle <= y && y < paddle + PADDLE
private enum Side:
case Player, Cpu
// Where the ball goes next, given the paddles' new positions, and who scored, if anyone.
private def moveBall(state: PongState, player: Int, cpu: Int): (Point, Velocity, Option[Side]) =
val (ball, vel) = (state.ball, state.vel)
val wall = ball.y + vel.dy < 0 || ball.y + vel.dy >= H
val dy = if wall then -vel.dy else vel.dy
val ny = ball.y + dy
val nx = ball.x + vel.dx
val bounce =
(nx == PLAYER_X && vel.dx < 0 && hits(player, ny)) ||
(nx == CPU_X && vel.dx > 0 && hits(cpu, ny))
val scorer = if nx < 0 then Some(Side.Cpu) else if nx >= W then Some(Side.Player) else None
(Point(if bounce then ball.x else nx, ny), Velocity(if bounce then -vel.dx else vel.dx, dy), scorer)
// After a point the ball restarts in the middle, heading toward whoever conceded; the seed picks up or down.
private def serve(state: PongState, scorer: Side): PongState =
val (r, seed) = Random.nextUint32.run(state.seed)
val score = scorer match
case Side.Player => state.score.copy(player = state.score.player + 1)
case Side.Cpu => state.score.copy(cpu = state.score.cpu + 1)
state.copy(
ball = Point(40, 25),
vel = Velocity(if scorer == Side.Cpu then -1 else 1, if r % 2 == 0 then 1 else -1),
score = score,
seed = seed,
status = if math.max(score.player, score.cpu) >= WIN then PongStatus.Over else PongStatus.Playing
)
private def frame(state: PongState): PongState =
if state.status != PongStatus.Playing then state
else
val n = state.frame + 1
val player = movePlayer(state)
val cpu = moveCpu(state, n)
val (ball, vel, scorer) = moveBall(state, player, cpu)
val next = state.copy(ball = ball, vel = vel, player = player, cpu = cpu, frame = n)
scorer.fold(next)(serve(next, _))
private def setHeld(state: PongState, key: Paddle, value: Boolean): PongState = key match
case Paddle.Up => state.copy(held = state.held.copy(up = value))
case Paddle.Down => state.copy(held = state.held.copy(down = value))
def pongUpdate(state: PongState, action: PongAction): PongState = action match
case PongAction.Press(key) => setHeld(state, key, true)
case PongAction.Release(key) => setHeld(state, key, false)
case PongAction.Restart => initialPong(state.seed)
case PongAction.Frame => frame(state)
+29
View File
@@ -0,0 +1,29 @@
package snake
package pong
import PongModel.*
/** m11 pong view: the same Scene shapes as the snake view, so the same drawScene and runtime draw it. */
object PongView:
private def text(x: Int, y: Int, str: String, size: Int): Shape = Shape.Text(x, y, str, "#ffffff", size, "center")
private def paddle(column: Int, y: Int): Shape =
Shape.Rect(column * PCELL, y * PCELL, PCELL, PADDLE * PCELL, "#ffffff")
private def overlay(state: PongState): Scene = state.status match
case PongStatus.Over =>
List(
text(320, 200, if state.score.player > state.score.cpu then "You Win!" else "CPU Wins", 32),
text(320, 230, "Press R to restart", 16)
)
case PongStatus.Playing => Nil
def pongView(state: PongState): Scene =
List(
Shape.Rect(0, 0, 640, 400, "#111111"),
paddle(PLAYER_X, state.player),
paddle(CPU_X, state.cpu),
Shape.Rect(state.ball.x * PCELL, state.ball.y * PCELL, PCELL, PCELL, "#ffeb3b"),
text(320, 30, s"${state.score.player} ${state.score.cpu}", 24)
) ++ overlay(state)