diff --git a/scala/solutions/App.scala b/scala/solutions/App.scala new file mode 100644 index 0000000..8c73077 --- /dev/null +++ b/scala/solutions/App.scala @@ -0,0 +1,25 @@ +package snake + +/** m09 app: the whole game wired together as values: streams of actions in, a stream of drawings out. */ +object App: + + val TICK_MS: Long = 150 + val FRAME_MS: Long = 25 + + // Everything that can happen to the game: a Tick every TICK_MS, and an action for each bound key. + lazy val classicActions: Stream[Action] = + Stream.interval(TICK_MS).map(_ => Action.Tick).merge(Stream.keyDowns.filterMap(Input.keyToAction)) + + // m10: a Frame every FRAME_MS instead of a Tick; the game itself decides (movesEvery) which frames take a step. + lazy val speedActions: Stream[Action] = + Stream.interval(FRAME_MS).map(_ => Action.Frame).merge(Stream.keyDowns.filterMap(Input.keyToAction)) + + // Actions folded into game states. startWith puts the initial state first so it is drawn right away. + def states(seed: Long, actions: Stream[Action]): Stream[GameState] = + actions.scan(Spawn.newGame(seed))(Game.update).startWith(Spawn.newGame(seed)) + + def render(state: GameState): IO[Unit] = IO.drawScene(View.view(state)) + + // The whole program as a value: get a seed, then turn the stream of states into a stream of drawings. + // Nothing runs here; Runtime interprets it. + lazy val main: IO[Stream[IO[Unit]]] = IO.randomSeed.map(seed => states(seed, speedActions).map(render)) diff --git a/scala/solutions/Game.scala b/scala/solutions/Game.scala index cb43e6e..43985c2 100644 --- a/scala/solutions/Game.scala +++ b/scala/solutions/Game.scala @@ -1,12 +1,34 @@ package snake -/** m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions. */ +/** + * m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions. + * m10 adds pause, restart and a frame clock that speeds up as the score grows. + */ object Game: + /** How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2. */ + def movesEvery(score: Int): Int = math.max(2, 8 - score / 3) + + // One animation frame: count it, and every `movesEvery(score)`-th frame run a game step. + private def frame(state: GameState): GameState = + if state.status != Status.Playing then state + else + val counted = state.copy(frame = state.frame + 1) + if counted.frame % movesEvery(counted.score) == 0 then Spawn.tick(counted) else counted + + // Pause and resume only make sense in those two statuses; a finished game stays finished. + private def togglePause(state: GameState): GameState = state.status match + case Status.Playing => state.copy(status = Status.Paused) + case Status.Paused => state.copy(status = Status.Playing) + case Status.GameOver | Status.Won => state + + // Every case is listed (no `case _`): adding an Action later makes the compiler point here. 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 + case Action.Tick => Spawn.tick(state) + case Action.Turn(dir) => Rules.turn(state, dir) + case Action.TogglePause => togglePause(state) + case Action.Restart => Spawn.newGame(state.seed) + case Action.Frame => frame(state) def replay(state: GameState, actions: List[Action]): GameState = actions.foldLeft(state)(update) diff --git a/scala/solutions/Input.scala b/scala/solutions/Input.scala new file mode 100644 index 0000000..db44553 --- /dev/null +++ b/scala/solutions/Input.scala @@ -0,0 +1,14 @@ +package snake + +/** m09 input: from keyboard keys to game actions. */ +object Input: + + // A pattern match on string literals is the lookup table; `|` lists the alternatives. + def keyToAction(key: String): Option[Action] = key match + case "ArrowUp" | "w" | "W" => Some(Action.Turn(Direction.Up)) + case "ArrowDown" | "s" | "S" => Some(Action.Turn(Direction.Down)) + case "ArrowLeft" | "a" | "A" => Some(Action.Turn(Direction.Left)) + case "ArrowRight" | "d" | "D" => Some(Action.Turn(Direction.Right)) + case " " => Some(Action.TogglePause) // m10 + case "r" | "R" => Some(Action.Restart) // m10 + case _ => None diff --git a/scala/solutions/Runtime.scala b/scala/solutions/Runtime.scala new file mode 100644 index 0000000..a6e43b7 --- /dev/null +++ b/scala/solutions/Runtime.scala @@ -0,0 +1,42 @@ +package snake + +/** The outside world, as the runtime sees it. */ +trait Platform: + def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit + def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit + def randomSeed(): Long + def startClock(onTime: Long => Unit): Unit + def onKeyDown(cb: String => Unit): Unit + def onKeyUp(cb: String => Unit): Unit + +/** + * m09 runtime: the single impure place in the course (with the web/ glue); everything else only builds values. + * Here we interpret IO descriptions against a Platform and hold the one mutable variable, the current stream. + */ +object Runtime: + + // Perform one op on the platform and return its answer. + private def perform(op: Op, p: Platform): OpResult = op match + case Op.RandomSeed => OpResult.Seed(p.randomSeed()) + case Op.Draw(Shape.Rect(x, y, w, h, color)) => + p.fillRect(x, y, w, h, color) + OpResult.Done + case Op.Draw(Shape.Text(x, y, text, color, size, align)) => + p.fillText(x, y, text, color, size, align) + OpResult.Done + + // Tail recursive, so it compiles to a loop: a scene is hundreds of ops and the stack must not grow with them. + @annotation.tailrec + def runIO[A](io: IO[A], p: Platform): A = io match + case IO.Pure(a) => a + case IO.Effect(op, k) => runIO(k(perform(op, p)), p) + + def runApp(main: IO[Stream[IO[Unit]]], p: Platform): Unit = + var stream = runIO(main, p) + def feed(event: Event): Unit = + val (ios, next) = stream.step(event) + stream = next + ios.foreach(runIO(_, p)) + p.onKeyDown(key => feed(Event.KeyDown(key))) + p.onKeyUp(key => feed(Event.KeyUp(key))) + p.startClock(t => feed(Event.Time(t))) diff --git a/scala/src/App.scala b/scala/src/App.scala new file mode 100644 index 0000000..3e21eb3 --- /dev/null +++ b/scala/src/App.scala @@ -0,0 +1,37 @@ +package snake + +/** m09 app: the whole game wired together as values: streams of actions in, a stream of drawings out. */ +object App: + + /** Milliseconds between game steps. */ + val TICK_MS: Long = 150 + + /** Milliseconds between animation frames (m10). */ + val FRAME_MS: Long = 25 + + /** + * Stream of everything that can happen to the game: `Action.Tick` every TICK_MS (from `Stream.interval`), + * merged with the action for each key press that `Input.keyToAction` recognises (from `Stream.keyDowns`). + */ + lazy val classicActions: Stream[Action] = ??? + + /** + * (m10) Like `classicActions`, but the clock emits `Action.Frame` every FRAME_MS instead of `Tick` every TICK_MS; + * key presses are translated by `Input.keyToAction` as before (now including pause and restart). + */ + lazy val speedActions: Stream[Action] = ??? + + /** + * Fold a stream of actions into a stream of game states: start from `Spawn.newGame(seed)`, apply `Game.update` to + * each action (scan), and emit the initial state first (startWith) so there is something to draw before anything happens. + */ + def states(seed: Long, actions: Stream[Action]): Stream[GameState] = ??? + + /** The drawing of one state, as an IO: `IO.drawScene(View.view(state))`. */ + def render(state: GameState): IO[Unit] = ??? + + /** + * The whole program as a value: ask for a random seed, then turn the stream of states (built from `classicActions` + * in m09, from `speedActions` after m10) into a stream of drawings (`render`). Nothing runs here. + */ + lazy val main: IO[Stream[IO[Unit]]] = ??? diff --git a/scala/src/Game.scala b/scala/src/Game.scala index 39347a3..1dfa3cc 100644 --- a/scala/src/Game.scala +++ b/scala/src/Game.scala @@ -3,11 +3,23 @@ package snake /** m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions. */ object Game: + /** + * How many frames pass between game steps (m10): `max(2, 8 - score / 3)` (integer division). + * Score 0-2 -> 8, 3-5 -> 7, ..., 18 and up -> 2. + */ + def movesEvery(score: Int): Int = ??? + /** * The one function that says how the game changes: `(state, action) => state`. * - * `Tick` runs a game step (`Spawn.tick`), `Turn` asks to change direction (`Rules.turn`); - * every other action returns the state unchanged (m10 gives them meaning). + * m05: `Tick` runs a game step (`Spawn.tick`), `Turn` asks to change direction (`Rules.turn`); + * every other action returns the state unchanged. + * + * (m10) `TogglePause` switches Playing <-> Paused and does nothing in any other status. + * (m10) `Restart` gives `Spawn.newGame(state.seed)`, whatever the status. + * (m10) `Frame` does nothing unless Playing; otherwise it adds 1 to `frame`, and when the new `frame` is a multiple of + * `movesEvery(score)` it also runs a game step (`Spawn.tick`) on that incremented state. + * In m10, match every case of `Action` explicitly (no `case _`), so the compiler tells you if one is forgotten. */ def update(state: GameState, action: Action): GameState = ??? diff --git a/scala/src/Input.scala b/scala/src/Input.scala new file mode 100644 index 0000000..99628b7 --- /dev/null +++ b/scala/src/Input.scala @@ -0,0 +1,12 @@ +package snake + +/** m09 input: from keyboard keys to game actions. */ +object Input: + + /** + * Translate a keyboard key name (as in the browser's `KeyboardEvent.key`) into the action it triggers, if any. + * m09: "ArrowUp"/"w"/"W" -> `Action.Turn(Direction.Up)`, "ArrowDown"/"s"/"S" -> Down, "ArrowLeft"/"a"/"A" -> Left, + * "ArrowRight"/"d"/"D" -> Right. Any other key -> `None`. + * (m10) " " (Space) -> `Action.TogglePause`, "r"/"R" -> `Action.Restart`. + */ + def keyToAction(key: String): Option[Action] = ??? diff --git a/scala/src/Runtime.scala b/scala/src/Runtime.scala new file mode 100644 index 0000000..fc56290 --- /dev/null +++ b/scala/src/Runtime.scala @@ -0,0 +1,37 @@ +package snake + +/** + * The outside world, as the runtime sees it. The browser implements it with a canvas (web/BrowserPlatform.scala); + * tests implement it with a fake that records calls. + */ +trait Platform: + def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit + def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit + /** A random unsigned 32-bit seed. */ + def randomSeed(): Long + /** Call `onTime(t)` again and again, `t` = milliseconds since the clock started. */ + def startClock(onTime: Long => Unit): Unit + def onKeyDown(cb: String => Unit): Unit + def onKeyUp(cb: String => Unit): Unit + +/** + * m09 runtime: the single impure place in the course (with the web/ glue); everything else only builds values. + * Here a `var` is allowed (exactly one). Keep it tiny. + */ +object Runtime: + + /** + * Interpret an IO against a platform and return its final value. + * `Draw` of a Rect -> `p.fillRect(x, y, w, h, color)` and of a Text -> `p.fillText(x, y, text, color, size, align)`, + * both answered with `OpResult.Done`; `RandomSeed` -> `OpResult.Seed(p.randomSeed())`. Each answer is passed to `k`. + * Must not grow the stack with the number of ops: a loop, or a `@annotation.tailrec` function (which the compiler + * turns into a loop). + */ + def runIO[A](io: IO[A], p: Platform): A = ??? + + /** + * Run the program: `runIO(main, p)` gives the stream; keep the current stream in the one `var`. + * Register `p.onKeyDown` / `p.onKeyUp` / `p.startClock` so that each event steps the stream with the matching + * `Event` (`KeyDown(key)`, `KeyUp(key)`, `Time(t)`), stores the next stream, and runs every IO the step emitted, in order. + */ + def runApp(main: IO[Stream[IO[Unit]]], p: Platform): Unit = ??? diff --git a/scala/test/M09App.test.scala b/scala/test/M09App.test.scala new file mode 100644 index 0000000..51e37c1 --- /dev/null +++ b/scala/test/M09App.test.scala @@ -0,0 +1,56 @@ +package snake + +import M09Helpers.{classicMain, drawn} + +class M09AppSuite extends munit.FunSuite: + private def time(t: Long): Event = Event.Time(t) + private def down(key: String): Event = Event.KeyDown(key) + + test("the game ticks every 150 ms") { assertEquals(App.TICK_MS, 150L) } + + test("classicActions turns the clock into Tick actions and keys into Turn actions") { + assertEquals( + App.classicActions.run(List(time(150), down("ArrowUp"), down("x"), time(300))), + List(Action.Tick, Action.Turn(Direction.Up), Action.Tick) + ) + } + + test("states starts with the new game, then yields one state per action") { + val out = App.states(42, App.classicActions).run(List(time(150), down("ArrowUp"), time(300))) + assertEquals(out.length, 4) + assertEquals(out.head, Spawn.newGame(42)) + assertEquals(out.map(_.snake.head), List(Point(10, 10), Point(11, 10), Point(11, 10), Point(11, 9))) + assertEquals(out.map(_.nextDir), List(Direction.Right, Direction.Right, Direction.Up, Direction.Up)) + assertEquals(out.map(_.dir), List(Direction.Right, Direction.Right, Direction.Right, Direction.Up)) + } + + test("render draws the whole view of the state") { + assertEquals(IO.trace(App.render(Spawn.newGame(42)), 0).ops, View.view(Spawn.newGame(42)).map(Op.Draw(_))) + } + + test("main asks for a random seed, then builds the stream from it") { + val traced = IO.trace(App.main, 42) + assertEquals(traced.ops, List(Op.RandomSeed)) + val (outs, _) = traced.result.step(time(0)) + assertEquals(outs.length, 1) + assertEquals(drawn(outs.head), View.view(Spawn.newGame(42))) + } + + // `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions. + test("the program built from classicActions draws the new game first, then the ticked game after 150 ms") { + val stream = IO.trace(classicMain, 42).result + val (first, next) = stream.step(time(0)) + val (second, _) = next.step(time(150)) + assertEquals(drawn(first.head), View.view(Spawn.newGame(42))) + assertEquals(second.length, 1) + assertNotEquals(drawn(second.head), drawn(first.head)) + } + + // Holds before and after m10: whatever `main` ticks with, the clock must be wired in, so time passing produces more drawings. + test("main draws again once time has passed") { + val stream = IO.trace(App.main, 42).result + val (first, next) = stream.step(time(0)) + val (later, _) = next.step(time(150)) + assertEquals(first.length, 1) + assert(later.nonEmpty) + } diff --git a/scala/test/M09Helpers.test.scala b/scala/test/M09Helpers.test.scala new file mode 100644 index 0000000..2132766 --- /dev/null +++ b/scala/test/M09Helpers.test.scala @@ -0,0 +1,11 @@ +package snake + +/** Test helpers shared by the m09 suites. */ +object M09Helpers: + + /** The m09 program built from classicActions (ticks every 150 ms). The shipped `main` switches to speedActions in m10. */ + lazy val classicMain: IO[Stream[IO[Unit]]] = + IO.randomSeed.map(seed => App.states(seed, App.classicActions).map(App.render)) + + /** The shapes an IO draws, in order (interpreted by `IO.trace`). */ + def drawn(io: IO[Unit]): List[Shape] = IO.trace(io, 0).ops.collect { case Op.Draw(shape) => shape } diff --git a/scala/test/M09Input.test.scala b/scala/test/M09Input.test.scala new file mode 100644 index 0000000..9144aa5 --- /dev/null +++ b/scala/test/M09Input.test.scala @@ -0,0 +1,10 @@ +package snake + +import Fixtures.* + +class M09InputSuite extends munit.FunSuite: + cases("m09-input").foreach { c => + test(s"keyToAction: ${c("name").str}") { + assertEquals(Input.keyToAction(c("args")(0).str), opt(c("expected"))(action)) + } + } diff --git a/scala/test/M09Runtime.test.scala b/scala/test/M09Runtime.test.scala new file mode 100644 index 0000000..b77dbb0 --- /dev/null +++ b/scala/test/M09Runtime.test.scala @@ -0,0 +1,112 @@ +package snake + +import scala.collection.mutable.ListBuffer + +/** A fake platform: records every call and lets the test fire events by hand. */ +class FakePlatform(seed: Long = 42) extends Platform: + val calls: ListBuffer[List[Any]] = ListBuffer.empty + private var onTime: Long => Unit = _ => () + private var onDown: String => Unit = _ => () + private var onUp: String => Unit = _ => () + + def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit = calls += List("fillRect", x, y, w, h, color) + def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit = + calls += List("fillText", x, y, text, color, size, align) + def randomSeed(): Long = + calls += List("randomSeed") + seed + def startClock(cb: Long => Unit): Unit = onTime = cb + def onKeyDown(cb: String => Unit): Unit = onDown = cb + def onKeyUp(cb: String => Unit): Unit = onUp = cb + + def fireTime(t: Long): Unit = onTime(t) + def fireKey(key: String): Unit = onDown(key) + def fireKeyUp(key: String): Unit = onUp(key) + +class M09RuntimeSuite extends munit.FunSuite: + // The m09 program (classicMain) ticks every 150 ms; the shipped `main` uses speedActions after m10. + private def main = M09Helpers.classicMain + + // What the platform should have been asked to draw for a scene. + private def expectedCalls(scene: Scene): List[List[Any]] = scene.map { + case Shape.Rect(x, y, w, h, color) => List("fillRect", x, y, w, h, color) + case Shape.Text(x, y, text, color, size, align) => List("fillText", x, y, text, color, size, align) + } + + private val rect = Shape.Rect(1, 2, 3, 4, "#abcdef") + private val text = Shape.Text(5, 6, "hi", "#fff", 16, "center") + + test("runIO of a pure value returns it without touching the platform") { + val p = FakePlatform() + assertEquals(Runtime.runIO(IO.pure(7), p), 7) + assertEquals(p.calls.toList, Nil) + } + + test("runIO maps Draw Rect to fillRect(x, y, w, h, color)") { + val p = FakePlatform() + Runtime.runIO(IO.draw(rect), p) + assertEquals(p.calls.toList, List(List("fillRect", 1, 2, 3, 4, "#abcdef"))) + } + + test("runIO maps Draw Text to fillText(x, y, text, color, size, align)") { + val p = FakePlatform() + Runtime.runIO(IO.draw(text), p) + assertEquals(p.calls.toList, List(List("fillText", 5, 6, "hi", "#fff", 16, "center"))) + } + + test("runIO answers RandomSeed with platform.randomSeed() and continues with it") { + val p = FakePlatform(99) + assertEquals(Runtime.runIO(IO.randomSeed.map(_ + 1), p), 100L) + assertEquals(p.calls.toList, List(List("randomSeed"))) + } + + test("runIO performs effects in order") { + val p = FakePlatform() + Runtime.runIO(IO.sequence(List(IO.draw(rect), IO.draw(text), IO.draw(rect))), p) + assertEquals(p.calls.toList.map(_.head), List("fillRect", "fillText", "fillRect")) + } + + test("runIO handles 100000 effects without overflowing the stack") { + val p = FakePlatform() + val n = 100000 + Runtime.runIO(IO.sequence(List.fill(n)(IO.draw(rect))), p) + assertEquals(p.calls.length, n) + } + + test("runApp asks the platform for a seed once, at start, and draws nothing before the first event") { + val p = FakePlatform() + Runtime.runApp(main, p) + assertEquals(p.calls.toList, List(List("randomSeed"))) + } + + test("runApp draws the initial scene on the first clock tick, then the ticked scene after 150 ms") { + val p = FakePlatform(42) + Runtime.runApp(main, p) + p.calls.clear() + p.fireTime(150) + assertEquals( + p.calls.toList, + expectedCalls(View.view(Spawn.newGame(42))) ++ expectedCalls(View.view(Spawn.tick(Spawn.newGame(42)))) + ) + } + + test("runApp feeds key presses to the game") { + val p = FakePlatform(42) + Runtime.runApp(main, p) + p.fireTime(0) + p.calls.clear() + p.fireKey("ArrowUp") + p.fireTime(150) + val turned = Spawn.newGame(42).copy(nextDir = Direction.Up) + assertEquals(p.calls.toList, expectedCalls(View.view(turned)) ++ expectedCalls(View.view(Spawn.tick(turned)))) + } + + test("runApp ignores key releases and unmapped keys (no redraw)") { + val p = FakePlatform(42) + Runtime.runApp(main, p) + p.fireTime(0) + p.calls.clear() + p.fireKeyUp("ArrowUp") + p.fireKey("x") + assertEquals(p.calls.toList, Nil) + } diff --git a/scala/test/M10Extensions.test.scala b/scala/test/M10Extensions.test.scala new file mode 100644 index 0000000..15ee8ed --- /dev/null +++ b/scala/test/M10Extensions.test.scala @@ -0,0 +1,52 @@ +package snake + +import Fixtures.* +import M09Helpers.drawn + +class M10ExtensionsSuite extends munit.FunSuite: + cases("m10-update").foreach { c => + test(s"update: ${c("name").str}") { + val a = c("args") + assertEquals(Game.update(gameState(a(0)), action(a(1))), gameState(c("expected"))) + } + } + + cases("m10-moves-every").foreach { c => + test(s"movesEvery: ${c("name").str}") { + assertEquals(Game.movesEvery(c("args")(0).num.toInt), c("expected").num.toInt) + } + } + + cases("m10-input").foreach { c => + test(s"keyToAction: ${c("name").str}") { + assertEquals(Input.keyToAction(c("args")(0).str), opt(c("expected"))(action)) + } + } + + private def time(t: Long): Event = Event.Time(t) + private val frameTimes = List(25L, 50L, 75L, 100L, 125L, 150L, 175L, 200L) + + test("the animation clock runs every 25 ms") { assertEquals(App.FRAME_MS, 25L) } + + test("speedActions turns the clock into Frame actions every 25 ms") { + assertEquals(App.speedActions.run(frameTimes.map(time)), List.fill(8)(Action.Frame)) + } + + test("speedActions still turns keys into actions, including pause and restart") { + assertEquals( + App.speedActions.run(List(Event.KeyDown(" "), Event.KeyDown("r"), Event.KeyDown("ArrowUp"))), + List(Action.TogglePause, Action.Restart, Action.Turn(Direction.Up)) + ) + } + + test("main (seed 42) draws a moved snake after eight 25 ms frames") { + val traced = IO.trace(App.main, 42) + assertEquals(traced.ops, List(Op.RandomSeed)) + val (first, afterFirst) = traced.result.step(time(0)) + assertEquals(drawn(first.head), View.view(Spawn.newGame(42))) + val drawings = afterFirst.run(frameTimes.map(time)) + assertEquals(drawings.length, 8) + val start = Spawn.newGame(42) + val moved = start.copy(snake = start.snake.map(p => p.copy(x = p.x + 1)), frame = 8) + assertEquals(drawn(drawings(7)), View.view(moved)) + }