feat(scala): m01–m06 port with shared fixtures
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
045029639e
commit
1e186733d1
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user