|
| 1 | +app [main!] { pf: platform "../platform/main.roc" } |
| 2 | + |
| 3 | +import pf.Stdin |
| 4 | +import pf.Stdout |
| 5 | +import pf.Tty |
| 6 | +import pf.Arg exposing [Arg] |
| 7 | + |
| 8 | +# To run this example: check the README.md in this folder |
| 9 | + |
| 10 | +# If you want to make a full screen terminal app, you probably want to switch the terminal to [raw mode](https://en.wikipedia.org/wiki/Terminal_mode). |
| 11 | +# Here we demonstrate `Tty.enable_raw_mode!` and `Tty.disable_raw_mode!` with a simple snake game. |
| 12 | + |
| 13 | +Position : { x : I64, y : I64 } |
| 14 | + |
| 15 | +GameState : { |
| 16 | + snake_lst : NonEmptyList, |
| 17 | + food_pos : Position, |
| 18 | + direction : [Up, Down, Left, Right], |
| 19 | + game_over : Bool, |
| 20 | +} |
| 21 | + |
| 22 | +# The snake list should never be empty, so we use a non-empty list. |
| 23 | +# Typically we'd use head and tail, but this would be confusing with the snake's head and tail later on :) |
| 24 | +NonEmptyList : { first : Position, rest : List Position } |
| 25 | + |
| 26 | +initial_state = { |
| 27 | + snake_lst: { first: { x: 10, y: 10 }, rest: [{ x: 9, y: 10 }, { x: 8, y: 10 }] }, |
| 28 | + food_pos: { x: 15, y: 15 }, |
| 29 | + direction: Right, |
| 30 | + game_over: Bool.false, |
| 31 | +} |
| 32 | + |
| 33 | +# Keep this above 15 for the initial food_pos |
| 34 | +grid_size = 20 |
| 35 | + |
| 36 | +init_snake_len = len(initial_state.snake_lst) |
| 37 | + |
| 38 | +main! : List Arg => Result {} _ |
| 39 | +main! = |_args| |
| 40 | + Tty.enable_raw_mode!({}) |
| 41 | + |
| 42 | + game_loop!(initial_state)? |
| 43 | + |
| 44 | + Tty.disable_raw_mode!({}) |
| 45 | + Stdout.line!("\n--- Game Over ---") |
| 46 | +
|
| 47 | +game_loop! : GameState => Result {} _ |
| 48 | +game_loop! = |state| |
| 49 | + if state.game_over then |
| 50 | + Ok({}) |
| 51 | + else |
| 52 | + draw_game!(state)? |
| 53 | +
|
| 54 | + # Check keyboard input |
| 55 | + input_bytes = Stdin.bytes!({})? |
| 56 | +
|
| 57 | + partial_new_state = |
| 58 | + when input_bytes is |
| 59 | + ['w'] -> { state & direction: Up } |
| 60 | + ['s'] -> { state & direction: Down } |
| 61 | + ['a'] -> { state & direction: Left } |
| 62 | + ['d'] -> { state & direction: Right } |
| 63 | + ['q'] -> { state & game_over: Bool.true } |
| 64 | + _ -> state |
| 65 | +
|
| 66 | + new_state = update_game(partial_new_state) |
| 67 | + game_loop!(new_state) |
| 68 | +
|
| 69 | +update_game : GameState -> GameState |
| 70 | +update_game = |state| |
| 71 | + if state.game_over then |
| 72 | + state |
| 73 | + else |
| 74 | + snake_head_pos = state.snake_lst.first |
| 75 | + new_head_pos = move_head(snake_head_pos, state.direction) |
| 76 | +
|
| 77 | + new_state = |
| 78 | + # Check wall collision |
| 79 | + if new_head_pos.x < 0 or new_head_pos.x >= grid_size or new_head_pos.y < 0 or new_head_pos.y >= grid_size then |
| 80 | + { state & game_over: Bool.true } |
| 81 | +
|
| 82 | + # Check self collision |
| 83 | + else if contains(state.snake_lst, new_head_pos) then |
| 84 | + { state & game_over: Bool.true } |
| 85 | + |
| 86 | + # Check food collision |
| 87 | + else if new_head_pos == state.food_pos then |
| 88 | + new_snake_lst = prepend(state.snake_lst, new_head_pos) |
| 89 | +
|
| 90 | + new_food_pos = { x: (new_head_pos.x + 3) % grid_size, y: (new_head_pos.y + 3) % grid_size } |
| 91 | +
|
| 92 | + { state & snake_lst: new_snake_lst, food_pos: new_food_pos } |
| 93 | + |
| 94 | + # No collision; move the snake |
| 95 | + else |
| 96 | + new_snake_lst = |
| 97 | + prepend(state.snake_lst, new_head_pos) |
| 98 | + |> |snake_lst| { first: snake_lst.first, rest: List.drop_last(snake_lst.rest, 1) } |
| 99 | +
|
| 100 | + { state & snake_lst: new_snake_lst } |
| 101 | +
|
| 102 | + new_state |
| 103 | +
|
| 104 | +move_head : Position, [Down, Left, Right, Up] -> Position |
| 105 | +move_head = |head, direction| |
| 106 | + when direction is |
| 107 | + Up -> { head & y: head.y - 1 } |
| 108 | + Down -> { head & y: head.y + 1 } |
| 109 | + Left -> { head & x: head.x - 1 } |
| 110 | + Right -> { head & x: head.x + 1 } |
| 111 | +
|
| 112 | +draw_game! : GameState => Result {} _ |
| 113 | +draw_game! = |state| |
| 114 | + clear_screen!({})? |
| 115 | +
|
| 116 | + Stdout.line!("\nControls: W A S D to move, Q to quit\n\r")? |
| 117 | +
|
| 118 | + # \r to fix indentation because we're in raw mode |
| 119 | + Stdout.line!("Score: ${Num.to_str(len(state.snake_lst) - init_snake_len)}\r")? |
| 120 | +
|
| 121 | + rendered_game_str = draw_game_pure(state) |
| 122 | +
|
| 123 | + Stdout.line!("${rendered_game_str}\r") |
| 124 | +
|
| 125 | +draw_game_pure : GameState -> Str |
| 126 | +draw_game_pure = |state| |
| 127 | + List.range({ start: At 0, end: Before grid_size }) |
| 128 | + |> List.map( |
| 129 | + |yy| |
| 130 | + line = |
| 131 | + List.range({ start: At 0, end: Before grid_size }) |
| 132 | + |> List.map( |
| 133 | + |xx| |
| 134 | + pos = { x: xx, y: yy } |
| 135 | + if contains(state.snake_lst, pos) then |
| 136 | + if pos == state.snake_lst.first then |
| 137 | + "O" # Snake head |
| 138 | + else |
| 139 | + "o" # Snake body |
| 140 | + else if pos == state.food_pos then |
| 141 | + "*" # food_pos |
| 142 | + else |
| 143 | + ".", # Empty space |
| 144 | + ) |
| 145 | + |> Str.join_with("") |
| 146 | +
|
| 147 | + line, |
| 148 | + ) |
| 149 | + |> Str.join_with("\r\n") |
| 150 | +
|
| 151 | +clear_screen! = |{}| |
| 152 | + Stdout.write!("\u(001b)[2J\u(001b)[H") # ANSI escape codes to clear screen |
| 153 | +
|
| 154 | +# NonEmptyList helpers |
| 155 | +
|
| 156 | +contains : NonEmptyList, Position -> Bool |
| 157 | +contains = |list, pos| |
| 158 | + list.first == pos or List.contains(list.rest, pos) |
| 159 | +
|
| 160 | +prepend : NonEmptyList, Position -> NonEmptyList |
| 161 | +prepend = |list, pos| |
| 162 | + { first: pos, rest: List.prepend(list.rest, list.first) } |
| 163 | +
|
| 164 | +len : NonEmptyList -> U64 |
| 165 | +len = |list| |
| 166 | + 1 + List.len(list.rest) |
| 167 | +
|
| 168 | +# Tests |
| 169 | +
|
| 170 | +expect |
| 171 | + grid_size == 20 # The tests below assume a grid size of 20 |
| 172 | +
|
| 173 | +expect |
| 174 | + initial_grid = draw_game_pure(initial_state) |
| 175 | + expected_grid = |
| 176 | + """ |
| 177 | + ....................\r |
| 178 | + ....................\r |
| 179 | + ....................\r |
| 180 | + ....................\r |
| 181 | + ....................\r |
| 182 | + ....................\r |
| 183 | + ....................\r |
| 184 | + ....................\r |
| 185 | + ....................\r |
| 186 | + ....................\r |
| 187 | + ........ooO.........\r |
| 188 | + ....................\r |
| 189 | + ....................\r |
| 190 | + ....................\r |
| 191 | + ....................\r |
| 192 | + ...............*....\r |
| 193 | + ....................\r |
| 194 | + ....................\r |
| 195 | + ....................\r |
| 196 | + .................... |
| 197 | + """ |
| 198 | +
|
| 199 | + initial_grid == expected_grid |
| 200 | +
|
| 201 | +# Test moving down |
| 202 | +expect |
| 203 | + new_state = update_game({ initial_state & direction: Down }) |
| 204 | + new_grid = draw_game_pure(new_state) |
| 205 | +
|
| 206 | + expected_grid = |
| 207 | + """ |
| 208 | + ....................\r |
| 209 | + ....................\r |
| 210 | + ....................\r |
| 211 | + ....................\r |
| 212 | + ....................\r |
| 213 | + ....................\r |
| 214 | + ....................\r |
| 215 | + ....................\r |
| 216 | + ....................\r |
| 217 | + ....................\r |
| 218 | + .........oo.........\r |
| 219 | + ..........O.........\r |
| 220 | + ....................\r |
| 221 | + ....................\r |
| 222 | + ....................\r |
| 223 | + ...............*....\r |
| 224 | + ....................\r |
| 225 | + ....................\r |
| 226 | + ....................\r |
| 227 | + .................... |
| 228 | + """ |
| 229 | +
|
| 230 | + new_grid == expected_grid |
0 commit comments