feat(scala): m09 input/app/runtime and m10 extensions with tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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)))
|
||||
Reference in New Issue
Block a user