Skip to content

Commit a82e565

Browse files
authored
Made Tty example: snake (#369)
* Made Tty example: snake * add expect test * no timeout * cleanup
1 parent 38ffadd commit a82e565

File tree

6 files changed

+279
-45
lines changed

6 files changed

+279
-45
lines changed

ci/all_tests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ for roc_file in $EXAMPLES_DIR*.roc; do
102102
base_file=$(basename "$roc_file")
103103

104104
# countdown, echo, form... all require user input or special setup
105-
ignore_list=("stdin-basic.roc" "stdin-pipe.roc" "command-line-args.roc" "http.roc" "env-var.roc" "bytes-stdin-stdout.roc" "error-handling.roc" "tcp-client.roc")
105+
ignore_list=("stdin-basic.roc" "stdin-pipe.roc" "command-line-args.roc" "http.roc" "env-var.roc" "bytes-stdin-stdout.roc" "error-handling.roc" "tcp-client.roc" "terminal-app-snake.roc")
106106

107107
# check if base_file matches something from ignore_list
108108
for file in "${ignore_list[@]}"; do
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/expect
2+
3+
# uncomment line below for debugging
4+
# exp_internal 1
5+
6+
set timeout 7
7+
8+
source ./ci/expect_scripts/shared-code.exp
9+
10+
spawn $env(EXAMPLES_DIR)terminal-app-snake
11+
12+
expect "Score: 0\r\n" {
13+
14+
# Press 's' key 9 times
15+
for {set i 1} {$i <= 9} {incr i} {
16+
send "s"
17+
18+
expect -re {Score:.*}
19+
}
20+
21+
# This press should make the snake collide with the bottom wall and lead to game over
22+
send "s"
23+
24+
expect -re {.*Game Over.*} {
25+
expect eof {
26+
check_exit_and_segfault
27+
}
28+
}
29+
30+
}
31+
32+
puts stderr "\nExpect script failed: output was different from expected value."
33+
exit 1

examples/.gitignore

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,6 @@
1-
args
2-
command
3-
command-line-args
4-
dir
5-
error-handling
6-
env
7-
example-stdin
8-
file-mixed
9-
file-mixedBROKEN
10-
file-read-write
11-
file-read-buffered
12-
file-read-memory-map
13-
file-size
14-
file-permissions
15-
file-accessed-modified-created-time
16-
form
17-
hello-world
18-
http
19-
dup-bytes
20-
out.txt
21-
path
22-
print
23-
stdin-pipe
24-
record-builder
25-
result
26-
stdin
27-
stdin-basic
28-
sqlite-basic
29-
sqlite-everything
30-
sqlite-test
31-
task-list
32-
tcp-client
33-
time
34-
bytes-stdin-stdout
35-
locale
36-
piping
1+
# Ignore all files including binary files that have no extension
2+
*
3+
# Unignore all files with extensions
4+
!*.*
5+
# Unignore all directories
6+
!*/

examples/stdin-basic.roc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import pf.Arg exposing [Arg]
66

77
# To run this example: check the README.md in this folder
88

9-
# Reading text from stdin
9+
# Reading text from stdin.
10+
# If you want to read Stdin from a pipe, check out examples/stdin-pipe.roc
1011

1112
main! : List Arg => Result {} _
1213
main! = |_args|

examples/terminal-app-snake.roc

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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

platform/Tty.roc

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
## Provides functionality to work with the terminal
1+
## Provides functionality to change the behaviour of the terminal.
2+
## This is useful for running an app like vim or a game in the terminal.
23
##
34
## Note: we plan on moving this file away from basic-cli in the future, see github.com/roc-lang/basic-cli/issues/73
45
##
@@ -9,13 +10,12 @@ module [
910

1011
import Host
1112

12-
## Enable terminal raw mode which disables some default terminal bevahiour.
13+
## Enable terminal [raw mode](https://en.wikipedia.org/wiki/Terminal_mode) to disable some default terminal bevahiour.
1314
##
14-
## The following modes are disabled:
15-
## - Input will not be echo to the terminal screen
16-
## - Input will not be buffered until Enter key is pressed
17-
## - Input will not be line buffered (input sent byte-by-byte to input buffer)
18-
## - Special keys like Backspace and CTRL+C will not be processed by terminal driver
15+
## This leads to the following changes:
16+
## - Input will not be echoed to the terminal screen.
17+
## - Input will be sent straight to the program instead of being buffered (= collected) until the Enter key is pressed.
18+
## - Special keys like Backspace and CTRL+C will not be processed by the terminal driver but will be passed to the program.
1919
##
2020
## Note: we plan on moving this function away from basic-cli in the future, see github.com/roc-lang/basic-cli/issues/73
2121
##

0 commit comments

Comments
 (0)