plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
68edfc2
— Peter John
2026-11-04T19:52:18+05:30
docs: kids' games review plan (queued last) and Balloon Pop draft
docs/backlog.md
CHANGED
|
@@ -13,6 +13,7 @@ Open work, in the order the user queued it, plus smaller items found during buil
|
|
|
13
13
|
4. **FBIP: no heap mutation** — persistent std collections updated in place when unique, Koka-style `fip` functions as a checked in-place guarantee, a reference count used only as a uniqueness check if static proof falls short. Worklist = the `mut` fields left today (see below). (Decision record §9.)
|
|
14
14
|
5. **One product type, `struct`** — after FBIP, merge `record` into an immutable `struct` with compiler-chosen boxing (optional hint, shown by `--explain`). (Decision record §9b.)
|
|
15
15
|
6. **Deterministic games** — fixed timestep, input recording and replay, seeded `Random` in game state, state checksums; port the design from `../kam_macroquad` (`main.rs` tick accumulator, `game/replay.rs`). The `Io` capability makes recording and replaying host input straightforward. (Decision record §10.)
|
|
16
|
+
7. **Kids' games (ages 4-7)** (user 2026-11-04: LAST, after all the refactoring is done) — review plan `docs/plans/2026-11-04-kids-games.review.md` (written, NOT approved; re-present for a fresh human approve). Seven functional-style games in `plum-examples/games/functional/kids/`: Balloon Pop (tap), Bunny Hop, Letter Bubbles, Caterpillar, Color Piano, Star Rocket (gentle Orb Run), Hoppy Frog (platformer); nothing can be lost. Draft `balloon_pop.plum` exists (untracked) and is blocked by the `List.length` bug below. Re-check the plan against the post-refactor APIs (explicit generics, `Io` primitive, crate paths) before approving.
|
|
16
17
|
|
|
17
18
|
## Planned, not yet placed in the queue
|
|
18
19
|
|
|
@@ -34,6 +35,7 @@ Open work, in the order the user queued it, plus smaller items found during buil
|
|
|
34
35
|
|
|
35
36
|
## Smaller open items
|
|
36
37
|
|
|
38
|
+
- **`List.length()` on a locally built list fails codegen** — `fun count() -> Int =` / `xs := List(1, 2)` / `xs.length()` gives `codegen: unknown method 'List.lengthFrom' (in 'count')`; the same call on a `List` parameter, or inside a `test` block, works. Also hit with a static `Hue.palette().length()`. Found drafting the kids' games; the user wants it fixed (with a regression test) before those games are built. Check whether the cleanup refactor already fixed it.
|
|
37
39
|
- **Spread from a field path** — `Stage(..w.stage, x: 1)` is rejected; only a plain variable can be a spread source. Worked around with small helpers in the pure game. (language-restrictions T14)
|
|
38
40
|
- **Fields declared inside `init` can't be `mut`** (`self.camX: Float = 0.0`) — such fields were moved into the record header. (T8)
|
|
39
41
|
- **Benchmark harness can't pass `--no-fusion`** — `plum-game/web-test` compiles without flags, so fusion-off numbers can't be taken since the env var was removed. (T11)
|
docs/plans/2026-11-04-kids-games.review.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Kids' games (ages 4-7): review plan
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
Seven small example games for children aged 4 to 7, written in the functional style of Orb Run (one immutable game state, a list of messages that can happen to it, and a picture rebuilt every frame). Two are played by tapping or clicking, five with the keyboard, and two of those five are child-friendly versions of the ship game and of a platformer. None of them can be lost. There are no lives, no countdowns and no "game over", and mistakes get a gentle wiggle, never a penalty. Before the games, a compiler bug found while drafting them gets fixed: asking a list built inside a function for its length fails to compile.
|
|
5
|
+
|
|
6
|
+
## What you will get
|
|
7
|
+
- A `kids` folder of functional-style games, each one file that runs with `plum game <file>`:
|
|
8
|
+
- **Balloon Pop** (tap): balloons float up. Tap one to pop it, and its colour's name floats up ("Red!"). Every 10 pops there is a confetti party.
|
|
9
|
+
- **Bunny Hop** (arrows or WASD): steer a bunny around a garden to collect carrots, and a flower grows for each one. There are no enemies.
|
|
10
|
+
- **Letter Bubbles** (letter keys): bubbles with big letters drift down. Press that letter to pop the bubble. It starts with a few easy letters and adds more over time.
|
|
11
|
+
- **Caterpillar** (arrows): a gentle snake. It eats leaves and grows, the edges wrap around, and it passes through itself, so it never dies.
|
|
12
|
+
- **Color Piano** (keys 1-4 or tap): four coloured pads that light up and play notes, plus a "copy me" tune that only grows when the child copies it right.
|
|
13
|
+
- **Star Rocket** (arrows, like Orb Run): fly a friendly rocket and collect stars. Clouds just bump you softly, and there are no mines and no lives.
|
|
14
|
+
- **Hoppy Frog** (arrows plus Space, a platformer): hop across lily pads and logs to catch flies. Falling in the water puts the frog back on the last pad with a splash.
|
|
15
|
+
- Big, bright shapes and big text, sized for small hands and early readers.
|
|
16
|
+
- Five new synthesized sounds (four piano notes and a cheer), made by the existing sound generator, with the current pop, bump and music sounds reused.
|
|
17
|
+
- Tests in every game for its rules, plus a check that each game compiles and runs frames without a browser.
|
|
18
|
+
- A short "Kids' games" entry in the README games section.
|
|
19
|
+
- The list-length compiler bug fixed, with a regression test.
|
|
20
|
+
|
|
21
|
+
## What will be built
|
|
22
|
+
| Part | What it does | Size | Built by |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| List-length fix | Lets a list built inside a function report its length, with a regression test | M | a careful helper |
|
|
25
|
+
| New sounds | Adds four piano notes and a cheer to the sound generator and the asset folder | S | a fast helper |
|
|
26
|
+
| Balloon Pop | Finishes the existing draft (it currently hits the list bug) and its tests | S | a careful helper |
|
|
27
|
+
| Bunny Hop | Arrow-key garden walk that collects carrots and grows flowers | M | a careful helper |
|
|
28
|
+
| Letter Bubbles | Falling letter bubbles popped by pressing the matching key | M | a careful helper |
|
|
29
|
+
| Caterpillar | Grid-stepping caterpillar that grows, wraps at the edges and never dies | M | a careful helper |
|
|
30
|
+
| Color Piano | Four lit pads with notes and a growing "copy me" tune | M | a careful helper |
|
|
31
|
+
| Star Rocket | Orb Run's flying rules with stars, soft cloud bumps and no losing | M | a careful helper |
|
|
32
|
+
| Hoppy Frog | Gravity, jumping and landing on platforms, with a safe respawn from the water | L | a careful helper |
|
|
33
|
+
| Headless run check | Compiles each game and runs a few hundred frames with no browser | S | a fast helper |
|
|
34
|
+
| README entry | Lists the games and how to run them | S | a fast helper |
|
|
35
|
+
|
|
36
|
+
## How it fits together
|
|
37
|
+
```mermaid
|
|
38
|
+
flowchart LR
|
|
39
|
+
K[Keyboard and mouse] --> P[Input polling, once a frame]
|
|
40
|
+
P --> M["Messages: key, tap, tick"]
|
|
41
|
+
M --> S[Game state update]
|
|
42
|
+
S --> R[Picture rebuilt every frame]
|
|
43
|
+
R --> D[Browser renderer]
|
|
44
|
+
R -->|"what is under the tap"| S
|
|
45
|
+
S --> A[Sounds]
|
|
46
|
+
L[Shared functional scene library] --> R
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## How it gets built
|
|
50
|
+
```mermaid
|
|
51
|
+
flowchart TB
|
|
52
|
+
F[List-length fix] --> W2
|
|
53
|
+
N[New sounds] --> W2
|
|
54
|
+
subgraph W2["Wave 2, seven games in parallel"]
|
|
55
|
+
G1[Balloon Pop]
|
|
56
|
+
G2[Bunny Hop]
|
|
57
|
+
G3[Letter Bubbles]
|
|
58
|
+
G4[Caterpillar]
|
|
59
|
+
G5[Color Piano]
|
|
60
|
+
G6[Star Rocket]
|
|
61
|
+
G7[Hoppy Frog]
|
|
62
|
+
end
|
|
63
|
+
W2 --> H[Headless run check and README]
|
|
64
|
+
H --> V[Final review]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Decisions you can change
|
|
68
|
+
- Where the games live: a `kids` folder inside the functional examples (default) | a top-level kids folder beside imperative, pure and functional.
|
|
69
|
+
- Shared code: each game is one self-contained file, as Orb Run is (default) | a small shared kids helper module for the camera and the host shell.
|
|
70
|
+
- How taps are read: pop as soon as the finger touches (default, because small children tap and do not click) | the usual press-and-release click.
|
|
71
|
+
- Platformer controls: arrows plus Space to jump (default) | arrows only, with Up to jump.
|
|
72
|
+
- Letter Bubbles letters: capital letters, starting with A to E and adding more every few pops (default) | the whole alphabet from the start.
|
|
73
|
+
|
|
74
|
+
## Risks
|
|
75
|
+
- **The list fix touches the compiler, which the open compiler-cleanup run is also changing.** We keep the fix small and local, with a regression test, and leave rebasing to whichever run lands second.
|
|
76
|
+
- **More compiler gaps may turn up while writing the games.** Each one is noted in the backlog. A game works around it only when the fix would be large, and the workaround is called out in the final report.
|
|
77
|
+
- **Rectangles do not rotate in the functional renderer.** The games use circles, lines and upright rectangles, and the frog's and rocket's turning is shown with circles and lines.
|
|
78
|
+
- **Text width is only estimated, so centred text may sit slightly off.** Text is kept short and checked in the browser.
|
|
79
|
+
- **Hoppy Frog's collision is the hardest logic.** It gets the most tests: landing, walking off an edge, hitting a pad from below, and respawning.
|
|
80
|
+
|
|
81
|
+
## Out of scope
|
|
82
|
+
- Images or sprite art drawn for these games (they use shapes, like the drafts).
|
|
83
|
+
- Saving progress or high scores, menus, a game picker, settings, and voice-over (spoken colour or letter names).
|
|
84
|
+
- Touch-specific gestures beyond a tap (no drag or swipe).
|
|
85
|
+
- Changes to the shared functional scene library, unless a game is truly blocked.
|
|
86
|
+
|
|
87
|
+
## How we will know it works
|
|
88
|
+
- `plum test` passes for the whole kids folder and for the existing examples.
|
|
89
|
+
- The headless check runs every kids game for 300 frames with no error.
|
|
90
|
+
- In the browser, each game: Balloon Pop pops a balloon on tap. Bunny Hop collects carrots. Letter Bubbles pops on the right key and wiggles on a wrong one. Caterpillar grows and wraps. Color Piano plays and grows its tune. Star Rocket collects stars and survives clouds. Hoppy Frog jumps, lands and respawns.
|
|
91
|
+
- The list-length regression test fails before the fix and passes after it, and the whole compiler test suite still passes.
|
|
92
|
+
|
|
93
|
+
## Rough time and cost
|
|
94
|
+
About 40 to 70 minutes and $15 to $30 (rough, until the machine plan is linted).
|
plum-examples/games/functional/kids/balloon_pop.plum
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# Balloon Pop (`plum game plum-examples/games/functional/kids/balloon_pop.plum`): a
|
|
2
|
+
# game for small children. Balloons float up the sky; tap or click one to pop it.
|
|
3
|
+
# A popped balloon bursts into dots and its colour's name floats up ("Red!"), so the
|
|
4
|
+
# child hears the pop and sees the word. Every 10 pops a "Hooray!" party with
|
|
5
|
+
# confetti. There is no way to lose and no timer.
|
|
6
|
+
#
|
|
7
|
+
# Functional style, as in ../game_state_view.plum: `State` holds the whole game,
|
|
8
|
+
# `Msg` is everything that can happen to it, `update(msg)` returns the next `State`
|
|
9
|
+
# and `render()` returns a fresh `SceneNode[Msg]` picture every frame. Each balloon
|
|
10
|
+
# is a `Clickable` carrying its own `Pop(id)` message, so a pointer press finds the
|
|
11
|
+
# message under it in the picture (`hitId`, then `msgOf`) and sends it to `update`.
|
|
12
|
+
# A press pops at once: small children tap, they do not click.
|
|
13
|
+
module balloon_pop
|
|
14
|
+
import std/Str
|
|
15
|
+
import std/Int
|
|
16
|
+
import std/Float
|
|
17
|
+
import std/Slice
|
|
18
|
+
import std/List
|
|
19
|
+
import std/Math
|
|
20
|
+
import std/Random
|
|
21
|
+
import std/game/Vec2
|
|
22
|
+
import std/game/Color
|
|
23
|
+
import std/game/Game
|
|
24
|
+
import std/game/functional/FunctionalScene
|
|
25
|
+
import std/game/functional/Anim
|
|
26
|
+
import std/game/Input
|
|
27
|
+
import std/Io
|
|
28
|
+
|
|
29
|
+
primitive Rules =
|
|
30
|
+
ArenaW = 800.0
|
|
31
|
+
ArenaH = 600.0
|
|
32
|
+
Count = 6
|
|
33
|
+
Radius = 40.0
|
|
34
|
+
MinSpeed = 35.0
|
|
35
|
+
MaxSpeed = 70.0
|
|
36
|
+
PopTime = 0.6
|
|
37
|
+
PartyEvery = 10
|
|
38
|
+
PartyTime = 2.5
|
|
39
|
+
|
|
40
|
+
primitive Id =
|
|
41
|
+
Balloon = 100
|
|
42
|
+
|
|
43
|
+
# The balloon colours a child can name.
|
|
44
|
+
record Hue(name: Str, color: Color) =
|
|
45
|
+
fun palette() -> List[Hue] =
|
|
46
|
+
List(
|
|
47
|
+
Hue(name: "Red", color: Color.rgb(0.93, 0.2, 0.22)),
|
|
48
|
+
Hue(name: "Blue", color: Color.rgb(0.2, 0.45, 0.95)),
|
|
49
|
+
Hue(name: "Yellow", color: Color.rgb(1.0, 0.85, 0.15)),
|
|
50
|
+
Hue(name: "Green", color: Color.rgb(0.25, 0.75, 0.3)),
|
|
51
|
+
Hue(name: "Orange", color: Color.rgb(1.0, 0.55, 0.1)),
|
|
52
|
+
Hue(name: "Purple", color: Color.rgb(0.6, 0.3, 0.85)),
|
|
53
|
+
Hue(name: "Pink", color: Color.rgb(1.0, 0.5, 0.75))
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
fun of(i: Int) -> Hue =
|
|
57
|
+
hues := Hue.palette()
|
|
58
|
+
hues.at(i % hues.length())
|
|
59
|
+
|
|
60
|
+
fun count() -> Int =
|
|
61
|
+
Hue.palette().length()
|
|
62
|
+
|
|
63
|
+
# --- small pure helpers ---
|
|
64
|
+
|
|
65
|
+
fun nothing() -> SceneNode[Msg] =
|
|
66
|
+
Group(transform: Identity, children: Nil)
|
|
67
|
+
|
|
68
|
+
# Text centred on `cx` (the host font is about 0.6 of its size wide per letter).
|
|
69
|
+
fun centred(s: Str, cx: Float, y: Float, size: Float, color: Color) -> SceneNode[Msg] =
|
|
70
|
+
Text(text: s, x: cx - size * 0.3 * Float(s.length()), y: y, size: size, color: color)
|
|
71
|
+
|
|
72
|
+
# Grows in, then pulses gently.
|
|
73
|
+
fun partyAnim() -> Anim =
|
|
74
|
+
Seq(
|
|
75
|
+
steps: List(
|
|
76
|
+
ScaleTo(sx: 1.4, sy: 1.4, secs: 0.25),
|
|
77
|
+
Forever(anim: Seq(steps: List(ScaleTo(sx: 0.85, sy: 0.85, secs: 0.4), ScaleTo(sx: 1.0 / 0.85, sy: 1.0 / 0.85, secs: 0.4))))
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# One balloon. `poppedAt` is the clock time it was popped, or negative while whole.
|
|
82
|
+
record Balloon(id: Int, x: Float, y: Float, speed: Float, hue: Int, phase: Float, poppedAt: Float) =
|
|
83
|
+
|
|
84
|
+
# A fresh balloon below the bottom edge, `depth` further down so they do not
|
|
85
|
+
# all arrive together.
|
|
86
|
+
fun spawn(id: Int, rng: Random, depth: Float) -> Spawned =
|
|
87
|
+
rx := rng.nextFloat()
|
|
88
|
+
rs := rx.rng.nextFloat()
|
|
89
|
+
rh := rs.rng.nextIntBelow(Hue.count())
|
|
90
|
+
x := 60.0 + rx.value * {Rules.ArenaW - 120.0}
|
|
91
|
+
speed := Rules.MinSpeed + rs.value * {Rules.MaxSpeed - Rules.MinSpeed}
|
|
92
|
+
b := Balloon(id: id, x: x, y: Rules.ArenaH + Rules.Radius + depth, speed: speed, hue: rh.value, phase: rx.value * Float.Tau, poppedAt: -1.0)
|
|
93
|
+
Spawned(balloon: b, rng: rh.rng)
|
|
94
|
+
|
|
95
|
+
fun popped(self) -> Bool =
|
|
96
|
+
self.poppedAt >= 0.0
|
|
97
|
+
|
|
98
|
+
# Where the balloon is drawn: it sways side to side as it rises.
|
|
99
|
+
fun swayX(self, clock: Float) -> Float =
|
|
100
|
+
self.x + 14.0 * Math.sin(clock * 1.3 + self.phase)
|
|
101
|
+
|
|
102
|
+
# Rises while whole; once popped it stays where it burst.
|
|
103
|
+
fun update(self, dt: Float) -> Balloon =
|
|
104
|
+
if self.popped() then self else Balloon(..self, y: self.y - self.speed * dt)
|
|
105
|
+
|
|
106
|
+
# Gone off the top, or popped long enough ago that the burst has faded.
|
|
107
|
+
fun done(self, clock: Float) -> Bool =
|
|
108
|
+
self.y < 0.0 - Rules.Radius * 3.0 || {self.popped() && clock - self.poppedAt > Rules.PopTime}
|
|
109
|
+
|
|
110
|
+
fun render(self, clock: Float) -> SceneNode[Msg] =
|
|
111
|
+
if self.popped() then self.renderBurst(clock - self.poppedAt) else self.renderWhole(clock)
|
|
112
|
+
|
|
113
|
+
fun renderWhole(self, clock: Float) -> SceneNode[Msg] =
|
|
114
|
+
r := Rules.Radius
|
|
115
|
+
c := Hue.of(self.hue).color
|
|
116
|
+
Clickable(
|
|
117
|
+
id: self.id,
|
|
118
|
+
msg: Pop(id: self.id),
|
|
119
|
+
child: Group(
|
|
120
|
+
transform: Translate(dx: self.swayX(clock), dy: self.y),
|
|
121
|
+
children: List(
|
|
122
|
+
LineShape(x1: 0.0, y1: r, x2: 6.0 * Math.sin(clock * 3.0 + self.phase), y2: r * 2.6, thickness: 2.0, color: Color.rgb(0.35, 0.35, 0.35)),
|
|
123
|
+
CircleShape(cx: 0.0, cy: r + 2.0, r: 6.0, color: c),
|
|
124
|
+
CircleShape(cx: 0.0, cy: 0.0, r: r, color: c),
|
|
125
|
+
CircleShape(cx: 0.0 - r * 0.35, cy: 0.0 - r * 0.4, r: r * 0.22, color: Color.White.withAlpha(0.55))
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Eight dots fly out and fade, and the colour's name floats up.
|
|
131
|
+
fun renderBurst(self, age: Float) -> SceneNode[Msg] =
|
|
132
|
+
t := {age / Rules.PopTime}.min(1.0)
|
|
133
|
+
fade := 1.0 - t
|
|
134
|
+
hue := Hue.of(self.hue)
|
|
135
|
+
reach := 20.0 + 70.0 * t
|
|
136
|
+
Group(
|
|
137
|
+
transform: Translate(dx: self.x, dy: self.y),
|
|
138
|
+
children: Cons(
|
|
139
|
+
head: centred("{hue.name}!", 0.0, -20.0 - 60.0 * t, 40.0, Color.rgb(0.15, 0.15, 0.3).withAlpha(fade)),
|
|
140
|
+
tail: burstDots(0, reach, hue.color.withAlpha(fade))
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
fun burstDots(i: Int, reach: Float, c: Color) -> List[SceneNode[Msg]] =
|
|
145
|
+
if i >= 8
|
|
146
|
+
Nil
|
|
147
|
+
else
|
|
148
|
+
a := Float(i) * Float.Tau / 8.0
|
|
149
|
+
Cons(head: CircleShape(cx: Math.cos(a) * reach, cy: Math.sin(a) * reach, r: 8.0, color: c), tail: burstDots(i + 1, reach, c))
|
|
150
|
+
|
|
151
|
+
record Spawned(balloon: Balloon, rng: Random)
|
|
152
|
+
|
|
153
|
+
# What can happen to the game.
|
|
154
|
+
enum Msg =
|
|
155
|
+
| Tick(dt: Float)
|
|
156
|
+
| Resize(w: Float, h: Float)
|
|
157
|
+
| Event(input: Input)
|
|
158
|
+
| Pop(id: Int)
|
|
159
|
+
|
|
160
|
+
record State(balloons: List[Balloon], rng: Random, pops: Int, clock: Float, partyAt: Float, sw: Float, sh: Float) =
|
|
161
|
+
|
|
162
|
+
fun start(seed: Int, sw: Float, sh: Float) -> State =
|
|
163
|
+
State(balloons: Nil, rng: Random.seeded(seed), pops: 0, clock: 0.0, partyAt: -100.0, sw: sw, sh: sh).fill(0)
|
|
164
|
+
|
|
165
|
+
# Balloons `i` and up, spread out below the screen.
|
|
166
|
+
fun fill(self, i: Int) -> State =
|
|
167
|
+
if i >= Rules.Count
|
|
168
|
+
return self
|
|
169
|
+
sp := Balloon.spawn(Id.Balloon + i, self.rng, Float(i) * 110.0 - Rules.ArenaH * 0.6)
|
|
170
|
+
State(..self, balloons: self.balloons.append(List(sp.balloon)), rng: sp.rng).fill(i + 1)
|
|
171
|
+
|
|
172
|
+
fun update(self, msg: Msg) -> State = match msg
|
|
173
|
+
Tick(dt) => self.tick(dt)
|
|
174
|
+
Resize(w, h) => State(..self, sw: w, sh: h)
|
|
175
|
+
Event(input) => self.onInput(input)
|
|
176
|
+
Pop(id) => self.pop(id)
|
|
177
|
+
|
|
178
|
+
fun tick(self, dt: Float) -> State =
|
|
179
|
+
moved := State(..self, clock: self.clock + dt, balloons: self.balloons.map(|b| b.update(dt)))
|
|
180
|
+
moved.balloons.fold(moved, |s, b| s.renew(b))
|
|
181
|
+
|
|
182
|
+
# A balloon that is gone comes back at the bottom with a new colour.
|
|
183
|
+
fun renew(self, b: Balloon) -> State =
|
|
184
|
+
if !b.done(self.clock)
|
|
185
|
+
return self
|
|
186
|
+
sp := Balloon.spawn(b.id, self.rng, 0.0)
|
|
187
|
+
State(..self, balloons: self.balloons.map(|x| if x.id == b.id then sp.balloon else x), rng: sp.rng)
|
|
188
|
+
|
|
189
|
+
fun pop(self, id: Int) -> State =
|
|
190
|
+
hit := self.balloons.fold(false, |found, b| found || {b.id == id && !b.popped()})
|
|
191
|
+
if !hit
|
|
192
|
+
return self
|
|
193
|
+
pops := self.pops + 1
|
|
194
|
+
State(
|
|
195
|
+
..self,
|
|
196
|
+
pops: pops,
|
|
197
|
+
partyAt: if pops % Rules.PartyEvery == 0 then self.clock else self.partyAt,
|
|
198
|
+
balloons: self.balloons.map(|b| if b.id == id then Balloon(..b, x: b.swayX(self.clock), poppedAt: self.clock) else b)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
fun partying(self) -> Bool =
|
|
202
|
+
self.clock - self.partyAt < Rules.PartyTime
|
|
203
|
+
|
|
204
|
+
# A press pops the balloon under it; everything else is ignored.
|
|
205
|
+
fun onInput(self, e: Input) -> State = match e
|
|
206
|
+
PointerDown(x, y) => self.pressAt(x, y)
|
|
207
|
+
_ => self
|
|
208
|
+
|
|
209
|
+
fun pressAt(self, x: Float, y: Float) -> State =
|
|
210
|
+
view := self.render()
|
|
211
|
+
id := view.hitId(x, y) ?: return self
|
|
212
|
+
msg := view.msgOf(id) ?: return self
|
|
213
|
+
self.update(msg)
|
|
214
|
+
|
|
215
|
+
# --- render ---
|
|
216
|
+
|
|
217
|
+
fun zoom(self) -> Float =
|
|
218
|
+
{self.sw / Rules.ArenaW}.min(self.sh / Rules.ArenaH)
|
|
219
|
+
|
|
220
|
+
fun offsetX(self) -> Float =
|
|
221
|
+
{self.sw - self.zoom() * Rules.ArenaW} / 2.0
|
|
222
|
+
|
|
223
|
+
fun offsetY(self) -> Float =
|
|
224
|
+
{self.sh - self.zoom() * Rules.ArenaH} / 2.0
|
|
225
|
+
|
|
226
|
+
fun render(self) -> SceneNode[Msg] =
|
|
227
|
+
z := self.zoom()
|
|
228
|
+
Group(
|
|
229
|
+
transform: Compose(first: Translate(dx: self.offsetX(), dy: self.offsetY()), then: Scale(sx: z, sy: z)),
|
|
230
|
+
children: List(
|
|
231
|
+
renderSky(self.clock),
|
|
232
|
+
Group(transform: Identity, children: self.balloons.map(|b| b.render(self.clock))),
|
|
233
|
+
self.renderHud(),
|
|
234
|
+
self.renderParty()
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
fun renderHud(self) -> SceneNode[Msg] =
|
|
239
|
+
Group(
|
|
240
|
+
transform: Identity,
|
|
241
|
+
children: List(
|
|
242
|
+
RectShape(x: 16.0, y: 14.0, w: 170.0, h: 56.0, color: Color.White.withAlpha(0.7)),
|
|
243
|
+
CircleShape(cx: 46.0, cy: 42.0, r: 18.0, color: Hue.of(0).color),
|
|
244
|
+
Text(text: "{self.pops}", x: 80.0, y: 20.0, size: 44.0, color: Color.rgb(0.15, 0.15, 0.3))
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
fun renderParty(self) -> SceneNode[Msg] =
|
|
249
|
+
if !self.partying()
|
|
250
|
+
return nothing()
|
|
251
|
+
age := self.clock - self.partyAt
|
|
252
|
+
p := Anim.at(partyAnim(), age)
|
|
253
|
+
Group(
|
|
254
|
+
transform: Identity,
|
|
255
|
+
children: List(
|
|
256
|
+
Group(transform: Identity, children: confetti(0, age)),
|
|
257
|
+
Group(
|
|
258
|
+
transform: Compose(first: Translate(dx: Rules.ArenaW / 2.0, dy: 250.0), then: Scale(sx: p.scaleX, sy: p.scaleY)),
|
|
259
|
+
children: List(
|
|
260
|
+
centred("Hooray!", 0.0, -40.0, 72.0, Color.rgb(0.95, 0.3, 0.5)),
|
|
261
|
+
centred("{self.pops} balloons!", 0.0, 40.0, 40.0, Color.rgb(0.15, 0.15, 0.3))
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# 30 confetti squares falling from the top, `age` seconds into the party.
|
|
268
|
+
fun confetti(i: Int, age: Float) -> List[SceneNode[Msg]] =
|
|
269
|
+
if i >= 30
|
|
270
|
+
Nil
|
|
271
|
+
else
|
|
272
|
+
x := Float({i * 7919} % 800)
|
|
273
|
+
y := Float({i * 104729} % 200) - 200.0 + age * {180.0 + Float(i % 5) * 40.0}
|
|
274
|
+
wob := 10.0 * Math.sin(age * 4.0 + Float(i))
|
|
275
|
+
Cons(head: RectShape(x: x + wob, y: y, w: 12.0, h: 12.0, color: Hue.of(i).color), tail: confetti(i + 1, age))
|
|
276
|
+
|
|
277
|
+
# Sky, two drifting clouds and the grass.
|
|
278
|
+
fun renderSky(clock: Float) -> SceneNode[Msg] =
|
|
279
|
+
Group(
|
|
280
|
+
transform: Identity,
|
|
281
|
+
children: List(
|
|
282
|
+
RectShape(x: 0.0, y: 0.0, w: Rules.ArenaW, h: Rules.ArenaH, color: Color.rgb(0.55, 0.8, 1.0)),
|
|
283
|
+
RectShape(x: 0.0, y: 0.0, w: Rules.ArenaW, h: 200.0, color: Color.rgb(0.45, 0.72, 0.98)),
|
|
284
|
+
CircleShape(cx: 690.0, cy: 90.0, r: 46.0, color: Color.rgb(1.0, 0.9, 0.35)),
|
|
285
|
+
cloud(wrapX(120.0 + clock * 12.0), 130.0),
|
|
286
|
+
cloud(wrapX(520.0 + clock * 8.0), 220.0),
|
|
287
|
+
RectShape(x: 0.0, y: Rules.ArenaH - 50.0, w: Rules.ArenaW, h: 50.0, color: Color.rgb(0.4, 0.78, 0.35))
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
fun wrapX(x: Float) -> Float =
|
|
292
|
+
span := Rules.ArenaW + 200.0
|
|
293
|
+
x - span * Float(Int(x / span)) - 100.0
|
|
294
|
+
|
|
295
|
+
fun cloud(x: Float, y: Float) -> SceneNode[Msg] =
|
|
296
|
+
white := Color.White.withAlpha(0.9)
|
|
297
|
+
Group(
|
|
298
|
+
transform: Translate(dx: x, dy: y),
|
|
299
|
+
children: List(
|
|
300
|
+
CircleShape(cx: -30.0, cy: 8.0, r: 24.0, color: white),
|
|
301
|
+
CircleShape(cx: 0.0, cy: 0.0, r: 32.0, color: white),
|
|
302
|
+
CircleShape(cx: 32.0, cy: 8.0, r: 24.0, color: white)
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# --- the host shell ---
|
|
307
|
+
|
|
308
|
+
record World(mut state: State, mut held: Held, popSound: Int, partySound: Int, musicSound: Int)
|
|
309
|
+
|
|
310
|
+
export fun init(io: Io) -> World =
|
|
311
|
+
w := World(
|
|
312
|
+
state: State.start(io.randomSeed(), Rules.ArenaW, Rules.ArenaH),
|
|
313
|
+
held: Held.none(),
|
|
314
|
+
popSound: io.soundLoad("pickup.wav"),
|
|
315
|
+
partySound: io.soundLoad("gameover.wav"),
|
|
316
|
+
musicSound: io.soundLoad("music.wav")
|
|
317
|
+
)
|
|
318
|
+
io.soundLoop(w.musicSound, 0.2)
|
|
319
|
+
w
|
|
320
|
+
|
|
321
|
+
# Polls the host once, sends every input to the state, then one tick of time;
|
|
322
|
+
# plays a pop for each new pop and a fanfare when a party starts.
|
|
323
|
+
export fun update(io: Io, w: World, dt: Float) =
|
|
324
|
+
polled := Input.poll(io, w.held)
|
|
325
|
+
w.held = polled.held
|
|
326
|
+
s := w.state.update(Resize(w: io.screenWidth(), h: io.screenHeight()))
|
|
327
|
+
before := s
|
|
328
|
+
for i := range polled.inputs.length()
|
|
329
|
+
s = s.update(Event(input: polled.inputs.at(i)))
|
|
330
|
+
if s.pops > before.pops
|
|
331
|
+
io.soundPlay(w.popSound, 0.9)
|
|
332
|
+
if s.partyAt != before.partyAt
|
|
333
|
+
io.soundPlay(w.partySound, 0.6)
|
|
334
|
+
w.state = s.update(Tick(dt: dt))
|
|
335
|
+
|
|
336
|
+
export fun draw(io: Io, w: World) =
|
|
337
|
+
io.clear(Color.rgb(0.55, 0.8, 1.0))
|
|
338
|
+
w.state.render().draw(io)
|
|
339
|
+
|
|
340
|
+
# --- tests ---
|
|
341
|
+
|
|
342
|
+
fun newState() -> State =
|
|
343
|
+
State.start(3, Rules.ArenaW, Rules.ArenaH)
|
|
344
|
+
|
|
345
|
+
# A state whose first balloon is still and in the middle of the screen.
|
|
346
|
+
fun withCentred(s: State) -> State =
|
|
347
|
+
State(..s, balloons: s.balloons.map(|b| if b.id == Id.Balloon then Balloon(..b, x: 400.0, y: 300.0, speed: 0.0, phase: 0.0) else b))
|
|
348
|
+
|
|
349
|
+
fun first(s: State) -> Balloon =
|
|
350
|
+
s.balloons.at(0)
|
|
351
|
+
|
|
352
|
+
test "a new game has its balloons below the screen and no pops"
|
|
353
|
+
s := newState()
|
|
354
|
+
assert s.balloons.length() == Rules.Count
|
|
355
|
+
assert s.pops == 0
|
|
356
|
+
assert s.balloons.fold(true, |ok, b| ok && b.y > Rules.ArenaH * 0.4)
|
|
357
|
+
|
|
358
|
+
test "balloons rise"
|
|
359
|
+
s := newState()
|
|
360
|
+
t := s.update(Tick(dt: 1.0))
|
|
361
|
+
assert first(t).y < first(s).y
|
|
362
|
+
assert first(s).y == newState().balloons.at(0).y
|
|
363
|
+
|
|
364
|
+
test "pressing a balloon pops it and counts"
|
|
365
|
+
s := withCentred(newState())
|
|
366
|
+
t := s.update(Event(input: PointerDown(x: 400.0, y: 300.0)))
|
|
367
|
+
assert t.pops == 1
|
|
368
|
+
assert first(t).popped()
|
|
369
|
+
again := t.update(Event(input: PointerDown(x: 400.0, y: 300.0)))
|
|
370
|
+
assert again.pops == 1
|
|
371
|
+
|
|
372
|
+
test "pressing the sky does nothing"
|
|
373
|
+
s := withCentred(newState())
|
|
374
|
+
t := s.update(Event(input: PointerDown(x: 50.0, y: 560.0)))
|
|
375
|
+
assert t.pops == 0
|
|
376
|
+
|
|
377
|
+
test "a popped balloon comes back at the bottom"
|
|
378
|
+
s := withCentred(newState()).update(Pop(id: Id.Balloon))
|
|
379
|
+
t := s.update(Tick(dt: Rules.PopTime + 0.1))
|
|
380
|
+
assert !first(t).popped()
|
|
381
|
+
assert first(t).y > Rules.ArenaH
|
|
382
|
+
|
|
383
|
+
test "every tenth pop starts a party that ends"
|
|
384
|
+
s := newState()
|
|
385
|
+
for i := range 9
|
|
386
|
+
s = State(..s, pops: s.pops + 1)
|
|
387
|
+
assert !s.partying()
|
|
388
|
+
t := withCentred(s).update(Pop(id: Id.Balloon))
|
|
389
|
+
assert t.pops == 10
|
|
390
|
+
assert t.partying()
|
|
391
|
+
u := t.update(Tick(dt: Rules.PartyTime + 0.1))
|
|
392
|
+
assert !u.partying()
|
|
393
|
+
|
|
394
|
+
test "the press is found through the camera"
|
|
395
|
+
s := withCentred(newState()).update(Resize(w: 1600.0, h: 600.0))
|
|
396
|
+
miss := s.update(Event(input: PointerDown(x: 400.0, y: 300.0)))
|
|
397
|
+
assert miss.pops == 0
|
|
398
|
+
hit := s.update(Event(input: PointerDown(x: 800.0, y: 300.0)))
|
|
399
|
+
assert hit.pops == 1
|
|
400
|
+
|
|
401
|
+
test "the same seed gives the same balloons"
|
|
402
|
+
a := State.start(9, Rules.ArenaW, Rules.ArenaH)
|
|
403
|
+
b := State.start(9, Rules.ArenaW, Rules.ArenaH)
|
|
404
|
+
assert first(a).x == first(b).x
|
|
405
|
+
assert first(a).hue == first(b).hue
|