feat(scala): m09 input/app/runtime and m10 extensions with tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bd0c9fcc0c
commit
c6ecb7c013
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)))
|
||||
@@ -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]]] = ???
|
||||
+14
-2
@@ -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 = ???
|
||||
|
||||
|
||||
@@ -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] = ???
|
||||
@@ -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 = ???
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user