package snake /** * (m10) A replay viewer: a recorded game is a seed and a list of actions; watching it is a second, tiny program * whose state is only a position in the list of the game's states. */ final case class Recording(seed: Long, actions: List[Action]) /** The viewer's whole state: which frame is shown, the index of the last frame, and whether autoplay is on. */ final case class Viewer(index: Int, last: Int, playing: Boolean) /** Everything the viewer can be asked to do, as data. `Beat` is one beat of the autoplay clock. */ enum ViewerAction: case Next, Prev, First, Last, TogglePlay, Beat object Replay: val REPLAY_MS: Long = 100 // Every state of the recorded game, from the new game to the end: the recording replayed with `history`. def frames(recording: Recording): List[GameState] = Game.history(Spawn.newGame(recording.seed), recording.actions) def viewerStart(last: Int): Viewer = Viewer(0, last, playing = false) private def clampIndex(viewer: Viewer, index: Int): Int = math.min(viewer.last, math.max(0, index)) // Where each action moves the viewer, and whether it plays afterwards (before the end-of-recording rule). private def move(viewer: Viewer, action: ViewerAction): Viewer = action match case ViewerAction.Next => viewer.copy(index = clampIndex(viewer, viewer.index + 1)) case ViewerAction.Prev => viewer.copy(index = clampIndex(viewer, viewer.index - 1)) case ViewerAction.First => viewer.copy(index = 0) case ViewerAction.Last => viewer.copy(index = viewer.last) // Play from the end means: watch it again from the start. case ViewerAction.TogglePlay => if viewer.playing then viewer.copy(playing = false) else viewer.copy(index = if viewer.index == viewer.last then 0 else viewer.index, playing = true) case ViewerAction.Beat => if viewer.playing then viewer.copy(index = clampIndex(viewer, viewer.index + 1)) else viewer // Total: the index never leaves [0, last], and the viewer never plays on the last frame, // so autoplay stops by itself at the end. def viewerUpdate(viewer: Viewer, action: ViewerAction): Viewer = val next = move(viewer, action) next.copy(playing = next.playing && next.index < next.last) def viewerKey(key: String): Option[ViewerAction] = key match case "ArrowRight" | "d" | "D" => Some(ViewerAction.Next) case "ArrowLeft" | "a" | "A" => Some(ViewerAction.Prev) case "Home" => Some(ViewerAction.First) case "End" => Some(ViewerAction.Last) case " " => Some(ViewerAction.TogglePlay) case _ => None // The game's own picture of the current frame, then the position at the top right and a hint at the bottom. def viewerView(frames: List[GameState], viewer: Viewer): Scene = val game = frames(viewer.index) val w = game.width * View.CELL val hint = if viewer.playing then "Space: pause" else "Left/Right: step Home/End: jump Space: play" View.view(game) ++ List( Shape.Text(w - 6, 18, s"step ${viewer.index} / ${viewer.last}", "#ffffff", 16, "right"), Shape.Text(w / 2, game.height * View.CELL - 8, hint, "#aaaaaa", 12, "center") ) lazy val replayActions: Stream[ViewerAction] = Stream.interval(REPLAY_MS).map(_ => ViewerAction.Beat).merge(Stream.keyDowns.filterMap(viewerKey)) def viewerStates(last: Int, actions: Stream[ViewerAction]): Stream[Viewer] = actions.scan(viewerStart(last))(viewerUpdate).startWith(viewerStart(last)) // It needs no seed from the world (the recording has one), so it starts with `pure`. // The frames are computed once, here, and every drawing picks one of them. def replayMain(recording: Recording): IO[Stream[IO[Unit]]] = val fs = frames(recording) IO.pure( viewerStates(fs.length - 1, replayActions).map(v => IO.drawScene(viewerView(fs, v))).clampTime(App.MAX_GAP_MS) )