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:
1 parent
c6ecb7c013
commit
f4b671678e
10 files changed
+479
-1
No files matched your search
@@ -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))))
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user