diff --git a/fsharp/solutions/Game.fs b/fsharp/solutions/Game.fs new file mode 100644 index 0000000..627c439 --- /dev/null +++ b/fsharp/solutions/Game.fs @@ -0,0 +1,38 @@ +/// 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. +module Snake.Game + +open Snake.Model + +/// How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2. +let movesEvery (score: int) : int = max 2 (8 - score / 3) + +// One animation frame: count it, and every `movesEvery score`-th frame run a game step. +let private frame (state: GameState) : GameState = + if state.Status <> Playing then + state + else + let counted = { state with 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. +let private togglePause (state: GameState) : GameState = + match state.Status with + | Playing -> { state with Status = Paused } + | Paused -> { state with Status = Playing } + | GameOver + | Won -> state + +// Every case is listed (no `| _ ->`): adding an Action later makes the compiler point here. +let update (state: GameState) (action: Action) : GameState = + match action with + | Tick -> Spawn.tick state + | Turn dir -> Rules.turn state dir + | TogglePause -> togglePause state + | Restart -> Spawn.newGame state.Seed + | Frame -> frame state + +let replay (state: GameState) (actions: Action list) : GameState = List.fold update state actions + +// List.scan is List.fold that keeps every intermediate result (the start state included). +let history (state: GameState) (actions: Action list) : GameState list = List.scan update state actions diff --git a/fsharp/solutions/Model.fs b/fsharp/solutions/Model.fs new file mode 100644 index 0000000..4f018e5 --- /dev/null +++ b/fsharp/solutions/Model.fs @@ -0,0 +1,64 @@ +/// m02 model: the data the whole game is built from. Records and unions are immutable. +module Snake.Model + +/// A grid cell. Records compare structurally: two points with the same X and Y are `=`. +type Point = { X: int; Y: int } + +type Direction = + | Up + | Down + | Left + | Right + +type Status = + | Playing + | Paused + | GameOver + | Won + +/// Everything that can happen to the game, as data. +type Action = + | Tick + | Turn of Direction + | TogglePause + | Restart + | Frame + +/// The whole game. `Snake` is head first; `Seed` is the unsigned 32-bit random seed. +type GameState = + { Width: int + Height: int + Snake: Point list + Dir: Direction + NextDir: Direction + Apple: Point option + Seed: uint32 + Score: int + Status: Status + Frame: int } + +let delta (dir: Direction) : Point = + match dir with + | Up -> { X = 0; Y = -1 } + | Down -> { X = 0; Y = 1 } + | Left -> { X = -1; Y = 0 } + | Right -> { X = 1; Y = 0 } + +let opposite (dir: Direction) : Direction = + match dir with + | Up -> Down + | Down -> Up + | Left -> Right + | Right -> Left + +let initialState (seed: uint32) : GameState = + { Width = 20 + Height = 20 + Snake = [ { X = 10; Y = 10 }; { X = 9; Y = 10 }; { X = 8; Y = 10 } ] + Dir = Right + NextDir = Right + Apple = None + Seed = seed + Score = 0 + Status = Playing + Frame = 0 } diff --git a/fsharp/solutions/Random.fs b/fsharp/solutions/Random.fs new file mode 100644 index 0000000..737c63a --- /dev/null +++ b/fsharp/solutions/Random.fs @@ -0,0 +1,31 @@ +/// m04 random: a tiny "state monad" for randomness. A Random<'a> wraps a function seed -> (value, nextSeed), +/// so every draw is a pure function and the seed is passed along explicitly instead of hidden. +/// Seeds and values are unsigned 32-bit numbers (uint32). +module Snake.Random + +type Random<'a> = Random of (uint32 -> 'a * uint32) + +let run (seed: uint32) (Random f: Random<'a>) : 'a * uint32 = f seed + +let pure (a: 'a) : Random<'a> = Random(fun seed -> (a, seed)) + +let map (f: 'a -> 'b) (r: Random<'a>) : Random<'b> = + Random(fun seed -> + let a, next = run seed r + (f a, next)) + +let bind (f: 'a -> Random<'b>) (r: Random<'a>) : Random<'b> = + Random(fun seed -> + let a, next = run seed r + run next (f a)) + +// mulberry32. uint32 arithmetic wraps around at 2^32 (F# operators are unchecked) and `>>>` on uint32 is an +// unsigned shift, so the formula can be written down as it is. +let nextUint32: Random = + Random(fun seed -> + let s = seed + 0x6D2B79F5u + let t1 = (s ^^^ (s >>> 15)) * (s ||| 1u) + let t2 = t1 ^^^ (t1 + (t1 ^^^ (t1 >>> 7)) * (t1 ||| 61u)) + (t2 ^^^ (t2 >>> 14), s)) + +let intBelow (n: int) : Random = map (fun v -> int (v % uint32 n)) nextUint32 diff --git a/fsharp/solutions/Rules.fs b/fsharp/solutions/Rules.fs new file mode 100644 index 0000000..2e48520 --- /dev/null +++ b/fsharp/solutions/Rules.fs @@ -0,0 +1,34 @@ +/// m03 rules: the pure game rules. Each function takes a state and returns a new one. +module Snake.Rules + +open Snake.Model + +let move (p: Point) (dir: Direction) : Point = + let d = delta dir + { X = p.X + d.X; Y = p.Y + d.Y } + +let inBounds (state: GameState) (p: Point) : bool = + p.X >= 0 && p.X < state.Width && p.Y >= 0 && p.Y < state.Height + +// Validate against `Dir` (last moved), not `NextDir`: Up then Left within one tick must not fold the snake back on itself. +let turn (state: GameState) (dir: Direction) : GameState = + if state.Status = Playing && dir <> opposite state.Dir then { state with NextDir = dir } else state + +let advance (state: GameState) : GameState = + if state.Status <> Playing then + state + else + let d = state.NextDir + let head = move (List.head state.Snake) d + let eating = state.Apple = Some head + // When not eating, the tail leaves its cell this step, so the head may move into it. + let body = if eating then state.Snake else Util.dropLast state.Snake + + if not (inBounds state head) || Util.contains body head (=) then + { state with Dir = d; Status = GameOver } + else + { state with + Snake = head :: body + Dir = d + Apple = if eating then None else state.Apple + Score = state.Score + (if eating then 1 else 0) } diff --git a/fsharp/solutions/Snake.fsproj b/fsharp/solutions/Snake.fsproj index 2ff9cb0..5bc1a8e 100644 --- a/fsharp/solutions/Snake.fsproj +++ b/fsharp/solutions/Snake.fsproj @@ -1,9 +1,17 @@ net10.0 + + $(NoWarn);FS0046 false + + + + + + diff --git a/fsharp/solutions/Spawn.fs b/fsharp/solutions/Spawn.fs new file mode 100644 index 0000000..8f055f9 --- /dev/null +++ b/fsharp/solutions/Spawn.fs @@ -0,0 +1,25 @@ +/// m04 spawn: placing apples with the Random from Random.fs. +module Snake.Spawn + +open Snake.Model + +let freeCells (state: GameState) : Point list = + [ for y in Util.range state.Height do + for x in Util.range state.Width do + let p = { X = x; Y = y } + if not (Util.contains state.Snake p (=)) then + yield p ] + +let spawnApple (state: GameState) : GameState = + if state.Apple.IsSome || state.Status <> Playing then + state + else + match freeCells state with + | [] -> { state with Status = Won } + | cells -> + let i, seed = Random.run state.Seed (Random.intBelow (List.length cells)) + { state with Apple = Some(List.item i cells); Seed = seed } + +let tick (state: GameState) : GameState = spawnApple (Rules.advance state) + +let newGame (seed: uint32) : GameState = spawnApple (initialState seed) diff --git a/fsharp/solutions/Util.fs b/fsharp/solutions/Util.fs index d84ca40..0260dfa 100644 --- a/fsharp/solutions/Util.fs +++ b/fsharp/solutions/Util.fs @@ -1,2 +1,12 @@ -/// Placeholder; replaced by the m01 utilities. +/// m01 util: small helpers used by every later module. +/// (The JavaScript track also writes `pipe` and `compose` here; F# has them built in as `|>` and `>>`.) module Snake.Util + +let range (n: int) : int list = [ 0 .. n - 1 ] + +let dropLast (xs: 'a list) : 'a list = + match xs with + | [] -> [] + | _ -> List.take (List.length xs - 1) xs + +let contains (xs: 'a list) (p: 'a) (eq: 'a -> 'a -> bool) : bool = List.exists (fun x -> eq x p) xs diff --git a/fsharp/solutions/View.fs b/fsharp/solutions/View.fs new file mode 100644 index 0000000..aaeb186 --- /dev/null +++ b/fsharp/solutions/View.fs @@ -0,0 +1,38 @@ +/// m06 view: the picture is plain data (a list of shapes); drawing it is somebody else's job (m07). +module Snake.View + +open Snake.Model + +/// A thing to draw. +type Shape = + | Rect of x: int * y: int * w: int * h: int * color: string + | Text of x: int * y: int * text: string * color: string * size: int * align: string + +/// A picture: shapes drawn in order. +type Scene = Shape list + +let CELL = 20 + +let private text x y str size align = Text(x, y, str, "#ffffff", size, align) + +// One cell, inset by 1px so neighbouring segments show a gap. +let private cell (p: Point) color = + Rect(p.X * CELL + 1, p.Y * CELL + 1, CELL - 2, CELL - 2, color) + +let private overlay (state: GameState) : Scene = + let cx = state.Width * CELL / 2 + let cy = state.Height * CELL / 2 + let banner title hint = [ text cx cy title 32 "center"; text cx (cy + 30) hint 16 "center" ] + + match state.Status with + | Paused -> banner "Paused" "Press Space to resume" + | GameOver -> banner "Game Over" "Press R to restart" + | Won -> banner "You Win!" "Press R to restart" + | Playing -> [] + +let view (state: GameState) : Scene = + [ Rect(0, 0, state.Width * CELL, state.Height * CELL, "#111111") ] + @ (state.Apple |> Option.map (fun a -> cell a "#e53935") |> Option.toList) + @ (state.Snake |> List.mapi (fun i p -> cell p (if i = 0 then "#7cb342" else "#43a047"))) + @ [ text 6 18 $"Score: {state.Score}" 16 "left" ] + @ overlay state diff --git a/fsharp/src/Game.fs b/fsharp/src/Game.fs new file mode 100644 index 0000000..4cc8d7a --- /dev/null +++ b/fsharp/src/Game.fs @@ -0,0 +1,26 @@ +/// m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions. +module Snake.Game + +open Snake.Model + +/// 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. +let movesEvery (score: int) : int = failwith "TODO m10" + +/// The one function that says how the game changes: `state -> action -> state`. +/// +/// 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 `| _ ->`), so the compiler tells you if one is forgotten. +let update (state: GameState) (action: Action) : GameState = failwith "TODO m05" + +/// Apply all actions in order, starting from `state`, and return the final state. +let replay (state: GameState) (actions: Action list) : GameState = failwith "TODO m05" + +/// Like `replay`, but keep every state: `[state; afterFirst; afterSecond; ...]`, so its length is `List.length actions + 1`. +let history (state: GameState) (actions: Action list) : GameState list = failwith "TODO m05" diff --git a/fsharp/src/Model.fs b/fsharp/src/Model.fs new file mode 100644 index 0000000..ed8f0c8 --- /dev/null +++ b/fsharp/src/Model.fs @@ -0,0 +1,48 @@ +/// m02 model: the data the game is built from, plus two tiny functions over directions. +module Snake.Model + +/// A grid cell. Records compare structurally: two points with the same X and Y are `=`. +type Point = { X: int; Y: int } + +type Direction = + | Up + | Down + | Left + | Right + +type Status = + | Playing + | Paused + | GameOver + | Won + +/// Everything that can happen to the game, as data. +type Action = + | Tick + | Turn of Direction + | TogglePause + | Restart + | Frame + +/// The whole game. `Snake` is head first; `Seed` is the unsigned 32-bit random seed. +type GameState = + { Width: int + Height: int + Snake: Point list + Dir: Direction + NextDir: Direction + Apple: Point option + Seed: uint32 + Score: int + Status: Status + Frame: int } + +/// The unit step for a direction: Up (0,-1), Down (0,1), Left (-1,0), Right (1,0). Y grows downward. +let delta (dir: Direction) : Point = failwith "TODO m02" + +/// The direction pointing the other way. +let opposite (dir: Direction) : Direction = failwith "TODO m02" + +/// A fresh game: 20x20, snake (10,10),(9,10),(8,10) heading Right (`Dir` and `NextDir`), +/// no apple, the given seed, score 0, status Playing, frame 0. +let initialState (seed: uint32) : GameState = failwith "TODO m02" diff --git a/fsharp/src/Random.fs b/fsharp/src/Random.fs new file mode 100644 index 0000000..230707d --- /dev/null +++ b/fsharp/src/Random.fs @@ -0,0 +1,30 @@ +/// m04 random. A `Random<'a>` wraps a plain function from a seed to a pair: `seed -> (value, nextSeed)`. +/// Seeds and values are unsigned 32-bit integers (`uint32`). +/// Nothing is hidden or mutated: the caller carries the seed. +module Snake.Random + +type Random<'a> = Random of (uint32 -> 'a * uint32) + +/// Run `r` from `seed`: the value it produces and the seed it leaves behind. +let run (seed: uint32) (r: Random<'a>) : 'a * uint32 = failwith "TODO m04" + +/// A Random that always gives `a` and leaves the seed untouched. +let pure (a: 'a) : Random<'a> = failwith "TODO m04" + +/// Transform the value `r` produces; the seed threading is unchanged. +let map (f: 'a -> 'b) (r: Random<'a>) : Random<'b> = failwith "TODO m04" + +/// Run `r`, feed its value to `f` to pick the next Random, and run that with the seed `r` left behind. +let bind (f: 'a -> Random<'b>) (r: Random<'a>) : Random<'b> = failwith "TODO m04" + +/// The generator (mulberry32). uint32 arithmetic already wraps around at 2^32 and `>>>` on a uint32 is an +/// unsigned shift. With `s = seed + 0x6D2B79F5u`: +/// t = (s ^^^ (s >>> 15)) * (s ||| 1u) +/// t = t ^^^ (t + (t ^^^ (t >>> 7)) * (t ||| 61u)) +/// value = t ^^^ (t >>> 14) +/// It returns `value` and the new seed `s`. +/// (The stub is wrapped in `Random` so the TODO only fires when the generator is run.) +let nextUint32: Random = Random(fun _ -> failwith "TODO m04") + +/// A Random giving a number in `[0, n)`: the `nextUint32` value modulo `n`. +let intBelow (n: int) : Random = failwith "TODO m04" diff --git a/fsharp/src/Rules.fs b/fsharp/src/Rules.fs new file mode 100644 index 0000000..d6139c2 --- /dev/null +++ b/fsharp/src/Rules.fs @@ -0,0 +1,22 @@ +/// m03 rules: the pure game rules. Each function takes a state and returns a new one. +module Snake.Rules + +open Snake.Model + +/// The cell one step from `p` in direction `dir`. No wrapping: it may land outside the board. +let move (p: Point) (dir: Direction) : Point = failwith "TODO m03" + +/// True if `p` lies on the board: `0 <= X < Width` and `0 <= Y < Height`. +let inBounds (state: GameState) (p: Point) : bool = failwith "TODO m03" + +/// Ask to turn the snake. Returns `state` unchanged unless the game is Playing and +/// `dir` is not the opposite of `state.Dir` (the direction the snake last *moved*, not `NextDir`). +/// Otherwise sets `NextDir = dir`. Several turns in one tick: the last allowed one wins. +let turn (state: GameState) (dir: Direction) : GameState = failwith "TODO m03" + +/// One step of the game. Returns `state` unchanged unless Playing. Otherwise the head moves in `NextDir` +/// (which becomes `Dir`): +/// - head lands on the apple: the snake grows (tail kept), score + 1, apple becomes None; +/// - otherwise the tail cell is vacated, so the head may enter the cell the tail is leaving; +/// - head off the board, or onto a remaining body cell: status GameOver, snake unchanged (but `Dir` updated). +let advance (state: GameState) : GameState = failwith "TODO m03" diff --git a/fsharp/src/Snake.fsproj b/fsharp/src/Snake.fsproj index 2ff9cb0..5bc1a8e 100644 --- a/fsharp/src/Snake.fsproj +++ b/fsharp/src/Snake.fsproj @@ -1,9 +1,17 @@ net10.0 + + $(NoWarn);FS0046 false + + + + + + diff --git a/fsharp/src/Spawn.fs b/fsharp/src/Spawn.fs new file mode 100644 index 0000000..e73912b --- /dev/null +++ b/fsharp/src/Spawn.fs @@ -0,0 +1,18 @@ +/// m04 spawn: placing apples with the Random from Random.fs. +module Snake.Spawn + +open Snake.Model + +/// Every board cell not covered by the snake, row-major (y outer loop, x inner loop). The apple does not count as occupied. +let freeCells (state: GameState) : Point list = failwith "TODO m04" + +/// Put an apple on a random free cell, drawing from `state.Seed` (use `Random.intBelow` and store the new seed). +/// Unchanged if there already is an apple or the status is not Playing. +/// If no cell is free the board is full: status becomes Won (seed unchanged, no apple). +let spawnApple (state: GameState) : GameState = failwith "TODO m04" + +/// One game step: `Rules.advance`, then make sure there is an apple. +let tick (state: GameState) : GameState = failwith "TODO m04" + +/// A fresh game (`initialState`) with its first apple already placed. +let newGame (seed: uint32) : GameState = failwith "TODO m04" diff --git a/fsharp/src/Util.fs b/fsharp/src/Util.fs index d84ca40..15edef2 100644 --- a/fsharp/src/Util.fs +++ b/fsharp/src/Util.fs @@ -1,2 +1,12 @@ -/// Placeholder; replaced by the m01 utilities. +/// m01 util: small helpers used by every later module. +/// (The JavaScript track also writes `pipe` and `compose` here; F# has them built in as `|>` and `>>`.) module Snake.Util + +/// `range n` is `[0; 1; ...; n-1]`; `range 0` is `[]`. +let range (n: int) : int list = failwith "TODO m01" + +/// `xs` without its last element; an empty list stays empty. +let dropLast (xs: 'a list) : 'a list = failwith "TODO m01" + +/// True if some element `x` of `xs` satisfies `eq x p`. `eq` lets callers choose how elements are compared. +let contains (xs: 'a list) (p: 'a) (eq: 'a -> 'a -> bool) : bool = failwith "TODO m01" diff --git a/fsharp/src/View.fs b/fsharp/src/View.fs new file mode 100644 index 0000000..4d28e21 --- /dev/null +++ b/fsharp/src/View.fs @@ -0,0 +1,23 @@ +/// m06 view: from game state to a Scene. The picture is plain data; drawing it is somebody else's job (m07). +module Snake.View + +open Snake.Model + +/// A thing to draw. +type Shape = + | Rect of x: int * y: int * w: int * h: int * color: string + | Text of x: int * y: int * text: string * color: string * size: int * align: string + +/// A picture: shapes drawn in order. +type Scene = Shape list + +/// Pixels per board cell. +let CELL = 20 + +/// Describe the picture as data, in this order: background `Rect(0, 0, Width*CELL, Height*CELL, "#111111")`; +/// the apple (if any) as `Rect(ax*CELL+1, ay*CELL+1, CELL-2, CELL-2, "#e53935")`; each snake segment in list order, +/// same size, head "#7cb342" and body "#43a047"; `Text(6, 18, "Score: ", "#ffffff", 16, "left")`; +/// then an overlay centred at (Width*CELL/2, Height*CELL/2), all "#ffffff", align "center": +/// Paused: "Paused" (size 32) and, 30px lower, "Press Space to resume" (size 16); +/// GameOver: "Game Over" + "Press R to restart"; Won: "You Win!" + "Press R to restart"; Playing: none. +let view (state: GameState) : Scene = failwith "TODO m06" diff --git a/fsharp/tests/Fixtures.fs b/fsharp/tests/Fixtures.fs index 031ad3e..171cb95 100644 --- a/fsharp/tests/Fixtures.fs +++ b/fsharp/tests/Fixtures.fs @@ -1,9 +1,11 @@ -/// Loads shared fixtures from ../../fixtures/.json. -/// Typed decoders (point, direction, status, action, state, opt) arrive with the model (Task 13). +/// Loads shared fixtures from ../fixtures/.json and decodes them into the course's types. module Snake.Tests.Fixtures open System.IO open System.Text.Json +open Expecto +open Snake.Model +open Snake.View let private dir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "fixtures")) @@ -19,3 +21,68 @@ let cases (name: string) : JsonElement list = /// Decode a nullable JSON value into an Option. let opt (f: JsonElement -> 'a) (v: JsonElement) : 'a option = if v.ValueKind = JsonValueKind.Null then None else Some(f v) + +let prop (key: string) (v: JsonElement) : JsonElement = v.GetProperty(key) +let str (v: JsonElement) : string = v.GetString() +let num (v: JsonElement) : int = v.GetInt32() +let u32 (v: JsonElement) : uint32 = v.GetUInt32() +let bool (v: JsonElement) : bool = v.GetBoolean() +let list (f: JsonElement -> 'a) (v: JsonElement) : 'a list = v.EnumerateArray() |> Seq.map f |> List.ofSeq + +/// The i-th argument of a case. +let arg (i: int) (c: JsonElement) : JsonElement = (prop "args" c)[i] +let expected (c: JsonElement) : JsonElement = prop "expected" c +let name (c: JsonElement) : string = str (prop "name" c) + +let point (v: JsonElement) : Point = { X = num (prop "x" v); Y = num (prop "y" v) } + +let direction (v: JsonElement) : Direction = + match str v with + | "Up" -> Up + | "Down" -> Down + | "Left" -> Left + | "Right" -> Right + | other -> failwith $"unknown direction: {other}" + +let status (v: JsonElement) : Status = + match str v with + | "Playing" -> Playing + | "Paused" -> Paused + | "GameOver" -> GameOver + | "Won" -> Won + | other -> failwith $"unknown status: {other}" + +let action (v: JsonElement) : Action = + match str (prop "tag" v) with + | "Tick" -> Tick + | "Turn" -> Turn(direction (prop "dir" v)) + | "TogglePause" -> TogglePause + | "Restart" -> Restart + | "Frame" -> Frame + | other -> failwith $"unknown action tag: {other}" + +/// JSON uses camelCase field names; the F# record uses PascalCase. +let gameState (v: JsonElement) : GameState = + { Width = num (prop "width" v) + Height = num (prop "height" v) + Snake = list point (prop "snake" v) + Dir = direction (prop "dir" v) + NextDir = direction (prop "nextDir" v) + Apple = opt point (prop "apple" v) + Seed = u32 (prop "seed" v) + Score = num (prop "score" v) + Status = status (prop "status" v) + Frame = num (prop "frame" v) } + +let shape (v: JsonElement) : Shape = + let n key = num (prop key v) + let s key = str (prop key v) + + match s "tag" with + | "Rect" -> Rect(n "x", n "y", n "w", n "h", s "color") + | "Text" -> Text(n "x", n "y", s "text", s "color", n "size", s "align") + | other -> failwith $"unknown shape tag: {other}" + +/// One test per case of a fixture, named "