plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


6203667 — Peter John 2026-11-04T13:09:59+05:30
examples: consolidate 39 files into 25 topical ones (tests unchanged: 408); assets and tools move under games/
Files changed (39) hide show
  1. plum-examples/annotated_locals.plum +0 -78
  2. plum-examples/{struct_arrays.plum → arrays.plum} +54 -6
  3. plum-examples/basics.plum +115 -2
  4. plum-examples/cons_list.plum +0 -70
  5. plum-examples/constants.plum +0 -72
  6. plum-examples/control_flow.plum +133 -1
  7. plum-examples/digit_names.plum +0 -59
  8. plum-examples/{error_propagation.plum → errors.plum} +238 -0
  9. plum-examples/forms.plum +0 -155
  10. plum-examples/functions.plum +0 -77
  11. plum-examples/{assets → games/assets}/gameover.wav +0 -0
  12. plum-examples/{assets → games/assets}/hit.wav +0 -0
  13. plum-examples/{assets → games/assets}/music.wav +0 -0
  14. plum-examples/{assets → games/assets}/pickup.wav +0 -0
  15. plum-examples/{assets → games/assets}/sprites.png +0 -0
  16. plum-examples/games/functional/game_state_view.plum +450 -549
  17. plum-examples/games/imperative/game.plum +43 -23
  18. plum-examples/{game_loop.plum → games/imperative/game_loop.plum} +1 -1
  19. plum-examples/games/imperative/game_scene.plum +51 -47
  20. plum-examples/games/imperative/scene.plum +7 -8
  21. plum-examples/games/pure/game_pure_scene.plum +181 -163
  22. plum-examples/{tools → games/tools}/make_sounds.py +1 -1
  23. plum-examples/{tools → games/tools}/make_sprites.py +0 -0
  24. plum-examples/io.plum +0 -14
  25. plum-examples/loops.plum +0 -125
  26. plum-examples/numbers.plum +142 -0
  27. plum-examples/operators.plum +0 -67
  28. plum-examples/optionals.plum +0 -232
  29. plum-examples/own_list.plum +0 -33
  30. plum-examples/recursion.plum +221 -0
  31. plum-examples/{seq.plum → sequences.plum} +161 -2
  32. plum-examples/struct_union_members.plum +1 -1
  33. plum-examples/structs_records.plum +156 -12
  34. plum-examples/tail_calls.plum +0 -110
  35. plum-examples/testing.plum +0 -20
  36. plum-examples/trait_scene.plum +0 -50
  37. plum-examples/traits.plum +62 -6
  38. plum-examples/typed_arrays.plum +0 -39
  39. plum-examples/unboxed_variants.plum +6 -6
plum-examples/annotated_locals.plum DELETED
@@ -1,78 +0,0 @@
1
- # Annotated local declarations: `name: Type := value` fixes the local's type to
2
- # the annotation. The value converts to it exactly as it would for a parameter of
3
- # that type (a union member into its enum, `T` into `T?`, `null` into `T?`), and
4
- # a later `name = value` is checked and converted against the same type.
5
- module annotated_locals
6
- import std/Str
7
- import std/Int
8
- import std/Float
9
- import std/Slice
10
-
11
- record Circle(r: Float)
12
-
13
- record Rect(w: Float, h: Float)
14
-
15
- enum Shape = Circle | Rect
16
-
17
- error LocalError = Bad | Worse
18
-
19
- fun area(s: Shape) -> Float =
20
- match s
21
- Circle(r) => r * r * 3.0
22
- Rect(w, h) => w * h
23
-
24
- fun parseLocal(n: Int) -> LocalError!Int =
25
- if n < 0
26
- return error.Bad
27
- n * 2
28
-
29
- fun twice(x: Int) -> Int = x * 2
30
-
31
- fun same(x: T) -> T =
32
- y: T := x
33
- y
34
-
35
- test "union_local"
36
- s: Shape := Circle(r: 2.0)
37
- assert area(s) == 12.0
38
-
39
- test "union_local_reassigned"
40
- s: Shape := Circle(r: 1.0)
41
- s = Rect(w: 2.0, h: 3.0)
42
- assert area(s) == 6.0
43
-
44
- test "optional_local_present"
45
- o: Int? := 4
46
- assert {o ?: 0} == 4
47
-
48
- test "optional_local_null"
49
- o: Int? := null
50
- assert {o ?: 9} == 9
51
- o = 5
52
- assert {o ?: 9} == 5
53
-
54
- test "list_local"
55
- xs: Slice[Shape] := Slice[Shape]()
56
- xs.push(Circle(r: 1.0))
57
- xs.push(Rect(w: 1.0, h: 1.0))
58
- assert xs.length() == 2
59
-
60
- test "fn_local"
61
- f: fn(Int) -> Int := twice
62
- assert f(4) == 8
63
-
64
- test "scalar_locals"
65
- n: Int := 3
66
- x: Float := 2.5
67
- assert n + 1 == 4
68
- assert x * 2.0 == 5.0
69
-
70
- test "generic_param_annotation"
71
- assert same(7) == 7
72
- assert same("a") == "a"
73
-
74
- test "named_error_set_local"
75
- ok: LocalError!Int := parseLocal(4)
76
- bad: LocalError!Int := parseLocal(-1)
77
- assert {ok catch 0} == 8
78
- assert {bad catch 7} == 7
plum-examples/{struct_arrays.plum → arrays.plum} RENAMED
@@ -1,13 +1,48 @@
1
- # Arrays of structs are stored field by field (struct-of-arrays): `Array[Vec2]`
1
+ # Arrays: typed native arrays and arrays of structs.
2
- # is one object holding an `f64` array for `x` and one for `y`, so `get`/`set`
3
- # allocate nothing per element. `Slice`, `Grid` and friends get this for free.
4
- module struct_arrays
5
- import std/Str
6
2
  import std/Int
7
3
  import std/Float
8
4
  import std/Array
9
- import std/Slice
10
5
  import std/Grid
6
+ import std/Seq
7
+ import std/Str
8
+ import std/Slice
9
+
10
+ # ==== typed arrays ====
11
+
12
+ # Typed arrays: unboxed native `Array[Int]` and `Array[Float]` lowering to
13
+ # WebAssembly primitive arrays, along with flat 2D `Grid[T]`.
14
+
15
+ # Tile-grid style data: Array[Int] / Array[Float] / Array[Bool] are native
16
+ # unboxed wasm arrays (i64 / f64 / i32 elements), not boxed anyref arrays.
17
+ fun sumInts(n: Int) -> Int =
18
+ a := Array.filled(n, 3)
19
+ total := 0
20
+ for i := range n
21
+ total = total + a.get(i)
22
+ return total
23
+
24
+ fun sumFloats(n: Int) -> Float =
25
+ a := Array.filled(n, 0.5)
26
+ total := 0.0
27
+ for i := range n
28
+ total = total + a.get(i)
29
+ return total
30
+
31
+ fun countTrue(n: Int) -> Int =
32
+ a := Array.filled(n, false)
33
+ a.set(1, true)
34
+ a.set(3, true)
35
+ c := 0
36
+ for i := range n
37
+ if a.get(i)
38
+ c = c + 1
39
+ return c
40
+
41
+ # ==== struct arrays ====
42
+
43
+ # Arrays of structs are stored field by field (struct-of-arrays): `Array[Vec2]`
44
+ # is one object holding an `f64` array for `x` and one for `y`, so `get`/`set`
45
+ # allocate nothing per element. `Slice`, `Grid` and friends get this for free.
11
46
 
12
47
  struct Vec2(x: Float, y: Float)
13
48
 
@@ -21,9 +56,13 @@ struct Tile(id: Int, solid: Bool, weight: Float)
21
56
  record World(points: Slice[Vec2], name: Str)
22
57
 
23
58
  struct One(n: Int)
59
+
24
60
  struct Flags(a: Bool, b: Bool, c: Int)
61
+
25
62
  struct L3(r: Rect, k: Int)
63
+
26
64
  record Holder(arr: Array[Vec2])
65
+
27
66
  record Named(label: Str, v: Vec2)
28
67
 
29
68
  fun bump(h: Holder) =
@@ -40,6 +79,15 @@ struct Px(r: Byte, g: Byte, b: Byte, a: Byte)
40
79
  fun mkPx(v: Int) -> Px =
41
80
  Px(r: Byte(v), g: Byte(v + 1), b: Byte(v + 2), a: Byte(255))
42
81
 
82
+ # ==== typed arrays tests ====
83
+
84
+ test "array_int_native: filled arrays hold their values"
85
+ assert sumInts(10) == 30
86
+ assert sumFloats(4) == 2.0
87
+ assert countTrue(5) == 2
88
+
89
+ # ==== struct arrays tests ====
90
+
43
91
  test "List[Vec2] push/get/set round-trips across growth"
44
92
  l := Slice[Vec2]()
45
93
  for i := range 100
plum-examples/basics.plum CHANGED
@@ -1,7 +1,10 @@
1
- module basics
1
+ # Basics: literals, operators, functions, testing and the Io entry point.
2
2
  import std/Int
3
3
  import std/Float
4
4
  import std/Str
5
+ import std/Io
6
+
7
+ # ==== basics ====
5
8
 
6
9
  # A `primitive` with a non-built-in name is a stateless namespace: constants and
7
10
  # static functions, reached as `Config.MaxRetries`. It is not a value or a type.
@@ -10,7 +13,7 @@ primitive Config =
10
13
  GoldenRatio = 1.61803
11
14
  Greeting = "hello"
12
15
 
13
- fun main() =
16
+ fun syntaxTour() =
14
17
  dec := 42
15
18
  hex := 0xFF
16
19
  bin := 0b1010
@@ -31,3 +34,113 @@ fun main() =
31
34
  positive := +sum
32
35
  inverted := !cmp
33
36
  a, b := 1, 2
37
+
38
+ # ==== io ====
39
+
40
+ # Every call that leaves the program goes through the `Io` capability. An entry
41
+ # point (`main`, an `export fun`, a `test`) may take it as its first parameter.
42
+ fun main(io: Io) =
43
+ io.printLn("hello from plum")
44
+ name := "world"
45
+ io.printLn("hello, {name}!")
46
+
47
+ # ==== testing ====
48
+
49
+ # Testing: native `test "name"` blocks and `assert` statements executed by
50
+ # the `plum test` runner.
51
+
52
+ fun add(a: Int, b: Int) -> Int =
53
+ a + b
54
+
55
+ fun isEven(n: Int) -> Bool =
56
+ n % 2 == 0
57
+
58
+ # ==== functions ====
59
+
60
+ # Functions: top-level function declarations, typed arguments, default and
61
+ # variadic parameters, return expressions, and recursion.
62
+
63
+ fun addInts(a: Int, b: Int) -> Int =
64
+ a + b
65
+
66
+ fun greet() =
67
+ todo
68
+
69
+ fun withDefault(a: Int, step: Int = 1) -> Int =
70
+ a + step
71
+
72
+ fun sumAll(nums: ...Int) -> Int =
73
+ total := 0
74
+ for v := range nums
75
+ total = total + v
76
+ total
77
+
78
+ fun wrap(value: T) -> Bool =
79
+ true
80
+
81
+ fun pair(first: T, second: U) -> Bool =
82
+ true
83
+
84
+ fun useWrap() -> Bool =
85
+ wrap(5)
86
+
87
+ fun usePair() -> Bool =
88
+ pair(1, "x")
89
+
90
+ # ---- recursion, defaults and variadics ----
91
+
92
+ fun factorial(x: Int) -> Int =
93
+ if x < 2
94
+ return 1
95
+ return x * factorial(x - 1)
96
+
97
+ # Regression test: `double(...)` (an all-lowercase, no-uppercase, no-underscore
98
+ # callee) used to fail to parse at all — `var_identifier` and `fn_identifier`
99
+ # both matched its text and the grammar's lexer would nondeterministically
100
+ # commit to `var_identifier`, breaking every such call site.
101
+ fun double(n: Int) -> Int =
102
+ n * 2
103
+
104
+ fun combineVariadic(prefix: Int, rest: ...Int) -> Int =
105
+ prefix
106
+
107
+ fun weighted(nums: ...Int) -> Int =
108
+ total := 0
109
+ for i, v := range nums
110
+ total = total + i * v
111
+ total
112
+
113
+ # ==== testing tests ====
114
+
115
+ test "add works"
116
+ assert add(1, 2) == 3
117
+ assert add(2, 2) == 4
118
+
119
+ test "isEven works"
120
+ assert isEven(4)
121
+ assert !isEven(3)
122
+ assert isEven(2)
123
+
124
+ # ==== functions tests ====
125
+
126
+ test "factorial runs correctly"
127
+ assert factorial(5) == 120
128
+
129
+ test "lowercase single word function call runs correctly"
130
+ assert double(21) == 42
131
+
132
+ test "variadic call with varying trailing arg counts runs correctly"
133
+ a := combineVariadic(10)
134
+ b := combineVariadic(20, 1)
135
+ c := combineVariadic(30, 1, 2, 3)
136
+ assert a + b + c == 60
137
+
138
+ test "sum all variadic int runs correctly"
139
+ assert sumAll(1, 2, 3, 4) == 10
140
+
141
+ test "sum all variadic int with zero args runs correctly"
142
+ assert sumAll() == 0
143
+
144
+ test "for over a variadic binds index and element"
145
+ assert weighted() == 0
146
+ assert weighted(5, 10, 20) == 50
plum-examples/cons_list.plum DELETED
@@ -1,70 +0,0 @@
1
- # The std cons List: constant stack at 1,000,000 elements.
2
- module cons_list
3
- import std/Int
4
- import std/Str
5
- import std/Slice
6
- import std/Array
7
- import std/List
8
-
9
- fun range(a: Int, b: Int) -> List[Int] =
10
- if a >= b then Nil else Cons(head: a, tail: range(a + 1, b))
11
-
12
- test "init_sum"
13
- assert List(1, 2, 3, 4).fold(0, |a, x| a + x) == 10
14
- assert List(1, 2, 3).length() == 3
15
- assert { List(1, 2, 3).get(1) ?: -1 } == 2
16
- assert List(1, 2, 3).get(5) == null
17
- nil: List[Int] := Nil
18
- assert nil.isEmpty()
19
- assert !List(1).isEmpty()
20
-
21
- test "map_filter_fold"
22
- xs := List(1, 2, 3, 4, 5, 6)
23
- ys := xs.map(|x| x * 2).filter(|x| x % 3 == 0)
24
- assert ys.length() == 2
25
- assert ys.fold(0, |a, x| a + x) == 18
26
-
27
- test "reverse_append"
28
- xs := List(1, 2, 3).reverse()
29
- assert { xs.get(0) ?: -1 } == 3
30
- assert { xs.get(2) ?: -1 } == 1
31
- ys := List(1, 2).append(List(3, 4))
32
- assert ys.equals(List(1, 2, 3, 4))
33
-
34
- test "for_range"
35
- total := 0
36
- for x := range List(1, 2, 3, 4)
37
- total = total + x
38
- assert total == 10
39
- big := 0
40
- for x := range range(0, 1000000).items()
41
- big = big + x
42
- assert big == 499999500000
43
-
44
- test "to_slice_round_trip"
45
- s := List(1, 2, 3).map(|x| x * 2).toSlice()
46
- assert s.equals(Slice(2, 4, 6))
47
- assert List.fromSlice(s).equals(List(2, 4, 6))
48
-
49
- test "to_array"
50
- a := List(7, 8, 9).toArray()
51
- assert a.length() == 3
52
- assert a.get(2) == 9
53
-
54
- test "map_million"
55
- ys := range(0, 1000000).map(|x| x + 1)
56
- assert ys.length() == 1000000
57
- assert ys.fold(0, |a, x| a + x) == 500000500000
58
-
59
- test "fold_million"
60
- xs := range(0, 1000000)
61
- assert xs.fold(0, |a, x| a + x) == 499999500000
62
- assert xs.filter(|x| x % 2 == 0).length() == 500000
63
- assert { xs.reverse().get(0) ?: -1 } == 999999
64
-
65
- test "equals_and_toStr"
66
- assert List(1, 2, 3).equals(List(1, 2, 3))
67
- assert !List(1, 2, 3).equals(List(1, 2))
68
- assert !List(1, 2).equals(List(1, 2, 3))
69
- assert !List(1, 2, 3).equals(List(1, 2, 4))
70
- assert List(1, 2, 3).toStr() == "[1,2,3]"
plum-examples/constants.plum DELETED
@@ -1,72 +0,0 @@
1
- module constants
2
- import std/Int
3
- import std/Float
4
- import std/Str
5
- import std/Slice
6
-
7
- # Constants and static functions live inside a type's body or a `primitive`
8
- # namespace — there are no top-level constants. `Name = expr` declares an
9
- # associated constant, a `fun` without `self` a static function. They are
10
- # reached as `Type.Name` / `Type.f()` from anywhere, including inside the same
11
- # body. Constant names are unique per type, not global.
12
-
13
- # A `primitive` whose name is not a built-in type is a stateless namespace.
14
- primitive Grid =
15
- Width = 8
16
- Height = Grid.Width * 2
17
- CellScale = 2.5
18
- Name = "plum"
19
- DebugDraw = true
20
-
21
- fun cells() -> Int =
22
- Grid.Width * Grid.Height
23
-
24
- record Tile(x: Int) =
25
-
26
- Width = 100
27
-
28
- fun offset(self) -> Int = Tile.Width + self.x
29
-
30
- fun origin() -> Tile =
31
- Tile(x: 0)
32
-
33
- record Wrap[T](v: T) =
34
-
35
- fun area(self) -> Int = Grid.Width * Grid.Height
36
-
37
- struct Vec2(x: Float, y: Float) =
38
-
39
- Zero = Vec2(x: 0.0, y: 0.0)
40
-
41
- fun scaled(n: Float) -> Float = n * Grid.CellScale
42
-
43
- primitive Shelf =
44
- Sides = 4
45
- Names = Slice("a", "b", "c")
46
-
47
- fun sidesTwice() -> Int =
48
- twice(Shelf.Sides)
49
-
50
- fun twice(n: Int) -> Int =
51
- n * 2
52
-
53
- test "consts are usable in fns, methods, generics and derived consts"
54
- assert Grid.Height == 16
55
- assert Grid.cells() == 128
56
- assert Tile(x: 1).offset() == 101
57
- assert Wrap(v: 3.5).area() == 128
58
- assert scaled(2.0) == 5.0
59
- assert Grid.Name == "plum"
60
- assert Grid.DebugDraw
61
-
62
- test "the same const name can live on two types"
63
- assert Grid.Width == 8
64
- assert Tile.Width == 100
65
-
66
- test "a struct can hold a constant instance of itself"
67
- assert Vec2.Zero.x == 0.0
68
- assert Tile.origin().x == 0
69
-
70
- test "a primitive holds a list constant and a static fn reading a sibling const"
71
- assert Shelf.Names.length() == 3
72
- assert Shelf.sidesTwice() == 8
plum-examples/control_flow.plum CHANGED
@@ -1,8 +1,15 @@
1
- # Control flow: `if`/`else` branching, `while` loops, and `for ... := range` iteration.
1
+ # Control flow: branching and loops.
2
2
  import std/Str
3
3
  import std/Int
4
4
  import std/Float
5
5
  import std/Seq
6
+ import std/Slice
7
+ import std/Array
8
+ import std/Testing
9
+
10
+ # ==== control flow ====
11
+
12
+ # Control flow: `if`/`else` branching, `while` loops, and `for ... := range` iteration.
6
13
 
7
14
  fun loopSum(limit: Int) -> Int =
8
15
  total := 0
@@ -74,6 +81,25 @@ fun checkPositiveAndReturn(n: Int) -> Int =
74
81
  assert n > 0
75
82
  n
76
83
 
84
+ # ==== loops ====
85
+
86
+ # Loops: `while` condition loops, `for ... := range` iteration over ranges and
87
+ # collections, with inline filtering and mapping.
88
+
89
+ fun sumList(xs: Slice[Int]) -> Int =
90
+ total := 0
91
+ for x := range xs
92
+ total = total + x
93
+ return total
94
+
95
+ fun firstOver(xs: Slice[Int], limit: Int) -> Int =
96
+ for x := range xs
97
+ if x > limit
98
+ return x
99
+ return -1
100
+
101
+ # ==== control flow tests ====
102
+
77
103
  test "tail match without return runs correctly"
78
104
  assert bindExample(5) == 5
79
105
 
@@ -91,3 +117,109 @@ test "tail enum match without return runs correctly"
91
117
 
92
118
  test "assert passes through on true"
93
119
  assert checkPositiveAndReturn(5) == 5
120
+
121
+ # ==== loops tests ====
122
+
123
+ test "plain_iterable_for: List and Array"
124
+ xs := Slice(1, 2, 3, 4)
125
+ assert sumList(xs) == 10
126
+ arr := Array.filled(3, 7)
127
+ total := 0
128
+ for a := range arr
129
+ total = total + a
130
+ assert total == 21
131
+
132
+ test "index_value_binders: i, x"
133
+ xs := Slice(10, 20, 30)
134
+ acc := 0
135
+ for i, x := range xs
136
+ acc = acc + i * x
137
+ assert acc == 80
138
+
139
+ test "clause_if_keeps_when_true: if cond keeps the element, false skips it"
140
+ xs := Slice(1, 2, 3, 4, 5, 6)
141
+ total := 0
142
+ for x := range xs, if x % 2 == 0
143
+ total = total + x
144
+ assert total == 12
145
+
146
+ test "clause_bind_scoped: name := expr is visible to later clauses and body"
147
+ xs := Slice(1, 2, 3, 4)
148
+ total := 0
149
+ for x := range xs, sq := x * x, if sq > 9
150
+ total = total + sq
151
+ assert total == 16
152
+
153
+ test "break_continue_return_semantics"
154
+ xs := Slice(1, 2, 3, 4, 5)
155
+ total := 0
156
+ for x := range xs
157
+ if x == 2
158
+ continue
159
+ if x == 5
160
+ break
161
+ total = total + x
162
+ assert total == 8
163
+ assert firstOver(xs, 3) == 4
164
+ assert firstOver(xs, 9) == -1
165
+
166
+ test "nested iterable loops"
167
+ xs := Slice(1, 2)
168
+ ys := Slice(10, 20)
169
+ total := 0
170
+ for x := range xs
171
+ for y := range ys
172
+ total = total + x * y
173
+ assert total == 90
174
+
175
+ test "range_int: 0 to n-1, empty for 0, continue still advances"
176
+ total := 0
177
+ for i := range 5
178
+ total = total + i
179
+ assert total == 10
180
+ n := 0
181
+ for _ := range 0
182
+ n = n + 1
183
+ assert n == 0
184
+ odd := 0
185
+ for i := range 6
186
+ if i % 2 == 0
187
+ continue
188
+ odd = odd + i
189
+ assert odd == 9
190
+
191
+ test "range_until_to_downTo: bounds incl. empty ranges"
192
+ a := 0
193
+ for i := range 2.until(6)
194
+ a = a + i
195
+ assert a == 14
196
+ b := 0
197
+ for i := range 1.to(4)
198
+ b = b + i
199
+ assert b == 10
200
+ c := 0
201
+ for i := range 4.downTo(1)
202
+ c = c * 10 + i
203
+ assert c == 4321
204
+ n := 0
205
+ for _ := range 5.until(5)
206
+ n = n + 1
207
+ for _ := range 1.downTo(2)
208
+ n = n + 1
209
+ for _ := range 3.to(2)
210
+ n = n + 1
211
+ assert n == 0
212
+
213
+ test "range_stepBy: stepBy on until and to"
214
+ total := 0
215
+ for i := range 0.until(10).stepBy(3)
216
+ total = total + i
217
+ assert total == 18
218
+ for i := range 1.to(10).stepBy(4)
219
+ total = total + i
220
+ assert total == 18 + 15
221
+
222
+ test "range_seq_terminal: a terminal over an Int range"
223
+ evens := for i := range 0.until(10).filter()
224
+ i % 2 == 0
225
+ assert evens.length() == 5
plum-examples/digit_names.plum DELETED
@@ -1,59 +0,0 @@
1
- module digit_names
2
- import std/Int
3
-
4
- # Type and variant names may contain digits (`V2`, `A1`, `E1`). They are
5
- # resolved by position: where a type or variant name is expected it is one;
6
- # `Type.Name` consts such as `V2.K1` and `Int.Max` keep working.
7
-
8
- enum V2 =
9
- | A1
10
- | B2
11
- K1 = 40
12
-
13
- record A1(n: Int)
14
-
15
- error S1 = E1 | E2
16
-
17
- fun fail(n: Int) -> S1!Int =
18
- if n == 1
19
- return error.E1
20
- if n == 2
21
- return error.E2
22
- n
23
-
24
- fun asInt(w: V2) -> Int =
25
- match w
26
- A1(n) => n
27
- B2 => 0
28
-
29
- fun payload(w: V2) -> Int =
30
- if A1(n) := w
31
- return n
32
- 0
33
-
34
- fun isB2(w: V2) -> Int =
35
- if B2 := w
36
- return 1
37
- 0
38
-
39
- test "digit_variant_match"
40
- assert asInt(A1(n: 5)) == 5
41
- x := B2
42
- assert asInt(x) == 0
43
-
44
- test "digit_variant_pattern_if"
45
- assert payload(A1(n: 7)) == 7
46
- assert payload(B2) == 0
47
- w := B2
48
- assert isB2(w) == 1
49
- assert isB2(A1(n: 1)) == 0
50
-
51
- test "digit_error_names"
52
- a := fail(3) catch 0
53
- assert a == 3
54
- b := fail(1) catch 9
55
- assert b == 9
56
-
57
- test "digit_enum_const_still_works"
58
- assert V2.K1 == 40
59
- assert Int.Max > 0
plum-examples/{error_propagation.plum → errors.plum} RENAMED
@@ -1,9 +1,12 @@
1
+ # Errors and optionals: error propagation and T?.
1
2
  import std/Int
2
3
  import std/Float
3
4
  import std/Str
4
5
  import std/Slice
5
6
  import std/Map
6
7
 
8
+ # ==== error propagation ====
9
+
7
10
  # `!T` is "a `T` or an error". An error is a value written `error.Name` (for
8
11
  # example `return error.NegativeNum`): it needs no declaration and carries no
9
12
  # data. A function's error set is inferred from its body (every error value it
@@ -158,6 +161,103 @@ record Ages[T](items: Slice[T]) =
158
161
 
159
162
  fun parseAges(xs: Slice[Str]) -> !Ages[Age] = Ages.parseAll(xs)
160
163
 
164
+ # ==== optionals ====
165
+
166
+ # Optionals: `T?` is a `T` or `null`. A plain `T` in a `T?` slot is the present
167
+ # value. An optional is handled with `?:` (Kotlin elvis), `if x := opt`,
168
+ # `while x := opt`, `opt!` (crashes on null) and `opt == null`; `catch` and `try`
169
+ # are for errors only. `!T?` is an error union whose success value is an optional.
170
+ #
171
+ # `obj?.field` / `obj?.method(args)` is safe navigation: it reaches into an
172
+ # optional without unwrapping it and passes null through. A field that is itself
173
+ # a `T?` flattens, so `u?.target?.name` is one `Str?`. It is sugar for
174
+ # `obj.andThen(|v| v.field)`.
175
+
176
+ record Box(label: Str)
177
+
178
+ record Holder(mut item: Box?, mut count: Int?)
179
+
180
+ fun maybeBox(c: Bool) -> Box? =
181
+ if c
182
+ return Box(label: "in")
183
+ return null
184
+
185
+ fun maybeInt(c: Bool) -> Int? =
186
+ if c
187
+ return 7
188
+ null
189
+
190
+ fun orFive(n: Int? = null) -> Int =
191
+ n ?: 5
192
+
193
+ fun failOnNull(c: Bool) -> error{Absent}!Int =
194
+ n := maybeInt(c) ?: return error.Absent
195
+ n + 1
196
+
197
+ fun nullOnNull(c: Bool) -> Int? =
198
+ n := maybeInt(c) ?: return null
199
+ n * 2
200
+
201
+ fun chainDefault(a: Int?, b: Int?) -> Int =
202
+ a ?: b ?: 99
203
+
204
+ fun fallible(n: Int) -> error{Bad}!Int? =
205
+ if n < 0
206
+ return error.Bad
207
+ if n == 0
208
+ return null
209
+ n
210
+
211
+ fun viaTry(n: Int) -> error{Bad}!Int =
212
+ v := try fallible(n)
213
+ v ?: -1
214
+
215
+ fun nextItem(items: Slice[Int], i: Int) -> Int? =
216
+ if i < items.length()
217
+ return items.get(i)!
218
+ null
219
+
220
+ fun sparse() -> Slice[Int?] =
221
+ Slice(1, null, 3)
222
+
223
+ fun sumBig() -> Int =
224
+ Int.Max + Int.Min
225
+
226
+ record Person(name: Str, age: Int) =
227
+
228
+ fun birthday(self) -> Int =
229
+ self.age + 1
230
+
231
+ record Target(name: Str)
232
+
233
+ record User(name: Str, target: Target?)
234
+
235
+ fun personName(p: Person?) -> Str? =
236
+ p?.name
237
+
238
+ fun personBirthday(p: Person?) -> Int? =
239
+ p?.birthday()
240
+
241
+ fun somePerson() -> Person? =
242
+ Person(name: "Ann", age: 3)
243
+
244
+ fun noPerson() -> Person? =
245
+ return null
246
+
247
+ fun noTarget() -> Target? =
248
+ return null
249
+
250
+ fun maybeCount(n: Int? = null) -> Int? =
251
+ n
252
+
253
+ fun noUser() -> User? =
254
+ return null
255
+
256
+ fun targetName(u: User?) -> Str? =
257
+ u?.target?.name
258
+
259
+ # ==== error propagation tests ====
260
+
161
261
  test "try operator unwraps the value and propagates it through two calls"
162
262
  assert sumTwo("2", "3")! == 5
163
263
 
@@ -253,3 +353,141 @@ test "a Map can hold error unions as values"
253
353
  assert lookup(m) == 7
254
354
  m.set(1, negOrSeven(-2))
255
355
  assert lookup(m) == 0
356
+
357
+ # ==== optionals tests ====
358
+
359
+ test "null_return_absent"
360
+ assert maybeBox(false) == null
361
+ assert maybeBox(true) != null
362
+ assert maybeInt(false) == null
363
+ assert null != maybeInt(true)
364
+
365
+ test "null_field_and_arg"
366
+ h := Holder(item: null, count: null)
367
+ assert h.item == null
368
+ assert h.count == null
369
+ h2 := Holder(item: Box(label: "x"), count: 3)
370
+ assert h2.count! == 3
371
+ assert orFive(null) == 5
372
+ assert orFive(8) == 8
373
+ xs := sparse()
374
+ assert xs.length() == 3
375
+
376
+ test "elvis_default"
377
+ assert {maybeInt(false) ?: 4} == 4
378
+ assert {maybeInt(true) ?: 4} == 7
379
+ assert {maybeBox(false)?.label ?: "none"} == "none"
380
+ assert {maybeBox(true)?.label ?: "none"} == "in"
381
+
382
+ test "elvis_return_error"
383
+ assert {failOnNull(true) catch 0} == 8
384
+ assert {failOnNull(false) catch 0} == 0
385
+
386
+ test "elvis_return_null"
387
+ assert nullOnNull(true)! == 14
388
+ assert nullOnNull(false) == null
389
+
390
+ test "elvis_chain_right_assoc"
391
+ assert chainDefault(null, null) == 99
392
+ assert chainDefault(null, 2) == 2
393
+ assert chainDefault(1, 2) == 1
394
+
395
+ test "if_bind_optional"
396
+ r := 0
397
+ if x := maybeInt(true)
398
+ r = x
399
+ else
400
+ r = -1
401
+ assert r == 7
402
+ if y := maybeInt(false)
403
+ r = y
404
+ else
405
+ r = -1
406
+ assert r == -1
407
+ assert {if v := maybeInt(true) then v + 1 else 0} == 8
408
+ assert {if w := maybeInt(false) then w + 1 else 0} == 0
409
+ if b := maybeBox(true)
410
+ assert b.label == "in"
411
+
412
+ test "while_bind_optional"
413
+ items := Slice(3, 2, 1)
414
+ total := 0
415
+ i := 0
416
+ while v := nextItem(items, i)
417
+ total = total + v
418
+ i = i + 1
419
+ assert total == 6
420
+
421
+ test "safe_nav_optional_flattens"
422
+ assert maybeBox(true)?.label! == "in"
423
+ assert maybeBox(false)?.label == null
424
+
425
+ test "null_eq_ref and null_eq_int"
426
+ b := maybeBox(false)
427
+ assert b == null
428
+ n := maybeInt(true)
429
+ assert n != null
430
+ assert !{n == null}
431
+
432
+ test "error_union_of_optional"
433
+ assert {viaTry(5) catch 0} == 5
434
+ assert {viaTry(0) catch 0} == -1
435
+ assert {viaTry(-3) catch 100} == 100
436
+ x := fallible(0)!
437
+ assert x == null
438
+ y := fallible(4)!
439
+ assert y! == 4
440
+
441
+ test "int_min_literal"
442
+ assert Int.Min == -0x8000_0000_0000_0000
443
+ assert sumBig() == -1
444
+
445
+ test "reassigning an optional local or field to null or a value"
446
+ o := maybeInt(true)
447
+ o = null
448
+ assert o == null
449
+ o = 4
450
+ assert o! == 4
451
+ b := maybeBox(true)
452
+ b = null
453
+ assert b == null
454
+ h := Holder(item: Box(label: "x"), count: 2)
455
+ h.count = null
456
+ h.item = null
457
+ assert h.count == null
458
+ assert h.item == null
459
+ h.count = 5
460
+ h.item = Box(label: "y")
461
+ assert h.count! == 5
462
+ assert h.item!.label == "y"
463
+
464
+ test "safe navigation maps a field access over a present value, passes absence through"
465
+ assert personName(somePerson())! == "Ann"
466
+ assert {personName(noPerson()) ?: "gone"} == "gone"
467
+
468
+ test "safe navigation maps a method call over a present value"
469
+ assert personBirthday(somePerson())! == 4
470
+ assert {personBirthday(noPerson()) ?: -1} == -1
471
+
472
+ test "safe navigation chains through a nullable field"
473
+ full := User(name: "u", target: Target(name: "home"))
474
+ assert {targetName(full) ?: "none"} == "home"
475
+ assert {targetName(User(name: "v", target: noTarget())) ?: "none"} == "none"
476
+ assert {targetName(noUser()) ?: "none"} == "none"
477
+
478
+ test "?: handles a nullable with a diverging or computed default"
479
+ p := somePerson() ?: crash "missing"
480
+ assert p.age == 3
481
+ q := noPerson() ?: Person(name: "gone", age: 1)
482
+ assert q.name == "gone"
483
+
484
+ test "null is the absent value in an argument, a list element and a default"
485
+ u := User(name: "w", target: null)
486
+ assert {targetName(u) ?: "none"} == "none"
487
+ xs := sparse()
488
+ second := xs.get(1)!
489
+ assert {second ?: 9} == 9
490
+ first := xs.get(0)!
491
+ assert first! == 1
492
+ assert {maybeCount() ?: 5} == 5
493
+ assert maybeCount(7)! == 7
plum-examples/forms.plum DELETED
@@ -1,155 +0,0 @@
1
- # User-defined `fun*` forms with `yield`: sources, folds and methods written in
2
- # plum and expanded at the call site (the built-in forms are in seq.plum).
3
- import std/Str
4
- import std/Int
5
- import std/Float
6
- import std/Slice
7
- import std/Testing
8
- import std/Seq
9
-
10
- # for-forms: `for binders := range form(args), clauses` + body. A form is a `fun*`
11
- # with a yield type (`*T`); each `yield` runs the `for` body, and the whole
12
- # thing is expanded at the call site, so there are no closures. See
13
- # plum-std/Seq.plum and plum-core/src/forms.rs.
14
-
15
- record Fighter(name: Str, hp: Int, dead: Bool)
16
-
17
- fun sampleFighters() -> Slice[Fighter] =
18
- Slice(Fighter(name: "a", hp: 10, dead: false), Fighter(name: "b", hp: 0, dead: true), Fighter(name: "c", hp: 7, dead: false))
19
-
20
- # a user form: the sum of the body's value over a list
21
- fun* sumBy(xs: Slice[T]) *T -> Int =
22
- total := 0
23
- for x := range xs
24
- v := yield x
25
- total = total + v
26
- total
27
-
28
- # a user form with two binders and a statement body: the 8 neighbours of (x, y)
29
- fun* neighbours(x: Int, y: Int) *(Int, Int) =
30
- for dx := range 3
31
- for dy := range 3
32
- if dx != 1 || dy != 1
33
- yield x + dx - 1, y + dy - 1
34
-
35
- # a user form built from another form: `yield` sits inside `each`'s body
36
- fun* sumEvens(xs: Slice[Int]) *Int -> Int =
37
- total := 0
38
- for x := range xs.each(), if x % 2 == 0
39
- v := yield x
40
- total = total + v
41
- total
42
-
43
- fun firstBig(xs: Slice[Int]) -> Int =
44
- for x := range xs.each()
45
- if x > 10
46
- return x
47
- return -1
48
-
49
- fun returnedForm(xs: Slice[Int]) -> Slice[Int] =
50
- return for x := range xs.map()
51
- x + 1
52
-
53
- fun tailForm(xs: Slice[Int]) -> Int =
54
- for x := range sumBy(xs)
55
- x * 2
56
-
57
-
58
- # A type with a fun* method: `self` is bound to the receiver at the use site.
59
- record Countdown(from: Int) =
60
-
61
- fun* ticks(self) *Int =
62
- i := self.from
63
- while i > 0
64
- cur := i
65
- i = i - 1
66
- yield cur
67
-
68
- # `for a, b := range countdown`: two binders pick `entries`.
69
- fun* entries(self) *(Int, Int) =
70
- i := self.from
71
- while i > 0
72
- cur := i
73
- i = i - 1
74
- yield cur, cur * cur
75
-
76
- test "user_form_sumBy"
77
- fighters := sampleFighters()
78
- hp := for u := range sumBy(fighters)
79
- u.hp
80
- assert hp == 17
81
- odd := for x := range sumBy(Slice(1, 2, 3, 4, 5)), if x % 2 == 1
82
- x
83
- assert odd == 9
84
-
85
- test "user_form_neighbours_grid: two binders, statement body"
86
- count := 0
87
- sum := 0
88
- for nx, ny := range neighbours(5, 5), if nx > 5
89
- count = count + 1
90
- sum = sum + ny
91
- assert count == 3
92
- assert sum == 15
93
-
94
- test "break_continue_return_semantics"
95
- xs := Slice(1, 2, 3, 4, 5)
96
- total := 0
97
- for x := range xs.each()
98
- if x == 2
99
- continue
100
- if x == 5
101
- break
102
- total = total + x
103
- assert total == 8
104
- assert firstBig(Slice(1, 20, 30)) == 20
105
- assert firstBig(xs) == -1
106
- prefix := for x := range xs.filter()
107
- if x == 4
108
- break
109
- x > 1
110
- assert prefix.length() == 2
111
-
112
- test "composed form: a form that uses another form"
113
- xs := Slice(1, 2, 3, 4, 5, 6)
114
- total := for x := range sumEvens(xs)
115
- x * 10
116
- assert total == 120
117
-
118
- test "forms nest: a form inside a form body"
119
- xs := Slice(1, 2)
120
- ys := Slice(10, 20)
121
- total := for a := range sumBy(xs)
122
- inner := for b := range sumBy(ys)
123
- a * b
124
- inner
125
- assert total == 90
126
-
127
- test "return for-form: the form's value is returned"
128
- ys := returnedForm(Slice(1, 2, 3))
129
- assert ys.length() == 3
130
- assert ys.at(2) == 4
131
-
132
- test "tail for-form: the last statement of a fn is its value"
133
- assert tailForm(Slice(1, 2, 3)) == 12
134
-
135
- test "fun_star_method_uses_self"
136
- c := Countdown(from: 4)
137
- seen := 0
138
- for t := range c.ticks()
139
- seen = seen * 10 + t
140
- assert seen == 4321
141
- evens := for t := range c.ticks().map(), if t % 2 == 0
142
- t * 100
143
- assert evens.length() == 2
144
- assert evens.at(0) == 400
145
-
146
- test "two_binder_uses_entries_method: a custom type's entries()"
147
- c := Countdown(from: 3)
148
- total := 0
149
- for n, sq := range c
150
- total = total + n * 1000 + sq
151
- assert total == 3 * 1000 + 9 + 2 * 1000 + 4 + 1000 + 1
152
- for _, sq := range c
153
- total = total + sq
154
- assert total == 6014 + 14
155
-
plum-examples/functions.plum DELETED
@@ -1,77 +0,0 @@
1
- # Functions: top-level function declarations, typed arguments, default and
2
- # variadic parameters, return expressions, and recursion.
3
- import std/Str
4
- import std/Int
5
- import std/Float
6
-
7
- fun addInts(a: Int, b: Int) -> Int =
8
- a + b
9
-
10
- fun greet() =
11
- todo
12
-
13
- fun withDefault(a: Int, step: Int = 1) -> Int =
14
- a + step
15
-
16
- fun sumAll(nums: ...Int) -> Int =
17
- total := 0
18
- for v := range nums
19
- total = total + v
20
- total
21
-
22
- fun wrap(value: T) -> Bool =
23
- true
24
-
25
- fun pair(first: T, second: U) -> Bool =
26
- true
27
-
28
- fun useWrap() -> Bool =
29
- wrap(5)
30
-
31
- fun usePair() -> Bool =
32
- pair(1, "x")
33
-
34
- # ---- recursion, defaults and variadics ----
35
-
36
- fun factorial(x: Int) -> Int =
37
- if x < 2
38
- return 1
39
- return x * factorial(x - 1)
40
-
41
- # Regression test: `double(...)` (an all-lowercase, no-uppercase, no-underscore
42
- # callee) used to fail to parse at all — `var_identifier` and `fn_identifier`
43
- # both matched its text and the grammar's lexer would nondeterministically
44
- # commit to `var_identifier`, breaking every such call site.
45
- fun double(n: Int) -> Int =
46
- n * 2
47
-
48
- fun combineVariadic(prefix: Int, rest: ...Int) -> Int =
49
- prefix
50
-
51
- fun weighted(nums: ...Int) -> Int =
52
- total := 0
53
- for i, v := range nums
54
- total = total + i * v
55
- total
56
-
57
- test "factorial runs correctly"
58
- assert factorial(5) == 120
59
-
60
- test "lowercase single word function call runs correctly"
61
- assert double(21) == 42
62
-
63
- test "variadic call with varying trailing arg counts runs correctly"
64
- a := combineVariadic(10)
65
- b := combineVariadic(20, 1)
66
- c := combineVariadic(30, 1, 2, 3)
67
- assert a + b + c == 60
68
-
69
- test "sum all variadic int runs correctly"
70
- assert sumAll(1, 2, 3, 4) == 10
71
-
72
- test "sum all variadic int with zero args runs correctly"
73
- assert sumAll() == 0
74
-
75
- test "for over a variadic binds index and element"
76
- assert weighted() == 0
77
- assert weighted(5, 10, 20) == 50
plum-examples/{assets → games/assets}/gameover.wav RENAMED
File without changes
plum-examples/{assets → games/assets}/hit.wav RENAMED
File without changes
plum-examples/{assets → games/assets}/music.wav RENAMED
File without changes
plum-examples/{assets → games/assets}/pickup.wav RENAMED
File without changes
plum-examples/{assets → games/assets}/sprites.png RENAMED
File without changes
plum-examples/games/functional/game_state_view.plum CHANGED
@@ -1,21 +1,23 @@
1
- # Orb Run as state and view (`plum game plum-examples/games/functional/game_state_view.plum`): the same
1
+ # Orb Run as components (`plum game plum-examples/games/functional/game_state_view.plum`): the same
2
2
  # game as game_pure_scene.plum (same arena, rules, sprites, sounds, keys and scoring),
3
- # written the way a Rust state/view program is. Steer the ship with the arrow keys or
3
+ # written in the component style of Elm and React but purely functional. Steer the ship
4
- # WASD, collect the drifting orbs and avoid the mines. Three lives; Enter, R or the
4
+ # with the arrow keys or WASD, collect the drifting orbs and avoid the mines. Three
5
- # "Again" button restarts after the game is over.
5
+ # lives; Enter, R or the "Again" button restarts after the game is over.
6
6
  #
7
+ # Each kind (`Ship`, `Orb`, `Mine`, `Star`) keeps its data, its behaviour and its
8
+ # drawing together: `update(self, ..)` returns the next value of the kind (a spread,
9
+ # nothing is changed in place) and `render(self, ..)` returns its `SceneNode` picture.
7
- # The game is three plain pieces. `State` is plain data: the ship, the orbs and the
10
+ # `State` is the same for the whole game: the components, the score, a `Random` and the
8
- # mines (cons `List` lists keyed by id), the score, the random seed and the screen
9
- # size. `Msg` is what can happen to it (a tick of time, a resize, a restart), an enum
11
+ # screen size. `Msg` is what can happen to it (a tick of time, a resize, a restart, a
10
- # with inline variants. `step(state, msg)` is pure and returns the next `State`;
12
+ # Ui event); `State.update(msg)` is a short sequence of named steps and
11
- # `view(state)` is pure too and builds a fresh `SceneNode` picture every frame, which
13
+ # `State.render()` builds a fresh picture every frame, which `Picture.draw` interprets
12
- # `Picture.draw` interprets. Both are structural recursion over `Link` and `List[SceneNode]`:
13
- # no loop runs in `step` or `view`. There is no
14
+ # (the compiler fuses the two, so the tree is never built). There is no retained
14
- # retained scene: the restart button is a `Ui.button` in the picture; the host turns
15
+ # scene: the restart button is a `Ui.button` in the picture; the host turns the mouse
15
- # the mouse into a `UiInput`, finds what is under it with `Picture.hitId` on the
16
+ # into a `UiInput`, finds what is under it with `Picture.hitId` on the picture the
16
- # picture the player is looking at, runs `Ui.step`, and feeds each `UiEvent` to `step`.
17
+ # player is looking at, runs `Ui.step`, and feeds each `UiEvent` to `State.update`.
17
- # The game-over title and the ship's hurt flash are `Anim`s of time since they began. The `World` record is the host shell: it holds the
18
+ # The game-over title and the ship's hurt flash are `Anim`s of time since they began.
19
+ # The `World` record is the host shell: it holds the current `State`, reads the keyboard
18
- # current `State`, reads the keyboard and mouse into messages, and plays sounds.
20
+ # and mouse into messages, and plays sounds.
19
21
  module game_state_view
20
22
  import std/Str
21
23
  import std/Int
@@ -23,12 +25,14 @@ import std/Float
23
25
  import std/Slice
24
26
  import std/List
25
27
  import std/Math
28
+ import std/Random
26
29
  import std/game/Vec2
27
30
  import std/game/Color
28
31
  import std/game/Game
29
32
  import std/game/functional/Picture
30
33
  import std/game/functional/Anim
31
34
  import std/game/functional/Ui
35
+ import std/Io
32
36
 
33
37
  primitive Rules =
34
38
  ArenaW = 800.0
@@ -46,16 +50,6 @@ primitive Rules =
46
50
  Size = 48.0
47
51
  Half = 24.0
48
52
 
49
- # Where each layer sits among the root's children in the picture `view` builds.
50
- primitive Slot =
51
- Backdrop = 0
52
- Stars = 1
53
- Mines = 2
54
- Orbs = 3
55
- Ship = 4
56
- Hud = 5
57
- Overlay = 6
58
-
59
53
  # Entity ids: orb `i` is `Id.Orb + i`, mine `i` is `Id.Mine + i`.
60
54
  primitive Id =
61
55
  Orb = 100
@@ -65,71 +59,8 @@ primitive Id =
65
59
  # A position and velocity: what a spawn returns and what drifters are bounced with.
66
60
  struct Motion(x: Float, y: Float, vx: Float, vy: Float)
67
61
 
68
- # A random spot plus the seed to continue from.
62
+ # A random spot and the generator to continue from.
69
- struct Spawn(x: Float, y: Float, vx: Float, vy: Float, seed: Int)
70
-
71
- struct ShipState(x: Float, y: Float, vx: Float, vy: Float, rotation: Float, hurt: Float)
72
-
73
- struct Orb(id: Int, x: Float, y: Float, vx: Float, vy: Float, phase: Float, clock: Float)
74
-
75
- struct Mine(id: Int, x: Float, y: Float, vx: Float, vy: Float, rotation: Float, spin: Float, clock: Float)
76
-
77
- # Everything that changes during a run. `sheet` and `target` are resource handles
78
- # the view needs: the sprite sheet and the render target of the game-over filter.
79
- record State(
80
- ship: ShipState,
81
- orbs: List[Orb],
82
- mines: List[Mine],
83
- seed: Int,
84
- score: Int,
85
- best: Int,
86
- lives: Int,
87
- over: Bool,
88
- clock: Float,
89
- overClock: Float,
90
- hurtAt: Float,
91
- overAt: Float,
92
- ui: UiState,
93
- sw: Float,
94
- sh: Float,
95
- sheet: Int,
96
- target: Int)
97
-
98
- # What can happen to the state.
99
- enum Msg =
100
- | Tick(dt: Float, ax: Float, ay: Float)
63
+ record Spawned(at: Motion, rng: Random)
101
- | Resize(w: Float, h: Float)
102
- | Restart(seed: Int)
103
- | UiEv(event: UiEvent)
104
-
105
-
106
- fun mapLink(xs: List[A], f: fn(A) -> B) -> List[B] = match xs
107
- Nil => Nil
108
- Cons(h, t) => Cons(head: f(h), tail: mapLink(t, f))
109
-
110
- fun lenLink(xs: List[T]) -> Int = match xs
111
- Nil => 0
112
- Cons(_, t) => 1 + lenLink(t)
113
-
114
- # `xs` with `x` added at the end.
115
- fun snoc(xs: List[T], x: T) -> List[T] = match xs
116
- Nil => Cons(head: x, tail: Nil)
117
- Cons(h, t) => Cons(head: h, tail: snoc(t, x))
118
-
119
- # `xs` with the element at `i` replaced by `x`.
120
- fun replaceNth(xs: List[T], i: Int, x: T) -> List[T] = match xs
121
- Nil => Nil
122
- Cons(h, t) => if i <= 0 then Cons(head: x, tail: t) else Cons(head: h, tail: replaceNth(t, i - 1, x))
123
-
124
- # The element at `i`, or `dflt` past the end.
125
- fun nthOr(xs: List[T], i: Int, dflt: T) -> T = match xs
126
- Nil => dflt
127
- Cons(h, t) => if i <= 0 then h else nthOr(t, i - 1, dflt)
128
-
129
- # The nodes f(x) for every x of `xs`, followed by `rest`.
130
- fun toNodes(xs: List[T], f: fn(T) -> SceneNode, rest: List[SceneNode]) -> List[SceneNode] = match xs
131
- Nil => rest
132
- Cons(h, t) => Cons(head: f(h), tail: toNodes(t, f, rest))
133
64
 
134
65
  # --- small pure helpers ---
135
66
 
@@ -137,269 +68,51 @@ fun toNodes(xs: List[T], f: fn(T) -> SceneNode, rest: List[SceneNode]) -> List[S
137
68
  fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
138
69
  Int(clock * fps) % count
139
70
 
140
- fun nextSeed(seed: Int) -> Int =
71
+ fun nothing() -> SceneNode =
141
- {seed * 1103515245 + 12345} % 2147483648
72
+ Group(transform: Identity, children: Nil)
73
+
74
+ fun cell(sheet: Int, x: Float, y: Float, size: Float, col: Int, row: Int) -> SceneNode =
75
+ Sprite(
76
+ tex: sheet, x: x, y: y, w: size, h: size, srcX: Float(col) * Rules.Cell, srcY: Float(row) * Rules.Cell,
77
+ srcW: Rules.Cell, srcH: Rules.Cell, tint: Color.White)
142
78
 
143
- # A float in 0..1 from a seed.
144
- fun unit(seed: Int) -> Float =
79
+ fun overlaps(ax: Float, ay: Float, ra: Float, bx: Float, by: Float, rb: Float) -> Bool =
145
- Float({seed / 65536} % 32768) / 32768.0
80
+ Math.hypot(ax - bx, ay - by) < ra + rb
146
81
 
147
82
  # A random spot at least `gap` away from (ax, ay), drifting at up to `speed`.
148
- fun spawn(seed: Int, ax: Float, ay: Float, gap: Float, speed: Float) -> Spawn =
83
+ fun spawn(rng: Random, ax: Float, ay: Float, gap: Float, speed: Float) -> Spawned =
149
84
  margin := 30.0
150
- sd := seed
85
+ r := rng
151
86
  x := 0.0
152
87
  y := 0.0
153
88
  tries := 0
154
89
  while tries < 20
155
- sd = nextSeed(sd)
90
+ dx := r.nextFloat()
91
+ dy := dx.rng.nextFloat()
92
+ r = dy.rng
156
- x = margin + unit(sd) * {Rules.ArenaW - 2.0 * margin}
93
+ x = margin + dx.value * {Rules.ArenaW - 2.0 * margin}
157
- sd = nextSeed(sd)
158
- y = margin + unit(sd) * {Rules.ArenaH - 2.0 * margin}
94
+ y = margin + dy.value * {Rules.ArenaH - 2.0 * margin}
159
95
  if Math.hypot(x - ax, y - ay) >= gap
160
96
  break
161
97
  tries = tries + 1
162
- sd = nextSeed(sd)
98
+ dh := r.nextFloat()
99
+ dp := dh.rng.nextFloat()
163
- heading := unit(sd) * Float.Tau
100
+ heading := dh.value * Float.Tau
164
- sd = nextSeed(sd)
165
- pace := speed * {0.4 + 0.6 * unit(sd)}
101
+ pace := speed * {0.4 + 0.6 * dp.value}
166
- Spawn(x: x, y: y, vx: Math.cos(heading) * pace, vy: Math.sin(heading) * pace, seed: sd)
102
+ Spawned(at: Motion(x: x, y: y, vx: Math.cos(heading) * pace, vy: Math.sin(heading) * pace), rng: dp.rng)
167
103
 
168
104
  # `b` moved for `dt` seconds and bounced off the arena walls; `b` itself is unchanged.
169
105
  fun driftOf(b: Motion, r: Float, dt: Float) -> Motion =
170
- x := b.x + b.vx * dt
106
+ x0 := b.x + b.vx * dt
171
- y := b.y + b.vy * dt
107
+ y0 := b.y + b.vy * dt
172
- vx := b.vx
173
- vy := b.vy
174
- if x < r || x > Rules.ArenaW - r
175
- vx = 0.0 - b.vx
176
- x = x.max(r).min(Rules.ArenaW - r)
108
+ x := x0.max(r).min(Rules.ArenaW - r)
177
- if y < r || y > Rules.ArenaH - r
178
- vy = 0.0 - b.vy
179
- y = y.max(r).min(Rules.ArenaH - r)
109
+ y := y0.max(r).min(Rules.ArenaH - r)
180
- Motion(x: x, y: y, vx: vx, vy: vy)
110
+ Motion(x: x, y: y, vx: if x != x0 then 0.0 - b.vx else b.vx, vy: if y != y0 then 0.0 - b.vy else b.vy)
181
111
 
182
112
  # How many mines are out: more as the score grows.
183
113
  fun mineCount(score: Int) -> Int =
184
114
  {3 + score / 50}.min(8)
185
115
 
186
- # Accelerates by the input axes, applies drag and keeps the ship in the arena.
187
- fun shipStep(s: ShipState, ax: Float, ay: Float, dt: Float) -> ShipState =
188
- keep := {1.0 - Rules.Drag * dt}.max(0.0)
189
- vx := {s.vx + ax * Rules.Thrust * dt} * keep
190
- vy := {s.vy + ay * Rules.Thrust * dt} * keep
191
- x := s.x + vx * dt
192
- y := s.y + vy * dt
193
- r := Rules.ShipRadius
194
- if x < r
195
- x = r
196
- vx = 0.0
197
- if x > Rules.ArenaW - r
198
- x = Rules.ArenaW - r
199
- vx = 0.0
200
- if y < r
201
- y = r
202
- vy = 0.0
203
- if y > Rules.ArenaH - r
204
- y = Rules.ArenaH - r
205
- vy = 0.0
206
- rot := s.rotation
207
- if vx * vx + vy * vy > 400.0
208
- rot = Math.atan2(vx, 0.0 - vy)
209
- ShipState(x: x, y: y, vx: vx, vy: vy, rotation: rot, hurt: {s.hurt - dt}.max(0.0))
210
-
211
- fun newShip() -> ShipState =
212
- ShipState(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, rotation: 0.0, hurt: 0.0)
213
-
214
- fun overlaps(ax: Float, ay: Float, ra: Float, bx: Float, by: Float, rb: Float) -> Bool =
215
- Math.hypot(ax - bx, ay - by) < ra + rb
216
-
217
- fun orbAt(id: Int, phase: Float, clock: Float, at: Motion) -> Orb =
218
- Orb(id: id, x: at.x, y: at.y, vx: at.vx, vy: at.vy, phase: phase, clock: clock)
219
-
220
- fun mineAt(id: Int, rotation: Float, spin: Float, clock: Float, at: Motion) -> Mine =
221
- Mine(id: id, x: at.x, y: at.y, vx: at.vx, vy: at.vy, rotation: rotation, spin: spin, clock: clock)
222
-
223
- fun orbMove(o: Orb, dt: Float) -> Orb =
224
- m := driftOf(Motion(x: o.x, y: o.y, vx: o.vx, vy: o.vy), Rules.OrbRadius, dt)
225
- orbAt(o.id, o.phase, o.clock + dt, m)
226
-
227
- fun mineMove(k: Mine, dt: Float) -> Mine =
228
- m := driftOf(Motion(x: k.x, y: k.y, vx: k.vx, vy: k.vy), Rules.MineRadius, dt)
229
- mineAt(k.id, k.rotation + k.spin * dt, k.spin, k.clock + dt, m)
230
-
231
- # --- the state ---
232
-
233
- # A built list of orbs and the seed to continue from.
234
- record OrbsMade(orbs: List[Orb], seed: Int)
235
-
236
- record MinesMade(mines: List[Mine], seed: Int)
237
-
238
- # Orbs `i` and up to OrbCount, each at a random spot away from the ship.
239
- fun makeOrbs(i: Int, seed: Int, ship: ShipState) -> OrbsMade =
240
- if i >= Rules.OrbCount
241
- return OrbsMade(orbs: Nil, seed: seed)
242
- sp := spawn(seed, ship.x, ship.y, 120.0, 40.0)
243
- rest := makeOrbs(i + 1, sp.seed, ship)
244
- OrbsMade(orbs: Cons(head: orbAt(Id.Orb + i, Float(i) * 0.1, 0.0, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy)), tail: rest.orbs), seed: rest.seed)
245
-
246
- # Mines `i` and up to `count`.
247
- fun makeMines(i: Int, count: Int, seed: Int, ship: ShipState) -> MinesMade =
248
- if i >= count
249
- return MinesMade(mines: Nil, seed: seed)
250
- sp := spawn(seed, ship.x, ship.y, 220.0, 90.0)
251
- rest := makeMines(i + 1, count, sp.seed, ship)
252
- MinesMade(mines: Cons(head: mineAt(Id.Mine + i, 0.0, 2.0, 0.0, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy)), tail: rest.mines), seed: rest.seed)
253
-
254
- fun newState(seed: Int, best: Int, sheet: Int, target: Int, sw: Float, sh: Float) -> State =
255
- ship := newShip()
256
- made := makeOrbs(0, seed, ship)
257
- mm := makeMines(0, mineCount(0), made.seed, ship)
258
- State(
259
- ship: ship, orbs: made.orbs, mines: mm.mines, seed: mm.seed, score: 0, best: best, lives: Rules.StartLives,
260
- over: false, clock: 0.0, overClock: 0.0, hurtAt: -1.0, overAt: -1.0, ui: Ui.init(), sw: sw, sh: sh, sheet: sheet, target: target)
261
-
262
- fun orbStill(o: Orb) -> Orb =
263
- orbAt(o.id, o.phase, o.clock, Motion(x: o.x, y: o.y, vx: 0.0, vy: 0.0))
264
-
265
- fun mineStill(k: Mine) -> Mine =
266
- mineAt(k.id, k.rotation, 0.0, k.clock, Motion(x: k.x, y: k.y, vx: 0.0, vy: 0.0))
267
-
268
- # Stops every orb and mine: after game over they only animate.
269
- fun freeze(s: State) -> State =
270
- n := State(..s)
271
- n.orbs = mapLink(s.orbs, |o| orbStill(o))
272
- n.mines = mapLink(s.mines, |k| mineStill(k))
273
- n
274
-
275
- # The orbs after the ship collected the ones it touches, with the score and seed.
276
- record Collected(orbs: List[Orb], seed: Int, score: Int)
277
-
278
- fun collectOrbs(orbs: List[Orb], ship: ShipState, seed: Int, score: Int) -> Collected = match orbs
279
- Nil => Collected(orbs: Nil, seed: seed, score: score)
280
- Cons(o, rest) =>
281
- if overlaps(ship.x, ship.y, Rules.ShipRadius, o.x, o.y, Rules.OrbRadius)
282
- sp := spawn(seed, ship.x, ship.y, 120.0, 40.0)
283
- later := collectOrbs(rest, ship, sp.seed, score + Rules.OrbPoints)
284
- moved := orbAt(o.id, o.phase, o.clock, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy))
285
- Collected(orbs: Cons(head: moved, tail: later.orbs), seed: later.seed, score: later.score)
286
- else
287
- kept := collectOrbs(rest, ship, seed, score)
288
- Collected(orbs: Cons(head: o, tail: kept.orbs), seed: kept.seed, score: kept.score)
289
-
290
- # `mines` grown (new mines go at the end) until there are `want`.
291
- fun growMines(mines: List[Mine], have: Int, want: Int, seed: Int, ship: ShipState) -> MinesMade =
292
- if have >= want
293
- return MinesMade(mines: mines, seed: seed)
294
- sp := spawn(seed, ship.x, ship.y, 220.0, 90.0)
295
- added := snoc(mines, mineAt(Id.Mine + have, 0.0, 2.0, 0.0, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy)))
296
- growMines(added, have + 1, want, sp.seed, ship)
297
-
298
- fun touchesMine(mines: List[Mine], ship: ShipState) -> Bool = match mines
299
- Nil => false
300
- Cons(k, rest) => overlaps(ship.x, ship.y, Rules.ShipRadius, k.x, k.y, Rules.MineRadius) || touchesMine(rest, ship)
301
-
302
- # One frame of time. Once the game is over the actors only animate.
303
- fun tick(s: State, dt: Float, ax: Float, ay: Float) -> State =
304
- n := State(..s)
305
- n.clock = s.clock + dt
306
- n.orbs = mapLink(s.orbs, |o| orbMove(o, dt))
307
- n.mines = mapLink(s.mines, |k| mineMove(k, dt))
308
- if s.over
309
- n.overClock = s.overClock + dt
310
- return n
311
- ship := shipStep(s.ship, ax, ay, dt)
312
- n.ship = ship
313
- got := collectOrbs(n.orbs, ship, n.seed, n.score)
314
- n.orbs = got.orbs
315
- n.seed = got.seed
316
- n.score = got.score
317
- grown := growMines(n.mines, lenLink(n.mines), mineCount(n.score), n.seed, ship)
318
- n.mines = grown.mines
319
- n.seed = grown.seed
320
- if n.ship.hurt <= 0.0 && touchesMine(n.mines, ship)
321
- n.lives = n.lives - 1
322
- n.ship = ShipState(x: ship.x, y: ship.y, vx: ship.vx, vy: ship.vy, rotation: ship.rotation, hurt: Rules.HurtTime)
323
- n.hurtAt = n.clock
324
- if n.lives <= 0
325
- n.over = true
326
- n.overAt = n.clock
327
- n.best = n.best.max(n.score)
328
- if n.over
329
- return freeze(n)
330
- n
331
-
332
- # The next state: pure, nothing that is passed in is changed.
333
- fun step(s: State, msg: Msg) -> State = match msg
334
- Tick(dt, ax, ay) => tick(s, dt, ax, ay)
335
- Resize(w, h) => resized(s, w, h)
336
- Restart(seed) => restarted(s, seed)
337
- UiEv(event) => uiEvent(s, event)
338
-
339
- fun restarted(s: State, seed: Int) -> State =
340
- n := newState(seed, s.best.max(s.score), s.sheet, s.target, s.sw, s.sh)
341
- n.ui = s.ui
342
- n
343
-
344
- # A click on the restart button restarts a finished game; other events change nothing.
345
- fun uiEvent(s: State, e: UiEvent) -> State =
346
- if s.over && e.id == Id.Restart && e.kind == Click
347
- return restarted(s, nextSeed(s.seed))
348
- s
349
-
350
- fun resized(s: State, w: Float, h: Float) -> State =
351
- n := State(..s)
352
- n.sw = w
353
- n.sh = h
354
- n
355
-
356
- # The zoom that fits the arena into the screen, and where the arena's corner lands.
357
- fun zoomOf(s: State) -> Float =
358
- {s.sw / Rules.ArenaW}.min(s.sh / Rules.ArenaH)
359
-
360
- fun offsetX(s: State) -> Float =
361
- {s.sw - zoomOf(s) * Rules.ArenaW} / 2.0
362
-
363
- fun offsetY(s: State) -> Float =
364
- {s.sh - zoomOf(s) * Rules.ArenaH} / 2.0
365
-
366
- # --- the view ---
367
-
368
- fun cell(sheet: Int, x: Float, y: Float, size: Float, col: Int, row: Int) -> SceneNode =
369
- Sprite(
370
- tex: sheet, x: x, y: y, w: size, h: size, srcX: Float(col) * Rules.Cell, srcY: Float(row) * Rules.Cell,
371
- srcW: Rules.Cell, srcH: Rules.Cell, tint: Color.White)
372
-
373
- fun starView(i: Int, clock: Float) -> SceneNode =
374
- r := 1.0 + Float(i % 3) * 0.5
375
- twinkle := 0.25 + 0.2 * Math.sin(clock * 2.0 + Float(i))
376
- CircleShape(cx: Float({i * 7919} % 800), cy: Float({i * 104729} % 600), r: r, color: Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
377
-
378
- # The stars `i` and up to 40.
379
- fun starNodes(i: Int, clock: Float) -> List[SceneNode] =
380
- if i >= 40 then Nil else Cons(head: starView(i, clock), tail: starNodes(i + 1, clock))
381
-
382
- fun starsView(clock: Float) -> SceneNode =
383
- Group(transform: Identity, children: starNodes(0, clock))
384
-
385
- fun mineView(sheet: Int, k: Mine) -> SceneNode =
386
- Group(
387
- transform: Compose(first: Translate(dx: k.x, dy: k.y), then: Rotate(radians: k.rotation)),
388
- children: Cons(head: cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(k.clock, 4, 4.0), 2), tail: Nil))
389
-
390
- # The glow is drawn additively, then the sprite on top.
391
- fun orbView(sheet: Int, o: Orb) -> SceneNode =
392
- glow := Group(
393
- transform: Identity,
394
- children: Cons(
395
- head: CircleShape(cx: 0.0, cy: 0.0, r: 26.0, color: Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5)),
396
- tail: Cons(head: CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5)), tail: Nil)))
397
- Group(
398
- transform: Translate(dx: o.x, dy: o.y),
399
- children: Cons(
400
- head: Filter(effect: BlendFx(mode: Blend.Add), child: glow, target: 0 - 1),
401
- tail: Cons(head: cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(o.clock + o.phase, 4, 8.0), 1), tail: Nil)))
402
-
403
116
  # The hurt flash: the ship shrinks and recovers every 0.1 s (the scale returns to 1 each lap).
404
117
  fun hitFlash() -> Anim =
405
118
  Forever(anim: Seq(steps: List(ScaleTo(sx: 0.4, sy: 0.4, secs: 0.05), ScaleTo(sx: 2.5, sy: 2.5, secs: 0.05))))
@@ -416,85 +129,297 @@ fun titleAnim() -> Anim =
416
129
  fun titlePose(t: Float) -> AnimPose =
417
130
  Anim.at(titleAnim(), t)
418
131
 
419
- fun titleView(s: State) -> SceneNode =
420
- p := titlePose(s.clock - s.overAt)
421
- Group(
422
- transform: Compose(first: Translate(dx: 400.0 + p.dx, dy: 268.0 + p.dy), then: Scale(sx: p.scaleX, sy: p.scaleY)),
423
- children: Cons(head: Text(text: "Game over", x: -100.0, y: -24.0, size: 48.0, color: Color.Red), tail: Nil))
424
-
425
- # The ship blinks while hurt and is gone once the game is over (an empty group keeps the slots).
426
- fun shipView(s: State) -> SceneNode =
427
- blink := s.ship.hurt > 0.0 && flashPose(s.clock - s.hurtAt).scaleX < 0.7
428
- if s.over || blink
429
- return Group(transform: Identity, children: Nil)
430
- Group(
431
- transform: Compose(first: Translate(dx: s.ship.x, dy: s.ship.y), then: Rotate(radians: s.ship.rotation)),
432
- children: Cons(
433
- head: CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12)),
434
- tail: Cons(head: cell(s.sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(s.clock, 4, 14.0), 0), tail: Nil)))
435
-
436
- # One life icon per remaining life, from the right edge going left.
437
- fun lifeNodes(i: Int, lives: Int, sheet: Int) -> List[SceneNode] =
438
- if i >= lives
439
- Nil
440
- else
441
- Cons(head: cell(sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 0, 0), tail: lifeNodes(i + 1, lives, sheet))
442
-
443
- fun hudView(s: State) -> SceneNode =
444
- Group(
445
- transform: Identity,
446
- children: Cons(
447
- head: Text(text: "Score {s.score}", x: 16.0, y: 12.0, size: 28.0, color: Color.White),
448
- tail: Cons(head: Text(text: "Best {s.best.max(s.score)}", x: 16.0, y: 44.0, size: 20.0, color: Color.Yellow), tail: lifeNodes(0, s.lives, s.sheet))))
449
-
450
- # The game-over panel with the restart button, faded in through a render-target filter.
451
- # While playing it is an empty group, so nothing in it can be hit.
452
- fun overlayView(s: State) -> SceneNode =
453
- if !s.over
454
- return Group(transform: Identity, children: Nil)
455
- button := Ui.button(Id.Restart, "Again", 340.0, 342.0, 120.0, 44.0, s.ui)
456
- panel := Group(
457
- transform: Identity,
458
- children: Cons(
459
- head: RectShape(x: 0.0, y: 230.0, w: Rules.ArenaW, h: 170.0, color: Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
460
- tail: Cons(
461
- head: titleView(s),
462
- tail: Cons(
463
- head: Text(text: "Press Enter to play again", x: 270.0, y: 300.0, size: 24.0, color: Color.White),
464
- tail: Cons(head: button, tail: Nil)))))
465
- Filter(effect: Alpha(alpha: {s.overClock * 2.0}.min(1.0)), child: panel, target: s.target)
466
-
467
- # The whole picture, in drawing order, under the camera that fits the arena on screen.
468
- fun view(s: State) -> SceneNode =
469
- z := zoomOf(s)
470
- layers := Cons(
471
- head: RectShape(x: 0.0, y: 0.0, w: Rules.ArenaW, h: Rules.ArenaH, color: Color.rgb(0.07, 0.08, 0.16)),
472
- tail: Cons(
473
- head: starsView(s.clock),
474
- tail: Cons(
475
- head: Group(transform: Identity, children: toNodes(s.mines, |k| mineView(s.sheet, k), Nil)),
476
- tail: Cons(
477
- head: Group(transform: Identity, children: toNodes(s.orbs, |o| orbView(s.sheet, o), Nil)),
478
- tail: Cons(head: shipView(s), tail: Cons(head: hudView(s), tail: Cons(head: overlayView(s), tail: Nil)))))))
479
- Group(transform: Compose(first: Translate(dx: offsetX(s), dy: offsetY(s)), then: Scale(sx: z, sy: z)), children: layers)
480
-
481
- # True when (x, y) in screen pixels is on the restart button of the picture `view` builds.
482
- fun onRestart(s: State, x: Float, y: Float) -> Bool =
483
- if h := Picture.hitId(view(s), x, y)
484
- return h == Id.Restart
485
- false
486
-
487
- # One pointer input: finds what is under it in the current view, runs the Ui, and
488
- # feeds each raised event to `step`.
489
- fun feed(s: State, input: UiInput) -> State =
490
- r := Ui.step(s.ui, input, Picture.hitId(view(s), input.x, input.y))
491
- n := State(..s)
492
- n.ui = r.ui
493
- feedEvents(n, r.events)
494
-
495
- fun feedEvents(s: State, evs: List[UiEvent]) -> State = match evs
496
- Nil => s
497
- Cons(e, rest) => feedEvents(step(s, UiEv(event: e)), rest)
132
+ # --- the components ---
133
+
134
+ # The player.
135
+ record Ship(x: Float, y: Float, vx: Float, vy: Float, rotation: Float, hurt: Float) =
136
+
137
+ fun start() -> Ship =
138
+ Ship(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, rotation: 0.0, hurt: 0.0)
139
+
140
+ # Accelerates by the input axes, applies drag and keeps the ship in the arena.
141
+ fun update(self, ax: Float, ay: Float, dt: Float) -> Ship =
142
+ keep := {1.0 - Rules.Drag * dt}.max(0.0)
143
+ r := Rules.ShipRadius
144
+ vx0 := {self.vx + ax * Rules.Thrust * dt} * keep
145
+ vy0 := {self.vy + ay * Rules.Thrust * dt} * keep
146
+ x0 := self.x + vx0 * dt
147
+ y0 := self.y + vy0 * dt
148
+ x := x0.max(r).min(Rules.ArenaW - r)
149
+ y := y0.max(r).min(Rules.ArenaH - r)
150
+ vx := if x != x0 then 0.0 else vx0
151
+ vy := if y != y0 then 0.0 else vy0
152
+ Ship(
153
+ x: x, y: y, vx: vx, vy: vy,
154
+ rotation: if vx * vx + vy * vy > 400.0 then Math.atan2(vx, 0.0 - vy) else self.rotation,
155
+ hurt: {self.hurt - dt}.max(0.0))
156
+
157
+ fun touches(self, at: Motion, radius: Float) -> Bool =
158
+ overlaps(self.x, self.y, Rules.ShipRadius, at.x, at.y, radius)
159
+
160
+ # The ship blinks while hurt (`hurtAge` seconds since the hit).
161
+ fun render(self, sheet: Int, clock: Float, hurtAge: Float) -> SceneNode =
162
+ blink := self.hurt > 0.0 && flashPose(hurtAge).scaleX < 0.7
163
+ if blink
164
+ nothing()
165
+ else
166
+ Group(
167
+ transform: Compose(first: Translate(dx: self.x, dy: self.y), then: Rotate(radians: self.rotation)),
168
+ children: List(
169
+ CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12)),
170
+ cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(clock, 4, 14.0), 0)))
171
+
172
+ # A glowing orb drifting about. `phase` offsets its animation from the other orbs.
173
+ record Orb(id: Int, at: Motion, phase: Float, clock: Float) =
174
+
175
+ fun update(self, dt: Float) -> Orb =
176
+ Orb(..self, at: driftOf(self.at, Rules.OrbRadius, dt), clock: self.clock + dt)
177
+
178
+ fun moved(self, to: Motion) -> Orb =
179
+ Orb(..self, at: to)
180
+
181
+ fun still(self) -> Orb =
182
+ p := self.at
183
+ Orb(..self, at: Motion(..p, vx: 0.0, vy: 0.0))
184
+
185
+ # The glow is drawn additively, then the sprite on top.
186
+ fun render(self, sheet: Int) -> SceneNode =
187
+ glow := Group(
188
+ transform: Identity,
189
+ children: List(
190
+ CircleShape(cx: 0.0, cy: 0.0, r: 26.0, color: Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5)),
191
+ CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5))))
192
+ Group(
193
+ transform: Translate(dx: self.at.x, dy: self.at.y),
194
+ children: List(
195
+ Filter(effect: BlendFx(mode: Blend.Add), child: glow, target: 0 - 1),
196
+ cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(self.clock + self.phase, 4, 8.0), 1)))
197
+
198
+ # A spinning mine drifting about.
199
+ record Mine(id: Int, at: Motion, rotation: Float, spin: Float, clock: Float) =
200
+
201
+ fun update(self, dt: Float) -> Mine =
202
+ Mine(..self, at: driftOf(self.at, Rules.MineRadius, dt), rotation: self.rotation + self.spin * dt, clock: self.clock + dt)
203
+
204
+ fun moved(self, to: Motion) -> Mine =
205
+ Mine(..self, at: to)
206
+
207
+ fun still(self) -> Mine =
208
+ p := self.at
209
+ Mine(..self, at: Motion(..p, vx: 0.0, vy: 0.0), spin: 0.0)
210
+
211
+ fun render(self, sheet: Int) -> SceneNode =
212
+ Group(
213
+ transform: Compose(first: Translate(dx: self.at.x, dy: self.at.y), then: Rotate(radians: self.rotation)),
214
+ children: List(cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(self.clock, 4, 4.0), 2)))
215
+
216
+ # A twinkling background star; it never moves.
217
+ record Star(i: Int) =
218
+
219
+ # Stars `i` and up to 40.
220
+ fun field(i: Int) -> List[Star] =
221
+ if i >= 40 then Nil else Cons(head: Star(i: i), tail: Star.field(i + 1))
222
+
223
+ fun render(self, clock: Float) -> SceneNode =
224
+ r := 1.0 + Float(self.i % 3) * 0.5
225
+ twinkle := 0.25 + 0.2 * Math.sin(clock * 2.0 + Float(self.i))
226
+ CircleShape(cx: Float({self.i * 7919} % 800), cy: Float({self.i * 104729} % 600), r: r, color: Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
227
+
228
+ # What can happen to the state.
229
+ enum Msg =
230
+ | Tick(dt: Float, ax: Float, ay: Float)
231
+ | Resize(w: Float, h: Float)
232
+ | Restart(seed: Int)
233
+ | UiEv(event: UiEvent)
234
+
235
+ # Everything that changes during a run. `sheet` and `target` are resource handles
236
+ # the picture needs: the sprite sheet and the render target of the game-over filter.
237
+ record State(
238
+ ship: Ship,
239
+ orbs: List[Orb],
240
+ mines: List[Mine],
241
+ stars: List[Star],
242
+ rng: Random,
243
+ score: Int,
244
+ best: Int,
245
+ lives: Int,
246
+ over: Bool,
247
+ clock: Float,
248
+ overClock: Float,
249
+ hurtAt: Float,
250
+ overAt: Float,
251
+ ui: UiState,
252
+ sw: Float,
253
+ sh: Float,
254
+ sheet: Int,
255
+ target: Int) =
256
+
257
+ # A fresh run: the orbs and the mines spread over the arena away from the ship.
258
+ fun start(seed: Int, best: Int, sheet: Int, target: Int, sw: Float, sh: Float) -> State =
259
+ State(
260
+ ship: Ship.start(), orbs: Nil, mines: Nil, stars: Star.field(0), rng: Random.seeded(seed), score: 0, best: best,
261
+ lives: Rules.StartLives, over: false, clock: 0.0, overClock: 0.0, hurtAt: -1.0, overAt: -1.0, ui: Ui.init(),
262
+ sw: sw, sh: sh, sheet: sheet, target: target).fillOrbs(0).growMines()
263
+
264
+ # --- update ---
265
+
266
+ fun update(self, msg: Msg) -> State = match msg
267
+ Tick(dt, ax, ay) => self.tick(dt, ax, ay)
268
+ Resize(w, h) => State(..self, sw: w, sh: h)
269
+ Restart(seed) => self.restarted(seed)
270
+ UiEv(event) => self.onUi(event)
271
+
272
+ # One frame of time. Once the game is over the actors only animate.
273
+ fun tick(self, dt: Float, ax: Float, ay: Float) -> State =
274
+ moved := self.moveAll(dt)
275
+ if self.over
276
+ State(..moved, overClock: self.overClock + dt)
277
+ else
278
+ moved.sail(ax, ay, dt).collect().growMines().hurt().settle()
279
+
280
+ fun moveAll(self, dt: Float) -> State =
281
+ State(..self, clock: self.clock + dt, orbs: self.orbs.map(|o| o.update(dt)), mines: self.mines.map(|k| k.update(dt)))
282
+
283
+ fun sail(self, ax: Float, ay: Float, dt: Float) -> State =
284
+ State(..self, ship: self.ship.update(ax, ay, dt))
285
+
286
+ # The ship collects every orb it touches.
287
+ fun collect(self) -> State =
288
+ self.orbs.fold(self, |s, o| s.takeOrb(o))
289
+
290
+ fun takeOrb(self, o: Orb) -> State =
291
+ if self.ship.touches(o.at, Rules.OrbRadius) then self.respawnOrb(o) else self
292
+
293
+ # Scores `o` and puts it at a new random spot.
294
+ fun respawnOrb(self, o: Orb) -> State =
295
+ sp := spawn(self.rng, self.ship.x, self.ship.y, 120.0, 40.0)
296
+ State(
297
+ ..self, orbs: self.orbs.map(|x| if x.id == o.id then x.moved(sp.at) else x), rng: sp.rng,
298
+ score: self.score + Rules.OrbPoints)
299
+
300
+ # Orbs `i` and up to OrbCount, each at a random spot away from the ship.
301
+ fun fillOrbs(self, i: Int) -> State =
302
+ if i >= Rules.OrbCount then self else self.addOrb(i).fillOrbs(i + 1)
303
+
304
+ fun addOrb(self, i: Int) -> State =
305
+ sp := spawn(self.rng, self.ship.x, self.ship.y, 120.0, 40.0)
306
+ State(
307
+ ..self, orbs: self.orbs.append(List(Orb(id: Id.Orb + i, at: sp.at, phase: Float(i) * 0.1, clock: 0.0))),
308
+ rng: sp.rng)
309
+
310
+ # Mines are added (at the end) until there are as many as the score asks for.
311
+ fun growMines(self) -> State =
312
+ if self.mines.length() >= mineCount(self.score) then self else self.addMine().growMines()
313
+
314
+ fun addMine(self) -> State =
315
+ sp := spawn(self.rng, self.ship.x, self.ship.y, 220.0, 90.0)
316
+ State(
317
+ ..self,
318
+ mines: self.mines.append(List(Mine(id: Id.Mine + self.mines.length(), at: sp.at, rotation: 0.0, spin: 2.0, clock: 0.0))),
319
+ rng: sp.rng)
320
+
321
+ fun touchesMine(self) -> Bool =
322
+ self.mines.fold(false, |hit, k| hit || self.ship.touches(k.at, Rules.MineRadius))
323
+
324
+ # A mine costs a life, makes the ship flash and, at the last life, ends the game.
325
+ fun hurt(self) -> State =
326
+ if self.ship.hurt <= 0.0 && self.touchesMine() then self.struck() else self
327
+
328
+ fun struck(self) -> State =
329
+ lives := self.lives - 1
330
+ done := lives <= 0
331
+ ship := self.ship
332
+ State(
333
+ ..self, lives: lives, ship: Ship(..ship, hurt: Rules.HurtTime), hurtAt: self.clock, over: done,
334
+ overAt: if done then self.clock else self.overAt, best: if done then self.best.max(self.score) else self.best)
335
+
336
+ # After game over the orbs and mines stop: they only animate.
337
+ fun settle(self) -> State =
338
+ if self.over then State(..self, orbs: self.orbs.map(|o| o.still()), mines: self.mines.map(|k| k.still())) else self
339
+
340
+ fun restarted(self, seed: Int) -> State =
341
+ fresh := State.start(seed, self.best.max(self.score), self.sheet, self.target, self.sw, self.sh)
342
+ State(..fresh, ui: self.ui)
343
+
344
+ # A click on the restart button restarts a finished game; other events change nothing.
345
+ fun onUi(self, e: UiEvent) -> State =
346
+ if self.over && e.id == Id.Restart && e.kind == Click then self.restarted(self.rng.nextInt().value) else self
347
+
348
+ # One pointer input: finds what is under it in the current picture, runs the Ui, and
349
+ # feeds each raised event to `update`.
350
+ fun feed(self, input: UiInput) -> State =
351
+ r := Ui.step(self.ui, input, Picture.hitId(self.render(), input.x, input.y))
352
+ r.events.fold(State(..self, ui: r.ui), |s, e| s.update(UiEv(event: e)))
353
+
354
+ # --- render ---
355
+
356
+ # The zoom that fits the arena into the screen, and where the arena's corner lands.
357
+ fun zoom(self) -> Float =
358
+ {self.sw / Rules.ArenaW}.min(self.sh / Rules.ArenaH)
359
+
360
+ fun offsetX(self) -> Float =
361
+ {self.sw - self.zoom() * Rules.ArenaW} / 2.0
362
+
363
+ fun offsetY(self) -> Float =
364
+ {self.sh - self.zoom() * Rules.ArenaH} / 2.0
365
+
366
+ # The whole picture, in drawing order, under the camera that fits the arena on screen.
367
+ fun render(self) -> SceneNode =
368
+ z := self.zoom()
369
+ Group(
370
+ transform: Compose(first: Translate(dx: self.offsetX(), dy: self.offsetY()), then: Scale(sx: z, sy: z)),
371
+ children: List(
372
+ RectShape(x: 0.0, y: 0.0, w: Rules.ArenaW, h: Rules.ArenaH, color: Color.rgb(0.07, 0.08, 0.16)),
373
+ Group(transform: Identity, children: self.stars.map(|st| st.render(self.clock))),
374
+ Group(transform: Identity, children: self.mines.map(|k| k.render(self.sheet))),
375
+ Group(transform: Identity, children: self.orbs.map(|o| o.render(self.sheet))),
376
+ self.renderShip(),
377
+ self.renderHud(),
378
+ self.renderOverlay()))
379
+
380
+ # The ship is gone once the game is over (an empty group keeps the slots).
381
+ fun renderShip(self) -> SceneNode =
382
+ if self.over then nothing() else self.ship.render(self.sheet, self.clock, self.clock - self.hurtAt)
383
+
384
+ # One life icon per remaining life, from the right edge going left.
385
+ fun lifeIcons(self, i: Int) -> List[SceneNode] =
386
+ if i >= self.lives
387
+ Nil
388
+ else
389
+ Cons(head: cell(self.sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 0, 0), tail: self.lifeIcons(i + 1))
390
+
391
+ fun renderHud(self) -> SceneNode =
392
+ Group(
393
+ transform: Identity,
394
+ children: Cons(
395
+ head: Text(text: "Score {self.score}", x: 16.0, y: 12.0, size: 28.0, color: Color.White),
396
+ tail: Cons(head: Text(text: "Best {self.best.max(self.score)}", x: 16.0, y: 44.0, size: 20.0, color: Color.Yellow), tail: self.lifeIcons(0))))
397
+
398
+ fun renderTitle(self) -> SceneNode =
399
+ p := titlePose(self.clock - self.overAt)
400
+ Group(
401
+ transform: Compose(first: Translate(dx: 400.0 + p.dx, dy: 268.0 + p.dy), then: Scale(sx: p.scaleX, sy: p.scaleY)),
402
+ children: List(Text(text: "Game over", x: -100.0, y: -24.0, size: 48.0, color: Color.Red)))
403
+
404
+ # The game-over panel is empty while playing, so nothing in it can be hit.
405
+ fun renderOverlay(self) -> SceneNode =
406
+ if self.over then self.renderPanel() else nothing()
407
+
408
+ # The panel with the restart button, faded in through a render-target filter.
409
+ fun renderPanel(self) -> SceneNode =
410
+ button := Ui.button(Id.Restart, "Again", 340.0, 342.0, 120.0, 44.0, self.ui)
411
+ panel := Group(
412
+ transform: Identity,
413
+ children: List(
414
+ RectShape(x: 0.0, y: 230.0, w: Rules.ArenaW, h: 170.0, color: Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
415
+ self.renderTitle(),
416
+ Text(text: "Press Enter to play again", x: 270.0, y: 300.0, size: 24.0, color: Color.White),
417
+ button))
418
+ Filter(effect: Alpha(alpha: {self.overClock * 2.0}.min(1.0)), child: panel, target: self.target)
419
+
420
+ # True when (x, y) in screen pixels is on the restart button of the picture `render` builds.
421
+ fun onRestart(self, x: Float, y: Float) -> Bool =
422
+ {Picture.hitId(self.render(), x, y) ?: -1} == Id.Restart
498
423
 
499
424
  fun pointerKind(down: Bool, wasDown: Bool) -> UiInputKind =
500
425
  if down && !wasDown
@@ -506,22 +431,22 @@ fun pointerKind(down: Bool, wasDown: Bool) -> UiInputKind =
506
431
 
507
432
  # --- the host shell ---
508
433
 
509
- record World(state: State, wasDown: Bool, pickupSound: Int, hitSound: Int, overSound: Int, musicSound: Int)
434
+ record World(mut state: State, mut wasDown: Bool, pickupSound: Int, hitSound: Int, overSound: Int, musicSound: Int)
510
435
 
511
436
  fun musicVolume() -> Float =
512
437
  0.3
513
438
 
514
439
  # Restarts the music loop from the top.
515
- fun startMusic(w: World) =
440
+ fun startMusic(io: Io, w: World) =
516
- soundStop(w.musicSound)
441
+ io.soundStop(w.musicSound)
517
- soundLoop(w.musicSound, musicVolume())
442
+ io.soundLoop(w.musicSound, musicVolume())
518
443
 
519
- export fun init() -> World =
444
+ export fun init(io: Io) -> World =
520
- s := newState(2024, 0, textureLoad("sprites.png"), textureCreate(1600, 1200), Rules.ArenaW, Rules.ArenaH)
445
+ s := State.start(io.randomSeed(), 0, io.textureLoad("sprites.png"), io.textureCreate(1600, 1200), Rules.ArenaW, Rules.ArenaH)
521
446
  w := World(
522
- state: s, wasDown: false, pickupSound: soundLoad("pickup.wav"), hitSound: soundLoad("hit.wav"),
447
+ state: s, wasDown: false, pickupSound: io.soundLoad("pickup.wav"), hitSound: io.soundLoad("hit.wav"),
523
- overSound: soundLoad("gameover.wav"), musicSound: soundLoad("music.wav"))
448
+ overSound: io.soundLoad("gameover.wav"), musicSound: io.soundLoad("music.wav"))
524
- startMusic(w)
449
+ startMusic(io, w)
525
450
  w
526
451
 
527
452
  fun axis(negative: Bool, positive: Bool) -> Float =
@@ -532,39 +457,39 @@ fun axis(negative: Bool, positive: Bool) -> Float =
532
457
  else
533
458
  0.0
534
459
 
535
- export fun update(w: World, dt: Float) =
460
+ export fun update(io: Io, w: World, dt: Float) =
536
- s := step(w.state, Resize(w: screenWidth(), h: screenHeight()))
461
+ s := w.state.update(Resize(w: io.screenWidth(), h: io.screenHeight()))
537
- down := isMouseDown(0)
462
+ down := io.isMouseDown(0)
538
- m := mousePos()
463
+ m := io.mousePos()
539
- clicked := feed(s, UiInput(kind: pointerKind(down, w.wasDown), x: m.x, y: m.y))
464
+ clicked := s.feed(UiInput(kind: pointerKind(down, w.wasDown), x: m.x, y: m.y))
540
465
  w.wasDown = down
541
466
  if s.over && !clicked.over
542
467
  w.state = clicked
543
- startMusic(w)
468
+ startMusic(io, w)
544
469
  return
545
- s = clicked
546
- if s.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
470
+ if clicked.over && {io.isKeyPressed(Key.Enter) || io.isKeyPressed(Key.KeyR)}
547
- w.state = step(s, Restart(seed: nextSeed(s.seed)))
471
+ w.state = clicked.update(Restart(seed: clicked.rng.nextInt().value))
548
- startMusic(w)
472
+ startMusic(io, w)
549
473
  return
550
- left := isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
474
+ left := io.isKeyDown(Key.Left) || io.isKeyDown(Key.KeyA)
551
- right := isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
475
+ right := io.isKeyDown(Key.Right) || io.isKeyDown(Key.KeyD)
552
- up := isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
476
+ up := io.isKeyDown(Key.Up) || io.isKeyDown(Key.KeyW)
553
- downKey := isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
477
+ downKey := io.isKeyDown(Key.Down) || io.isKeyDown(Key.KeyS)
554
- t := step(s, Tick(dt: dt, ax: axis(left, right), ay: axis(up, downKey)))
478
+ t := clicked.update(Tick(dt: dt, ax: axis(left, right), ay: axis(up, downKey)))
555
- if t.score > s.score
479
+ if t.score > clicked.score
556
- soundPlay(w.pickupSound, 0.8)
480
+ io.soundPlay(w.pickupSound, 0.8)
557
- if t.lives < s.lives
481
+ if t.lives < clicked.lives
558
482
  if t.over
559
- soundStop(w.musicSound)
483
+ io.soundStop(w.musicSound)
560
- soundPlay(w.overSound, 1.0)
484
+ io.soundPlay(w.overSound, 1.0)
561
485
  else
562
- soundPlay(w.hitSound, 1.0)
486
+ io.soundPlay(w.hitSound, 1.0)
563
487
  w.state = t
564
488
 
565
- export fun draw(w: World) =
489
+ export fun draw(io: Io, w: World) =
490
+ s := w.state
566
- clear(Color.rgb(0.04, 0.05, 0.1))
491
+ io.clear(Color.rgb(0.04, 0.05, 0.1))
567
- Picture.draw(view(w.state))
492
+ Picture.draw(io, s.render())
568
493
 
569
494
  # --- tests ---
570
495
 
@@ -572,46 +497,30 @@ fun near(a: Float, b: Float) -> Bool =
572
497
  {a - b}.abs() < 0.01
573
498
 
574
499
  fun tickOf(s: State, ax: Float, ay: Float, dt: Float) -> State =
575
- step(s, Tick(dt: dt, ax: ax, ay: ay))
500
+ s.update(Tick(dt: dt, ax: ax, ay: ay))
576
501
 
577
502
  fun away() -> Motion =
578
503
  Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0)
579
504
 
505
+ fun newState(seed: Int) -> State =
506
+ State.start(seed, 0, -1, -1, Rules.ArenaW, Rules.ArenaH)
507
+
580
508
  fun withOrb(s: State, i: Int, m: Motion) -> State =
581
- n := State(..s)
582
- n.orbs = replaceNth(s.orbs, i, orbAt(Id.Orb + i, Float(i) * 0.1, 0.0, m))
509
+ State(..s, orbs: s.orbs.map(|o| if o.id == Id.Orb + i then Orb(id: o.id, at: m, phase: Float(i) * 0.1, clock: 0.0) else o))
583
- n
584
510
 
585
511
  fun withMine(s: State, i: Int, m: Motion) -> State =
586
- n := State(..s)
587
- n.mines = replaceNth(s.mines, i, mineAt(Id.Mine + i, 0.0, 2.0, 0.0, m))
512
+ State(..s, mines: s.mines.map(|k| if k.id == Id.Mine + i then Mine(id: k.id, at: m, rotation: 0.0, spin: 2.0, clock: 0.0) else k))
588
- n
589
513
 
590
514
  # A state with the orbs and mines moved out of the way.
591
515
  fun emptyState() -> State =
592
- s := newState(7, 0, -1, -1, Rules.ArenaW, Rules.ArenaH)
593
- for i := range Rules.OrbCount
594
- s = withOrb(s, i, away())
595
- for i := range lenLink(s.mines)
596
- s = withMine(s, i, away())
516
+ s := newState(7)
597
- s
598
-
599
- fun orbN(s: State, i: Int) -> Orb =
600
- nthOr(s.orbs, i, orbAt(0, 0.0, 0.0, away()))
601
-
602
- fun mineN(s: State, i: Int) -> Mine =
603
- nthOr(s.mines, i, mineAt(0, 0.0, 0.0, 0.0, away()))
517
+ State(..s, orbs: s.orbs.map(|o| o.moved(away())), mines: s.mines.map(|k| k.moved(away())))
604
518
 
605
519
  fun onShip(s: State) -> Motion =
606
520
  Motion(x: s.ship.x, y: s.ship.y, vx: 0.0, vy: 0.0)
607
521
 
608
522
  fun forceOver(s: State) -> State =
609
- n := State(..s)
610
- n.lives = 0
611
- n.over = true
612
- n.overAt = s.clock
613
- n.best = s.best.max(s.score)
523
+ State(..s, lives: 0, over: true, overAt: s.clock, best: s.best.max(s.score))
614
- n
615
524
 
616
525
  fun countNodes(n: SceneNode) -> Int = match n
617
526
  Group(_, kids) => 1 + countKids(kids)
@@ -623,26 +532,26 @@ fun countKids(ks: List[SceneNode]) -> Int = match ks
623
532
  Cons(h, t) => countNodes(h) + countKids(t)
624
533
 
625
534
  test "steering accelerates and drag slows the ship"
626
- s := shipStep(newShip(), 1.0, 0.0, 0.1)
535
+ s := Ship.start().update(1.0, 0.0, 0.1)
627
536
  assert s.vx > 0.0
628
537
  assert s.x > Rules.ArenaW / 2.0
629
538
  fast := s.vx
630
539
  t := s
631
540
  for i := range 30
632
- t = shipStep(t, 0.0, 0.0, 0.1)
541
+ t = t.update(0.0, 0.0, 0.1)
633
542
  assert t.vx < fast / 4.0
634
543
 
635
544
  test "the ship stops at the arena walls"
636
- s := ShipState(x: 20.0, y: 20.0, vx: 0.0, vy: 0.0, rotation: 0.0, hurt: 0.0)
545
+ s := Ship(x: 20.0, y: 20.0, vx: 0.0, vy: 0.0, rotation: 0.0, hurt: 0.0)
637
546
  for i := range 20
638
- s = shipStep(s, -1.0, -1.0, 0.1)
547
+ s = s.update(-1.0, -1.0, 0.1)
639
548
  assert s.x == Rules.ShipRadius
640
549
  assert s.y == Rules.ShipRadius
641
550
  assert s.vx == 0.0
642
551
 
643
552
  test "shipStep leaves its input unchanged"
644
- s := newShip()
553
+ s := Ship.start()
645
- _ := shipStep(s, 1.0, 1.0, 0.1)
554
+ _ := s.update(1.0, 1.0, 0.1)
646
555
  assert s.vx == 0.0
647
556
  assert s.x == Rules.ArenaW / 2.0
648
557
 
@@ -650,24 +559,24 @@ test "drifters bounce off the walls"
650
559
  b := driftOf(Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0), Rules.OrbRadius, 0.1)
651
560
  assert b.vx == -100.0
652
561
  assert b.x == Rules.ArenaW - 10.0
653
- orb := orbMove(orbAt(Id.Orb, 0.0, 0.0, Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0)), 0.1)
562
+ orb := Orb(id: Id.Orb, at: Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0), phase: 0.0, clock: 0.0).update(0.1)
654
- assert near(orb.x, Rules.ArenaW - 10.0)
563
+ assert near(orb.at.x, Rules.ArenaW - 10.0)
655
564
 
656
565
  test "a mine spins as it drifts"
657
- mine := mineAt(Id.Mine, 0.0, 2.0, 0.0, Motion(x: 400.0, y: 300.0, vx: 10.0, vy: 0.0))
566
+ mine := Mine(id: Id.Mine, at: Motion(x: 400.0, y: 300.0, vx: 10.0, vy: 0.0), rotation: 0.0, spin: 2.0, clock: 0.0)
658
- next := mineMove(mine, 0.5)
567
+ next := mine.update(0.5)
659
568
  assert near(next.rotation, 1.0)
660
- assert near(next.x, 405.0)
569
+ assert near(next.at.x, 405.0)
661
570
  assert near(mine.rotation, 0.0)
662
571
 
663
572
  test "collecting an orb scores and respawns it elsewhere"
664
573
  s := withOrb(emptyState(), 0, onShip(emptyState()))
665
574
  t := tickOf(s, 0.0, 0.0, 0.016)
666
- orb := orbN(t, 0)
575
+ orb := t.orbs.at(0)
667
576
  assert t.score == Rules.OrbPoints
668
- assert Math.hypot(orb.x - t.ship.x, orb.y - t.ship.y) >= 120.0
577
+ assert Math.hypot(orb.at.x - t.ship.x, orb.at.y - t.ship.y) >= 120.0
669
- assert orb.x < 5000.0
578
+ assert orb.at.x < 5000.0
670
- assert lenLink(t.orbs) == Rules.OrbCount
579
+ assert t.orbs.length() == Rules.OrbCount
671
580
 
672
581
  test "a mine costs one life and makes the ship flash"
673
582
  e := emptyState()
@@ -680,14 +589,14 @@ test "a mine costs one life and makes the ship flash"
680
589
  shown := 0
681
590
  for i := range 100
682
591
  t = tickOf(t, 0.0, 0.0, 0.016)
683
- if countNodes(shipView(t)) == 1
592
+ if countNodes(t.renderShip()) == 1
684
593
  hidden = hidden + 1
685
594
  else
686
595
  shown = shown + 1
687
596
  assert hidden > 0
688
597
  assert shown > 0
689
598
  assert t.ship.hurt == 0.0
690
- assert countNodes(shipView(t)) == 3
599
+ assert countNodes(t.renderShip()) == 3
691
600
  assert t.lives == Rules.StartLives - 1
692
601
 
693
602
  test "the ship is briefly safe after a hit"
@@ -698,80 +607,74 @@ test "the ship is briefly safe after a hit"
698
607
 
699
608
  test "the game ends at zero lives and then ignores steps"
700
609
  e := emptyState()
701
- s := State(..e)
610
+ s := State(..e, lives: 1, score: 40)
702
- s.lives = 1
703
- s.score = 40
704
611
  t := tickOf(withMine(s, 0, onShip(s)), 0.0, 0.0, 0.016)
705
612
  assert t.over
706
613
  assert t.lives == 0
707
614
  assert t.best == 40
708
- assert countNodes(shipView(t)) == 1
615
+ assert countNodes(t.renderShip()) == 1
709
616
  x := t.ship.x
710
617
  u := tickOf(t, 1.0, 0.0, 0.5)
711
618
  assert u.ship.x == x
712
619
 
713
620
  test "after game over nothing moves but the animations keep running"
714
621
  e := emptyState()
715
- s := State(..e)
622
+ s := State(..e, lives: 1)
716
- s.lives = 1
717
623
  t := tickOf(withMine(s, 0, Motion(x: s.ship.x, y: s.ship.y, vx: 10.0, vy: 0.0)), 0.0, 0.0, 0.016)
718
624
  assert t.over
719
- mine := mineN(t, 0)
625
+ mine := t.mines.at(0)
720
626
  for i := range 10
721
627
  t = tickOf(t, 1.0, 0.0, 0.1)
722
- after := mineN(t, 0)
628
+ after := t.mines.at(0)
723
- assert after.x == mine.x
629
+ assert after.at.x == mine.at.x
724
630
  assert after.rotation == mine.rotation
725
631
  assert t.ship.x == Rules.ArenaW / 2.0
726
632
  assert after.clock > mine.clock + 0.9
727
- assert orbN(t, 0).clock > 0.9
633
+ assert t.orbs.at(0).clock > 0.9
728
634
  assert t.clock > 0.9
729
635
  assert t.score == 0
730
636
 
731
637
  test "restart resets the run and keeps the best score"
732
638
  e := emptyState()
733
- s := State(..e)
734
- s.score = 90
735
- s = forceOver(s)
639
+ s := forceOver(State(..e, score: 90))
736
- r := step(s, Restart(seed: 5))
640
+ r := s.update(Restart(seed: 5))
737
641
  assert !r.over
738
642
  assert r.score == 0
739
643
  assert r.best == 90
740
644
  assert r.lives == Rules.StartLives
741
645
  assert r.ship.x == Rules.ArenaW / 2.0
742
- assert lenLink(r.mines) == mineCount(0)
646
+ assert r.mines.length() == mineCount(0)
743
- assert countNodes(shipView(r)) == 3
647
+ assert countNodes(r.renderShip()) == 3
744
- assert countNodes(overlayView(r)) == 1
648
+ assert countNodes(r.renderOverlay()) == 1
745
649
 
746
650
  test "more mines appear as the score grows and restart removes them"
747
651
  assert mineCount(0) == 3
748
652
  assert mineCount(100) == 5
749
653
  assert mineCount(10000) == 8
750
654
  e := emptyState()
751
- s := State(..e)
655
+ s := State(..e, score: 100)
752
- s.score = 100
753
656
  t := tickOf(s, 0.0, 0.0, 0.016)
754
- assert lenLink(t.mines) == 5
657
+ assert t.mines.length() == 5
755
- r := step(t, Restart(seed: 3))
658
+ r := t.update(Restart(seed: 3))
756
- assert lenLink(r.mines) == 3
659
+ assert r.mines.length() == 3
757
660
 
758
661
  test "the same seed plays out the same way"
759
- a := newState(11, 0, -1, -1, Rules.ArenaW, Rules.ArenaH)
662
+ a := newState(11)
760
- b := newState(11, 0, -1, -1, Rules.ArenaW, Rules.ArenaH)
663
+ b := newState(11)
761
664
  for i := range 120
762
665
  a = tickOf(a, 1.0, -1.0, 0.016)
763
666
  b = tickOf(b, 1.0, -1.0, 0.016)
764
667
  assert a.ship.x == b.ship.x
765
668
  assert a.score == b.score
766
- assert mineN(a, 0).x == mineN(b, 0).x
669
+ assert a.mines.at(0).at.x == b.mines.at(0).at.x
767
- other := newState(12, 0, -1, -1, Rules.ArenaW, Rules.ArenaH)
670
+ other := newState(12)
768
- assert orbN(other, 0).x != orbN(a, 0).x
671
+ assert other.orbs.at(0).at.x != a.orbs.at(0).at.x
769
672
 
770
673
  test "fit centres the arena on the screen"
771
- s := step(emptyState(), Resize(w: 1600.0, h: 600.0))
674
+ s := emptyState().update(Resize(w: 1600.0, h: 600.0))
772
- assert near(zoomOf(s), 1.0)
675
+ assert near(s.zoom(), 1.0)
773
- assert near(offsetX(s), 400.0)
676
+ assert near(s.offsetX(), 400.0)
774
- assert near(offsetY(s), 0.0)
677
+ assert near(s.offsetY(), 0.0)
775
678
 
776
679
  test "animation frames cycle"
777
680
  assert frameOf(0.0, 4, 8.0) == 0
@@ -782,21 +685,21 @@ test "animation frames cycle"
782
685
 
783
686
  test "restart_button_hidden_while_playing"
784
687
  s := emptyState()
785
- assert !onRestart(s, 400.0, 364.0)
688
+ assert !s.onRestart(400.0, 364.0)
786
689
 
787
690
  test "restart_button_click_restarts"
788
691
  s := forceOver(emptyState())
789
- assert onRestart(s, 400.0, 364.0)
692
+ assert s.onRestart(400.0, 364.0)
790
- assert !onRestart(s, 100.0, 100.0)
693
+ assert !s.onRestart(100.0, 100.0)
791
- assert !onRestart(s, 400.0, 260.0)
694
+ assert !s.onRestart(400.0, 260.0)
792
- r := step(s, Restart(seed: 9))
695
+ r := s.update(Restart(seed: 9))
793
696
  assert !r.over
794
- assert !onRestart(r, 400.0, 364.0)
697
+ assert !r.onRestart(400.0, 364.0)
795
698
 
796
699
  test "the restart button is found through the camera"
797
- s := step(forceOver(emptyState()), Resize(w: 1600.0, h: 600.0))
700
+ s := forceOver(emptyState()).update(Resize(w: 1600.0, h: 600.0))
798
- assert onRestart(s, 800.0, 364.0)
701
+ assert s.onRestart(800.0, 364.0)
799
- assert !onRestart(s, 400.0, 364.0)
702
+ assert !s.onRestart(400.0, 364.0)
800
703
 
801
704
  test "update_is_pure"
802
705
  s := emptyState()
@@ -809,49 +712,47 @@ test "freezing at game over keeps the orb and mine animation clocks"
809
712
  t := emptyState()
810
713
  for i := range 5
811
714
  t = tickOf(t, 0.0, 0.0, 0.1)
812
- orbClock := orbN(t, 0).clock
715
+ orbClock := t.orbs.at(0).clock
813
- mineClock := mineN(t, 0).clock
716
+ mineClock := t.mines.at(0).clock
814
717
  assert orbClock > 0.4
815
- t.lives = 1
718
+ t = State(..t, lives: 1)
816
719
  t = tickOf(withMine(t, 1, onShip(t)), 0.0, 0.0, 0.1)
817
720
  assert t.over
818
- assert orbN(t, 0).clock > orbClock
721
+ assert t.orbs.at(0).clock > orbClock
819
- assert mineN(t, 0).clock > mineClock
722
+ assert t.mines.at(0).clock > mineClock
820
723
 
821
724
  test "view_is_pure"
822
725
  s := emptyState()
823
- a := countNodes(view(s))
726
+ a := countNodes(s.render())
824
- b := countNodes(view(s))
727
+ b := countNodes(s.render())
825
728
  assert a == b
826
- ba := Picture.bounds(view(s))
729
+ ba := Picture.bounds(s.render())
827
- bb := Picture.bounds(view(s))
730
+ bb := Picture.bounds(s.render())
828
731
  assert ba.x == bb.x
829
732
  assert ba.w == bb.w
830
733
  assert s.clock == 0.0
831
734
 
832
735
  test "view_node_count"
833
- s := newState(7, 0, -1, -1, Rules.ArenaW, Rules.ArenaH)
736
+ s := newState(7)
834
- assert countNodes(view(s)) == 79
737
+ assert countNodes(s.render()) == 79
835
738
  over := forceOver(s)
836
- assert countNodes(overlayView(over)) > 5
739
+ assert countNodes(over.renderOverlay()) > 5
837
- assert countNodes(view(over)) > countNodes(view(s)) - 3
740
+ assert countNodes(over.render()) > countNodes(s.render()) - 3
838
741
 
839
742
  test "restart_click_through_ui"
840
743
  e := emptyState()
841
- s := State(..e)
842
- s.score = 90
843
- s = forceOver(s)
744
+ s := forceOver(State(..e, score: 90))
844
- down := feed(s, UiInput(kind: PointerDown, x: 400.0, y: 364.0))
745
+ down := s.feed(UiInput(kind: PointerDown, x: 400.0, y: 364.0))
845
746
  assert down.over
846
747
  assert Ui.pressed(down.ui, Id.Restart)
847
- up := feed(down, UiInput(kind: PointerUp, x: 400.0, y: 364.0))
748
+ up := down.feed(UiInput(kind: PointerUp, x: 400.0, y: 364.0))
848
749
  assert !up.over
849
750
  assert up.score == 0
850
751
  assert up.best == 90
851
752
  assert up.lives == Rules.StartLives
852
- off := feed(feed(s, UiInput(kind: PointerDown, x: 400.0, y: 364.0)), UiInput(kind: PointerUp, x: 100.0, y: 100.0))
753
+ off := s.feed(UiInput(kind: PointerDown, x: 400.0, y: 364.0)).feed(UiInput(kind: PointerUp, x: 100.0, y: 100.0))
853
754
  assert off.over
854
- miss := feed(feed(s, UiInput(kind: PointerDown, x: 100.0, y: 100.0)), UiInput(kind: PointerUp, x: 100.0, y: 100.0))
755
+ miss := s.feed(UiInput(kind: PointerDown, x: 100.0, y: 100.0)).feed(UiInput(kind: PointerUp, x: 100.0, y: 100.0))
855
756
  assert miss.over
856
757
 
857
758
  test "hit_flash_pose_at_fixed_times"
plum-examples/games/imperative/game.plum CHANGED
@@ -34,19 +34,19 @@ primitive Rules =
34
34
  Zoom = 3.0
35
35
 
36
36
  # A moving circle. `angle` is the ship's heading or a mine's spin, in radians clockwise.
37
- record Body(x: Float, y: Float, vx: Float, vy: Float, angle: Float)
37
+ record Body(mut x: Float, mut y: Float, mut vx: Float, mut vy: Float, mut angle: Float)
38
38
 
39
39
  record World(
40
40
  ship: Body,
41
41
  orbs: Slice[Body],
42
42
  mines: Slice[Body],
43
- rng: Random,
43
+ mut rng: Random,
44
- score: Int,
44
+ mut score: Int,
45
- best: Int,
45
+ mut best: Int,
46
- lives: Int,
46
+ mut lives: Int,
47
- hurt: Float,
47
+ mut hurt: Float,
48
- clock: Float,
48
+ mut clock: Float,
49
- over: Bool,
49
+ mut over: Bool,
50
50
  sheet: Int,
51
51
  pickupSound: Int,
52
52
  hitSound: Int,
@@ -54,16 +54,19 @@ record World(
54
54
  musicSound: Int) =
55
55
 
56
56
  fun create(seed: Int, sheet: Int) -> World =
57
- rng := Random(seed: seed)
58
57
  ship := Body(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, angle: 0.0)
59
58
  w := World(
60
- ship: ship, orbs: Slice[Body](), mines: Slice[Body](), rng: rng, score: 0, best: 0,
59
+ ship: ship, orbs: Slice[Body](), mines: Slice[Body](), rng: Random.seeded(seed), score: 0, best: 0,
61
60
  lives: Rules.StartLives, hurt: 0.0, clock: 0.0, over: false, sheet: sheet,
62
61
  pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
63
62
  for i := range Rules.OrbCount
64
- w.orbs.push(spawn(rng, ship, 120.0, 40.0))
63
+ sp := spawn(w.rng, ship, 120.0, 40.0)
64
+ w.rng = sp.rng
65
+ w.orbs.push(sp.at)
65
66
  for i := range mineCount(0)
66
- w.mines.push(spawn(rng, ship, 220.0, 90.0))
67
+ sp := spawn(w.rng, ship, 220.0, 90.0)
68
+ w.rng = sp.rng
69
+ w.mines.push(sp.at)
67
70
  w
68
71
 
69
72
  # A world with the orbs and mines moved out of the way.
@@ -91,7 +94,9 @@ record World(
91
94
  self.ship.vx = 0.0
92
95
  self.ship.vy = 0.0
93
96
  for i := range self.orbs.length()
94
- moved := spawn(self.rng, self.ship, 120.0, 40.0)
97
+ sp := spawn(self.rng, self.ship, 120.0, 40.0)
98
+ self.rng = sp.rng
99
+ moved := sp.at
95
100
  self.orbs.at(i).x = moved.x
96
101
  self.orbs.at(i).y = moved.y
97
102
  self.orbs.at(i).vx = moved.vx
@@ -99,7 +104,9 @@ record World(
99
104
  while self.mines.length() > mineCount(0)
100
105
  _ := self.mines.pop()!
101
106
  for i := range self.mines.length()
102
- moved := spawn(self.rng, self.ship, 220.0, 90.0)
107
+ sp := spawn(self.rng, self.ship, 220.0, 90.0)
108
+ self.rng = sp.rng
109
+ moved := sp.at
103
110
  self.mines.at(i).x = moved.x
104
111
  self.mines.at(i).y = moved.y
105
112
  self.mines.at(i).vx = moved.vx
@@ -117,13 +124,17 @@ record World(
117
124
  drift(orb, Rules.OrbRadius, dt)
118
125
  if overlaps(self.ship, Rules.ShipRadius, orb, Rules.OrbRadius)
119
126
  self.score = self.score + Rules.OrbPoints
120
- moved := spawn(self.rng, self.ship, 120.0, 40.0)
127
+ sp := spawn(self.rng, self.ship, 120.0, 40.0)
128
+ self.rng = sp.rng
129
+ moved := sp.at
121
130
  orb.x = moved.x
122
131
  orb.y = moved.y
123
132
  orb.vx = moved.vx
124
133
  orb.vy = moved.vy
125
134
  while self.mines.length() < mineCount(self.score)
126
- self.mines.push(spawn(self.rng, self.ship, 220.0, 90.0))
135
+ sp := spawn(self.rng, self.ship, 220.0, 90.0)
136
+ self.rng = sp.rng
137
+ self.mines.push(sp.at)
127
138
  for i := range self.mines.length()
128
139
  mine := self.mines.at(i)
129
140
  drift(mine, Rules.MineRadius, dt)
@@ -220,21 +231,30 @@ fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
220
231
  Int(clock * fps) % count
221
232
 
222
233
  # A body at a random spot at least `gap` away from `avoid`, drifting at up to `speed`.
234
+ # A Body and the generator to continue from.
235
+ record Spawned(at: Body, rng: Random)
236
+
223
- fun spawn(rng: Random, avoid: Body, gap: Float, speed: Float) -> Body =
237
+ fun spawn(rng: Random, avoid: Body, gap: Float, speed: Float) -> Spawned =
224
238
  margin := 30.0
225
239
  b := Body(x: 0.0, y: 0.0, vx: 0.0, vy: 0.0, angle: 0.0)
240
+ r := rng
226
241
  tries := 0
227
242
  while tries < 20
243
+ dx := r.nextFloat()
244
+ dy := dx.rng.nextFloat()
245
+ r = dy.rng
228
- b.x = margin + rng.nextFloat() * {Rules.ArenaW - 2.0 * margin}
246
+ b.x = margin + dx.value * {Rules.ArenaW - 2.0 * margin}
229
- b.y = margin + rng.nextFloat() * {Rules.ArenaH - 2.0 * margin}
247
+ b.y = margin + dy.value * {Rules.ArenaH - 2.0 * margin}
230
248
  if distance(b, avoid) >= gap
231
249
  break
232
250
  tries = tries + 1
251
+ dh := r.nextFloat()
252
+ dp := dh.rng.nextFloat()
233
- heading := rng.nextFloat() * Float.Tau
253
+ heading := dh.value * Float.Tau
234
- pace := speed * {0.4 + 0.6 * rng.nextFloat()}
254
+ pace := speed * {0.4 + 0.6 * dp.value}
235
255
  b.vx = Math.cos(heading) * pace
236
256
  b.vy = Math.sin(heading) * pace
237
- b
257
+ Spawned(at: b, rng: dp.rng)
238
258
 
239
259
  # How many mines are out: more as the score grows.
240
260
  fun mineCount(score: Int) -> Int =
@@ -282,7 +302,7 @@ fun musicVolume() -> Float =
282
302
  0.3
283
303
 
284
304
  export fun init(io: Io) -> World =
285
- w := World.create(2024, io.textureLoad("sprites.png"))
305
+ w := World.create(io.randomSeed(), io.textureLoad("sprites.png"))
286
306
  w.pickupSound = io.soundLoad("pickup.wav")
287
307
  w.hitSound = io.soundLoad("hit.wav")
288
308
  w.overSound = io.soundLoad("gameover.wav")
plum-examples/{game_loop.plum → games/imperative/game_loop.plum} RENAMED
@@ -19,7 +19,7 @@ struct Vec2(x: Float, y: Float) =
19
19
 
20
20
  struct Particle(pos: Vec2, vel: Vec2, life: Int)
21
21
 
22
- record Entity(id: Int, name: Str, hp: Int)
22
+ record Entity(id: Int, name: Str, mut hp: Int)
23
23
 
24
24
  record Spawn(id: Int, name: Str)
25
25
 
plum-examples/games/imperative/game_scene.plum CHANGED
@@ -49,28 +49,37 @@ primitive Rules =
49
49
  Zoom = 3.0
50
50
 
51
51
  # A position and velocity: what a spawn returns and what drifters are bounced with.
52
- record Motion(x: Float, y: Float, vx: Float, vy: Float)
52
+ record Motion(mut x: Float, mut y: Float, mut vx: Float, mut vy: Float)
53
53
 
54
54
  # Which of `count` animation frames is showing after `clock` seconds at `fps` frames per second.
55
55
  fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
56
56
  Int(clock * fps) % count
57
57
 
58
58
  # A random spot at least `gap` away from (ax, ay), drifting at up to `speed`.
59
+ # A Motion and the generator to continue from.
60
+ record Spawned(at: Motion, rng: Random)
61
+
59
- fun spawn(rng: Random, ax: Float, ay: Float, gap: Float, speed: Float) -> Motion =
62
+ fun spawn(rng: Random, ax: Float, ay: Float, gap: Float, speed: Float) -> Spawned =
60
63
  margin := 30.0
61
64
  b := Motion(x: 0.0, y: 0.0, vx: 0.0, vy: 0.0)
65
+ r := rng
62
66
  tries := 0
63
67
  while tries < 20
68
+ dx := r.nextFloat()
69
+ dy := dx.rng.nextFloat()
70
+ r = dy.rng
64
- b.x = margin + rng.nextFloat() * {Rules.ArenaW - 2.0 * margin}
71
+ b.x = margin + dx.value * {Rules.ArenaW - 2.0 * margin}
65
- b.y = margin + rng.nextFloat() * {Rules.ArenaH - 2.0 * margin}
72
+ b.y = margin + dy.value * {Rules.ArenaH - 2.0 * margin}
66
73
  if Math.hypot(b.x - ax, b.y - ay) >= gap
67
74
  break
68
75
  tries = tries + 1
76
+ dh := r.nextFloat()
77
+ dp := dh.rng.nextFloat()
69
- heading := rng.nextFloat() * Float.Tau
78
+ heading := dh.value * Float.Tau
70
- pace := speed * {0.4 + 0.6 * rng.nextFloat()}
79
+ pace := speed * {0.4 + 0.6 * dp.value}
71
80
  b.vx = Math.cos(heading) * pace
72
81
  b.vy = Math.sin(heading) * pace
73
- b
82
+ Spawned(at: b, rng: dp.rng)
74
83
 
75
84
  # Moves a drifter and bounces it off the arena walls.
76
85
  fun drift(b: Motion, r: Float, dt: Float) =
@@ -91,20 +100,13 @@ fun mineCount(score: Int) -> Int =
91
100
  # (origin at the middle of the box, so `rotation` turns it about its centre).
92
101
 
93
102
  # The player. `ax` and `ay` are the input axes (-1..1) for the next `act`.
94
- record Ship(Actor(x, y), tex: Int) =
103
+ record Ship(Actor(x, y), tex: Int, mut vx: Float, mut vy: Float, mut ax: Float, mut ay: Float, mut hurt: Float, mut clock: Float, mut live: Bool) =
95
104
 
96
105
  fun init(self) =
97
106
  self.width = Rules.Cell * Rules.Zoom
98
107
  self.height = self.width
99
108
  self.originX = self.width / 2.0
100
109
  self.originY = self.height / 2.0
101
- self.vx = 0.0
102
- self.vy = 0.0
103
- self.ax = 0.0
104
- self.ay = 0.0
105
- self.hurt = 0.0
106
- self.clock = 0.0
107
- self.live = true
108
110
 
109
111
  # Accelerates by the input axes, applies drag, keeps the ship in the arena and
110
112
  # blinks it while it is hurt (the blink is just `visible`).
@@ -144,18 +146,16 @@ record Ship(Actor(x, y), tex: Int) =
144
146
  drawSpriteFrame(io, m, alpha, self.width, self.height, self.color, self.tex, frame * Rules.Cell, 0.0, Rules.Cell, Rules.Cell)
145
147
 
146
148
  # A glowing orb drifting about. `phase` offsets its animation from the other orbs.
147
- record Orb(Actor(x, y), vx: Float, vy: Float, phase: Float, tex: Int) =
149
+ record Orb(Actor(x, y), mut vx: Float, mut vy: Float, phase: Float, tex: Int, mut clock: Float, mut live: Bool) =
148
150
 
149
151
  fun init(self) =
150
152
  self.width = Rules.Cell * Rules.Zoom
151
153
  self.height = self.width
152
154
  self.originX = self.width / 2.0
153
155
  self.originY = self.height / 2.0
154
- self.clock = 0.0
155
- self.live = true
156
156
 
157
157
  fun create(sheet: Int, phase: Float, at: Motion) -> Orb =
158
- Orb(x: at.x, y: at.y, vx: at.vx, vy: at.vy, phase: phase, tex: sheet)
158
+ Orb(x: at.x, y: at.y, vx: at.vx, vy: at.vy, phase: phase, tex: sheet, clock: 0.0, live: true)
159
159
 
160
160
  fun motion(self) -> Motion =
161
161
  Motion(x: self.x, y: self.y, vx: self.vx, vy: self.vy)
@@ -185,18 +185,16 @@ record Orb(Actor(x, y), vx: Float, vy: Float, phase: Float, tex: Int) =
185
185
  drawSpriteFrame(io, m, alpha, self.width, self.height, self.color, self.tex, frame * Rules.Cell, Rules.Cell, Rules.Cell, Rules.Cell)
186
186
 
187
187
  # A spinning mine drifting about.
188
- record Mine(Actor(x, y), vx: Float, vy: Float, tex: Int) =
188
+ record Mine(Actor(x, y), mut vx: Float, mut vy: Float, tex: Int, mut clock: Float, mut live: Bool) =
189
189
 
190
190
  fun init(self) =
191
191
  self.width = Rules.Cell * Rules.Zoom
192
192
  self.height = self.width
193
193
  self.originX = self.width / 2.0
194
194
  self.originY = self.height / 2.0
195
- self.clock = 0.0
196
- self.live = true
197
195
 
198
196
  fun create(sheet: Int, at: Motion) -> Mine =
199
- Mine(x: at.x, y: at.y, vx: at.vx, vy: at.vy, tex: sheet)
197
+ Mine(x: at.x, y: at.y, vx: at.vx, vy: at.vy, tex: sheet, clock: 0.0, live: true)
200
198
 
201
199
  fun motion(self) -> Motion =
202
200
  Motion(x: self.x, y: self.y, vx: self.vx, vy: self.vy)
@@ -221,16 +219,13 @@ record Mine(Actor(x, y), vx: Float, vy: Float, tex: Int) =
221
219
  drawSpriteFrame(io, m, alpha, self.width, self.height, self.color, self.tex, frame * Rules.Cell, 2.0 * Rules.Cell, Rules.Cell, Rules.Cell)
222
220
 
223
221
  # A background star that twinkles.
224
- record Star(Actor(x, y, width, height), seed: Float) =
222
+ record Star(Actor(x, y, width, height), seed: Float, mut clock: Float) =
225
-
226
- fun init(self) =
227
- self.clock = 0.0
228
223
 
229
224
  fun create(i: Int) -> Star =
230
225
  r := 1.0 + Float(i % 3) * 0.5
231
226
  Star(
232
227
  x: Float({i * 7919} % 800) - r, y: Float({i * 104729} % 600) - r, width: r * 2.0,
233
- height: r * 2.0, seed: Float(i))
228
+ height: r * 2.0, seed: Float(i), clock: 0.0)
234
229
 
235
230
  override fun act(self, dt: Float) =
236
231
  self.clock = self.clock + dt
@@ -243,7 +238,7 @@ record Star(Actor(x, y, width, height), seed: Float) =
243
238
  # touch, and the run's state. `mines` and `orbs` are the layer Groups; `mineList`
244
239
  # and `orbList` are the same actors, typed.
245
240
  record World(
246
- Stage(camScale),
241
+ Stage(camX, camY, camScale),
247
242
  ship: Ship,
248
243
  mines: Group,
249
244
  orbs: Group,
@@ -253,12 +248,12 @@ record World(
253
248
  scoreText: Text,
254
249
  bestText: Text,
255
250
  lifeIcons: Slice[Sprite],
256
- rng: Random,
251
+ mut rng: Random,
257
- score: Int,
252
+ mut score: Int,
258
- best: Int,
253
+ mut best: Int,
259
- lives: Int,
254
+ mut lives: Int,
260
- over: Bool,
255
+ mut over: Bool,
261
- clock: Float,
256
+ mut clock: Float,
262
257
  sheet: Int,
263
258
  pickupSound: Int,
264
259
  hitSound: Int,
@@ -266,8 +261,7 @@ record World(
266
261
  musicSound: Int) =
267
262
 
268
263
  fun create(seed: Int, sheet: Int) -> World =
269
- rng := Random(seed: seed)
270
- ship := Ship(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, tex: sheet)
264
+ ship := Ship(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, tex: sheet, vx: 0.0, vy: 0.0, ax: 0.0, ay: 0.0, hurt: 0.0, clock: 0.0, live: true)
271
265
  stars := Group()
272
266
  for i := range 40
273
267
  stars.add(Star.create(i))
@@ -296,9 +290,9 @@ record World(
296
290
  overlay.add(Text.create("Press Enter to play again", 24.0, Color.White).at(270.0, 316.0))
297
291
  hud.add(overlay)
298
292
  w := World(
299
- camScale: 1.0, ship: ship, mines: mines, orbs: orbs, mineList: Slice[Mine](), orbList: Slice[Orb](),
293
+ camX: 0.0, camY: 0.0, camScale: 1.0, ship: ship, mines: mines, orbs: orbs, mineList: Slice[Mine](), orbList: Slice[Orb](),
300
294
  overlay: overlay, scoreText: scoreText, bestText: bestText, lifeIcons: icons,
301
- rng: rng, score: 0, best: 0, lives: Rules.StartLives, over: false, clock: 0.0, sheet: sheet,
295
+ rng: Random.seeded(seed), score: 0, best: 0, lives: Rules.StartLives, over: false, clock: 0.0, sheet: sheet,
302
296
  pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
303
297
  w.root.add(Rect.create(Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16)))
304
298
  w.root.add(stars)
@@ -307,7 +301,9 @@ record World(
307
301
  w.root.add(ship)
308
302
  w.root.add(hud)
309
303
  for i := range Rules.OrbCount
304
+ sp := spawn(w.rng, ship.x, ship.y, 120.0, 40.0)
305
+ w.rng = sp.rng
310
- orb := Orb.create(sheet, Float(i) * 0.1, spawn(rng, ship.x, ship.y, 120.0, 40.0))
306
+ orb := Orb.create(sheet, Float(i) * 0.1, sp.at)
311
307
  orbs.add(orb)
312
308
  w.orbList.push(orb)
313
309
  for i := range mineCount(0)
@@ -325,7 +321,9 @@ record World(
325
321
 
326
322
  # Adds one mine, far from the ship, to the mines layer.
327
323
  fun addMine(self) =
328
- mine := Mine.create(self.sheet, spawn(self.rng, self.ship.x, self.ship.y, 220.0, 90.0))
324
+ sp := spawn(self.rng, self.ship.x, self.ship.y, 220.0, 90.0)
325
+ self.rng = sp.rng
326
+ mine := Mine.create(self.sheet, sp.at)
329
327
  self.mines.add(mine)
330
328
  self.mineList.push(mine)
331
329
 
@@ -343,12 +341,16 @@ record World(
343
341
  ship.vx = 0.0
344
342
  ship.vy = 0.0
345
343
  for i := range self.orbList.length()
346
- self.orbList.at(i).place(spawn(self.rng, ship.x, ship.y, 120.0, 40.0))
344
+ sp := spawn(self.rng, ship.x, ship.y, 120.0, 40.0)
345
+ self.rng = sp.rng
346
+ self.orbList.at(i).place(sp.at)
347
347
  while self.mineList.length() > mineCount(0)
348
348
  gone := self.mineList.pop()!
349
349
  self.mines.remove(gone)
350
350
  for i := range self.mineList.length()
351
- self.mineList.at(i).place(spawn(self.rng, ship.x, ship.y, 220.0, 90.0))
351
+ sp := spawn(self.rng, ship.x, ship.y, 220.0, 90.0)
352
+ self.rng = sp.rng
353
+ self.mineList.at(i).place(sp.at)
352
354
  self.sync()
353
355
 
354
356
  # Copies the score, best score, lives and game-over panel into the HUD actors.
@@ -382,7 +384,9 @@ record World(
382
384
  orb := self.orbList.at(i)
383
385
  if overlaps(ship.x, ship.y, Rules.ShipRadius, orb.x, orb.y, Rules.OrbRadius)
384
386
  self.score = self.score + Rules.OrbPoints
385
- orb.place(spawn(self.rng, ship.x, ship.y, 120.0, 40.0))
387
+ sp := spawn(self.rng, ship.x, ship.y, 120.0, 40.0)
388
+ self.rng = sp.rng
389
+ orb.place(sp.at)
386
390
  while self.mineList.length() < mineCount(self.score)
387
391
  self.addMine()
388
392
  for i := range self.mineList.length()
@@ -442,7 +446,7 @@ fun musicVolume() -> Float =
442
446
  0.3
443
447
 
444
448
  export fun init(io: Io) -> World =
445
- w := World.create(2024, io.textureLoad("sprites.png"))
449
+ w := World.create(io.randomSeed(), io.textureLoad("sprites.png"))
446
450
  w.pickupSound = io.soundLoad("pickup.wav")
447
451
  w.hitSound = io.soundLoad("hit.wav")
448
452
  w.overSound = io.soundLoad("gameover.wav")
@@ -470,7 +474,7 @@ fun near(a: Float, b: Float) -> Bool =
470
474
  {a - b}.abs() < 0.01
471
475
 
472
476
  test "steering accelerates and drag slows the ship"
473
- ship := Ship(x: 400.0, y: 300.0, tex: -1)
477
+ ship := Ship(x: 400.0, y: 300.0, tex: -1, vx: 0.0, vy: 0.0, ax: 0.0, ay: 0.0, hurt: 0.0, clock: 0.0, live: true)
474
478
  ship.ax = 1.0
475
479
  ship.act(0.1)
476
480
  assert ship.vx > 0.0
@@ -482,7 +486,7 @@ test "steering accelerates and drag slows the ship"
482
486
  assert ship.vx < fast / 4.0
483
487
 
484
488
  test "the ship stops at the arena walls"
485
- ship := Ship(x: 20.0, y: 20.0, tex: -1)
489
+ ship := Ship(x: 20.0, y: 20.0, tex: -1, vx: 0.0, vy: 0.0, ax: 0.0, ay: 0.0, hurt: 0.0, clock: 0.0, live: true)
486
490
  ship.ax = -1.0
487
491
  ship.ay = -1.0
488
492
  for i := range 20
plum-examples/games/imperative/scene.plum CHANGED
@@ -48,7 +48,7 @@ record Orbit(Actor, speed: Float) =
48
48
  self.actChildren(dt)
49
49
 
50
50
  # A sprite that turns on its own axis, `speed` radians per second.
51
- record Spinner(Actor(width, height, originX, originY), speed: Float, tex: Int, srcX: Float, srcY: Float) =
51
+ record Spinner(Actor(width, height, originX, originY), speed: Float, tex: Int, mut srcX: Float, srcY: Float) =
52
52
 
53
53
  fun init(self) =
54
54
  self.srcW: Float = Layout.Cell
@@ -98,10 +98,10 @@ record Sky(
98
98
  sun: Spinner,
99
99
  label: Text,
100
100
  crowd: Group,
101
- front: Actor?,
101
+ mut front: Actor?,
102
- selected: Int,
102
+ mut selected: Int,
103
- clock: Float,
103
+ mut clock: Float,
104
- paused: Bool,
104
+ mut paused: Bool,
105
105
  map: Int)
106
106
 
107
107
  # A body sprite and its (hidden) halo in a group `slot` centred on the group's own
@@ -273,12 +273,11 @@ record ProbeSpinner(Actor) =
273
273
  self.actChildren(dt)
274
274
 
275
275
  # A walker: moves along x in its own `act` and counts the frames it draws itself in.
276
- record ProbeWalker(Actor, speed: Float) =
276
+ record ProbeWalker(Actor, speed: Float, mut drawn: Int) =
277
277
 
278
278
  fun init(self) =
279
279
  self.width = 10.0
280
280
  self.height = 10.0
281
- self.drawn = 0
282
281
 
283
282
  override fun act(self, dt: Float) =
284
283
  self.x = self.x + self.speed * dt
@@ -399,7 +398,7 @@ test "act runs behaviours depth first, including invisible actors"
399
398
 
400
399
  test "walker_record: a Walker in a Stage moves via its own act and draws via its own draw"
401
400
  stage := Stage.create()
402
- w := ProbeWalker(speed: 4.0)
401
+ w := ProbeWalker(speed: 4.0, drawn: 0)
403
402
  stage.root.add(w)
404
403
  stage.root.add(Group())
405
404
  stage.act(0.5)
plum-examples/games/pure/game_pure_scene.plum CHANGED
@@ -29,6 +29,7 @@ import std/game/pure/Actions
29
29
  import std/game/pure/PureScene
30
30
  import std/game/pure/SceneTree
31
31
  import std/game/pure/SceneInput
32
+ import std/Io
32
33
 
33
34
  primitive Rules =
34
35
  ArenaW = 800.0
@@ -75,34 +76,43 @@ fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
75
76
  Int(clock * fps) % count
76
77
 
77
78
  # A random spot at least `gap` away from (ax, ay), drifting at up to `speed`.
79
+ # A Motion and the generator to continue from.
80
+ record Spawned(at: Motion, rng: Random)
81
+
78
- fun spawn(rng: Random, ax: Float, ay: Float, gap: Float, speed: Float) -> Motion =
82
+ fun spawn(rng: Random, ax: Float, ay: Float, gap: Float, speed: Float) -> Spawned =
79
83
  margin := 30.0
80
- b := Motion(x: 0.0, y: 0.0, vx: 0.0, vy: 0.0)
84
+ bx := 0.0
85
+ by := 0.0
86
+ r := rng
81
87
  tries := 0
82
88
  while tries < 20
89
+ dx := r.nextFloat()
90
+ dy := dx.rng.nextFloat()
91
+ r = dy.rng
83
- b.x = margin + rng.nextFloat() * {Rules.ArenaW - 2.0 * margin}
92
+ bx = margin + dx.value * {Rules.ArenaW - 2.0 * margin}
84
- b.y = margin + rng.nextFloat() * {Rules.ArenaH - 2.0 * margin}
93
+ by = margin + dy.value * {Rules.ArenaH - 2.0 * margin}
85
- if Math.hypot(b.x - ax, b.y - ay) >= gap
94
+ if Math.hypot(bx - ax, by - ay) >= gap
86
95
  break
87
96
  tries = tries + 1
97
+ dh := r.nextFloat()
98
+ dp := dh.rng.nextFloat()
88
- heading := rng.nextFloat() * Float.Tau
99
+ heading := dh.value * Float.Tau
89
- pace := speed * {0.4 + 0.6 * rng.nextFloat()}
100
+ pace := speed * {0.4 + 0.6 * dp.value}
90
- b.vx = Math.cos(heading) * pace
91
- b.vy = Math.sin(heading) * pace
101
+ Spawned(at: Motion(x: bx, y: by, vx: Math.cos(heading) * pace, vy: Math.sin(heading) * pace), rng: dp.rng)
92
- b
93
102
 
94
103
  # `b` moved for `dt` seconds and bounced off the arena walls; `b` itself is unchanged.
95
104
  fun driftOf(b: Motion, r: Float, dt: Float) -> Motion =
96
- n := Motion(..b)
97
- n.x = b.x + b.vx * dt
105
+ x := b.x + b.vx * dt
98
- n.y = b.y + b.vy * dt
106
+ y := b.y + b.vy * dt
107
+ vx := b.vx
108
+ vy := b.vy
99
- if n.x < r || n.x > Rules.ArenaW - r
109
+ if x < r || x > Rules.ArenaW - r
100
- n.vx = 0.0 - b.vx
110
+ vx = 0.0 - b.vx
101
- n.x = n.x.max(r).min(Rules.ArenaW - r)
111
+ x = x.max(r).min(Rules.ArenaW - r)
102
- if n.y < r || n.y > Rules.ArenaH - r
112
+ if y < r || y > Rules.ArenaH - r
103
- n.vy = 0.0 - b.vy
113
+ vy = 0.0 - b.vy
104
- n.y = n.y.max(r).min(Rules.ArenaH - r)
114
+ y = y.max(r).min(Rules.ArenaH - r)
105
- n
115
+ Motion(x: x, y: y, vx: vx, vy: vy)
106
116
 
107
117
  # How many mines are out: more as the score grows.
108
118
  fun mineCount(score: Int) -> Int =
@@ -113,31 +123,29 @@ record ShipState(x: Float, y: Float, vx: Float, vy: Float, rotation: Float, hurt
113
123
 
114
124
  # Accelerates by the input axes, applies drag and keeps the ship in the arena.
115
125
  fun shipStep(s: ShipState, ax: Float, ay: Float, dt: Float) -> ShipState =
116
- n := ShipState(..s)
117
- n.hurt = {s.hurt - dt}.max(0.0)
126
+ hurt := {s.hurt - dt}.max(0.0)
118
- n.vx = s.vx + ax * Rules.Thrust * dt
119
- n.vy = s.vy + ay * Rules.Thrust * dt
120
127
  keep := {1.0 - Rules.Drag * dt}.max(0.0)
121
- n.vx = n.vx * keep
128
+ vx := {s.vx + ax * Rules.Thrust * dt} * keep
122
- n.vy = n.vy * keep
129
+ vy := {s.vy + ay * Rules.Thrust * dt} * keep
123
- n.x = s.x + n.vx * dt
130
+ x := s.x + vx * dt
124
- n.y = s.y + n.vy * dt
131
+ y := s.y + vy * dt
125
132
  r := Rules.ShipRadius
126
- if n.x < r
133
+ if x < r
127
- n.x = r
134
+ x = r
128
- n.vx = 0.0
135
+ vx = 0.0
129
- if n.x > Rules.ArenaW - r
136
+ if x > Rules.ArenaW - r
130
- n.x = Rules.ArenaW - r
137
+ x = Rules.ArenaW - r
131
- n.vx = 0.0
138
+ vx = 0.0
132
- if n.y < r
139
+ if y < r
133
- n.y = r
140
+ y = r
134
- n.vy = 0.0
141
+ vy = 0.0
135
- if n.y > Rules.ArenaH - r
142
+ if y > Rules.ArenaH - r
136
- n.y = Rules.ArenaH - r
143
+ y = Rules.ArenaH - r
137
- n.vy = 0.0
144
+ vy = 0.0
145
+ rotation := s.rotation
138
- if n.vx * n.vx + n.vy * n.vy > 400.0
146
+ if vx * vx + vy * vy > 400.0
139
- n.rotation = Math.atan2(n.vx, 0.0 - n.vy)
147
+ rotation = Math.atan2(vx, 0.0 - vy)
140
- n
148
+ ShipState(x: x, y: y, vx: vx, vy: vy, rotation: rotation, hurt: hurt)
141
149
 
142
150
  fun newShip() -> ShipState =
143
151
  ShipState(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, rotation: 0.0, hurt: 0.0)
@@ -157,34 +165,31 @@ record Panel(Actor) =
157
165
 
158
166
  fun create(id: Int, x: Float, y: Float, w: Float, h: Float, c: Color) -> Node =
159
167
  n := PureScene.custom(id, x, y, w, h, Panel())
160
- n.color = c
168
+ Node(..n, color: c)
161
- n
162
169
 
163
- override fun draw(self, n: Node, m: Affine, tint: Color) =
170
+ override fun draw(self, io: Io, n: Node, m: Affine, tint: Color) =
164
171
  p := m.apply(Vec2(x: 0.0, y: 0.0))
165
- drawRect(p.x, p.y, n.width * m.scaleX(), n.height * m.scaleY(), tint)
172
+ io.drawRect(p.x, p.y, n.width * m.scaleX(), n.height * m.scaleY(), tint)
166
173
 
167
174
  # The player: only a picture that follows `ShipState`; `act` runs its animation clock.
168
- record Ship(Actor, clock: Float, tex: Int) =
175
+ record Ship(Actor, mut clock: Float, tex: Int) =
169
176
 
170
177
  fun create(sheet: Int, s: ShipState) -> Node =
171
178
  n := PureScene.custom(Id.Ship, s.x - Rules.Half, s.y - Rules.Half, Rules.Size, Rules.Size, Ship(clock: 0.0, tex: sheet))
172
- n.originX = Rules.Half
179
+ Node(..n, originX: Rules.Half, originY: Rules.Half)
173
- n.originY = Rules.Half
174
- n
175
180
 
176
- override fun act(self, own n: Node, dt: Float) -> Node =
181
+ override fun act(self, n: Node, dt: Float) -> Node =
177
182
  self.clock = self.clock + dt
178
183
  n
179
184
 
180
- override fun draw(self, n: Node, m: Affine, tint: Color) =
185
+ override fun draw(self, io: Io, n: Node, m: Affine, tint: Color) =
181
186
  c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
182
- drawCircle(c.x, c.y, 17.0 * m.scaleX(), Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12 * tint.a))
187
+ io.drawCircle(c.x, c.y, 17.0 * m.scaleX(), Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12 * tint.a))
183
188
  frame := Float(frameOf(self.clock, 4, 14.0))
184
- pureDrawSprite(m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 0.0, Rules.Cell, Rules.Cell)
189
+ pureDrawSprite(io, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 0.0, Rules.Cell, Rules.Cell)
185
190
 
186
191
  # A glowing orb drifting about. `phase` offsets its animation from the other orbs.
187
- record Orb(Actor, vx: Float, vy: Float, phase: Float, clock: Float, tex: Int) =
192
+ record Orb(Actor, mut vx: Float, mut vy: Float, phase: Float, mut clock: Float, tex: Int) =
188
193
 
189
194
  fun create(sheet: Int, phase: Float, id: Int, at: Motion) -> Node =
190
195
  Orb.createAt(sheet, phase, id, at, 0.0)
@@ -193,31 +198,27 @@ record Orb(Actor, vx: Float, vy: Float, phase: Float, clock: Float, tex: Int) =
193
198
  fun createAt(sheet: Int, phase: Float, id: Int, at: Motion, clock: Float) -> Node =
194
199
  n := PureScene.custom(id, at.x - Rules.Half, at.y - Rules.Half, Rules.Size, Rules.Size,
195
200
  Orb(vx: at.vx, vy: at.vy, phase: phase, clock: clock, tex: sheet))
196
- n.originX = Rules.Half
201
+ Node(..n, originX: Rules.Half, originY: Rules.Half)
197
- n.originY = Rules.Half
198
- n
199
202
 
200
- override fun act(self, own n: Node, dt: Float) -> Node =
203
+ override fun act(self, n: Node, dt: Float) -> Node =
201
204
  m := driftOf(Motion(x: n.x + Rules.Half, y: n.y + Rules.Half, vx: self.vx, vy: self.vy), Rules.OrbRadius, dt)
202
205
  self.clock = self.clock + dt
203
- n.x = m.x - Rules.Half
204
- n.y = m.y - Rules.Half
205
206
  self.vx = m.vx
206
207
  self.vy = m.vy
207
- n
208
+ Node(..n, x: m.x - Rules.Half, y: m.y - Rules.Half)
208
209
 
209
- override fun draw(self, n: Node, m: Affine, tint: Color) =
210
+ override fun draw(self, io: Io, n: Node, m: Affine, tint: Color) =
210
211
  c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
211
212
  k := m.scaleX()
212
- blendMode(Blend.Add)
213
+ io.blendMode(Blend.Add)
213
- drawCircle(c.x, c.y, 26.0 * k, Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5 * tint.a))
214
+ io.drawCircle(c.x, c.y, 26.0 * k, Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5 * tint.a))
214
- drawCircle(c.x, c.y, 17.0 * k, Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5 * tint.a))
215
+ io.drawCircle(c.x, c.y, 17.0 * k, Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5 * tint.a))
215
- blendMode(Blend.Normal)
216
+ io.blendMode(Blend.Normal)
216
217
  frame := Float(frameOf(self.clock + self.phase, 4, 8.0))
217
- pureDrawSprite(m, tint, n.width, n.height, self.tex, frame * Rules.Cell, Rules.Cell, Rules.Cell, Rules.Cell)
218
+ pureDrawSprite(io, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, Rules.Cell, Rules.Cell, Rules.Cell)
218
219
 
219
220
  # A mine drifting about, turning `spin` radians per second (0 once the game is over).
220
- record Mine(Actor, vx: Float, vy: Float, spin: Float, clock: Float, tex: Int) =
221
+ record Mine(Actor, mut vx: Float, mut vy: Float, spin: Float, mut clock: Float, tex: Int) =
221
222
 
222
223
  fun create(sheet: Int, id: Int, at: Motion, spin: Float) -> Node =
223
224
  Mine.createAt(sheet, id, at, spin, 0.0)
@@ -225,41 +226,36 @@ record Mine(Actor, vx: Float, vy: Float, spin: Float, clock: Float, tex: Int) =
225
226
  fun createAt(sheet: Int, id: Int, at: Motion, spin: Float, clock: Float) -> Node =
226
227
  n := PureScene.custom(id, at.x - Rules.Half, at.y - Rules.Half, Rules.Size, Rules.Size,
227
228
  Mine(vx: at.vx, vy: at.vy, spin: spin, clock: clock, tex: sheet))
228
- n.originX = Rules.Half
229
+ Node(..n, originX: Rules.Half, originY: Rules.Half)
229
- n.originY = Rules.Half
230
- n
231
230
 
232
- override fun act(self, own n: Node, dt: Float) -> Node =
231
+ override fun act(self, n: Node, dt: Float) -> Node =
233
232
  m := driftOf(Motion(x: n.x + Rules.Half, y: n.y + Rules.Half, vx: self.vx, vy: self.vy), Rules.MineRadius, dt)
234
233
  self.clock = self.clock + dt
235
- n.x = m.x - Rules.Half
236
- n.y = m.y - Rules.Half
237
234
  self.vx = m.vx
238
235
  self.vy = m.vy
239
- n.rotation = n.rotation + self.spin * dt
236
+ Node(..n, x: m.x - Rules.Half, y: m.y - Rules.Half, rotation: n.rotation + self.spin * dt)
240
- n
241
237
 
242
- override fun draw(self, n: Node, m: Affine, tint: Color) =
238
+ override fun draw(self, io: Io, n: Node, m: Affine, tint: Color) =
243
239
  frame := Float(frameOf(self.clock, 4, 4.0))
244
- pureDrawSprite(m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 2.0 * Rules.Cell, Rules.Cell, Rules.Cell)
240
+ pureDrawSprite(io, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 2.0 * Rules.Cell, Rules.Cell, Rules.Cell)
245
241
 
246
242
  # A background star that twinkles.
247
- record Star(Actor, seed: Float, clock: Float) =
243
+ record Star(Actor, seed: Float, mut clock: Float) =
248
244
 
249
245
  fun create(i: Int) -> Node =
250
246
  r := 1.0 + Float(i % 3) * 0.5
251
247
  PureScene.custom(Id.Star + i, Float({i * 7919} % 800) - r, Float({i * 104729} % 600) - r, r * 2.0, r * 2.0,
252
248
  Star(seed: Float(i), clock: 0.0))
253
249
 
254
- override fun act(self, own n: Node, dt: Float) -> Node =
250
+ override fun act(self, n: Node, dt: Float) -> Node =
255
251
  self.clock = self.clock + dt
256
252
  n
257
253
 
258
- override fun draw(self, n: Node, m: Affine, tint: Color) =
254
+ override fun draw(self, io: Io, n: Node, m: Affine, tint: Color) =
259
255
  twinkle := 0.25 + 0.2 * Math.sin(self.clock * 2.0 + self.seed)
260
256
  r := n.width / 2.0
261
257
  c := m.apply(Vec2(x: r, y: r))
262
- drawCircle(c.x, c.y, r * m.scaleX(), Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle * tint.a))
258
+ io.drawCircle(c.x, c.y, r * m.scaleX(), Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle * tint.a))
263
259
 
264
260
  # --- reading and changing the tree ---
265
261
 
@@ -279,12 +275,11 @@ fun find(root: Node, id: Int) -> Node =
279
275
  fun kidsOf(n: Node) -> Slice[Node] =
280
276
  PureScene.childrenOf(n) ?: crash "not a group"
281
277
 
282
- fun setVisible(own n: Node, v: Bool) -> Node =
278
+ fun setVisible(n: Node, v: Bool) -> Node =
283
- n.visible = v
279
+ Node(..n, visible: v)
284
- n
285
280
 
286
281
  # The group `g` with `k` added as its last child.
287
- fun addKid(own g: Node, own k: Node) -> Node =
282
+ fun addKid(g: Node, k: Node) -> Node =
288
283
  ks := kidsOf(g)
289
284
  ks.push(k)
290
285
  g
@@ -308,18 +303,33 @@ fun textOf(n: Node) -> Str =
308
303
 
309
304
  fun hudLabel(id: Int, text: Str, x: Float, y: Float, size: Float, c: Color) -> Node =
310
305
  l := PureScene.label(id, text, x, y, size)
311
- l.color = c
306
+ Node(..l, color: c)
312
- l
313
307
 
314
308
  fun icon(sheet: Int, i: Int) -> Node =
315
309
  s := PureScene.sprite(Id.Icon + i, sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 32.0)
316
- s.kind = Sprite(tex: sheet, srcX: 0.0, srcY: 0.0, srcW: Rules.Cell, srcH: Rules.Cell)
310
+ Node(..s, kind: Sprite(tex: sheet, srcX: 0.0, srcY: 0.0, srcW: Rules.Cell, srcH: Rules.Cell))
317
- s
318
311
 
319
312
  # The whole scene: backdrop, stars, mines, orbs, ship and HUD layers, in drawing order.
320
313
  # The layer groups are 0 x 0 so they never swallow a click; the HUD holds the
321
314
  # game-over overlay with its restart button.
315
+ # The starting spots of the orbs and mines, and the generator to continue from.
316
+ record Spots(orbs: Slice[Motion], mines: Slice[Motion], rng: Random)
317
+
318
+ fun startSpots(rng: Random, ship: ShipState) -> Spots =
319
+ orbs := Slice[Motion]()
320
+ mines := Slice[Motion]()
321
+ r := rng
322
+ for i := range Rules.OrbCount
323
+ sp := spawn(r, ship.x, ship.y, 120.0, 40.0)
324
+ r = sp.rng
325
+ orbs.push(sp.at)
326
+ for i := range mineCount(0)
327
+ sp := spawn(r, ship.x, ship.y, 220.0, 90.0)
328
+ r = sp.rng
329
+ mines.push(sp.at)
330
+ Spots(orbs: orbs, mines: mines, rng: r)
331
+
322
- fun buildRoot(rng: Random, sheet: Int, best: Int) -> Node =
332
+ fun buildRoot(spots: Spots, sheet: Int, best: Int) -> Node =
323
333
  ship := newShip()
324
334
  stars := Slice[Node]()
325
335
  for i := range 40
@@ -327,19 +337,18 @@ fun buildRoot(rng: Random, sheet: Int, best: Int) -> Node =
327
337
  mines := Slice[Node]()
328
338
  orbs := Slice[Node]()
329
339
  for i := range Rules.OrbCount
330
- orbs.push(Orb.create(sheet, Float(i) * 0.1, Id.Orb + i, spawn(rng, ship.x, ship.y, 120.0, 40.0)))
340
+ orbs.push(Orb.create(sheet, Float(i) * 0.1, Id.Orb + i, spots.orbs.at(i)))
331
341
  for i := range mineCount(0)
332
- mines.push(Mine.create(sheet, Id.Mine + i, spawn(rng, ship.x, ship.y, 220.0, 90.0), 2.0))
342
+ mines.push(Mine.create(sheet, Id.Mine + i, spots.mines.at(i), 2.0))
333
- btn := PureScene.button(Id.Restart, "Again", sheet, sheet, 340.0, 342.0, 120.0, 44.0)
343
+ b0 := PureScene.button(Id.Restart, "Again", sheet, sheet, 340.0, 342.0, 120.0, 44.0)
334
- btn.color = Color(r: 0.5, g: 0.8, b: 1.0, a: 0.9)
344
+ btn := Node(..b0, color: Color(r: 0.5, g: 0.8, b: 1.0, a: 0.9), visible: false)
335
- btn.visible = false
336
345
  over := Slice(
337
346
  Panel.create(Id.OverlayPanel, 0.0, 230.0, Rules.ArenaW, 170.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
338
347
  hudLabel(Id.OverTitle, "Game over", 300.0, 244.0, 48.0, Color.Red),
339
348
  hudLabel(Id.OverHint, "Press Enter to play again", 270.0, 300.0, 24.0, Color.White),
340
349
  btn)
341
- overlay := PureScene.group(Id.Overlay, 0.0, 0.0, 0.0, 0.0, over)
350
+ o0 := PureScene.group(Id.Overlay, 0.0, 0.0, 0.0, 0.0, over)
342
- overlay.visible = false
351
+ overlay := Node(..o0, visible: false)
343
352
  hud := Slice(
344
353
  hudLabel(Id.Score, "Score 0", 16.0, 12.0, 28.0, Color.White),
345
354
  hudLabel(Id.Best, "Best {best}", 16.0, 44.0, 20.0, Color.Yellow))
@@ -360,32 +369,41 @@ fun buildRoot(rng: Random, sheet: Int, best: Int) -> Node =
360
369
  # The host-facing shell: the current pure `Stage` plus the run's state. It is the
361
370
  # only thing that is replaced from frame to frame.
362
371
  record World(
363
- stage: Stage,
372
+ mut stage: Stage,
364
- ship: ShipState,
373
+ mut ship: ShipState,
365
- rng: Random,
374
+ mut rng: Random,
366
- mines: Int,
375
+ mut mines: Int,
367
- score: Int,
376
+ mut score: Int,
368
- best: Int,
377
+ mut best: Int,
369
- lives: Int,
378
+ mut lives: Int,
370
- over: Bool,
379
+ mut over: Bool,
371
- clock: Float,
380
+ mut clock: Float,
372
- wasDown: Bool,
381
+ mut wasDown: Bool,
373
382
  sheet: Int,
374
- pickupSound: Int,
383
+ mut pickupSound: Int,
375
- hitSound: Int,
384
+ mut hitSound: Int,
376
- overSound: Int,
385
+ mut overSound: Int,
377
- musicSound: Int)
386
+ mut musicSound: Int)
378
387
 
379
388
  fun newWorld(seed: Int, sheet: Int) -> World =
389
+ ship := newShip()
380
- rng := Random(seed: seed)
390
+ spots := startSpots(Random.seeded(seed), ship)
381
- root := buildRoot(rng, sheet, 0)
391
+ next := spots.rng
382
392
  World(
383
- stage: SceneInput.stage(root), ship: newShip(), rng: rng, mines: mineCount(0), score: 0, best: 0,
393
+ stage: SceneInput.stage(buildRoot(spots, sheet, 0)), ship: ship, rng: next, mines: mineCount(0), score: 0, best: 0,
384
394
  lives: Rules.StartLives, over: false, clock: 0.0, wasDown: false, sheet: sheet,
385
395
  pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
386
396
 
397
+ fun modifyRoot(st: Stage, id: Int, f: fn(Node) -> Node) -> Stage =
398
+ match st
399
+ Stage(root, camera, captured) => Stage(root: SceneTree.modify(root, id, f), camera: camera, captured: captured)
400
+
401
+ fun withCamera(st: Stage, camera: Affine) -> Stage =
402
+ match st
403
+ Stage(root, _, captured) => Stage(root: root, camera: camera, captured: captured)
404
+
387
405
  fun swap(w: World, id: Int, f: fn(Node) -> Node) =
388
- w.stage.root = SceneTree.modify(w.stage.root, id, f)
406
+ w.stage = modifyRoot(w.stage, id, f)
389
407
 
390
408
  # Starts a fresh game, keeping the best score, the camera and the random stream.
391
409
  fun restart(w: World) =
@@ -395,7 +413,10 @@ fun restart(w: World) =
395
413
  w.over = false
396
414
  w.ship = newShip()
397
415
  w.mines = mineCount(0)
416
+ spots := startSpots(w.rng, w.ship)
417
+ next := spots.rng
418
+ w.rng = next
398
- w.stage = Stage(root: buildRoot(w.rng, w.sheet, w.best), camera: w.stage.camera, captured: null)
419
+ w.stage = Stage(root: buildRoot(spots, w.sheet, w.best), camera: w.stage.camera, captured: null)
399
420
 
400
421
  # Copies the score, best score, lives and game-over panel into the HUD actors.
401
422
  fun syncHud(w: World) =
@@ -434,8 +455,7 @@ fun mineActor(sheet: Int, id: Int, at: Motion, spin: Float) -> Node =
434
455
 
435
456
  fun stillMine(sheet: Int, id: Int, a: Node) -> Node =
436
457
  m := Mine.createAt(sheet, id, Motion(x: cx(a), y: cy(a), vx: 0.0, vy: 0.0), 0.0, clockOf(a))
437
- m.rotation = a.rotation
458
+ Node(..m, rotation: a.rotation)
438
- m
439
459
 
440
460
  # Moves the ship node to the ship state; it blinks while it is hurt, and is hidden once the game is over.
441
461
  fun showShip(w: World) =
@@ -444,18 +464,14 @@ fun showShip(w: World) =
444
464
  s := w.ship
445
465
  swap(w, Id.Ship, |a| shipActor(a, s, visible))
446
466
 
447
- fun shipActor(own a: Node, s: ShipState, visible: Bool) -> Node =
467
+ fun shipActor(a: Node, s: ShipState, visible: Bool) -> Node =
448
- a.x = s.x - Rules.Half
449
- a.y = s.y - Rules.Half
450
- a.rotation = s.rotation
468
+ Node(..a, x: s.x - Rules.Half, y: s.y - Rules.Half, rotation: s.rotation, visible: visible)
451
- a.visible = visible
452
- a
453
469
 
454
470
  fun overlaps(ax: Float, ay: Float, ra: Float, bx: Float, by: Float, rb: Float) -> Bool =
455
471
  Math.hypot(ax - bx, ay - by) < ra + rb
456
472
 
457
473
  # The stage after `inputs` and `dt`, appending the events it raised to `events`.
458
- fun frameInto(own s: Stage, dt: Float, inputs: Slice[Input], events: Slice[UiEvent]) -> Stage =
474
+ fun frameInto(s: Stage, dt: Float, inputs: Slice[Input], events: Slice[UiEvent]) -> Stage =
459
475
  h := s.frame(dt, inputs)
460
476
  for i := range h.events.length()
461
477
  events.push(h.events.at(i))
@@ -483,7 +499,9 @@ fun advance(w: World, ax: Float, ay: Float, dt: Float, inputs: Slice[Input]) ->
483
499
  if overlaps(ship.x, ship.y, Rules.ShipRadius, cx(orb), cy(orb), Rules.OrbRadius)
484
500
  w.score = w.score + Rules.OrbPoints
485
501
  changed = true
486
- at := spawn(w.rng, ship.x, ship.y, 120.0, 40.0)
502
+ sp := spawn(w.rng, ship.x, ship.y, 120.0, 40.0)
503
+ w.rng = sp.rng
504
+ at := sp.at
487
505
  sheet := w.sheet
488
506
  id := Id.Orb + i
489
507
  phase := Float(i) * 0.1
@@ -491,7 +509,9 @@ fun advance(w: World, ax: Float, ay: Float, dt: Float, inputs: Slice[Input]) ->
491
509
  while w.mines < mineCount(w.score)
492
510
  sheet := w.sheet
493
511
  id := Id.Mine + w.mines
494
- at := spawn(w.rng, ship.x, ship.y, 220.0, 90.0)
512
+ sp := spawn(w.rng, ship.x, ship.y, 220.0, 90.0)
513
+ w.rng = sp.rng
514
+ at := sp.at
495
515
  swap(w, Id.MineLayer, |g| addKid(g, Mine.create(sheet, id, at, 2.0)))
496
516
  w.mines = w.mines + 1
497
517
  for i := range w.mines
@@ -499,9 +519,7 @@ fun advance(w: World, ax: Float, ay: Float, dt: Float, inputs: Slice[Input]) ->
499
519
  if w.ship.hurt <= 0.0 && overlaps(ship.x, ship.y, Rules.ShipRadius, cx(mine), cy(mine), Rules.MineRadius)
500
520
  w.lives = w.lives - 1
501
521
  cur := w.ship
502
- s := ShipState(..cur)
503
- s.hurt = Rules.HurtTime
522
+ w.ship = ShipState(..cur, hurt: Rules.HurtTime)
504
- w.ship = s
505
523
  changed = true
506
524
  if w.lives <= 0
507
525
  w.over = true
@@ -523,7 +541,7 @@ fun fitCamera(sw: Float, sh: Float) -> Affine =
523
541
  Affine(a: zoom, b: 0.0, c: 0.0, d: zoom, e: {sw - zoom * Rules.ArenaW} / 2.0, f: {sh - zoom * Rules.ArenaH} / 2.0)
524
542
 
525
543
  fun fit(w: World, sw: Float, sh: Float) =
526
- w.stage.camera = fitCamera(sw, sh)
544
+ w.stage = withCamera(w.stage, fitCamera(sw, sh))
527
545
 
528
546
  # --- host glue: input, sound, drawing ---
529
547
 
@@ -531,17 +549,17 @@ fun musicVolume() -> Float =
531
549
  0.3
532
550
 
533
551
  # Restarts the music loop from the top.
534
- fun startMusic(w: World) =
552
+ fun startMusic(io: Io, w: World) =
535
- soundStop(w.musicSound)
553
+ io.soundStop(w.musicSound)
536
- soundLoop(w.musicSound, musicVolume())
554
+ io.soundLoop(w.musicSound, musicVolume())
537
-
555
+
538
- export fun init() -> World =
556
+ export fun init(io: Io) -> World =
539
- w := newWorld(2024, textureLoad("sprites.png"))
557
+ w := newWorld(io.randomSeed(), io.textureLoad("sprites.png"))
540
- w.pickupSound = soundLoad("pickup.wav")
558
+ w.pickupSound = io.soundLoad("pickup.wav")
541
- w.hitSound = soundLoad("hit.wav")
559
+ w.hitSound = io.soundLoad("hit.wav")
542
- w.overSound = soundLoad("gameover.wav")
560
+ w.overSound = io.soundLoad("gameover.wav")
543
- w.musicSound = soundLoad("music.wav")
561
+ w.musicSound = io.soundLoad("music.wav")
544
- startMusic(w)
562
+ startMusic(io, w)
545
563
  w
546
564
 
547
565
  fun axis(negative: Bool, positive: Bool) -> Float =
@@ -552,35 +570,35 @@ fun axis(negative: Bool, positive: Bool) -> Float =
552
570
  else
553
571
  0.0
554
572
 
555
- export fun update(w: World, dt: Float) =
573
+ export fun update(io: Io, w: World, dt: Float) =
556
- if w.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
574
+ if w.over && {io.isKeyPressed(Key.Enter) || io.isKeyPressed(Key.KeyR)}
557
575
  restart(w)
558
- startMusic(w)
576
+ startMusic(io, w)
559
- inputs := SceneInput.pollPointer(w.wasDown)
577
+ inputs := SceneInput.pollPointer(io, w.wasDown)
560
- w.wasDown = isMouseDown(0)
578
+ w.wasDown = io.isMouseDown(0)
561
- left := isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
579
+ left := io.isKeyDown(Key.Left) || io.isKeyDown(Key.KeyA)
562
- right := isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
580
+ right := io.isKeyDown(Key.Right) || io.isKeyDown(Key.KeyD)
563
- up := isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
581
+ up := io.isKeyDown(Key.Up) || io.isKeyDown(Key.KeyW)
564
- down := isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
582
+ down := io.isKeyDown(Key.Down) || io.isKeyDown(Key.KeyS)
565
583
  score := w.score
566
584
  lives := w.lives
567
585
  restarted := advance(w, axis(left, right), axis(up, down), dt, inputs)
568
586
  if restarted
569
- startMusic(w)
587
+ startMusic(io, w)
570
588
  return
571
589
  if w.score > score
572
- soundPlay(w.pickupSound, 0.8)
590
+ io.soundPlay(w.pickupSound, 0.8)
573
591
  if w.lives < lives
574
592
  if w.over
575
- soundStop(w.musicSound)
593
+ io.soundStop(w.musicSound)
576
- soundPlay(w.overSound, 1.0)
594
+ io.soundPlay(w.overSound, 1.0)
577
595
  else
578
- soundPlay(w.hitSound, 1.0)
596
+ io.soundPlay(w.hitSound, 1.0)
579
597
 
580
- export fun draw(w: World) =
598
+ export fun draw(io: Io, w: World) =
581
- fit(w, screenWidth(), screenHeight())
599
+ fit(w, io.screenWidth(), io.screenHeight())
582
- clear(Color.rgb(0.04, 0.05, 0.1))
600
+ io.clear(Color.rgb(0.04, 0.05, 0.1))
583
- w.stage.draw()
601
+ w.stage.draw(io)
584
602
 
585
603
  # --- tests ---
586
604
 
plum-examples/{tools → games/tools}/make_sounds.py RENAMED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """Generates the Orb Run sound effects and music loop into ../assets.
3
3
 
4
- Standard library only: python3 plum-examples/tools/make_sounds.py
4
+ Standard library only: python3 plum-examples/games/tools/make_sounds.py
5
5
  Everything is synthesized here, so the WAVs are original and reproducible.
6
6
  All files are mono, 16-bit, 22050 Hz.
7
7
  """
plum-examples/{tools → games/tools}/make_sprites.py RENAMED
File without changes
plum-examples/io.plum DELETED
@@ -1,14 +0,0 @@
1
- import std/Str
2
-
3
- # `printLn` mirrors `libs/std/os.plum`'s declaration; redeclared locally
4
- # (rather than `import std/os`) so this stays the SMALLEST possible example
5
- # — `import std/os` pulls in every other filesystem extern os.plum declares
6
- # too, which the Rust-level `ioExampleCompilesAndRunsCorrectly` test (which
7
- # actually RUNS this module under wasmtime, providing real host imports)
8
- # would then all need stubs for.
9
- extern fun printLn(s: Str)
10
-
11
- fun main() =
12
- printLn("hello from plum")
13
- name := "world"
14
- printLn("hello, {name}!")
plum-examples/loops.plum DELETED
@@ -1,125 +0,0 @@
1
- # Loops: `while` condition loops, `for ... := range` iteration over ranges and
2
- # collections, with inline filtering and mapping.
3
- import std/Str
4
- import std/Int
5
- import std/Float
6
- import std/Slice
7
- import std/Array
8
- import std/Testing
9
- import std/Seq
10
-
11
- fun sumList(xs: Slice[Int]) -> Int =
12
- total := 0
13
- for x := range xs
14
- total = total + x
15
- return total
16
-
17
- fun firstOver(xs: Slice[Int], limit: Int) -> Int =
18
- for x := range xs
19
- if x > limit
20
- return x
21
- return -1
22
-
23
- test "plain_iterable_for: List and Array"
24
- xs := Slice(1, 2, 3, 4)
25
- assert sumList(xs) == 10
26
- arr := Array.filled(3, 7)
27
- total := 0
28
- for a := range arr
29
- total = total + a
30
- assert total == 21
31
-
32
- test "index_value_binders: i, x"
33
- xs := Slice(10, 20, 30)
34
- acc := 0
35
- for i, x := range xs
36
- acc = acc + i * x
37
- assert acc == 80
38
-
39
- test "clause_if_keeps_when_true: if cond keeps the element, false skips it"
40
- xs := Slice(1, 2, 3, 4, 5, 6)
41
- total := 0
42
- for x := range xs, if x % 2 == 0
43
- total = total + x
44
- assert total == 12
45
-
46
- test "clause_bind_scoped: name := expr is visible to later clauses and body"
47
- xs := Slice(1, 2, 3, 4)
48
- total := 0
49
- for x := range xs, sq := x * x, if sq > 9
50
- total = total + sq
51
- assert total == 16
52
-
53
- test "break_continue_return_semantics"
54
- xs := Slice(1, 2, 3, 4, 5)
55
- total := 0
56
- for x := range xs
57
- if x == 2
58
- continue
59
- if x == 5
60
- break
61
- total = total + x
62
- assert total == 8
63
- assert firstOver(xs, 3) == 4
64
- assert firstOver(xs, 9) == -1
65
-
66
- test "nested iterable loops"
67
- xs := Slice(1, 2)
68
- ys := Slice(10, 20)
69
- total := 0
70
- for x := range xs
71
- for y := range ys
72
- total = total + x * y
73
- assert total == 90
74
-
75
- test "range_int: 0 to n-1, empty for 0, continue still advances"
76
- total := 0
77
- for i := range 5
78
- total = total + i
79
- assert total == 10
80
- n := 0
81
- for _ := range 0
82
- n = n + 1
83
- assert n == 0
84
- odd := 0
85
- for i := range 6
86
- if i % 2 == 0
87
- continue
88
- odd = odd + i
89
- assert odd == 9
90
-
91
- test "range_until_to_downTo: bounds incl. empty ranges"
92
- a := 0
93
- for i := range 2.until(6)
94
- a = a + i
95
- assert a == 14
96
- b := 0
97
- for i := range 1.to(4)
98
- b = b + i
99
- assert b == 10
100
- c := 0
101
- for i := range 4.downTo(1)
102
- c = c * 10 + i
103
- assert c == 4321
104
- n := 0
105
- for _ := range 5.until(5)
106
- n = n + 1
107
- for _ := range 1.downTo(2)
108
- n = n + 1
109
- for _ := range 3.to(2)
110
- n = n + 1
111
- assert n == 0
112
-
113
- test "range_stepBy: stepBy on until and to"
114
- total := 0
115
- for i := range 0.until(10).stepBy(3)
116
- total = total + i
117
- assert total == 18
118
- for i := range 1.to(10).stepBy(4)
119
- total = total + i
120
- assert total == 18 + 15
121
-
122
- test "range_seq_terminal: a terminal over an Int range"
123
- evens := for i := range 0.until(10).filter()
124
- i % 2 == 0
125
- assert evens.length() == 5
plum-examples/numbers.plum CHANGED
@@ -1,7 +1,91 @@
1
+ # Numbers: Int and Float methods, operators on numbers and constants.
1
2
  import std/Str
2
3
  import std/Int
3
4
  import std/Float
4
5
  import std/Math
6
+ import std/Slice
7
+
8
+ # ==== operators ====
9
+
10
+ # Operators on non-primitive operands call methods: `a + b` is `a.add(b)`,
11
+ # `-a` is `a.neg()`, `a == b` is `a.eq(b)`, `a < b` is `a.lt(b)`.
12
+ record Vec2(x: Float, y: Float) =
13
+
14
+ fun toStr(self) -> Str =
15
+ "Vec2({self.x}, {self.y})"
16
+
17
+ fun add(self, o: Vec2) -> Vec2 =
18
+ Vec2(x: self.x + o.x, y: self.y + o.y)
19
+
20
+ fun mul(self, s: Float) -> Vec2 =
21
+ Vec2(x: self.x * s, y: self.y * s)
22
+
23
+ fun neg(self) -> Vec2 =
24
+ Vec2(x: 0.0 - self.x, y: 0.0 - self.y)
25
+
26
+ fun eq(self, o: Vec2) -> Bool =
27
+ self.x == o.x && self.y == o.y
28
+
29
+ fun lt(self, o: Vec2) -> Bool =
30
+ self.x < o.x
31
+
32
+ fun le(self, o: Vec2) -> Bool =
33
+ self.x <= o.x
34
+
35
+ record Plain(v: Int)
36
+
37
+ fun double(a: T) -> T =
38
+ a + a
39
+
40
+ # ==== constants ====
41
+
42
+ # Constants and static functions live inside a type's body or a `primitive`
43
+ # namespace — there are no top-level constants. `Name = expr` declares an
44
+ # associated constant, a `fun` without `self` a static function. They are
45
+ # reached as `Type.Name` / `Type.f()` from anywhere, including inside the same
46
+ # body. Constant names are unique per type, not global.
47
+
48
+ # A `primitive` whose name is not a built-in type is a stateless namespace.
49
+ primitive Grid =
50
+ Width = 8
51
+ Height = Grid.Width * 2
52
+ CellScale = 2.5
53
+ Name = "plum"
54
+ DebugDraw = true
55
+
56
+ fun cells() -> Int =
57
+ Grid.Width * Grid.Height
58
+
59
+ record Tile(x: Int) =
60
+
61
+ Width = 100
62
+
63
+ fun offset(self) -> Int = Tile.Width + self.x
64
+
65
+ fun origin() -> Tile =
66
+ Tile(x: 0)
67
+
68
+ record Wrap[T](v: T) =
69
+
70
+ fun area(self) -> Int = Grid.Width * Grid.Height
71
+
72
+ struct ConstVec2(x: Float, y: Float) =
73
+
74
+ Zero = ConstVec2(x: 0.0, y: 0.0)
75
+
76
+ fun scaled(n: Float) -> Float = n * Grid.CellScale
77
+
78
+ primitive Shelf =
79
+ Sides = 4
80
+ Names = Slice("a", "b", "c")
81
+
82
+ fun sidesTwice() -> Int =
83
+ twice(Shelf.Sides)
84
+
85
+ fun twice(n: Int) -> Int =
86
+ n * 2
87
+
88
+ # ==== numbers tests ====
5
89
 
6
90
  # Regression coverage for the `primitive Int` / `primitive Float` methods
7
91
  # (`plum-std/Int.plum`, `plum-std/Float.plum`), the `Math` namespace and the
@@ -106,3 +190,61 @@ test "associated constants live on their type"
106
190
  assert Float.Tau == 2.0f * Float.Pi
107
191
  assert Float.HalfPi * 2.0f == Float.Pi
108
192
  assert Float.Max > 1.0e300
193
+
194
+ # ==== operators tests ====
195
+
196
+ test "vec2_add_operator"
197
+ r := Vec2(x: 1.0, y: 2.0) + Vec2(x: 3.0, y: 4.0)
198
+ assert r.x == 4.0
199
+ assert r.y == 6.0
200
+
201
+ test "mul_scalar_rhs"
202
+ r := Vec2(x: 1.0, y: 2.0) * 2.0
203
+ assert r.x == 2.0
204
+ assert r.y == 4.0
205
+
206
+ test "neg"
207
+ r := -Vec2(x: 1.0, y: -2.0)
208
+ assert r.x == -1.0
209
+ assert r.y == 2.0
210
+
211
+ test "eq_and_neq"
212
+ a := Vec2(x: 1.0, y: 2.0)
213
+ assert a == Vec2(x: 1.0, y: 2.0)
214
+ assert a != Vec2(x: 1.0, y: 3.0)
215
+ assert !{a == Vec2(x: 0.0, y: 0.0)}
216
+
217
+ test "lt_ordering"
218
+ a := Vec2(x: 1.0, y: 0.0)
219
+ b := Vec2(x: 2.0, y: 0.0)
220
+ assert a < b
221
+ assert !{b < a}
222
+ assert a <= b
223
+
224
+ test "operators work through a generic"
225
+ d := double(Vec2(x: 1.0, y: 1.0))
226
+ assert d.x == 2.0
227
+ assert double(4) == 8
228
+
229
+ # ==== constants tests ====
230
+
231
+ test "consts are usable in fns, methods, generics and derived consts"
232
+ assert Grid.Height == 16
233
+ assert Grid.cells() == 128
234
+ assert Tile(x: 1).offset() == 101
235
+ assert Wrap(v: 3.5).area() == 128
236
+ assert scaled(2.0) == 5.0
237
+ assert Grid.Name == "plum"
238
+ assert Grid.DebugDraw
239
+
240
+ test "the same const name can live on two types"
241
+ assert Grid.Width == 8
242
+ assert Tile.Width == 100
243
+
244
+ test "a struct can hold a constant instance of itself"
245
+ assert ConstVec2.Zero.x == 0.0
246
+ assert Tile.origin().x == 0
247
+
248
+ test "a primitive holds a list constant and a static fn reading a sibling const"
249
+ assert Shelf.Names.length() == 3
250
+ assert Shelf.sidesTwice() == 8
plum-examples/operators.plum DELETED
@@ -1,67 +0,0 @@
1
- module operators
2
- import std/Str
3
- import std/Int
4
- import std/Float
5
-
6
- # Operators on non-primitive operands call methods: `a + b` is `a.add(b)`,
7
- # `-a` is `a.neg()`, `a == b` is `a.eq(b)`, `a < b` is `a.lt(b)`.
8
- record Vec2(x: Float, y: Float) =
9
-
10
- fun toStr(self) -> Str =
11
- "Vec2({self.x}, {self.y})"
12
-
13
- fun add(self, o: Vec2) -> Vec2 =
14
- Vec2(x: self.x + o.x, y: self.y + o.y)
15
-
16
- fun mul(self, s: Float) -> Vec2 =
17
- Vec2(x: self.x * s, y: self.y * s)
18
-
19
- fun neg(self) -> Vec2 =
20
- Vec2(x: 0.0 - self.x, y: 0.0 - self.y)
21
-
22
- fun eq(self, o: Vec2) -> Bool =
23
- self.x == o.x && self.y == o.y
24
-
25
- fun lt(self, o: Vec2) -> Bool =
26
- self.x < o.x
27
-
28
- fun le(self, o: Vec2) -> Bool =
29
- self.x <= o.x
30
-
31
- record Plain(v: Int)
32
-
33
- fun double(a: T) -> T =
34
- a + a
35
-
36
- test "vec2_add_operator"
37
- r := Vec2(x: 1.0, y: 2.0) + Vec2(x: 3.0, y: 4.0)
38
- assert r.x == 4.0
39
- assert r.y == 6.0
40
-
41
- test "mul_scalar_rhs"
42
- r := Vec2(x: 1.0, y: 2.0) * 2.0
43
- assert r.x == 2.0
44
- assert r.y == 4.0
45
-
46
- test "neg"
47
- r := -Vec2(x: 1.0, y: -2.0)
48
- assert r.x == -1.0
49
- assert r.y == 2.0
50
-
51
- test "eq_and_neq"
52
- a := Vec2(x: 1.0, y: 2.0)
53
- assert a == Vec2(x: 1.0, y: 2.0)
54
- assert a != Vec2(x: 1.0, y: 3.0)
55
- assert !{a == Vec2(x: 0.0, y: 0.0)}
56
-
57
- test "lt_ordering"
58
- a := Vec2(x: 1.0, y: 0.0)
59
- b := Vec2(x: 2.0, y: 0.0)
60
- assert a < b
61
- assert !{b < a}
62
- assert a <= b
63
-
64
- test "operators work through a generic"
65
- d := double(Vec2(x: 1.0, y: 1.0))
66
- assert d.x == 2.0
67
- assert double(4) == 8
plum-examples/optionals.plum DELETED
@@ -1,232 +0,0 @@
1
- import std/Int
2
- import std/Str
3
- import std/Slice
4
- import std/Float
5
- # Optionals: `T?` is a `T` or `null`. A plain `T` in a `T?` slot is the present
6
- # value. An optional is handled with `?:` (Kotlin elvis), `if x := opt`,
7
- # `while x := opt`, `opt!` (crashes on null) and `opt == null`; `catch` and `try`
8
- # are for errors only. `!T?` is an error union whose success value is an optional.
9
- #
10
- # `obj?.field` / `obj?.method(args)` is safe navigation: it reaches into an
11
- # optional without unwrapping it and passes null through. A field that is itself
12
- # a `T?` flattens, so `u?.target?.name` is one `Str?`. It is sugar for
13
- # `obj.andThen(|v| v.field)`.
14
-
15
- record Box(label: Str)
16
-
17
- record Holder(item: Box?, count: Int?)
18
-
19
- fun maybeBox(c: Bool) -> Box? =
20
- if c
21
- return Box(label: "in")
22
- return null
23
-
24
- fun maybeInt(c: Bool) -> Int? =
25
- if c
26
- return 7
27
- null
28
-
29
- fun orFive(n: Int? = null) -> Int =
30
- n ?: 5
31
-
32
- fun failOnNull(c: Bool) -> error{Absent}!Int =
33
- n := maybeInt(c) ?: return error.Absent
34
- n + 1
35
-
36
- fun nullOnNull(c: Bool) -> Int? =
37
- n := maybeInt(c) ?: return null
38
- n * 2
39
-
40
- fun chainDefault(a: Int?, b: Int?) -> Int =
41
- a ?: b ?: 99
42
-
43
- fun fallible(n: Int) -> error{Bad}!Int? =
44
- if n < 0
45
- return error.Bad
46
- if n == 0
47
- return null
48
- n
49
-
50
- fun viaTry(n: Int) -> error{Bad}!Int =
51
- v := try fallible(n)
52
- v ?: -1
53
-
54
- fun nextItem(items: Slice[Int], i: Int) -> Int? =
55
- if i < items.length()
56
- return items.get(i)!
57
- null
58
-
59
- fun sparse() -> Slice[Int?] =
60
- Slice(1, null, 3)
61
-
62
- fun sumBig() -> Int =
63
- Int.Max + Int.Min
64
-
65
- record Person(name: Str, age: Int) =
66
-
67
- fun birthday(self) -> Int =
68
- self.age + 1
69
-
70
- record Target(name: Str)
71
-
72
- record User(name: Str, target: Target?)
73
-
74
- fun personName(p: Person?) -> Str? =
75
- p?.name
76
-
77
- fun personBirthday(p: Person?) -> Int? =
78
- p?.birthday()
79
-
80
- fun somePerson() -> Person? =
81
- Person(name: "Ann", age: 3)
82
-
83
- fun noPerson() -> Person? =
84
- return null
85
-
86
- fun noTarget() -> Target? =
87
- return null
88
-
89
- fun maybeCount(n: Int? = null) -> Int? =
90
- n
91
-
92
- fun noUser() -> User? =
93
- return null
94
-
95
- fun targetName(u: User?) -> Str? =
96
- u?.target?.name
97
-
98
- test "null_return_absent"
99
- assert maybeBox(false) == null
100
- assert maybeBox(true) != null
101
- assert maybeInt(false) == null
102
- assert null != maybeInt(true)
103
-
104
- test "null_field_and_arg"
105
- h := Holder(item: null, count: null)
106
- assert h.item == null
107
- assert h.count == null
108
- h2 := Holder(item: Box(label: "x"), count: 3)
109
- assert h2.count! == 3
110
- assert orFive(null) == 5
111
- assert orFive(8) == 8
112
- xs := sparse()
113
- assert xs.length() == 3
114
-
115
- test "elvis_default"
116
- assert {maybeInt(false) ?: 4} == 4
117
- assert {maybeInt(true) ?: 4} == 7
118
- assert {maybeBox(false)?.label ?: "none"} == "none"
119
- assert {maybeBox(true)?.label ?: "none"} == "in"
120
-
121
- test "elvis_return_error"
122
- assert {failOnNull(true) catch 0} == 8
123
- assert {failOnNull(false) catch 0} == 0
124
-
125
- test "elvis_return_null"
126
- assert nullOnNull(true)! == 14
127
- assert nullOnNull(false) == null
128
-
129
- test "elvis_chain_right_assoc"
130
- assert chainDefault(null, null) == 99
131
- assert chainDefault(null, 2) == 2
132
- assert chainDefault(1, 2) == 1
133
-
134
- test "if_bind_optional"
135
- r := 0
136
- if x := maybeInt(true)
137
- r = x
138
- else
139
- r = -1
140
- assert r == 7
141
- if y := maybeInt(false)
142
- r = y
143
- else
144
- r = -1
145
- assert r == -1
146
- assert {if v := maybeInt(true) then v + 1 else 0} == 8
147
- assert {if w := maybeInt(false) then w + 1 else 0} == 0
148
- if b := maybeBox(true)
149
- assert b.label == "in"
150
-
151
- test "while_bind_optional"
152
- items := Slice(3, 2, 1)
153
- total := 0
154
- i := 0
155
- while v := nextItem(items, i)
156
- total = total + v
157
- i = i + 1
158
- assert total == 6
159
-
160
- test "safe_nav_optional_flattens"
161
- assert maybeBox(true)?.label! == "in"
162
- assert maybeBox(false)?.label == null
163
-
164
- test "null_eq_ref and null_eq_int"
165
- b := maybeBox(false)
166
- assert b == null
167
- n := maybeInt(true)
168
- assert n != null
169
- assert !{n == null}
170
-
171
- test "error_union_of_optional"
172
- assert {viaTry(5) catch 0} == 5
173
- assert {viaTry(0) catch 0} == -1
174
- assert {viaTry(-3) catch 100} == 100
175
- x := fallible(0)!
176
- assert x == null
177
- y := fallible(4)!
178
- assert y! == 4
179
-
180
- test "int_min_literal"
181
- assert Int.Min == -0x8000_0000_0000_0000
182
- assert sumBig() == -1
183
-
184
- test "reassigning an optional local or field to null or a value"
185
- o := maybeInt(true)
186
- o = null
187
- assert o == null
188
- o = 4
189
- assert o! == 4
190
- b := maybeBox(true)
191
- b = null
192
- assert b == null
193
- h := Holder(item: Box(label: "x"), count: 2)
194
- h.count = null
195
- h.item = null
196
- assert h.count == null
197
- assert h.item == null
198
- h.count = 5
199
- h.item = Box(label: "y")
200
- assert h.count! == 5
201
- assert h.item!.label == "y"
202
-
203
- test "safe navigation maps a field access over a present value, passes absence through"
204
- assert personName(somePerson())! == "Ann"
205
- assert {personName(noPerson()) ?: "gone"} == "gone"
206
-
207
- test "safe navigation maps a method call over a present value"
208
- assert personBirthday(somePerson())! == 4
209
- assert {personBirthday(noPerson()) ?: -1} == -1
210
-
211
- test "safe navigation chains through a nullable field"
212
- full := User(name: "u", target: Target(name: "home"))
213
- assert {targetName(full) ?: "none"} == "home"
214
- assert {targetName(User(name: "v", target: noTarget())) ?: "none"} == "none"
215
- assert {targetName(noUser()) ?: "none"} == "none"
216
-
217
- test "?: handles a nullable with a diverging or computed default"
218
- p := somePerson() ?: crash "missing"
219
- assert p.age == 3
220
- q := noPerson() ?: Person(name: "gone", age: 1)
221
- assert q.name == "gone"
222
-
223
- test "null is the absent value in an argument, a list element and a default"
224
- u := User(name: "w", target: null)
225
- assert {targetName(u) ?: "none"} == "none"
226
- xs := sparse()
227
- second := xs.get(1)!
228
- assert {second ?: 9} == 9
229
- first := xs.get(0)!
230
- assert first! == 1
231
- assert {maybeCount() ?: 5} == 5
232
- assert maybeCount(7)! == 7
plum-examples/own_list.plum DELETED
@@ -1,33 +0,0 @@
1
- import std/Int
2
- import std/Float
3
- import std/Slice
4
-
5
- own record Cell(x: Float)
6
-
7
- test "map_in_place_updates_all"
8
- xs := Slice(Cell(x: 1.0), Cell(x: 2.0), Cell(x: 3.0))
9
- ys := xs.mapInPlace(|c| Cell(..c, x: c.x + 1.0))
10
- assert ys.length() == 3
11
- assert {ys.at(0).x - 2.0}.abs() < 1e-6
12
- assert {ys.at(1).x - 3.0}.abs() < 1e-6
13
- assert {ys.at(2).x - 4.0}.abs() < 1e-6
14
-
15
- test "take_then_put_round_trip"
16
- xs := Slice(Cell(x: 1.0), Cell(x: 2.0))
17
- c := xs.takeAt(1)
18
- d := Cell(..c, x: c.x + 10.0)
19
- xs.putAt(1, d)
20
- assert xs.length() == 2
21
- assert {xs.at(1).x - 12.0}.abs() < 1e-6
22
- assert {xs.at(0).x - 1.0}.abs() < 1e-6
23
-
24
- test "at_borrows"
25
- xs := Slice(Cell(x: 5.0))
26
- assert {xs.at(0).x - 5.0}.abs() < 1e-6
27
- assert {xs.at(0).x - 5.0}.abs() < 1e-6
28
-
29
- test "put_after_take_keeps_length"
30
- xs := Slice(Cell(x: 1.0), Cell(x: 2.0), Cell(x: 3.0))
31
- c := xs.takeAt(0)
32
- xs.putAt(0, c)
33
- assert xs.length() == 3
plum-examples/recursion.plum ADDED
@@ -0,0 +1,221 @@
1
+ # Recursion: tail calls, the cons list and in-place slice updates.
2
+ import std/Int
3
+ import std/Float
4
+ import std/List
5
+ import std/Str
6
+ import std/Slice
7
+ import std/Array
8
+
9
+ # ==== tail calls ====
10
+
11
+ # Tail calls: deep recursion that must not overflow the stack.
12
+ #
13
+ # Self tail calls compile to loops, other tail calls use `return_call`, and a
14
+ # constructor wrapped self call (`Cons(head: f(h), tail: map(t, f))`) is rewritten
15
+ # to a loop (tail recursion modulo cons). All deep tests use 1,000,000 steps.
16
+
17
+
18
+ fun sumTo(n: Int, acc: Int) -> Int =
19
+ if n == 0 then acc else sumTo(n - 1, acc + n)
20
+
21
+ fun build(n: Int, acc: List[Int]) -> List[Int] =
22
+ if n == 0 then acc else build(n - 1, Cons(head: n, tail: acc))
23
+
24
+ fun fromTo(a: Int, b: Int) -> List[Int] =
25
+ if a >= b then Nil else Cons(head: a, tail: fromTo(a + 1, b))
26
+
27
+ fun map(xs: List[A], f: fn(A) -> B) -> List[B] = match xs
28
+ Nil => Nil
29
+ Cons(h, t) => Cons(head: f(h), tail: map(t, f))
30
+
31
+ fun foldl(xs: List[T], acc: A, f: fn(A, T) -> A) -> A = match xs
32
+ Nil => acc
33
+ Cons(h, t) => foldl(t, f(acc, h), f)
34
+
35
+ fun length(xs: List[T], acc: Int) -> Int = match xs
36
+ Nil => acc
37
+ Cons(_, t) => length(t, acc + 1)
38
+
39
+ fun isEven(n: Int) -> Bool =
40
+ if n == 0 then true else isOdd(n - 1)
41
+
42
+ fun isOdd(n: Int) -> Bool =
43
+ if n == 0 then false else isEven(n - 1)
44
+
45
+ # `call` tail calls a closure value; `dec` tail calls `call`.
46
+ fun call(f: fn(Int) -> Int, n: Int) -> Int = f(n)
47
+
48
+ fun dec(n: Int) -> Int =
49
+ if n == 0 then 0 else call(dec, n - 1)
50
+
51
+ record Counter(n: Int, total: Int)
52
+
53
+ fun spin(c: Counter) -> Counter =
54
+ if c.n == 0 then c else spin(Counter(..c, n: c.n - 1, total: c.total + 2))
55
+
56
+ fun depth(n: Int) -> Int =
57
+ if n == 0 then 0 else 1 + depth(n - 1)
58
+
59
+ fun down(n: Int) -> error{Neg}!Int =
60
+ if n < 0
61
+ return error.Neg
62
+ if n == 0
63
+ return 5
64
+ down(n - 1)
65
+
66
+ # ==== cons list ====
67
+
68
+ # The std cons List: constant stack at 1,000,000 elements.
69
+
70
+ fun range(a: Int, b: Int) -> List[Int] =
71
+ if a >= b then Nil else Cons(head: a, tail: range(a + 1, b))
72
+
73
+ # ==== own list ====
74
+
75
+ record Cell(x: Float)
76
+
77
+ # ==== tail calls tests ====
78
+
79
+ test "tail_sum_million"
80
+ assert sumTo(1000000, 0) == 500000500000
81
+
82
+ test "tail_build_million"
83
+ assert length(build(1000000, Nil), 0) == 1000000
84
+
85
+ test "fold_million"
86
+ xs := fromTo(0, 1000000)
87
+ assert foldl(xs, 0, |a, x| a + x) == 499999500000
88
+
89
+ test "mutual_even_odd_million"
90
+ assert isEven(1000000)
91
+ assert !isOdd(1000000)
92
+ assert isOdd(999999)
93
+
94
+ test "trmc_map_million"
95
+ ys := map(fromTo(0, 1000000), |x| x + 1)
96
+ assert length(ys, 0) == 1000000
97
+ assert foldl(ys, 0, |a, x| a + x) == 500000500000
98
+
99
+ test "trmc_fromTo_million"
100
+ xs := fromTo(0, 1000000)
101
+ assert length(xs, 0) == 1000000
102
+
103
+ test "closure_tail_million"
104
+ assert dec(1000000) == 0
105
+
106
+ test "own_record_loop_100k"
107
+ c := spin(Counter(n: 100000, total: 0))
108
+ assert c.n == 0
109
+ assert c.total == 200000
110
+
111
+ test "recursive_closure_million"
112
+ go := |n, acc| if n == 0 then acc else go(n - 1, acc + n)
113
+ assert go(1000000, 0) == 500000500000
114
+
115
+ test "closure_body_tail_million"
116
+ fin := |n: Int| sumTo(n, 0)
117
+ assert fin(1000000) == 500000500000
118
+
119
+ test "error_union_loop_million"
120
+ assert { down(1000000) catch 99 } == 5
121
+
122
+ test "map_single_pass_million"
123
+ ys := map(fromTo(0, 1000000), |x| x * 2)
124
+ assert foldl(ys, 0, |a, x| a + x) == 999999000000
125
+
126
+ # Non-tail recursion still uses the wasm stack. Measured limit under the 4 MiB
127
+ # host stack: about 265,000 frames (260,000 passes, 270,000 traps) of `1 + depth(n - 1)`; tested well below it.
128
+ test "tree_recursion_depth_recorded"
129
+ assert depth(100000) == 100000
130
+
131
+ # ==== cons list tests ====
132
+
133
+ test "init_sum"
134
+ assert List(1, 2, 3, 4).fold(0, |a, x| a + x) == 10
135
+ assert List(1, 2, 3).length() == 3
136
+ assert { List(1, 2, 3).get(1) ?: -1 } == 2
137
+ assert List(1, 2, 3).get(5) == null
138
+ nil: List[Int] := Nil
139
+ assert nil.isEmpty()
140
+ assert !List(1).isEmpty()
141
+
142
+ test "map_filter_fold"
143
+ xs := List(1, 2, 3, 4, 5, 6)
144
+ ys := xs.map(|x| x * 2).filter(|x| x % 3 == 0)
145
+ assert ys.length() == 2
146
+ assert ys.fold(0, |a, x| a + x) == 18
147
+
148
+ test "reverse_append"
149
+ xs := List(1, 2, 3).reverse()
150
+ assert { xs.get(0) ?: -1 } == 3
151
+ assert { xs.get(2) ?: -1 } == 1
152
+ ys := List(1, 2).append(List(3, 4))
153
+ assert ys.equals(List(1, 2, 3, 4))
154
+
155
+ test "for_range"
156
+ total := 0
157
+ for x := range List(1, 2, 3, 4)
158
+ total = total + x
159
+ assert total == 10
160
+ big := 0
161
+ for x := range range(0, 1000000).items()
162
+ big = big + x
163
+ assert big == 499999500000
164
+
165
+ test "to_slice_round_trip"
166
+ s := List(1, 2, 3).map(|x| x * 2).toSlice()
167
+ assert s.equals(Slice(2, 4, 6))
168
+ assert List.fromSlice(s).equals(List(2, 4, 6))
169
+
170
+ test "to_array"
171
+ a := List(7, 8, 9).toArray()
172
+ assert a.length() == 3
173
+ assert a.get(2) == 9
174
+
175
+ test "map_million"
176
+ ys := range(0, 1000000).map(|x| x + 1)
177
+ assert ys.length() == 1000000
178
+ assert ys.fold(0, |a, x| a + x) == 500000500000
179
+
180
+ test "cons_fold_million"
181
+ xs := range(0, 1000000)
182
+ assert xs.fold(0, |a, x| a + x) == 499999500000
183
+ assert xs.filter(|x| x % 2 == 0).length() == 500000
184
+ assert { xs.reverse().get(0) ?: -1 } == 999999
185
+
186
+ test "equals_and_toStr"
187
+ assert List(1, 2, 3).equals(List(1, 2, 3))
188
+ assert !List(1, 2, 3).equals(List(1, 2))
189
+ assert !List(1, 2).equals(List(1, 2, 3))
190
+ assert !List(1, 2, 3).equals(List(1, 2, 4))
191
+ assert List(1, 2, 3).toStr() == "[1,2,3]"
192
+
193
+ # ==== own list tests ====
194
+
195
+ test "map_in_place_updates_all"
196
+ xs := Slice(Cell(x: 1.0), Cell(x: 2.0), Cell(x: 3.0))
197
+ ys := xs.mapInPlace(|c| Cell(..c, x: c.x + 1.0))
198
+ assert ys.length() == 3
199
+ assert {ys.at(0).x - 2.0}.abs() < 1e-6
200
+ assert {ys.at(1).x - 3.0}.abs() < 1e-6
201
+ assert {ys.at(2).x - 4.0}.abs() < 1e-6
202
+
203
+ test "take_then_put_round_trip"
204
+ xs := Slice(Cell(x: 1.0), Cell(x: 2.0))
205
+ c := xs.takeAt(1)
206
+ d := Cell(..c, x: c.x + 10.0)
207
+ xs.putAt(1, d)
208
+ assert xs.length() == 2
209
+ assert {xs.at(1).x - 12.0}.abs() < 1e-6
210
+ assert {xs.at(0).x - 1.0}.abs() < 1e-6
211
+
212
+ test "at_borrows"
213
+ xs := Slice(Cell(x: 5.0))
214
+ assert {xs.at(0).x - 5.0}.abs() < 1e-6
215
+ assert {xs.at(0).x - 5.0}.abs() < 1e-6
216
+
217
+ test "put_after_take_keeps_length"
218
+ xs := Slice(Cell(x: 1.0), Cell(x: 2.0), Cell(x: 3.0))
219
+ c := xs.takeAt(0)
220
+ xs.putAt(0, c)
221
+ assert xs.length() == 3
plum-examples/{seq.plum → sequences.plum} RENAMED
@@ -1,11 +1,16 @@
1
- # Sequence adapters, terminals and chains: using `std/Seq` methods (`take`,
1
+ # Sequences: Seq adapters and terminals, and user-defined fun* forms.
2
- # `skip`, `map`, `filter`, `find`, `fold`) to process collections in `for` loops.
3
2
  import std/Int
4
3
  import std/Float
5
4
  import std/Slice
6
5
  import std/Seq
7
6
  import std/Map
8
7
  import std/Str
8
+ import std/Testing
9
+
10
+ # ==== seq ====
11
+
12
+ # Sequence adapters, terminals and chains: using `std/Seq` methods (`take`,
13
+ # `skip`, `map`, `filter`, `find`, `fold`) to process collections in `for` loops.
9
14
 
10
15
  # Seq methods (`extend Seq[T]` in plum-std/Seq.plum): `xs.filter()` & co. in a
11
16
  # `for` source. Adapters (take, skip, stepBy, zip, reversed, chunks) reshape the
@@ -47,6 +52,78 @@ fun army() -> Slice[Soldier] =
47
52
  Soldier(name: "f", team: 2, hp: 8.0, x: 1.0, y: 0.0),
48
53
  )
49
54
 
55
+ # ==== forms ====
56
+
57
+ # User-defined `fun*` forms with `yield`: sources, folds and methods written in
58
+ # plum and expanded at the call site (the built-in forms are in seq.plum).
59
+
60
+ # for-forms: `for binders := range form(args), clauses` + body. A form is a `fun*`
61
+ # with a yield type (`*T`); each `yield` runs the `for` body, and the whole
62
+ # thing is expanded at the call site, so there are no closures. See
63
+ # plum-std/Seq.plum and plum-core/src/forms.rs.
64
+
65
+ record Fighter(name: Str, hp: Int, dead: Bool)
66
+
67
+ fun sampleFighters() -> Slice[Fighter] =
68
+ Slice(Fighter(name: "a", hp: 10, dead: false), Fighter(name: "b", hp: 0, dead: true), Fighter(name: "c", hp: 7, dead: false))
69
+
70
+ # a user form: the sum of the body's value over a list
71
+ fun* sumBy(xs: Slice[T]) *T -> Int =
72
+ total := 0
73
+ for x := range xs
74
+ v := yield x
75
+ total = total + v
76
+ total
77
+
78
+ # a user form with two binders and a statement body: the 8 neighbours of (x, y)
79
+ fun* neighbours(x: Int, y: Int) *(Int, Int) =
80
+ for dx := range 3
81
+ for dy := range 3
82
+ if dx != 1 || dy != 1
83
+ yield x + dx - 1, y + dy - 1
84
+
85
+ # a user form built from another form: `yield` sits inside `each`'s body
86
+ fun* sumEvens(xs: Slice[Int]) *Int -> Int =
87
+ total := 0
88
+ for x := range xs.each(), if x % 2 == 0
89
+ v := yield x
90
+ total = total + v
91
+ total
92
+
93
+ fun firstBig(xs: Slice[Int]) -> Int =
94
+ for x := range xs.each()
95
+ if x > 10
96
+ return x
97
+ return -1
98
+
99
+ fun returnedForm(xs: Slice[Int]) -> Slice[Int] =
100
+ return for x := range xs.map()
101
+ x + 1
102
+
103
+ fun tailForm(xs: Slice[Int]) -> Int =
104
+ for x := range sumBy(xs)
105
+ x * 2
106
+
107
+ # A type with a fun* method: `self` is bound to the receiver at the use site.
108
+ record Countdown(from: Int) =
109
+
110
+ fun* ticks(self) *Int =
111
+ i := self.from
112
+ while i > 0
113
+ cur := i
114
+ i = i - 1
115
+ yield cur
116
+
117
+ # `for a, b := range countdown`: two binders pick `entries`.
118
+ fun* entries(self) *(Int, Int) =
119
+ i := self.from
120
+ while i > 0
121
+ cur := i
122
+ i = i - 1
123
+ yield cur, cur * cur
124
+
125
+ # ==== seq tests ====
126
+
50
127
  test "seq_method_filter: a List has no filter of its own, so Seq.filter is used"
51
128
  xs := Slice(1, 2, 3, 4, 5, 6)
52
129
  evens := for x := range xs.filter()
@@ -234,3 +311,85 @@ test "materializing stages are separate statements"
234
311
  hps := for u := range alive.map(), if u.team == 1
235
312
  u.hp
236
313
  assert hps.length() == 3
314
+
315
+ # ==== forms tests ====
316
+
317
+ test "user_form_sumBy"
318
+ fighters := sampleFighters()
319
+ hp := for u := range sumBy(fighters)
320
+ u.hp
321
+ assert hp == 17
322
+ odd := for x := range sumBy(Slice(1, 2, 3, 4, 5)), if x % 2 == 1
323
+ x
324
+ assert odd == 9
325
+
326
+ test "user_form_neighbours_grid: two binders, statement body"
327
+ count := 0
328
+ sum := 0
329
+ for nx, ny := range neighbours(5, 5), if nx > 5
330
+ count = count + 1
331
+ sum = sum + ny
332
+ assert count == 3
333
+ assert sum == 15
334
+
335
+ test "break_continue_return_semantics"
336
+ xs := Slice(1, 2, 3, 4, 5)
337
+ total := 0
338
+ for x := range xs.each()
339
+ if x == 2
340
+ continue
341
+ if x == 5
342
+ break
343
+ total = total + x
344
+ assert total == 8
345
+ assert firstBig(Slice(1, 20, 30)) == 20
346
+ assert firstBig(xs) == -1
347
+ prefix := for x := range xs.filter()
348
+ if x == 4
349
+ break
350
+ x > 1
351
+ assert prefix.length() == 2
352
+
353
+ test "composed form: a form that uses another form"
354
+ xs := Slice(1, 2, 3, 4, 5, 6)
355
+ total := for x := range sumEvens(xs)
356
+ x * 10
357
+ assert total == 120
358
+
359
+ test "forms nest: a form inside a form body"
360
+ xs := Slice(1, 2)
361
+ ys := Slice(10, 20)
362
+ total := for a := range sumBy(xs)
363
+ inner := for b := range sumBy(ys)
364
+ a * b
365
+ inner
366
+ assert total == 90
367
+
368
+ test "return for-form: the form's value is returned"
369
+ ys := returnedForm(Slice(1, 2, 3))
370
+ assert ys.length() == 3
371
+ assert ys.at(2) == 4
372
+
373
+ test "tail for-form: the last statement of a fn is its value"
374
+ assert tailForm(Slice(1, 2, 3)) == 12
375
+
376
+ test "fun_star_method_uses_self"
377
+ c := Countdown(from: 4)
378
+ seen := 0
379
+ for t := range c.ticks()
380
+ seen = seen * 10 + t
381
+ assert seen == 4321
382
+ evens := for t := range c.ticks().map(), if t % 2 == 0
383
+ t * 100
384
+ assert evens.length() == 2
385
+ assert evens.at(0) == 400
386
+
387
+ test "two_binder_uses_entries_method: a custom type's entries()"
388
+ c := Countdown(from: 3)
389
+ total := 0
390
+ for n, sq := range c
391
+ total = total + n * 1000 + sq
392
+ assert total == 3 * 1000 + 9 + 2 * 1000 + 4 + 1000 + 1
393
+ for _, sq := range c
394
+ total = total + sq
395
+ assert total == 6014 + 14
plum-examples/struct_union_members.plum CHANGED
@@ -16,7 +16,7 @@ struct Wait(left: Float)
16
16
 
17
17
  record Chain(steps: Slice[Step])
18
18
 
19
- trait Thing(x: Float, tag: Int) =
19
+ trait Thing(mut x: Float, tag: Int) =
20
20
  fun clone(self) -> Thing
21
21
 
22
22
  record Box(Thing(x, tag), size: Float) =
plum-examples/structs_records.plum CHANGED
@@ -1,12 +1,16 @@
1
- # Structs and records: `struct` is a small copy-semantics value type (flattened
1
+ # Structs and records, digit names and annotated locals.
2
- # into wasm locals, no heap allocation); `record` is a reference type shared on
3
- # assignment. Both have named fields, methods, constants, positional or named
4
- # construction, field mutation and spread update (`Point(..p, x: 1)`).
5
- module structs_records
6
2
  import std/Str
7
3
  import std/Int
8
4
  import std/Float
9
5
  import std/Map
6
+ import std/Slice
7
+
8
+ # ==== structs records ====
9
+
10
+ # Structs and records: `struct` is a small copy-semantics value type (flattened
11
+ # into wasm locals, no heap allocation); `record` is a reference type shared on
12
+ # assignment. Both have named fields, methods, constants, positional or named
13
+ # construction, field mutation and spread update (`Point(..p, x: 1)`).
10
14
 
11
15
  # ---- struct: value semantics ----
12
16
 
@@ -36,7 +40,7 @@ struct Color(r: Int, g: Int, b: Int, a: Int)
36
40
 
37
41
  struct Cell(pos: Vec2, solid: Bool, tint: Color)
38
42
 
39
- record Player(pos: Vec2, hp: Int)
43
+ record Player(mut pos: Vec2, hp: Int)
40
44
 
41
45
  fun applyTo(v: Vec2, cb: fn(Vec2) -> Vec2) -> Vec2 =
42
46
  cb(v)
@@ -81,7 +85,7 @@ fun bump(v: Vec2) -> Float =
81
85
 
82
86
  # ---- record: reference semantics ----
83
87
 
84
- record Point(x: Int, y: Int)
88
+ record Point(mut x: Int, y: Int)
85
89
 
86
90
  # A method takes `self` first; a `fun` without `self` is a static (`Vec2.zero()`).
87
91
  record Cat(name: Str, age: Int) =
@@ -123,10 +127,9 @@ record Bag(n: Int) =
123
127
 
124
128
  # ---- embedding records and override ----
125
129
 
126
- record Counter(start: Int) =
130
+ record Counter(mut count: Int) =
127
131
 
128
132
  fun init(self) =
129
- self.count: Int = self.start
130
133
  self.log: Str = ""
131
134
 
132
135
  fun bump(self) =
@@ -135,7 +138,7 @@ record Counter(start: Int) =
135
138
  fun show(self) -> Str =
136
139
  "counter {self.count}"
137
140
 
138
- record Scoreboard(Counter(start), name: Str) =
141
+ record Scoreboard(Counter(count), name: Str) =
139
142
 
140
143
  fun init(self) =
141
144
  self.best: Int = 0
@@ -143,6 +146,76 @@ record Scoreboard(Counter(start), name: Str) =
143
146
  override fun show(self) -> Str =
144
147
  "{self.name}: {self.Counter.show()}"
145
148
 
149
+ # ==== digit names ====
150
+
151
+ # Type and variant names may contain digits (`V2`, `A1`, `E1`). They are
152
+ # resolved by position: where a type or variant name is expected it is one;
153
+ # `Type.Name` consts such as `V2.K1` and `Int.Max` keep working.
154
+
155
+ enum V2 =
156
+ | A1
157
+ | B2
158
+ K1 = 40
159
+
160
+ record A1(n: Int)
161
+
162
+ error S1 = E1 | E2
163
+
164
+ fun fail(n: Int) -> S1!Int =
165
+ if n == 1
166
+ return error.E1
167
+ if n == 2
168
+ return error.E2
169
+ n
170
+
171
+ fun asInt(w: V2) -> Int =
172
+ match w
173
+ A1(n) => n
174
+ B2 => 0
175
+
176
+ fun payload(w: V2) -> Int =
177
+ if A1(n) := w
178
+ return n
179
+ 0
180
+
181
+ fun isB2(w: V2) -> Int =
182
+ if B2 := w
183
+ return 1
184
+ 0
185
+
186
+ # ==== annotated locals ====
187
+
188
+ # Annotated local declarations: `name: Type := value` fixes the local's type to
189
+ # the annotation. The value converts to it exactly as it would for a parameter of
190
+ # that type (a union member into its enum, `T` into `T?`, `null` into `T?`), and
191
+ # a later `name = value` is checked and converted against the same type.
192
+
193
+ record Circle(r: Float)
194
+
195
+ record Region(w: Float, h: Float)
196
+
197
+ enum Shape = Circle | Region
198
+
199
+ error LocalError = Bad | Worse
200
+
201
+ fun area(s: Shape) -> Float =
202
+ match s
203
+ Circle(r) => r * r * 3.0
204
+ Region(w, h) => w * h
205
+
206
+ fun parseLocal(n: Int) -> LocalError!Int =
207
+ if n < 0
208
+ return error.Bad
209
+ n * 2
210
+
211
+ fun twice(x: Int) -> Int = x * 2
212
+
213
+ fun same(x: T) -> T =
214
+ y: T := x
215
+ y
216
+
217
+ # ==== structs records tests ====
218
+
146
219
  test "struct assignment copies"
147
220
  a := Vec2(x: 1.0, y: 2.0)
148
221
  b := a
@@ -299,12 +372,83 @@ test "init lists are fresh per construction"
299
372
  assert b.items.length() == 0
300
373
 
301
374
  test "a record embeds another: fields, init and methods are copied"
302
- s := Scoreboard(start: 5, name: "hi")
375
+ s := Scoreboard(count: 5, name: "hi")
303
376
  assert s.count == 5
304
377
  s.bump()
305
378
  assert s.count == 6
306
379
  assert s.best == 0
307
380
 
308
381
  test "override replaces an embedded method and self.X.m() calls the original"
309
- s := Scoreboard(start: 1, name: "hi")
382
+ s := Scoreboard(count: 1, name: "hi")
310
383
  assert s.show() == "hi: counter 1"
384
+
385
+ # ==== digit names tests ====
386
+
387
+ test "digit_variant_match"
388
+ assert asInt(A1(n: 5)) == 5
389
+ x := B2
390
+ assert asInt(x) == 0
391
+
392
+ test "digit_variant_pattern_if"
393
+ assert payload(A1(n: 7)) == 7
394
+ assert payload(B2) == 0
395
+ w := B2
396
+ assert isB2(w) == 1
397
+ assert isB2(A1(n: 1)) == 0
398
+
399
+ test "digit_error_names"
400
+ a := fail(3) catch 0
401
+ assert a == 3
402
+ b := fail(1) catch 9
403
+ assert b == 9
404
+
405
+ test "digit_enum_const_still_works"
406
+ assert V2.K1 == 40
407
+ assert Int.Max > 0
408
+
409
+ # ==== annotated locals tests ====
410
+
411
+ test "union_local"
412
+ s: Shape := Circle(r: 2.0)
413
+ assert area(s) == 12.0
414
+
415
+ test "union_local_reassigned"
416
+ s: Shape := Circle(r: 1.0)
417
+ s = Region(w: 2.0, h: 3.0)
418
+ assert area(s) == 6.0
419
+
420
+ test "optional_local_present"
421
+ o: Int? := 4
422
+ assert {o ?: 0} == 4
423
+
424
+ test "optional_local_null"
425
+ o: Int? := null
426
+ assert {o ?: 9} == 9
427
+ o = 5
428
+ assert {o ?: 9} == 5
429
+
430
+ test "list_local"
431
+ xs: Slice[Shape] := Slice[Shape]()
432
+ xs.push(Circle(r: 1.0))
433
+ xs.push(Region(w: 1.0, h: 1.0))
434
+ assert xs.length() == 2
435
+
436
+ test "fn_local"
437
+ f: fn(Int) -> Int := twice
438
+ assert f(4) == 8
439
+
440
+ test "scalar_locals"
441
+ n: Int := 3
442
+ x: Float := 2.5
443
+ assert n + 1 == 4
444
+ assert x * 2.0 == 5.0
445
+
446
+ test "generic_param_annotation"
447
+ assert same(7) == 7
448
+ assert same("a") == "a"
449
+
450
+ test "named_error_set_local"
451
+ ok: LocalError!Int := parseLocal(4)
452
+ bad: LocalError!Int := parseLocal(-1)
453
+ assert {ok catch 0} == 8
454
+ assert {bad catch 7} == 7
plum-examples/tail_calls.plum DELETED
@@ -1,110 +0,0 @@
1
- # Tail calls: deep recursion that must not overflow the stack.
2
- #
3
- # Self tail calls compile to loops, other tail calls use `return_call`, and a
4
- # constructor wrapped self call (`Cons(head: f(h), tail: map(t, f))`) is rewritten
5
- # to a loop (tail recursion modulo cons). All deep tests use 1,000,000 steps.
6
- module tail_calls
7
- import std/Int
8
- import std/Float
9
- import std/List
10
-
11
-
12
- fun sumTo(n: Int, acc: Int) -> Int =
13
- if n == 0 then acc else sumTo(n - 1, acc + n)
14
-
15
- fun build(n: Int, acc: List[Int]) -> List[Int] =
16
- if n == 0 then acc else build(n - 1, Cons(head: n, tail: acc))
17
-
18
- fun fromTo(a: Int, b: Int) -> List[Int] =
19
- if a >= b then Nil else Cons(head: a, tail: fromTo(a + 1, b))
20
-
21
- fun map(xs: List[A], f: fn(A) -> B) -> List[B] = match xs
22
- Nil => Nil
23
- Cons(h, t) => Cons(head: f(h), tail: map(t, f))
24
-
25
- fun foldl(xs: List[T], acc: A, f: fn(A, T) -> A) -> A = match xs
26
- Nil => acc
27
- Cons(h, t) => foldl(t, f(acc, h), f)
28
-
29
- fun length(xs: List[T], acc: Int) -> Int = match xs
30
- Nil => acc
31
- Cons(_, t) => length(t, acc + 1)
32
-
33
- fun isEven(n: Int) -> Bool =
34
- if n == 0 then true else isOdd(n - 1)
35
-
36
- fun isOdd(n: Int) -> Bool =
37
- if n == 0 then false else isEven(n - 1)
38
-
39
- # `call` tail calls a closure value; `dec` tail calls `call`.
40
- fun call(f: fn(Int) -> Int, n: Int) -> Int = f(n)
41
-
42
- fun dec(n: Int) -> Int =
43
- if n == 0 then 0 else call(dec, n - 1)
44
-
45
- own record Counter(n: Int, total: Int)
46
-
47
- fun spin(own c: Counter) -> Counter =
48
- if c.n == 0 then c else spin(Counter(..c, n: c.n - 1, total: c.total + 2))
49
-
50
- fun depth(n: Int) -> Int =
51
- if n == 0 then 0 else 1 + depth(n - 1)
52
-
53
- fun down(n: Int) -> error{Neg}!Int =
54
- if n < 0
55
- return error.Neg
56
- if n == 0
57
- return 5
58
- down(n - 1)
59
-
60
- test "tail_sum_million"
61
- assert sumTo(1000000, 0) == 500000500000
62
-
63
- test "tail_build_million"
64
- assert length(build(1000000, Nil), 0) == 1000000
65
-
66
- test "fold_million"
67
- xs := fromTo(0, 1000000)
68
- assert foldl(xs, 0, |a, x| a + x) == 499999500000
69
-
70
- test "mutual_even_odd_million"
71
- assert isEven(1000000)
72
- assert !isOdd(1000000)
73
- assert isOdd(999999)
74
-
75
- test "trmc_map_million"
76
- ys := map(fromTo(0, 1000000), |x| x + 1)
77
- assert length(ys, 0) == 1000000
78
- assert foldl(ys, 0, |a, x| a + x) == 500000500000
79
-
80
- test "trmc_fromTo_million"
81
- xs := fromTo(0, 1000000)
82
- assert length(xs, 0) == 1000000
83
-
84
- test "closure_tail_million"
85
- assert dec(1000000) == 0
86
-
87
- test "own_record_loop_100k"
88
- c := spin(Counter(n: 100000, total: 0))
89
- assert c.n == 0
90
- assert c.total == 200000
91
-
92
- test "recursive_closure_million"
93
- go := |n, acc| if n == 0 then acc else go(n - 1, acc + n)
94
- assert go(1000000, 0) == 500000500000
95
-
96
- test "closure_body_tail_million"
97
- fin := |n: Int| sumTo(n, 0)
98
- assert fin(1000000) == 500000500000
99
-
100
- test "error_union_loop_million"
101
- assert { down(1000000) catch 99 } == 5
102
-
103
- test "map_single_pass_million"
104
- ys := map(fromTo(0, 1000000), |x| x * 2)
105
- assert foldl(ys, 0, |a, x| a + x) == 999999000000
106
-
107
- # Non-tail recursion still uses the wasm stack. Measured limit under the 4 MiB
108
- # host stack: about 265,000 frames (260,000 passes, 270,000 traps) of `1 + depth(n - 1)`; tested well below it.
109
- test "tree_recursion_depth_recorded"
110
- assert depth(100000) == 100000
plum-examples/testing.plum DELETED
@@ -1,20 +0,0 @@
1
- # Testing: native `test "name"` blocks and `assert` statements executed by
2
- # the `plum test` runner.
3
- import std/Str
4
- import std/Int
5
- import std/Float
6
-
7
- fun add(a: Int, b: Int) -> Int =
8
- a + b
9
-
10
- fun isEven(n: Int) -> Bool =
11
- n % 2 == 0
12
-
13
- test "add works"
14
- assert add(1, 2) == 3
15
- assert add(2, 2) == 4
16
-
17
- test "isEven works"
18
- assert isEven(4)
19
- assert !isEven(3)
20
- assert isEven(2)
plum-examples/trait_scene.plum DELETED
@@ -1,50 +0,0 @@
1
- # A tiny pure scene: a `Scene` holds `Slice[Entity]`; the engine side knows only
2
- # the trait, the records fill it in. No game host needed.
3
- module trait_scene
4
- import std/Str
5
- import std/Int
6
- import std/Slice
7
-
8
- trait Entity(x: Int, y: Int) =
9
- fun update(self, dt: Int)
10
- fun draw(self) -> Int
11
- fun hit(self, px: Int, py: Int) -> Bool = px == self.x && py == self.y
12
-
13
- record House(Entity(x, y), floors: Int) =
14
- override fun update(self, dt: Int) =
15
- return
16
- override fun draw(self) -> Int = self.floors * 10
17
-
18
- record Walker(Entity(x, y), speed: Int) =
19
- override fun update(self, dt: Int) =
20
- self.x = self.x + self.speed * dt
21
- override fun draw(self) -> Int = self.x
22
-
23
- record Scene(entities: Slice[Entity]) =
24
- fun update(self, dt: Int) =
25
- for e := range self.entities
26
- e.update(dt)
27
- fun drawAll(self) -> Int =
28
- total := 0
29
- for e := range self.entities
30
- total = total + e.draw()
31
- total
32
-
33
- fun makeScene() -> Scene =
34
- es: Slice[Entity] := Slice[Entity]()
35
- es.push(House(x: 0, y: 0, floors: 2))
36
- es.push(Walker(x: 3, y: 1, speed: 2))
37
- Scene(entities: es)
38
-
39
- test "scene_updates_and_draws"
40
- s := makeScene()
41
- assert s.drawAll() == 23
42
- s.update(5)
43
- assert s.drawAll() == 33
44
-
45
- test "scene_hit_default"
46
- s := makeScene()
47
- assert s.entities.at(1).hit(3, 1)
48
- s.update(1)
49
- assert s.entities.at(1).hit(5, 1)
50
- assert !s.entities.at(0).hit(1, 1)
plum-examples/traits.plum CHANGED
@@ -1,19 +1,22 @@
1
+ # Traits: fields, defaults, dispatch and a pure scene.
2
+ import std/Str
3
+ import std/Int
4
+ import std/Float
5
+ import std/Slice
6
+
7
+ # ==== traits ====
8
+
1
9
  # Traits: `trait Entity(pos: Vec2, z: Int) = ...` bundles fields and methods. A
2
10
  # `record` embeds traits by listing them first in its field list, gains their
3
11
  # fields and default methods, and converts to each trait where one is expected.
4
12
  # `Entity(pos, z)` exposes those trait fields as constructor parameters; a bare
5
13
  # `Entity` exposes none, so the trait's or record's `fun init(self)` must set them.
6
14
  # A method without a body is required; one with a body is a default.
7
- module traits
8
- import std/Str
9
- import std/Int
10
- import std/Float
11
- import std/Slice
12
15
 
13
16
  struct Vec2(x: Int, y: Int) =
14
17
  fun add(self, o: Vec2) -> Vec2 = Vec2(x: self.x + o.x, y: self.y + o.y)
15
18
 
16
- trait Entity(pos: Vec2, z: Int) =
19
+ trait Entity(mut pos: Vec2, mut z: Int) =
17
20
  fun update(self, dt: Int) -> Int
18
21
  fun draw(self) -> Int
19
22
  fun moveBy(self, d: Vec2) =
@@ -52,6 +55,44 @@ fun describe(e: Entity) -> Int =
52
55
  House(_, _, _, kind) => kind
53
56
  _ => -1
54
57
 
58
+ # ==== trait scene ====
59
+
60
+ # A tiny pure scene: a `Scene` holds `Slice[Actor]`; the engine side knows only
61
+ # the trait, the records fill it in. No game host needed.
62
+
63
+ trait Actor(mut x: Int, y: Int) =
64
+ fun update(self, dt: Int)
65
+ fun draw(self) -> Int
66
+ fun hit(self, px: Int, py: Int) -> Bool = px == self.x && py == self.y
67
+
68
+ record Hut(Actor(x, y), floors: Int) =
69
+ override fun update(self, dt: Int) =
70
+ return
71
+ override fun draw(self) -> Int = self.floors * 10
72
+
73
+ record Walker(Actor(x, y), speed: Int) =
74
+ override fun update(self, dt: Int) =
75
+ self.x = self.x + self.speed * dt
76
+ override fun draw(self) -> Int = self.x
77
+
78
+ record Scene(entities: Slice[Actor]) =
79
+ fun update(self, dt: Int) =
80
+ for e := range self.entities
81
+ e.update(dt)
82
+ fun drawAll(self) -> Int =
83
+ total := 0
84
+ for e := range self.entities
85
+ total = total + e.draw()
86
+ total
87
+
88
+ fun makeScene() -> Scene =
89
+ es: Slice[Actor] := Slice[Actor]()
90
+ es.push(Hut(x: 0, y: 0, floors: 2))
91
+ es.push(Walker(x: 3, y: 1, speed: 2))
92
+ Scene(entities: es)
93
+
94
+ # ==== traits tests ====
95
+
55
96
  test "fields_through_trait"
56
97
  h := house()
57
98
  e: Entity := h
@@ -96,3 +137,18 @@ test "header_selection_init_and_internal_field"
96
137
  assert c.update(1) == 9
97
138
  e: Entity := c
98
139
  assert e.draw() == 9
140
+
141
+ # ==== trait scene tests ====
142
+
143
+ test "scene_updates_and_draws"
144
+ s := makeScene()
145
+ assert s.drawAll() == 23
146
+ s.update(5)
147
+ assert s.drawAll() == 33
148
+
149
+ test "scene_hit_default"
150
+ s := makeScene()
151
+ assert s.entities.at(1).hit(3, 1)
152
+ s.update(1)
153
+ assert s.entities.at(1).hit(5, 1)
154
+ assert !s.entities.at(0).hit(1, 1)
plum-examples/typed_arrays.plum DELETED
@@ -1,39 +0,0 @@
1
- # Typed arrays: unboxed native `Array[Int]` and `Array[Float]` lowering to
2
- # WebAssembly primitive arrays, along with flat 2D `Grid[T]`.
3
- module typed_arrays
4
- import std/Int
5
- import std/Float
6
- import std/Array
7
- import std/Grid
8
- import std/Seq
9
-
10
- # Tile-grid style data: Array[Int] / Array[Float] / Array[Bool] are native
11
- # unboxed wasm arrays (i64 / f64 / i32 elements), not boxed anyref arrays.
12
- fun sumInts(n: Int) -> Int =
13
- a := Array.filled(n, 3)
14
- total := 0
15
- for i := range n
16
- total = total + a.get(i)
17
- return total
18
-
19
- fun sumFloats(n: Int) -> Float =
20
- a := Array.filled(n, 0.5)
21
- total := 0.0
22
- for i := range n
23
- total = total + a.get(i)
24
- return total
25
-
26
- fun countTrue(n: Int) -> Int =
27
- a := Array.filled(n, false)
28
- a.set(1, true)
29
- a.set(3, true)
30
- c := 0
31
- for i := range n
32
- if a.get(i)
33
- c = c + 1
34
- return c
35
-
36
- test "array_int_native: filled arrays hold their values"
37
- assert sumInts(10) == 30
38
- assert sumFloats(4) == 2.0
39
- assert countTrue(5) == 2
plum-examples/unboxed_variants.plum CHANGED
@@ -1,6 +1,6 @@
1
1
  # Probes for unboxed variants (union-enum members and inline variants are
2
2
  # subtypes of the enum): deep nesting, Json round trips, toStr, a million-long
3
- # List of unions, member mutation, reassignment across members, own record
3
+ # List of unions, member mutation, reassignment across members, record
4
4
  # members, and enum `init` versus variant-name resolution.
5
5
  module unboxed_variants
6
6
  import std/Str
@@ -175,9 +175,9 @@ fun leaves(n: Int) -> List[Int] =
175
175
  if n == 0 then Nil else Cons(head: n, tail: leaves(n - 1))
176
176
 
177
177
  # State.
178
- record Circle(r: Float)
178
+ record Circle(mut r: Float)
179
179
 
180
- record Rect(w: Float, h: Float)
180
+ record Rect(mut w: Float, h: Float)
181
181
 
182
182
  enum Shape = Circle | Rect
183
183
 
@@ -200,13 +200,13 @@ fun kind(s: Shape) -> Int =
200
200
  Circle(_) => 1
201
201
  Rect(_, _) => 10
202
202
 
203
- own record Token(id: Int)
203
+ record Token(id: Int)
204
204
 
205
- own record Stamp(id: Int, note: Str)
205
+ record Stamp(id: Int, note: Str)
206
206
 
207
207
  enum Mark = Token | Stamp
208
208
 
209
- fun takeMark(own m: Mark) -> Mark = m
209
+ fun takeMark(m: Mark) -> Mark = m
210
210
 
211
211
  fun markId(m: Mark) -> Int =
212
212
  match m