feat(scala): m11 pong capstone (model, rules, view, app) with tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c6ecb7c013
commit
f4b671678e
@@ -0,0 +1,32 @@
|
||||
package snake
|
||||
package pong
|
||||
|
||||
import PongModel.initialPong
|
||||
|
||||
/** m11 pong app: the same wiring as App.scala (streams in, drawings out), reusing the course's modules. */
|
||||
object PongApp:
|
||||
|
||||
val FRAME_MS_PONG: Long = 30
|
||||
|
||||
private def paddleKey(key: String): Option[Paddle] = key match
|
||||
case "ArrowUp" | "w" | "W" => Some(Paddle.Up)
|
||||
case "ArrowDown" | "s" | "S" => Some(Paddle.Down)
|
||||
case _ => None
|
||||
|
||||
def pongKey(isDown: Boolean, key: String): Option[PongAction] =
|
||||
paddleKey(key) match
|
||||
case Some(p) => Some(if isDown then PongAction.Press(p) else PongAction.Release(p))
|
||||
case None => if isDown && (key == "r" || key == "R") then Some(PongAction.Restart) else None
|
||||
|
||||
lazy val pongActions: Stream[PongAction] =
|
||||
Stream
|
||||
.interval(FRAME_MS_PONG)
|
||||
.map(_ => PongAction.Frame)
|
||||
.merge(Stream.keyDowns.filterMap(pongKey(true, _)))
|
||||
.merge(Stream.keyUps.filterMap(pongKey(false, _)))
|
||||
|
||||
def pongStates(seed: Long, actions: Stream[PongAction]): Stream[PongState] =
|
||||
actions.scan(initialPong(seed))(PongRules.pongUpdate).startWith(initialPong(seed))
|
||||
|
||||
lazy val pongMain: IO[Stream[IO[Unit]]] =
|
||||
IO.randomSeed.map(seed => pongStates(seed, pongActions).map(state => IO.drawScene(PongView.pongView(state))))
|
||||
@@ -0,0 +1,73 @@
|
||||
package snake
|
||||
package pong
|
||||
|
||||
// m11 pong model: the same ideas as the snake model, for a different game.
|
||||
|
||||
/** The ball's direction of travel, one cell per frame on each axis. */
|
||||
final case class Velocity(dx: Int, dy: Int)
|
||||
|
||||
/** Which of the player's two keys are being held down right now. */
|
||||
final case class Held(up: Boolean, down: Boolean)
|
||||
|
||||
final case class Score(player: Int, cpu: Int)
|
||||
|
||||
enum PongStatus:
|
||||
case Playing, Over
|
||||
|
||||
/** The two keys that move the player's paddle. */
|
||||
enum Paddle:
|
||||
case Up, Down
|
||||
|
||||
/**
|
||||
* A whole match. Positions are in cells: `ball` is a cell of the 80x50 court; `player` and `cpu` are the y of the
|
||||
* top cell of each paddle. `seed` is the unsigned 32-bit random seed used to pick the direction of each serve.
|
||||
*/
|
||||
final case class PongState(
|
||||
ball: Point,
|
||||
vel: Velocity,
|
||||
player: Int,
|
||||
cpu: Int,
|
||||
held: Held,
|
||||
score: Score,
|
||||
status: PongStatus,
|
||||
seed: Long,
|
||||
frame: Int
|
||||
)
|
||||
|
||||
/** Everything that can happen in a match, as data. */
|
||||
enum PongAction:
|
||||
case Press(key: Paddle)
|
||||
case Release(key: Paddle)
|
||||
case Frame
|
||||
case Restart
|
||||
|
||||
object PongModel:
|
||||
|
||||
// The court, in cells.
|
||||
val W: Int = 80
|
||||
val H: Int = 50
|
||||
|
||||
/** Pixels per cell. */
|
||||
val PCELL: Int = 8
|
||||
|
||||
/** Paddle height in cells. */
|
||||
val PADDLE: Int = 8
|
||||
|
||||
/** Column of the player's paddle (left) and of the cpu's paddle (right). */
|
||||
val PLAYER_X: Int = 2
|
||||
val CPU_X: Int = 77
|
||||
|
||||
/** Points needed to win the match. */
|
||||
val WIN: Int = 5
|
||||
|
||||
def initialPong(seed: Long): PongState = PongState(
|
||||
ball = Point(40, 25),
|
||||
vel = Velocity(1, 1),
|
||||
player = 21,
|
||||
cpu = 21,
|
||||
held = Held(up = false, down = false),
|
||||
score = Score(player = 0, cpu = 0),
|
||||
status = PongStatus.Playing,
|
||||
seed = seed,
|
||||
frame = 0
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
package snake
|
||||
package pong
|
||||
|
||||
import PongModel.*
|
||||
|
||||
/** m11 pong rules: one pure `pongUpdate`, built from small steps that each return a new piece of state. */
|
||||
object PongRules:
|
||||
|
||||
def clamp(lo: Int, hi: Int, x: Int): Int = math.min(hi, math.max(lo, x))
|
||||
|
||||
private def clampPaddle(y: Int): Int = clamp(0, H - PADDLE, y)
|
||||
|
||||
private def movePlayer(state: PongState): Int =
|
||||
clampPaddle(state.player + (if state.held.down then 1 else 0) - (if state.held.up then 1 else 0))
|
||||
|
||||
// The cpu chases the middle of its paddle toward the ball, one cell every other frame.
|
||||
private def moveCpu(state: PongState, frame: Int): Int =
|
||||
if frame % 2 == 0 then clampPaddle(state.cpu + (state.ball.y - PADDLE / 2 - state.cpu).sign)
|
||||
else state.cpu
|
||||
|
||||
private def hits(paddle: Int, y: Int): Boolean = paddle <= y && y < paddle + PADDLE
|
||||
|
||||
private enum Side:
|
||||
case Player, Cpu
|
||||
|
||||
// Where the ball goes next, given the paddles' new positions, and who scored, if anyone.
|
||||
private def moveBall(state: PongState, player: Int, cpu: Int): (Point, Velocity, Option[Side]) =
|
||||
val (ball, vel) = (state.ball, state.vel)
|
||||
val wall = ball.y + vel.dy < 0 || ball.y + vel.dy >= H
|
||||
val dy = if wall then -vel.dy else vel.dy
|
||||
val ny = ball.y + dy
|
||||
val nx = ball.x + vel.dx
|
||||
val bounce =
|
||||
(nx == PLAYER_X && vel.dx < 0 && hits(player, ny)) ||
|
||||
(nx == CPU_X && vel.dx > 0 && hits(cpu, ny))
|
||||
val scorer = if nx < 0 then Some(Side.Cpu) else if nx >= W then Some(Side.Player) else None
|
||||
(Point(if bounce then ball.x else nx, ny), Velocity(if bounce then -vel.dx else vel.dx, dy), scorer)
|
||||
|
||||
// After a point the ball restarts in the middle, heading toward whoever conceded; the seed picks up or down.
|
||||
private def serve(state: PongState, scorer: Side): PongState =
|
||||
val (r, seed) = Random.nextUint32.run(state.seed)
|
||||
val score = scorer match
|
||||
case Side.Player => state.score.copy(player = state.score.player + 1)
|
||||
case Side.Cpu => state.score.copy(cpu = state.score.cpu + 1)
|
||||
state.copy(
|
||||
ball = Point(40, 25),
|
||||
vel = Velocity(if scorer == Side.Cpu then -1 else 1, if r % 2 == 0 then 1 else -1),
|
||||
score = score,
|
||||
seed = seed,
|
||||
status = if math.max(score.player, score.cpu) >= WIN then PongStatus.Over else PongStatus.Playing
|
||||
)
|
||||
|
||||
private def frame(state: PongState): PongState =
|
||||
if state.status != PongStatus.Playing then state
|
||||
else
|
||||
val n = state.frame + 1
|
||||
val player = movePlayer(state)
|
||||
val cpu = moveCpu(state, n)
|
||||
val (ball, vel, scorer) = moveBall(state, player, cpu)
|
||||
val next = state.copy(ball = ball, vel = vel, player = player, cpu = cpu, frame = n)
|
||||
scorer.fold(next)(serve(next, _))
|
||||
|
||||
private def setHeld(state: PongState, key: Paddle, value: Boolean): PongState = key match
|
||||
case Paddle.Up => state.copy(held = state.held.copy(up = value))
|
||||
case Paddle.Down => state.copy(held = state.held.copy(down = value))
|
||||
|
||||
def pongUpdate(state: PongState, action: PongAction): PongState = action match
|
||||
case PongAction.Press(key) => setHeld(state, key, true)
|
||||
case PongAction.Release(key) => setHeld(state, key, false)
|
||||
case PongAction.Restart => initialPong(state.seed)
|
||||
case PongAction.Frame => frame(state)
|
||||
@@ -0,0 +1,29 @@
|
||||
package snake
|
||||
package pong
|
||||
|
||||
import PongModel.*
|
||||
|
||||
/** m11 pong view: the same Scene shapes as the snake view, so the same drawScene and runtime draw it. */
|
||||
object PongView:
|
||||
|
||||
private def text(x: Int, y: Int, str: String, size: Int): Shape = Shape.Text(x, y, str, "#ffffff", size, "center")
|
||||
|
||||
private def paddle(column: Int, y: Int): Shape =
|
||||
Shape.Rect(column * PCELL, y * PCELL, PCELL, PADDLE * PCELL, "#ffffff")
|
||||
|
||||
private def overlay(state: PongState): Scene = state.status match
|
||||
case PongStatus.Over =>
|
||||
List(
|
||||
text(320, 200, if state.score.player > state.score.cpu then "You Win!" else "CPU Wins", 32),
|
||||
text(320, 230, "Press R to restart", 16)
|
||||
)
|
||||
case PongStatus.Playing => Nil
|
||||
|
||||
def pongView(state: PongState): Scene =
|
||||
List(
|
||||
Shape.Rect(0, 0, 640, 400, "#111111"),
|
||||
paddle(PLAYER_X, state.player),
|
||||
paddle(CPU_X, state.cpu),
|
||||
Shape.Rect(state.ball.x * PCELL, state.ball.y * PCELL, PCELL, PCELL, "#ffeb3b"),
|
||||
text(320, 30, s"${state.score.player} ${state.score.cpu}", 24)
|
||||
) ++ overlay(state)
|
||||
@@ -0,0 +1,27 @@
|
||||
package snake
|
||||
package pong
|
||||
|
||||
/** m11 pong app: the same wiring as App.scala (streams in, drawings out), reusing the course's modules. */
|
||||
object PongApp:
|
||||
|
||||
/** Milliseconds between frames of the match. */
|
||||
val FRAME_MS_PONG: Long = 30
|
||||
|
||||
/**
|
||||
* Translate a key event into a `PongAction`, if any. `isDown` is true for a key press, false for a release.
|
||||
* "ArrowUp"/"w"/"W" -> `Press(Paddle.Up)` / `Release(Paddle.Up)`, "ArrowDown"/"s"/"S" -> `Press(Paddle.Down)` /
|
||||
* `Release(Paddle.Down)`, "r"/"R" -> `Restart` (on press only; releasing r does nothing). Any other key -> `None`.
|
||||
*/
|
||||
def pongKey(isDown: Boolean, key: String): Option[PongAction] = ???
|
||||
|
||||
/**
|
||||
* Everything that can happen in a match: `Frame` every FRAME_MS_PONG (from `Stream.interval`), merged with the
|
||||
* `pongKey(true, key)` of every key press and the `pongKey(false, key)` of every key release.
|
||||
*/
|
||||
lazy val pongActions: Stream[PongAction] = ???
|
||||
|
||||
/** Fold the actions into a stream of states: start from `initialPong(seed)`, `pongUpdate` for each action, emit the initial state first. */
|
||||
def pongStates(seed: Long, actions: Stream[PongAction]): Stream[PongState] = ???
|
||||
|
||||
/** The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings of `pongView`. */
|
||||
lazy val pongMain: IO[Stream[IO[Unit]]] = ???
|
||||
@@ -0,0 +1,67 @@
|
||||
package snake
|
||||
package pong
|
||||
|
||||
// m11 pong model: the same ideas as the snake model, for a different game.
|
||||
|
||||
/** The ball's direction of travel, one cell per frame on each axis. */
|
||||
final case class Velocity(dx: Int, dy: Int)
|
||||
|
||||
/** Which of the player's two keys are being held down right now. */
|
||||
final case class Held(up: Boolean, down: Boolean)
|
||||
|
||||
final case class Score(player: Int, cpu: Int)
|
||||
|
||||
enum PongStatus:
|
||||
case Playing, Over
|
||||
|
||||
/** The two keys that move the player's paddle. */
|
||||
enum Paddle:
|
||||
case Up, Down
|
||||
|
||||
/**
|
||||
* A whole match. Positions are in cells: `ball` is a cell of the 80x50 court; `player` and `cpu` are the y of the
|
||||
* top cell of each paddle. `seed` is the unsigned 32-bit random seed used to pick the direction of each serve.
|
||||
*/
|
||||
final case class PongState(
|
||||
ball: Point,
|
||||
vel: Velocity,
|
||||
player: Int,
|
||||
cpu: Int,
|
||||
held: Held,
|
||||
score: Score,
|
||||
status: PongStatus,
|
||||
seed: Long,
|
||||
frame: Int
|
||||
)
|
||||
|
||||
/** Everything that can happen in a match, as data. */
|
||||
enum PongAction:
|
||||
case Press(key: Paddle)
|
||||
case Release(key: Paddle)
|
||||
case Frame
|
||||
case Restart
|
||||
|
||||
object PongModel:
|
||||
|
||||
// The court, in cells.
|
||||
val W: Int = 80
|
||||
val H: Int = 50
|
||||
|
||||
/** Pixels per cell. */
|
||||
val PCELL: Int = 8
|
||||
|
||||
/** Paddle height in cells. */
|
||||
val PADDLE: Int = 8
|
||||
|
||||
/** Column of the player's paddle (left) and of the cpu's paddle (right). */
|
||||
val PLAYER_X: Int = 2
|
||||
val CPU_X: Int = 77
|
||||
|
||||
/** Points needed to win the match. */
|
||||
val WIN: Int = 5
|
||||
|
||||
/**
|
||||
* A fresh match: ball at (40, 25) moving `Velocity(1, 1)`, both paddles at y 21, no keys held,
|
||||
* score 0 to 0, status Playing, the given seed, frame 0.
|
||||
*/
|
||||
def initialPong(seed: Long): PongState = ???
|
||||
@@ -0,0 +1,32 @@
|
||||
package snake
|
||||
package pong
|
||||
|
||||
import PongModel.*
|
||||
|
||||
/** m11 pong rules: one pure `pongUpdate`. */
|
||||
object PongRules:
|
||||
|
||||
/** Keep `x` inside `[lo, hi]`. */
|
||||
def clamp(lo: Int, hi: Int, x: Int): Int = ???
|
||||
|
||||
/**
|
||||
* The one function that says how a match changes: `(state, action) => state`.
|
||||
*
|
||||
* - `Press(key)` / `Release(key)`: set `held.up` or `held.down` to true / false (even when the match is over).
|
||||
* - `Restart`: `initialPong(state.seed)`.
|
||||
* - `Frame`: unchanged unless status is Playing. Otherwise, in this order:
|
||||
* 1. `frame + 1`.
|
||||
* 2. The player moves one cell down for `held.down` and one up for `held.up` (both cancel out),
|
||||
* kept within `[0, H - PADDLE]`.
|
||||
* 3. When the new `frame` is even, the cpu moves one cell toward `ball.y - PADDLE / 2` (it stays put when it is
|
||||
* already there), kept within `[0, H - PADDLE]`. It looks at the ball before the ball moves.
|
||||
* 4. The ball moves vertically: `ny = y + dy`; if `ny < 0 || ny >= H` then `dy` flips and `ny = y + dy`.
|
||||
* 5. Horizontally: `nx = x + dx`. It bounces (`dx` flips, `nx = x`) when `nx == PLAYER_X`, `dx < 0` and
|
||||
* `player <= ny < player + PADDLE` (using the player's new position), or when `nx == CPU_X`, `dx > 0` and
|
||||
* `cpu <= ny < cpu + PADDLE`.
|
||||
* 6. If `nx < 0` the cpu scores; if `nx >= W` the player scores. A point serves a new ball: position (40, 25),
|
||||
* `dx` toward the side that conceded (cpu scored -> -1, player scored -> +1), `dy = 1` when the value of
|
||||
* `Random.nextUint32.run(seed)` is even and `-1` otherwise, and the seed advances.
|
||||
* 7. A score of `WIN` ends the match: status Over.
|
||||
*/
|
||||
def pongUpdate(state: PongState, action: PongAction): PongState = ???
|
||||
@@ -0,0 +1,17 @@
|
||||
package snake
|
||||
package pong
|
||||
|
||||
import PongModel.*
|
||||
|
||||
/** m11 pong view: the same Scene shapes as the snake view, so the same drawScene and runtime draw it. */
|
||||
object PongView:
|
||||
|
||||
/**
|
||||
* Describe the match as shapes, drawn in order (all text is "#ffffff", align "center"):
|
||||
* background `Rect(0, 0, 640, 400, "#111111")`; the player paddle `Rect(PLAYER_X*8, player*8, 8, 64, "#ffffff")`,
|
||||
* then the cpu paddle the same way at CPU_X; the ball `Rect(x*8, y*8, 8, 8, "#ffeb3b")`;
|
||||
* the score `Text(320, 30, "<player> <cpu>" (three spaces), size 24)`.
|
||||
* When Over: `Text(320, 200, "You Win!" if the player has more points, else "CPU Wins", size 32)` and
|
||||
* `Text(320, 230, "Press R to restart", size 16)`.
|
||||
*/
|
||||
def pongView(state: PongState): Scene = ???
|
||||
@@ -0,0 +1,130 @@
|
||||
package snake
|
||||
package pong
|
||||
|
||||
import Fixtures.*
|
||||
import PongModel.*
|
||||
import PongRules.{pongUpdate, clamp}
|
||||
import PongView.pongView
|
||||
import PongApp.*
|
||||
import M09Helpers.drawn
|
||||
|
||||
class M11PongSuite extends munit.FunSuite:
|
||||
|
||||
private def pongState(v: ujson.Value): PongState = PongState(
|
||||
ball = point(v("ball")),
|
||||
vel = Velocity(v("vel")("dx").num.toInt, v("vel")("dy").num.toInt),
|
||||
player = v("player").num.toInt,
|
||||
cpu = v("cpu").num.toInt,
|
||||
held = Held(v("held")("up").bool, v("held")("down").bool),
|
||||
score = Score(v("score")("player").num.toInt, v("score")("cpu").num.toInt),
|
||||
status = PongStatus.valueOf(v("status").str),
|
||||
seed = v("seed").num.toLong,
|
||||
frame = v("frame").num.toInt
|
||||
)
|
||||
|
||||
// `Paddle` only has Up and Down, so a JSON key like "constructor" decodes to None instead of crashing.
|
||||
private def paddle(v: ujson.Value): Option[Paddle] = Paddle.values.find(_.toString == v.str)
|
||||
|
||||
/** Decode a PongAction; `None` when its key is not a `Paddle` (such an action cannot even be built in Scala). */
|
||||
private def pongAction(v: ujson.Value): Option[PongAction] = v("tag").str match
|
||||
case "Press" => paddle(v("key")).map(PongAction.Press(_))
|
||||
case "Release" => paddle(v("key")).map(PongAction.Release(_))
|
||||
case "Frame" => Some(PongAction.Frame)
|
||||
case "Restart" => Some(PongAction.Restart)
|
||||
case other => throw new IllegalArgumentException(s"unknown pong action tag: $other")
|
||||
|
||||
cases("m11-pong-update").foreach { c =>
|
||||
test(s"pongUpdate: ${c("name").str}") {
|
||||
val a = c("args")
|
||||
val state = pongState(a(0))
|
||||
pongAction(a(1)) match
|
||||
case Some(action) => assertEquals(pongUpdate(state, action), pongState(c("expected")))
|
||||
// An unknown key is ruled out by the types: the Paddle enum has no such case. What the
|
||||
// fixture asks for (state unchanged) therefore holds by construction; check that it says so.
|
||||
case None => assertEquals(pongState(c("expected")), state)
|
||||
}
|
||||
}
|
||||
|
||||
cases("m11-pong-view").foreach { c =>
|
||||
test(s"pongView: ${c("name").str}") {
|
||||
assertEquals(pongView(pongState(c("args")(0))), list(c("expected"))(shape))
|
||||
}
|
||||
}
|
||||
|
||||
cases("m11-pong-key").foreach { c =>
|
||||
test(s"pongKey: ${c("name").str}") {
|
||||
val a = c("args")
|
||||
val expected = opt(c("expected"))(e => pongAction(e).getOrElse(fail(s"undecodable expected action: $e")))
|
||||
assertEquals(pongKey(a(0).bool, a(1).str), expected)
|
||||
}
|
||||
}
|
||||
|
||||
private def time(t: Long): Event = Event.Time(t)
|
||||
private def down(key: String): Event = Event.KeyDown(key)
|
||||
private def up(key: String): Event = Event.KeyUp(key)
|
||||
|
||||
test("the court is 80 by 50 cells of 8 pixels, with 8-cell paddles") {
|
||||
assertEquals(List(W, H, PCELL, PADDLE, PLAYER_X, CPU_X, WIN), List(80, 50, 8, 8, 2, 77, 5))
|
||||
}
|
||||
|
||||
test("initialPong puts the ball in the middle, both paddles centred, and nobody ahead") {
|
||||
assertEquals(
|
||||
initialPong(5),
|
||||
PongState(Point(40, 25), Velocity(1, 1), 21, 21, Held(false, false), Score(0, 0), PongStatus.Playing, 5, 0)
|
||||
)
|
||||
}
|
||||
|
||||
test("clamp keeps a number inside [lo, hi]") {
|
||||
assertEquals(List(clamp(0, 42, -1), clamp(0, 42, 17), clamp(0, 42, 43)), List(0, 17, 42))
|
||||
}
|
||||
|
||||
test("pongUpdate does not modify the state it is given") {
|
||||
val s = initialPong(0)
|
||||
pongUpdate(s, PongAction.Frame)
|
||||
assertEquals(s, initialPong(0))
|
||||
}
|
||||
|
||||
// 3000 frames with no one touching the keys: the ball must always stay on the court and the match must end.
|
||||
test("an idle match stays on the court and ends once someone reaches five") {
|
||||
val states = List.fill(3000)(PongAction.Frame).scanLeft(initialPong(3))(pongUpdate)
|
||||
assert(states.forall(s => s.ball.y >= 0 && s.ball.y < H && s.ball.x >= 0 && s.ball.x < W))
|
||||
assert(states.forall(s => s.player >= 0 && s.player <= H - PADDLE && s.cpu >= 0 && s.cpu <= H - PADDLE))
|
||||
val last = states.last
|
||||
assertEquals(last.status, PongStatus.Over)
|
||||
assertEquals(math.max(last.score.player, last.score.cpu), WIN)
|
||||
}
|
||||
|
||||
test("the clock runs every 30 ms") { assertEquals(FRAME_MS_PONG, 30L) }
|
||||
|
||||
test("pongActions turns the clock into Frames and keys into Press, Release and Restart") {
|
||||
assertEquals(
|
||||
pongActions.run(List(time(30), down("ArrowUp"), up("ArrowUp"), down("s"), down("r"), up("r"), down("x"), time(90))),
|
||||
List(
|
||||
PongAction.Frame,
|
||||
PongAction.Press(Paddle.Up),
|
||||
PongAction.Release(Paddle.Up),
|
||||
PongAction.Press(Paddle.Down),
|
||||
PongAction.Restart,
|
||||
PongAction.Frame,
|
||||
PongAction.Frame
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
test("pongStates starts with the new match, then yields one state per action") {
|
||||
val out = pongStates(42, pongActions).run(List(time(30), down("w"), time(60)))
|
||||
assertEquals(out.length, 4)
|
||||
assertEquals(out.head, initialPong(42))
|
||||
assertEquals(out.map(_.frame), List(0, 1, 1, 2))
|
||||
assertEquals(out.map(_.held.up), List(false, false, true, true))
|
||||
assertEquals(out(3).player, 20)
|
||||
}
|
||||
|
||||
test("pongMain asks for a seed, draws the new match first, and then the updated one") {
|
||||
val traced = IO.trace(pongMain, 42)
|
||||
assertEquals(traced.ops, List(Op.RandomSeed))
|
||||
val (first, next) = traced.result.step(time(0))
|
||||
val (second, _) = next.step(time(30))
|
||||
assertEquals(drawn(first.head), pongView(initialPong(42)))
|
||||
assertEquals(drawn(second.head), pongView(pongUpdate(initialPong(42), PongAction.Frame)))
|
||||
}
|
||||
@@ -10,7 +10,7 @@ repo="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
export PATH="$HOME/.dotnet:$HOME/.local/bin:$PATH"
|
||||
|
||||
# Module -> files (relative to src/ and solutions/) that the module introduces. A directory entry copies it whole.
|
||||
# Later tasks: extend the entries for m07+ in the Scala and F# tables.
|
||||
# Later tasks: extend the F# table.
|
||||
files_for() {
|
||||
case "$track:$1" in
|
||||
js:01) echo "util.js todo.js" ;;
|
||||
|
||||
Reference in New Issue
Block a user