feat(scala): m01–m06 port with shared fixtures

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-29 00:07:27 +01:00
co-authored by Claude Sonnet 5.5
parent 045029639e
commit 1e186733d1
23 changed files with 771 additions and 2 deletions
+14
View File
@@ -0,0 +1,14 @@
package snake
/** m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions. */
object Game:
def update(state: GameState, action: Action): GameState = action match
case Action.Tick => Spawn.tick(state)
case Action.Turn(dir) => Rules.turn(state, dir)
case _ => state // TogglePause, Restart and Frame arrive in m10
def replay(state: GameState, actions: List[Action]): GameState = actions.foldLeft(state)(update)
// scanLeft is foldLeft that keeps every intermediate result.
def history(state: GameState, actions: List[Action]): List[GameState] = actions.scanLeft(state)(update)
+60
View File
@@ -0,0 +1,60 @@
package snake
/** A grid cell. Two points with the same `x` and `y` are `==` (case classes compare structurally). */
final case class Point(x: Int, y: Int)
enum Direction:
case Up, Down, Left, Right
enum Status:
case Playing, Paused, GameOver, Won
/** Everything that can happen to the game, as data. */
enum Action:
case Tick
case Turn(dir: Direction)
case TogglePause
case Restart
case Frame
/** The whole game. `snake` is head first; `seed` is the unsigned 32-bit random seed (0 until 2^32). */
final case class GameState(
width: Int,
height: Int,
snake: List[Point],
dir: Direction,
nextDir: Direction,
apple: Option[Point],
seed: Long,
score: Int,
status: Status,
frame: Int
)
/** m02 model: the data the whole game is built from. Case classes and enums are immutable. */
object Model:
def delta(dir: Direction): Point = dir match
case Direction.Up => Point(0, -1)
case Direction.Down => Point(0, 1)
case Direction.Left => Point(-1, 0)
case Direction.Right => Point(1, 0)
def opposite(dir: Direction): Direction = dir match
case Direction.Up => Direction.Down
case Direction.Down => Direction.Up
case Direction.Left => Direction.Right
case Direction.Right => Direction.Left
def initialState(seed: Long): GameState = GameState(
width = 20,
height = 20,
snake = List(Point(10, 10), Point(9, 10), Point(8, 10)),
dir = Direction.Right,
nextDir = Direction.Right,
apple = None,
seed = seed,
score = 0,
status = Status.Playing,
frame = 0
)
+36
View File
@@ -0,0 +1,36 @@
package snake
/**
* m04 random: a tiny "state monad" for randomness. A Random[A] is a function seed => (A, nextSeed),
* so every draw is a pure function and the seed is passed along explicitly instead of hidden.
* Seeds and values are unsigned 32-bit numbers kept in a Long.
*/
final case class Random[A](run: Long => (A, Long)):
def map[B](f: A => B): Random[B] =
Random { seed =>
val (a, next) = run(seed)
(f(a), next)
}
def flatMap[B](f: A => Random[B]): Random[B] =
Random { seed =>
val (a, next) = run(seed)
f(a).run(next)
}
object Random:
def pure[A](a: A): Random[A] = Random(seed => (a, seed))
// The mulberry32 arithmetic is done in Int: `*` wraps like 32-bit multiplication and `>>>` is unsigned.
// `& 0xFFFFFFFFL` turns the result back into an unsigned value held in a Long.
lazy val nextUint32: Random[Long] = Random { seed =>
val s = seed.toInt + 0x6d2b79f5
val t1 = (s ^ (s >>> 15)) * (s | 1)
val t2 = t1 ^ (t1 + (t1 ^ (t1 >>> 7)) * (t1 | 61))
val value = t2 ^ (t2 >>> 14)
(value.toLong & 0xffffffffL, s.toLong & 0xffffffffL)
}
def intBelow(n: Int): Random[Int] = nextUint32.map(v => (v % n).toInt)
+34
View File
@@ -0,0 +1,34 @@
package snake
/** m03 rules: the pure game rules. Each function takes a state and returns a new one. */
object Rules:
def move(p: Point, dir: Direction): Point =
val d = Model.delta(dir)
Point(p.x + d.x, p.y + d.y)
def inBounds(state: GameState, p: Point): Boolean =
p.x >= 0 && p.x < state.width && p.y >= 0 && p.y < state.height
// Validate against `dir` (last moved), not `nextDir`: Up then Left within one tick must not fold the snake back on itself.
def turn(state: GameState, dir: Direction): GameState =
if state.status == Status.Playing && dir != Model.opposite(state.dir) then state.copy(nextDir = dir)
else state
def advance(state: GameState): GameState =
if state.status != Status.Playing then state
else
val d = state.nextDir
val head = move(state.snake.head, d)
val eating = state.apple.contains(head)
// When not eating, the tail leaves its cell this step, so the head may move into it.
val body = if eating then state.snake else Util.dropLast(state.snake)
if !inBounds(state, head) || Util.contains(body, head, _ == _) then
state.copy(dir = d, status = Status.GameOver)
else
state.copy(
snake = head :: body,
dir = d,
apple = if eating then None else state.apple,
score = state.score + (if eating then 1 else 0)
)
+25
View File
@@ -0,0 +1,25 @@
package snake
/** m04 spawn: placing apples with the Random from Random.scala. */
object Spawn:
def freeCells(state: GameState): List[Point] =
for
y <- Util.range(state.height)
x <- Util.range(state.width)
p = Point(x, y)
if !Util.contains(state.snake, p, _ == _)
yield p
def spawnApple(state: GameState): GameState =
if state.apple.isDefined || state.status != Status.Playing then state
else
val cells = freeCells(state)
if cells.isEmpty then state.copy(status = Status.Won)
else
val (i, seed) = Random.intBelow(cells.length).run(state.seed)
state.copy(apple = Some(cells(i)), seed = seed)
def tick(state: GameState): GameState = spawnApple(Rules.advance(state))
def newGame(seed: Long): GameState = spawnApple(Model.initialState(seed))
+10
View File
@@ -0,0 +1,10 @@
package snake
/** m01 util: small helpers used by every later module. */
object Util:
def range(n: Int): List[Int] = List.range(0, n)
def dropLast[A](xs: List[A]): List[A] = xs.dropRight(1)
def contains[A](xs: List[A], p: A, eq: (A, A) => Boolean): Boolean = xs.exists(x => eq(x, p))
+40
View File
@@ -0,0 +1,40 @@
package snake
/** A thing to draw. The picture is plain data; drawing it is somebody else's job (m07). */
enum Shape:
case Rect(x: Int, y: Int, w: Int, h: Int, color: String)
case Text(x: Int, y: Int, text: String, color: String, size: Int, align: String)
/** A picture: shapes drawn in order. */
type Scene = List[Shape]
/** m06 view: the picture is plain data (a list of shapes); drawing it is somebody else's job (m07). */
object View:
val CELL: Int = 20
private def rect(x: Int, y: Int, w: Int, h: Int, color: String): Shape = Shape.Rect(x, y, w, h, color)
private def text(x: Int, y: Int, str: String, size: Int, align: String): Shape =
Shape.Text(x, y, str, "#ffffff", size, align)
// One cell, inset by 1px so neighbouring segments show a gap.
private def cell(p: Point, color: String): Shape = rect(p.x * CELL + 1, p.y * CELL + 1, CELL - 2, CELL - 2, color)
private def overlay(state: GameState): Scene =
val cx = state.width * CELL / 2
val cy = state.height * CELL / 2
def banner(title: String, hint: String): Scene =
List(text(cx, cy, title, 32, "center"), text(cx, cy + 30, hint, 16, "center"))
state.status match
case Status.Paused => banner("Paused", "Press Space to resume")
case Status.GameOver => banner("Game Over", "Press R to restart")
case Status.Won => banner("You Win!", "Press R to restart")
case Status.Playing => Nil
def view(state: GameState): Scene =
List(rect(0, 0, state.width * CELL, state.height * CELL, "#111111")) ++
state.apple.map(cell(_, "#e53935")).toList ++
state.snake.zipWithIndex.map((p, i) => cell(p, if i == 0 then "#7cb342" else "#43a047")) ++
List(text(6, 18, s"Score: ${state.score}", 16, "left")) ++
overlay(state)