plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
e465385
— Peter John
2026-11-03T12:10:24+05:30
examples: Orb Run World functions become World methods
- plum-examples/game.plum +182 -176
- plum-examples/game_scene.plum +202 -196
plum-examples/game.plum
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
# the mines. Three lives; Enter or R restarts after the game is over. The music
|
|
4
4
|
# loops from `init`; orbs, mines and the end of a run each play a sound.
|
|
5
5
|
#
|
|
6
|
-
# All rules are
|
|
6
|
+
# All rules are methods of `World` (plus a few free helpers) that never call the host, so
|
|
7
|
-
# `test` blocks below run under `plum test`; only `init`, `update` and `draw`
|
|
7
|
+
# the `test` blocks below run under `plum test`; only `init`, `update` and `draw`
|
|
8
|
-
# talk to the host.
|
|
8
|
+
# (and `startMusic`, `sprite`) talk to the host.
|
|
9
9
|
module game
|
|
10
10
|
import std/Str
|
|
11
11
|
import std/Int
|
|
@@ -50,7 +50,163 @@ record World(
|
|
|
50
50
|
pickupSound: Int,
|
|
51
51
|
hitSound: Int,
|
|
52
52
|
overSound: Int,
|
|
53
|
-
musicSound: Int)
|
|
53
|
+
musicSound: Int) =
|
|
54
|
+
|
|
55
|
+
fun create(seed: Int, sheet: Int) -> World =
|
|
56
|
+
rng := Random(seed: seed)
|
|
57
|
+
ship := Body(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, angle: 0.0)
|
|
58
|
+
w := World(
|
|
59
|
+
ship: ship, orbs: List[Body](), mines: List[Body](), rng: rng, score: 0, best: 0,
|
|
60
|
+
lives: Rules.StartLives, hurt: 0.0, clock: 0.0, over: false, sheet: sheet,
|
|
61
|
+
pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
|
|
62
|
+
for i := range Rules.OrbCount
|
|
63
|
+
w.orbs.push(spawn(rng, ship, 120.0, 40.0))
|
|
64
|
+
for i := range mineCount(0)
|
|
65
|
+
w.mines.push(spawn(rng, ship, 220.0, 90.0))
|
|
66
|
+
w
|
|
67
|
+
|
|
68
|
+
# A world with the orbs and mines moved out of the way.
|
|
69
|
+
fun empty() -> World =
|
|
70
|
+
w := World.create(7, -1)
|
|
71
|
+
for i := range w.orbs.length()
|
|
72
|
+
w.orbs.at(i).x = 5000.0
|
|
73
|
+
w.orbs.at(i).vx = 0.0
|
|
74
|
+
w.orbs.at(i).vy = 0.0
|
|
75
|
+
for i := range w.mines.length()
|
|
76
|
+
w.mines.at(i).x = 5000.0
|
|
77
|
+
w.mines.at(i).vx = 0.0
|
|
78
|
+
w.mines.at(i).vy = 0.0
|
|
79
|
+
w
|
|
80
|
+
|
|
81
|
+
# Starts a fresh game, keeping the best score and the random stream.
|
|
82
|
+
fun restart(self) =
|
|
83
|
+
self.best = self.best.max(self.score)
|
|
84
|
+
self.score = 0
|
|
85
|
+
self.lives = Rules.StartLives
|
|
86
|
+
self.hurt = 0.0
|
|
87
|
+
self.over = false
|
|
88
|
+
self.ship.x = Rules.ArenaW / 2.0
|
|
89
|
+
self.ship.y = Rules.ArenaH / 2.0
|
|
90
|
+
self.ship.vx = 0.0
|
|
91
|
+
self.ship.vy = 0.0
|
|
92
|
+
for i := range self.orbs.length()
|
|
93
|
+
moved := spawn(self.rng, self.ship, 120.0, 40.0)
|
|
94
|
+
self.orbs.at(i).x = moved.x
|
|
95
|
+
self.orbs.at(i).y = moved.y
|
|
96
|
+
self.orbs.at(i).vx = moved.vx
|
|
97
|
+
self.orbs.at(i).vy = moved.vy
|
|
98
|
+
while self.mines.length() > mineCount(0)
|
|
99
|
+
_ := self.mines.pop()!
|
|
100
|
+
for i := range self.mines.length()
|
|
101
|
+
moved := spawn(self.rng, self.ship, 220.0, 90.0)
|
|
102
|
+
self.mines.at(i).x = moved.x
|
|
103
|
+
self.mines.at(i).y = moved.y
|
|
104
|
+
self.mines.at(i).vx = moved.vx
|
|
105
|
+
self.mines.at(i).vy = moved.vy
|
|
106
|
+
|
|
107
|
+
# One simulation step. Does nothing once the game is over.
|
|
108
|
+
fun step(self, ax: Float, ay: Float, dt: Float) =
|
|
109
|
+
self.clock = self.clock + dt
|
|
110
|
+
if self.over
|
|
111
|
+
return
|
|
112
|
+
self.hurt = {self.hurt - dt}.max(0.0)
|
|
113
|
+
steer(self.ship, ax, ay, dt)
|
|
114
|
+
for i := range self.orbs.length()
|
|
115
|
+
orb := self.orbs.at(i)
|
|
116
|
+
drift(orb, Rules.OrbRadius, dt)
|
|
117
|
+
if overlaps(self.ship, Rules.ShipRadius, orb, Rules.OrbRadius)
|
|
118
|
+
self.score = self.score + Rules.OrbPoints
|
|
119
|
+
moved := spawn(self.rng, self.ship, 120.0, 40.0)
|
|
120
|
+
orb.x = moved.x
|
|
121
|
+
orb.y = moved.y
|
|
122
|
+
orb.vx = moved.vx
|
|
123
|
+
orb.vy = moved.vy
|
|
124
|
+
while self.mines.length() < mineCount(self.score)
|
|
125
|
+
self.mines.push(spawn(self.rng, self.ship, 220.0, 90.0))
|
|
126
|
+
for i := range self.mines.length()
|
|
127
|
+
mine := self.mines.at(i)
|
|
128
|
+
drift(mine, Rules.MineRadius, dt)
|
|
129
|
+
mine.angle = mine.angle + 2.0 * dt
|
|
130
|
+
if self.hurt <= 0.0 && overlaps(self.ship, Rules.ShipRadius, mine, Rules.MineRadius)
|
|
131
|
+
self.lives = self.lives - 1
|
|
132
|
+
self.hurt = Rules.HurtTime
|
|
133
|
+
if self.lives <= 0
|
|
134
|
+
self.over = true
|
|
135
|
+
self.best = self.best.max(self.score)
|
|
136
|
+
|
|
137
|
+
# Restarts the music loop from the top.
|
|
138
|
+
fun startMusic(self) =
|
|
139
|
+
soundStop(self.musicSound)
|
|
140
|
+
soundLoop(self.musicSound, musicVolume())
|
|
141
|
+
|
|
142
|
+
# Draws frame `frame` of sheet row `row`, centred on (x, y), at `Rules.Zoom` times its size.
|
|
143
|
+
fun sprite(self, row: Int, frame: Int, x: Float, y: Float, angle: Float, tint: Color) =
|
|
144
|
+
size := Rules.Cell * Rules.Zoom
|
|
145
|
+
drawTextureEx(
|
|
146
|
+
self.sheet, Float(frame) * Rules.Cell, Float(row) * Rules.Cell, Rules.Cell, Rules.Cell,
|
|
147
|
+
x - size / 2.0, y - size / 2.0, size, size, angle, false, tint)
|
|
148
|
+
|
|
149
|
+
fun drawStars(self) =
|
|
150
|
+
for i := range 40
|
|
151
|
+
sx := Float({i * 7919} % 800)
|
|
152
|
+
sy := Float({i * 104729} % 600)
|
|
153
|
+
twinkle := 0.25 + 0.2 * Math.sin(self.clock * 2.0 + Float(i))
|
|
154
|
+
drawCircle(sx, sy, 1.0 + Float(i % 3) * 0.5, Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
|
|
155
|
+
|
|
156
|
+
fun update(self, dt: Float) =
|
|
157
|
+
if self.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
|
|
158
|
+
self.restart()
|
|
159
|
+
self.startMusic()
|
|
160
|
+
left := isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
|
|
161
|
+
right := isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
|
|
162
|
+
up := isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
|
|
163
|
+
down := isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
|
|
164
|
+
score := self.score
|
|
165
|
+
lives := self.lives
|
|
166
|
+
self.step(axis(left, right), axis(up, down), dt)
|
|
167
|
+
if self.score > score
|
|
168
|
+
soundPlay(self.pickupSound, 0.8)
|
|
169
|
+
if self.lives < lives
|
|
170
|
+
if self.over
|
|
171
|
+
soundStop(self.musicSound)
|
|
172
|
+
soundPlay(self.overSound, 1.0)
|
|
173
|
+
else
|
|
174
|
+
soundPlay(self.hitSound, 1.0)
|
|
175
|
+
|
|
176
|
+
fun draw(self) =
|
|
177
|
+
sw := screenWidth()
|
|
178
|
+
sh := screenHeight()
|
|
179
|
+
zoom := {sw / Rules.ArenaW}.min(sh / Rules.ArenaH)
|
|
180
|
+
clear(Color.rgb(0.04, 0.05, 0.1))
|
|
181
|
+
setCamera(0.0 - {sw / zoom - Rules.ArenaW} / 2.0, 0.0 - {sh / zoom - Rules.ArenaH} / 2.0, zoom)
|
|
182
|
+
drawRect(0.0, 0.0, Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16))
|
|
183
|
+
self.drawStars()
|
|
184
|
+
for i := range self.orbs.length()
|
|
185
|
+
orb := self.orbs.at(i)
|
|
186
|
+
blendMode(Blend.Add)
|
|
187
|
+
drawCircle(orb.x, orb.y, 26.0, Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5))
|
|
188
|
+
drawCircle(orb.x, orb.y, 17.0, Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5))
|
|
189
|
+
blendMode(Blend.Normal)
|
|
190
|
+
self.sprite(1, frameOf(self.clock + Float(i) * 0.1, 4, 8.0), orb.x, orb.y, 0.0, Color.White)
|
|
191
|
+
for i := range self.mines.length()
|
|
192
|
+
mine := self.mines.at(i)
|
|
193
|
+
self.sprite(2, frameOf(self.clock, 4, 4.0), mine.x, mine.y, mine.angle, Color.White)
|
|
194
|
+
blink := self.hurt > 0.0 && frameOf(self.clock, 2, 10.0) == 0
|
|
195
|
+
if !self.over && !blink
|
|
196
|
+
flame := frameOf(self.clock, 4, 14.0)
|
|
197
|
+
drawCircle(self.ship.x, self.ship.y, 17.0, Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12))
|
|
198
|
+
self.sprite(0, flame, self.ship.x, self.ship.y, self.ship.angle, Color.White)
|
|
199
|
+
drawText("Score {self.score}", 16.0, 12.0, 28.0, Color.White)
|
|
200
|
+
drawText("Best {self.best.max(self.score)}", 16.0, 44.0, 20.0, Color.Yellow)
|
|
201
|
+
for i := range self.lives
|
|
202
|
+
drawTextureEx(
|
|
203
|
+
self.sheet, 0.0, 0.0, Rules.Cell, Rules.Cell,
|
|
204
|
+
Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 32.0, 0.0, false, Color.White)
|
|
205
|
+
if self.over
|
|
206
|
+
drawRect(0.0, 240.0, Rules.ArenaW, 120.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6))
|
|
207
|
+
drawText("Game over", 300.0, 258.0, 48.0, Color.Red)
|
|
208
|
+
drawText("Press Enter to play again", 270.0, 316.0, 24.0, Color.White)
|
|
209
|
+
resetCamera()
|
|
54
210
|
|
|
55
211
|
fun distance(a: Body, b: Body) -> Float =
|
|
56
212
|
Math.hypot(a.x - b.x, a.y - b.y)
|
|
@@ -83,45 +239,6 @@ fun spawn(rng: Random, avoid: Body, gap: Float, speed: Float) -> Body =
|
|
|
83
239
|
fun mineCount(score: Int) -> Int =
|
|
84
240
|
{3 + score / 50}.min(8)
|
|
85
241
|
|
|
86
|
-
fun newWorld(seed: Int, sheet: Int) -> World =
|
|
87
|
-
rng := Random(seed: seed)
|
|
88
|
-
ship := Body(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, angle: 0.0)
|
|
89
|
-
w := World(
|
|
90
|
-
ship: ship, orbs: List[Body](), mines: List[Body](), rng: rng, score: 0, best: 0,
|
|
91
|
-
lives: Rules.StartLives, hurt: 0.0, clock: 0.0, over: false, sheet: sheet,
|
|
92
|
-
pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
|
|
93
|
-
for i := range Rules.OrbCount
|
|
94
|
-
w.orbs.push(spawn(rng, ship, 120.0, 40.0))
|
|
95
|
-
for i := range mineCount(0)
|
|
96
|
-
w.mines.push(spawn(rng, ship, 220.0, 90.0))
|
|
97
|
-
w
|
|
98
|
-
|
|
99
|
-
# Starts a fresh game, keeping the best score and the random stream.
|
|
100
|
-
fun restart(w: World) =
|
|
101
|
-
w.best = w.best.max(w.score)
|
|
102
|
-
w.score = 0
|
|
103
|
-
w.lives = Rules.StartLives
|
|
104
|
-
w.hurt = 0.0
|
|
105
|
-
w.over = false
|
|
106
|
-
w.ship.x = Rules.ArenaW / 2.0
|
|
107
|
-
w.ship.y = Rules.ArenaH / 2.0
|
|
108
|
-
w.ship.vx = 0.0
|
|
109
|
-
w.ship.vy = 0.0
|
|
110
|
-
for i := range w.orbs.length()
|
|
111
|
-
moved := spawn(w.rng, w.ship, 120.0, 40.0)
|
|
112
|
-
w.orbs.at(i).x = moved.x
|
|
113
|
-
w.orbs.at(i).y = moved.y
|
|
114
|
-
w.orbs.at(i).vx = moved.vx
|
|
115
|
-
w.orbs.at(i).vy = moved.vy
|
|
116
|
-
while w.mines.length() > mineCount(0)
|
|
117
|
-
_ := w.mines.pop()!
|
|
118
|
-
for i := range w.mines.length()
|
|
119
|
-
moved := spawn(w.rng, w.ship, 220.0, 90.0)
|
|
120
|
-
w.mines.at(i).x = moved.x
|
|
121
|
-
w.mines.at(i).y = moved.y
|
|
122
|
-
w.mines.at(i).vx = moved.vx
|
|
123
|
-
w.mines.at(i).vy = moved.vy
|
|
124
|
-
|
|
125
242
|
# Accelerates the ship by input axes (-1..1), applies drag and keeps it in the arena.
|
|
126
243
|
fun steer(ship: Body, ax: Float, ay: Float, dt: Float) =
|
|
127
244
|
ship.vx = ship.vx + ax * Rules.Thrust * dt
|
|
@@ -158,53 +275,18 @@ fun drift(b: Body, r: Float, dt: Float) =
|
|
|
158
275
|
b.vy = 0.0 - b.vy
|
|
159
276
|
b.y = b.y.max(r).min(Rules.ArenaH - r)
|
|
160
277
|
|
|
161
|
-
# One simulation step. Does nothing once the game is over.
|
|
162
|
-
fun step(w: World, ax: Float, ay: Float, dt: Float) =
|
|
163
|
-
w.clock = w.clock + dt
|
|
164
|
-
if w.over
|
|
165
|
-
return
|
|
166
|
-
w.hurt = {w.hurt - dt}.max(0.0)
|
|
167
|
-
steer(w.ship, ax, ay, dt)
|
|
168
|
-
for i := range w.orbs.length()
|
|
169
|
-
orb := w.orbs.at(i)
|
|
170
|
-
drift(orb, Rules.OrbRadius, dt)
|
|
171
|
-
if overlaps(w.ship, Rules.ShipRadius, orb, Rules.OrbRadius)
|
|
172
|
-
w.score = w.score + Rules.OrbPoints
|
|
173
|
-
moved := spawn(w.rng, w.ship, 120.0, 40.0)
|
|
174
|
-
orb.x = moved.x
|
|
175
|
-
orb.y = moved.y
|
|
176
|
-
orb.vx = moved.vx
|
|
177
|
-
orb.vy = moved.vy
|
|
178
|
-
while w.mines.length() < mineCount(w.score)
|
|
179
|
-
w.mines.push(spawn(w.rng, w.ship, 220.0, 90.0))
|
|
180
|
-
for i := range w.mines.length()
|
|
181
|
-
mine := w.mines.at(i)
|
|
182
|
-
drift(mine, Rules.MineRadius, dt)
|
|
183
|
-
mine.angle = mine.angle + 2.0 * dt
|
|
184
|
-
if w.hurt <= 0.0 && overlaps(w.ship, Rules.ShipRadius, mine, Rules.MineRadius)
|
|
185
|
-
w.lives = w.lives - 1
|
|
186
|
-
w.hurt = Rules.HurtTime
|
|
187
|
-
if w.lives <= 0
|
|
188
|
-
w.over = true
|
|
189
|
-
w.best = w.best.max(w.score)
|
|
190
|
-
|
|
191
278
|
# --- host glue: input, drawing ---
|
|
192
279
|
|
|
193
280
|
fun musicVolume() -> Float =
|
|
194
281
|
0.3
|
|
195
282
|
|
|
196
|
-
# Restarts the music loop from the top.
|
|
197
|
-
fun startMusic(w: World) =
|
|
198
|
-
soundStop(w.musicSound)
|
|
199
|
-
soundLoop(w.musicSound, musicVolume())
|
|
200
|
-
|
|
201
283
|
export fun init() -> World =
|
|
202
|
-
w :=
|
|
284
|
+
w := World.create(2024, textureLoad("sprites.png"))
|
|
203
285
|
w.pickupSound = soundLoad("pickup.wav")
|
|
204
286
|
w.hitSound = soundLoad("hit.wav")
|
|
205
287
|
w.overSound = soundLoad("gameover.wav")
|
|
206
288
|
w.musicSound = soundLoad("music.wav")
|
|
207
|
-
startMusic(
|
|
289
|
+
w.startMusic()
|
|
208
290
|
w
|
|
209
291
|
|
|
210
292
|
fun axis(negative: Bool, positive: Bool) -> Float =
|
|
@@ -216,92 +298,16 @@ fun axis(negative: Bool, positive: Bool) -> Float =
|
|
|
216
298
|
0.0
|
|
217
299
|
|
|
218
300
|
export fun update(w: World, dt: Float) =
|
|
219
|
-
if w.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
|
|
220
|
-
|
|
301
|
+
w.update(dt)
|
|
221
|
-
startMusic(w)
|
|
222
|
-
left := isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
|
|
223
|
-
right := isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
|
|
224
|
-
up := isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
|
|
225
|
-
down := isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
|
|
226
|
-
score := w.score
|
|
227
|
-
lives := w.lives
|
|
228
|
-
step(w, axis(left, right), axis(up, down), dt)
|
|
229
|
-
if w.score > score
|
|
230
|
-
soundPlay(w.pickupSound, 0.8)
|
|
231
|
-
if w.lives < lives
|
|
232
|
-
if w.over
|
|
233
|
-
soundStop(w.musicSound)
|
|
234
|
-
soundPlay(w.overSound, 1.0)
|
|
235
|
-
else
|
|
236
|
-
soundPlay(w.hitSound, 1.0)
|
|
237
|
-
|
|
238
|
-
# Draws frame `frame` of sheet row `row`, centred on (x, y), at `Rules.Zoom` times its size.
|
|
239
|
-
fun sprite(w: World, row: Int, frame: Int, x: Float, y: Float, angle: Float, tint: Color) =
|
|
240
|
-
size := Rules.Cell * Rules.Zoom
|
|
241
|
-
drawTextureEx(
|
|
242
|
-
w.sheet, Float(frame) * Rules.Cell, Float(row) * Rules.Cell, Rules.Cell, Rules.Cell,
|
|
243
|
-
x - size / 2.0, y - size / 2.0, size, size, angle, false, tint)
|
|
244
|
-
|
|
245
|
-
fun drawStars(w: World) =
|
|
246
|
-
for i := range 40
|
|
247
|
-
sx := Float({i * 7919} % 800)
|
|
248
|
-
sy := Float({i * 104729} % 600)
|
|
249
|
-
twinkle := 0.25 + 0.2 * Math.sin(w.clock * 2.0 + Float(i))
|
|
250
|
-
drawCircle(sx, sy, 1.0 + Float(i % 3) * 0.5, Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
|
|
251
302
|
|
|
252
303
|
export fun draw(w: World) =
|
|
253
|
-
sw := screenWidth()
|
|
254
|
-
sh := screenHeight()
|
|
255
|
-
zoom := {sw / Rules.ArenaW}.min(sh / Rules.ArenaH)
|
|
256
|
-
clear(Color.rgb(0.04, 0.05, 0.1))
|
|
257
|
-
setCamera(0.0 - {sw / zoom - Rules.ArenaW} / 2.0, 0.0 - {sh / zoom - Rules.ArenaH} / 2.0, zoom)
|
|
258
|
-
drawRect(0.0, 0.0, Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16))
|
|
259
|
-
|
|
304
|
+
w.draw()
|
|
260
|
-
for i := range w.orbs.length()
|
|
261
|
-
orb := w.orbs.at(i)
|
|
262
|
-
blendMode(Blend.Add)
|
|
263
|
-
drawCircle(orb.x, orb.y, 26.0, Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5))
|
|
264
|
-
drawCircle(orb.x, orb.y, 17.0, Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5))
|
|
265
|
-
blendMode(Blend.Normal)
|
|
266
|
-
sprite(w, 1, frameOf(w.clock + Float(i) * 0.1, 4, 8.0), orb.x, orb.y, 0.0, Color.White)
|
|
267
|
-
for i := range w.mines.length()
|
|
268
|
-
mine := w.mines.at(i)
|
|
269
|
-
sprite(w, 2, frameOf(w.clock, 4, 4.0), mine.x, mine.y, mine.angle, Color.White)
|
|
270
|
-
blink := w.hurt > 0.0 && frameOf(w.clock, 2, 10.0) == 0
|
|
271
|
-
if !w.over && !blink
|
|
272
|
-
flame := frameOf(w.clock, 4, 14.0)
|
|
273
|
-
drawCircle(w.ship.x, w.ship.y, 17.0, Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12))
|
|
274
|
-
sprite(w, 0, flame, w.ship.x, w.ship.y, w.ship.angle, Color.White)
|
|
275
|
-
drawText("Score {w.score}", 16.0, 12.0, 28.0, Color.White)
|
|
276
|
-
drawText("Best {w.best.max(w.score)}", 16.0, 44.0, 20.0, Color.Yellow)
|
|
277
|
-
for i := range w.lives
|
|
278
|
-
drawTextureEx(
|
|
279
|
-
w.sheet, 0.0, 0.0, Rules.Cell, Rules.Cell,
|
|
280
|
-
Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 32.0, 0.0, false, Color.White)
|
|
281
|
-
if w.over
|
|
282
|
-
drawRect(0.0, 240.0, Rules.ArenaW, 120.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6))
|
|
283
|
-
drawText("Game over", 300.0, 258.0, 48.0, Color.Red)
|
|
284
|
-
drawText("Press Enter to play again", 270.0, 316.0, 24.0, Color.White)
|
|
285
|
-
resetCamera()
|
|
286
305
|
|
|
287
306
|
# --- tests ---
|
|
288
307
|
|
|
289
308
|
fun still(x: Float, y: Float) -> Body =
|
|
290
309
|
Body(x: x, y: y, vx: 0.0, vy: 0.0, angle: 0.0)
|
|
291
310
|
|
|
292
|
-
# A world with the orbs and mines moved out of the way.
|
|
293
|
-
fun emptyWorld() -> World =
|
|
294
|
-
w := newWorld(7, -1)
|
|
295
|
-
for i := range w.orbs.length()
|
|
296
|
-
w.orbs.at(i).x = 5000.0
|
|
297
|
-
w.orbs.at(i).vx = 0.0
|
|
298
|
-
w.orbs.at(i).vy = 0.0
|
|
299
|
-
for i := range w.mines.length()
|
|
300
|
-
w.mines.at(i).x = 5000.0
|
|
301
|
-
w.mines.at(i).vx = 0.0
|
|
302
|
-
w.mines.at(i).vy = 0.0
|
|
303
|
-
w
|
|
304
|
-
|
|
305
311
|
test "steering accelerates and drag slows the ship"
|
|
306
312
|
ship := still(400.0, 300.0)
|
|
307
313
|
steer(ship, 1.0, 0.0, 0.1)
|
|
@@ -327,51 +333,51 @@ test "drifters bounce off the walls"
|
|
|
327
333
|
assert b.x == Rules.ArenaW - 10.0
|
|
328
334
|
|
|
329
335
|
test "collecting an orb scores and respawns it elsewhere"
|
|
330
|
-
w :=
|
|
336
|
+
w := World.empty()
|
|
331
337
|
orb := w.orbs.at(0)
|
|
332
338
|
orb.x = w.ship.x
|
|
333
339
|
orb.y = w.ship.y
|
|
334
|
-
step(
|
|
340
|
+
w.step(0.0, 0.0, 0.016)
|
|
335
341
|
assert w.score == Rules.OrbPoints
|
|
336
342
|
assert distance(orb, w.ship) >= 120.0
|
|
337
343
|
assert orb.x < 5000.0
|
|
338
344
|
|
|
339
345
|
test "a mine costs one life, then the ship is briefly safe"
|
|
340
|
-
w :=
|
|
346
|
+
w := World.empty()
|
|
341
347
|
mine := w.mines.at(0)
|
|
342
348
|
mine.x = w.ship.x
|
|
343
349
|
mine.y = w.ship.y
|
|
344
|
-
step(
|
|
350
|
+
w.step(0.0, 0.0, 0.016)
|
|
345
351
|
assert w.lives == Rules.StartLives - 1
|
|
346
352
|
assert w.hurt > 0.0
|
|
347
|
-
step(
|
|
353
|
+
w.step(0.0, 0.0, 0.016)
|
|
348
354
|
assert w.lives == Rules.StartLives - 1
|
|
349
355
|
mine.x = 5000.0
|
|
350
356
|
for i := range 100
|
|
351
|
-
step(
|
|
357
|
+
w.step(0.0, 0.0, 0.016)
|
|
352
358
|
assert w.hurt == 0.0
|
|
353
359
|
|
|
354
360
|
test "the game ends at zero lives and then ignores steps"
|
|
355
|
-
w :=
|
|
361
|
+
w := World.empty()
|
|
356
362
|
w.lives = 1
|
|
357
363
|
w.score = 40
|
|
358
364
|
mine := w.mines.at(0)
|
|
359
365
|
mine.x = w.ship.x
|
|
360
366
|
mine.y = w.ship.y
|
|
361
|
-
step(
|
|
367
|
+
w.step(0.0, 0.0, 0.016)
|
|
362
368
|
assert w.over
|
|
363
369
|
assert w.lives == 0
|
|
364
370
|
assert w.best == 40
|
|
365
371
|
x := w.ship.x
|
|
366
|
-
step(
|
|
372
|
+
w.step(1.0, 0.0, 0.5)
|
|
367
373
|
assert w.ship.x == x
|
|
368
374
|
|
|
369
375
|
test "restart resets the run and keeps the best score"
|
|
370
|
-
w :=
|
|
376
|
+
w := World.empty()
|
|
371
377
|
w.score = 90
|
|
372
378
|
w.lives = 0
|
|
373
379
|
w.over = true
|
|
374
|
-
restart(
|
|
380
|
+
w.restart()
|
|
375
381
|
assert !w.over
|
|
376
382
|
assert w.score == 0
|
|
377
383
|
assert w.best == 90
|
|
@@ -383,21 +389,21 @@ test "more mines appear as the score grows"
|
|
|
383
389
|
assert mineCount(0) == 3
|
|
384
390
|
assert mineCount(100) == 5
|
|
385
391
|
assert mineCount(10000) == 8
|
|
386
|
-
w :=
|
|
392
|
+
w := World.empty()
|
|
387
393
|
w.score = 100
|
|
388
|
-
step(
|
|
394
|
+
w.step(0.0, 0.0, 0.016)
|
|
389
395
|
assert w.mines.length() == 5
|
|
390
396
|
|
|
391
397
|
test "the same seed plays out the same way"
|
|
392
|
-
a :=
|
|
398
|
+
a := World.create(11, -1)
|
|
393
|
-
b :=
|
|
399
|
+
b := World.create(11, -1)
|
|
394
400
|
for i := range 120
|
|
395
|
-
step(
|
|
401
|
+
a.step(1.0, -1.0, 0.016)
|
|
396
|
-
step(
|
|
402
|
+
b.step(1.0, -1.0, 0.016)
|
|
397
403
|
assert a.ship.x == b.ship.x
|
|
398
404
|
assert a.score == b.score
|
|
399
405
|
assert a.mines.at(0).x == b.mines.at(0).x
|
|
400
|
-
assert
|
|
406
|
+
assert World.create(12, -1).orbs.at(0).x != a.orbs.at(0).x
|
|
401
407
|
|
|
402
408
|
test "animation frames cycle"
|
|
403
409
|
assert frameOf(0.0, 4, 8.0) == 0
|
plum-examples/game_scene.plum
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# (its sprite frames), and the world is a tree: layer Groups under `stage.root`
|
|
12
12
|
# (backdrop, stars, mines, orbs, ship, HUD), so `stage.act(dt)` and `stage.draw()` do
|
|
13
13
|
# the per-kind work and z-order is the order of the layers. Collisions, scoring,
|
|
14
|
-
# spawning, lives and restart
|
|
14
|
+
# spawning, lives and restart are methods of `World`.
|
|
15
15
|
#
|
|
16
16
|
# Prefer game.plum's immediate mode for a flat arcade game with a handful of kinds
|
|
17
17
|
# where one state record and one pure `update` are easiest to test. Prefer this
|
|
@@ -262,158 +262,192 @@ record World(
|
|
|
262
262
|
pickupSound: Int,
|
|
263
263
|
hitSound: Int,
|
|
264
264
|
overSound: Int,
|
|
265
|
-
musicSound: Int)
|
|
266
|
-
|
|
267
|
-
fun
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
#
|
|
318
|
-
fun
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
265
|
+
musicSound: Int) =
|
|
266
|
+
|
|
267
|
+
fun create(seed: Int, sheet: Int) -> World =
|
|
268
|
+
rng := Random(seed: seed)
|
|
269
|
+
stage := Stage.create()
|
|
270
|
+
ship := Ship(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, tex: sheet)
|
|
271
|
+
stars := Group()
|
|
272
|
+
for i := range 40
|
|
273
|
+
stars.add(Star.create(i))
|
|
274
|
+
mines := Group()
|
|
275
|
+
orbs := Group()
|
|
276
|
+
hud := Group()
|
|
277
|
+
scoreText := Text.create("Score 0", 28.0, Color.White)
|
|
278
|
+
scoreText.x = 16.0
|
|
279
|
+
scoreText.y = 12.0
|
|
280
|
+
bestText := Text.create("Best 0", 20.0, Color.Yellow)
|
|
281
|
+
bestText.x = 16.0
|
|
282
|
+
bestText.y = 44.0
|
|
283
|
+
hud.add(scoreText)
|
|
284
|
+
hud.add(bestText)
|
|
285
|
+
icons := List[Sprite]()
|
|
286
|
+
for i := range Rules.StartLives
|
|
287
|
+
icon := Sprite.create(sheet, 0.0, 0.0, Rules.Cell, Rules.Cell, 32.0, 32.0)
|
|
288
|
+
icon.x = Rules.ArenaW - 40.0 - Float(i) * 36.0
|
|
289
|
+
icon.y = 12.0
|
|
290
|
+
hud.add(icon)
|
|
291
|
+
icons.push(icon)
|
|
292
|
+
overlay := Group()
|
|
293
|
+
overlay.visible = false
|
|
294
|
+
overlay.add(Rect.create(Rules.ArenaW, 120.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)).at(0.0, 240.0))
|
|
295
|
+
overlay.add(Text.create("Game over", 48.0, Color.Red).at(300.0, 258.0))
|
|
296
|
+
overlay.add(Text.create("Press Enter to play again", 24.0, Color.White).at(270.0, 316.0))
|
|
297
|
+
hud.add(overlay)
|
|
298
|
+
stage.root.add(Rect.create(Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16)))
|
|
299
|
+
stage.root.add(stars)
|
|
300
|
+
stage.root.add(mines)
|
|
301
|
+
stage.root.add(orbs)
|
|
302
|
+
stage.root.add(ship)
|
|
303
|
+
stage.root.add(hud)
|
|
304
|
+
w := World(
|
|
305
|
+
stage: stage, ship: ship, mines: mines, orbs: orbs, mineList: List[Mine](), orbList: List[Orb](),
|
|
306
|
+
overlay: overlay, scoreText: scoreText, bestText: bestText, lifeIcons: icons,
|
|
307
|
+
rng: rng, score: 0, best: 0, lives: Rules.StartLives, over: false, clock: 0.0, sheet: sheet,
|
|
308
|
+
pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
|
|
309
|
+
for i := range Rules.OrbCount
|
|
310
|
+
orb := Orb.create(sheet, Float(i) * 0.1, spawn(rng, ship.x, ship.y, 120.0, 40.0))
|
|
311
|
+
orbs.add(orb)
|
|
312
|
+
w.orbList.push(orb)
|
|
313
|
+
for i := range mineCount(0)
|
|
314
|
+
w.addMine()
|
|
315
|
+
w
|
|
316
|
+
|
|
317
|
+
# A world with the orbs and mines moved out of the way.
|
|
318
|
+
fun empty() -> World =
|
|
319
|
+
w := World.create(7, -1)
|
|
320
|
+
for i := range w.orbList.length()
|
|
321
|
+
w.orbList.at(i).place(Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0))
|
|
322
|
+
for i := range w.mineList.length()
|
|
323
|
+
w.mineList.at(i).place(Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0))
|
|
324
|
+
w
|
|
325
|
+
|
|
326
|
+
# Adds one mine, far from the ship, to the mines layer.
|
|
327
|
+
fun addMine(self) =
|
|
328
|
+
mine := Mine.create(self.sheet, spawn(self.rng, self.ship.x, self.ship.y, 220.0, 90.0))
|
|
329
|
+
self.mines.add(mine)
|
|
330
|
+
self.mineList.push(mine)
|
|
331
|
+
|
|
332
|
+
# Starts a fresh game, keeping the best score and the random stream.
|
|
333
|
+
fun restart(self) =
|
|
334
|
+
self.best = self.best.max(self.score)
|
|
335
|
+
self.score = 0
|
|
336
|
+
self.lives = Rules.StartLives
|
|
337
|
+
self.over = false
|
|
338
|
+
ship := self.ship
|
|
339
|
+
ship.hurt = 0.0
|
|
340
|
+
ship.visible = true
|
|
341
|
+
ship.x = Rules.ArenaW / 2.0
|
|
342
|
+
ship.y = Rules.ArenaH / 2.0
|
|
343
|
+
ship.vx = 0.0
|
|
344
|
+
ship.vy = 0.0
|
|
345
|
+
for i := range self.orbList.length()
|
|
346
|
+
self.orbList.at(i).place(spawn(self.rng, ship.x, ship.y, 120.0, 40.0))
|
|
347
|
+
while self.mineList.length() > mineCount(0)
|
|
348
|
+
gone := self.mineList.pop()!
|
|
349
|
+
self.mines.remove(gone)
|
|
350
|
+
for i := range self.mineList.length()
|
|
351
|
+
self.mineList.at(i).place(spawn(self.rng, ship.x, ship.y, 220.0, 90.0))
|
|
352
|
+
self.sync()
|
|
353
|
+
|
|
354
|
+
# Copies the score, best score, lives and game-over panel into the HUD actors.
|
|
355
|
+
fun sync(self) =
|
|
356
|
+
self.scoreText.text = "Score {self.score}"
|
|
357
|
+
self.bestText.text = "Best {self.best.max(self.score)}"
|
|
358
|
+
for i := range self.lifeIcons.length()
|
|
359
|
+
self.lifeIcons.at(i).visible = i < self.lives
|
|
360
|
+
self.overlay.visible = self.over
|
|
361
|
+
|
|
362
|
+
# Lets the actors move (while the game runs) or only animate (once it is over).
|
|
363
|
+
fun setLive(self, live: Bool) =
|
|
364
|
+
self.ship.live = live
|
|
365
|
+
for i := range self.orbList.length()
|
|
366
|
+
self.orbList.at(i).live = live
|
|
367
|
+
for i := range self.mineList.length()
|
|
368
|
+
self.mineList.at(i).live = live
|
|
369
|
+
|
|
370
|
+
# One simulation step: every actor acts through the stage, then the rules look at
|
|
371
|
+
# the result. Once the game is over the actors only animate; nothing moves or scores.
|
|
372
|
+
fun step(self, ax: Float, ay: Float, dt: Float) =
|
|
373
|
+
self.clock = self.clock + dt
|
|
374
|
+
self.setLive(!self.over)
|
|
375
|
+
self.ship.ax = ax
|
|
376
|
+
self.ship.ay = ay
|
|
377
|
+
self.stage.act(dt)
|
|
378
|
+
if self.over
|
|
379
|
+
return
|
|
380
|
+
ship := self.ship
|
|
381
|
+
for i := range self.orbList.length()
|
|
382
|
+
orb := self.orbList.at(i)
|
|
383
|
+
if overlaps(ship.x, ship.y, Rules.ShipRadius, orb.x, orb.y, Rules.OrbRadius)
|
|
384
|
+
self.score = self.score + Rules.OrbPoints
|
|
385
|
+
orb.place(spawn(self.rng, ship.x, ship.y, 120.0, 40.0))
|
|
386
|
+
while self.mineList.length() < mineCount(self.score)
|
|
387
|
+
self.addMine()
|
|
388
|
+
for i := range self.mineList.length()
|
|
389
|
+
mine := self.mineList.at(i)
|
|
390
|
+
if ship.hurt <= 0.0 && overlaps(ship.x, ship.y, Rules.ShipRadius, mine.x, mine.y, Rules.MineRadius)
|
|
391
|
+
self.lives = self.lives - 1
|
|
392
|
+
ship.hurt = Rules.HurtTime
|
|
393
|
+
if self.lives <= 0
|
|
394
|
+
self.over = true
|
|
395
|
+
self.best = self.best.max(self.score)
|
|
396
|
+
ship.visible = false
|
|
397
|
+
self.sync()
|
|
398
|
+
|
|
399
|
+
# Fits the arena into a screen of `sw` by `sh` pixels, centred.
|
|
400
|
+
fun fit(self, sw: Float, sh: Float) =
|
|
401
|
+
zoom := {sw / Rules.ArenaW}.min(sh / Rules.ArenaH)
|
|
402
|
+
self.stage.camScale = zoom
|
|
403
|
+
self.stage.camX = 0.0 - {sw / zoom - Rules.ArenaW} / 2.0
|
|
404
|
+
self.stage.camY = 0.0 - {sh / zoom - Rules.ArenaH} / 2.0
|
|
405
|
+
|
|
406
|
+
# Restarts the music loop from the top.
|
|
407
|
+
fun startMusic(self) =
|
|
408
|
+
soundStop(self.musicSound)
|
|
409
|
+
soundLoop(self.musicSound, musicVolume())
|
|
410
|
+
|
|
411
|
+
fun update(self, dt: Float) =
|
|
412
|
+
if self.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
|
|
413
|
+
self.restart()
|
|
414
|
+
self.startMusic()
|
|
415
|
+
left := isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
|
|
416
|
+
right := isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
|
|
417
|
+
up := isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
|
|
418
|
+
down := isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
|
|
419
|
+
score := self.score
|
|
420
|
+
lives := self.lives
|
|
421
|
+
self.step(axis(left, right), axis(up, down), dt)
|
|
422
|
+
if self.score > score
|
|
423
|
+
soundPlay(self.pickupSound, 0.8)
|
|
424
|
+
if self.lives < lives
|
|
425
|
+
if self.over
|
|
426
|
+
soundStop(self.musicSound)
|
|
427
|
+
soundPlay(self.overSound, 1.0)
|
|
428
|
+
else
|
|
429
|
+
soundPlay(self.hitSound, 1.0)
|
|
430
|
+
|
|
431
|
+
fun draw(self) =
|
|
432
|
+
self.fit(screenWidth(), screenHeight())
|
|
433
|
+
clear(Color.rgb(0.04, 0.05, 0.1))
|
|
434
|
+
self.stage.draw()
|
|
360
435
|
|
|
361
436
|
fun overlaps(ax: Float, ay: Float, ra: Float, bx: Float, by: Float, rb: Float) -> Bool =
|
|
362
437
|
Math.hypot(ax - bx, ay - by) < ra + rb
|
|
363
438
|
|
|
364
|
-
# One simulation step: every actor acts through the stage, then the rules look at
|
|
365
|
-
# the result. Once the game is over the actors only animate; nothing moves or scores.
|
|
366
|
-
fun step(w: World, ax: Float, ay: Float, dt: Float) =
|
|
367
|
-
w.clock = w.clock + dt
|
|
368
|
-
setLive(w, !w.over)
|
|
369
|
-
w.ship.ax = ax
|
|
370
|
-
w.ship.ay = ay
|
|
371
|
-
w.stage.act(dt)
|
|
372
|
-
if w.over
|
|
373
|
-
return
|
|
374
|
-
ship := w.ship
|
|
375
|
-
for i := range w.orbList.length()
|
|
376
|
-
orb := w.orbList.at(i)
|
|
377
|
-
if overlaps(ship.x, ship.y, Rules.ShipRadius, orb.x, orb.y, Rules.OrbRadius)
|
|
378
|
-
w.score = w.score + Rules.OrbPoints
|
|
379
|
-
orb.place(spawn(w.rng, ship.x, ship.y, 120.0, 40.0))
|
|
380
|
-
while w.mineList.length() < mineCount(w.score)
|
|
381
|
-
addMine(w)
|
|
382
|
-
for i := range w.mineList.length()
|
|
383
|
-
mine := w.mineList.at(i)
|
|
384
|
-
if ship.hurt <= 0.0 && overlaps(ship.x, ship.y, Rules.ShipRadius, mine.x, mine.y, Rules.MineRadius)
|
|
385
|
-
w.lives = w.lives - 1
|
|
386
|
-
ship.hurt = Rules.HurtTime
|
|
387
|
-
if w.lives <= 0
|
|
388
|
-
w.over = true
|
|
389
|
-
w.best = w.best.max(w.score)
|
|
390
|
-
ship.visible = false
|
|
391
|
-
sync(w)
|
|
392
|
-
|
|
393
|
-
# Fits the arena into a screen of `sw` by `sh` pixels, centred.
|
|
394
|
-
fun fit(w: World, sw: Float, sh: Float) =
|
|
395
|
-
zoom := {sw / Rules.ArenaW}.min(sh / Rules.ArenaH)
|
|
396
|
-
w.stage.camScale = zoom
|
|
397
|
-
w.stage.camX = 0.0 - {sw / zoom - Rules.ArenaW} / 2.0
|
|
398
|
-
w.stage.camY = 0.0 - {sh / zoom - Rules.ArenaH} / 2.0
|
|
399
|
-
|
|
400
439
|
# --- host glue: input, sound, drawing ---
|
|
401
440
|
|
|
402
441
|
fun musicVolume() -> Float =
|
|
403
442
|
0.3
|
|
404
443
|
|
|
405
|
-
# Restarts the music loop from the top.
|
|
406
|
-
fun startMusic(w: World) =
|
|
407
|
-
soundStop(w.musicSound)
|
|
408
|
-
soundLoop(w.musicSound, musicVolume())
|
|
409
|
-
|
|
410
444
|
export fun init() -> World =
|
|
411
|
-
w :=
|
|
445
|
+
w := World.create(2024, textureLoad("sprites.png"))
|
|
412
446
|
w.pickupSound = soundLoad("pickup.wav")
|
|
413
447
|
w.hitSound = soundLoad("hit.wav")
|
|
414
448
|
w.overSound = soundLoad("gameover.wav")
|
|
415
449
|
w.musicSound = soundLoad("music.wav")
|
|
416
|
-
startMusic(
|
|
450
|
+
w.startMusic()
|
|
417
451
|
w
|
|
418
452
|
|
|
419
453
|
fun axis(negative: Bool, positive: Bool) -> Float =
|
|
@@ -425,44 +459,16 @@ fun axis(negative: Bool, positive: Bool) -> Float =
|
|
|
425
459
|
0.0
|
|
426
460
|
|
|
427
461
|
export fun update(w: World, dt: Float) =
|
|
428
|
-
if w.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
|
|
429
|
-
|
|
462
|
+
w.update(dt)
|
|
430
|
-
startMusic(w)
|
|
431
|
-
left := isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
|
|
432
|
-
right := isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
|
|
433
|
-
up := isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
|
|
434
|
-
down := isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
|
|
435
|
-
score := w.score
|
|
436
|
-
lives := w.lives
|
|
437
|
-
step(w, axis(left, right), axis(up, down), dt)
|
|
438
|
-
if w.score > score
|
|
439
|
-
soundPlay(w.pickupSound, 0.8)
|
|
440
|
-
if w.lives < lives
|
|
441
|
-
if w.over
|
|
442
|
-
soundStop(w.musicSound)
|
|
443
|
-
soundPlay(w.overSound, 1.0)
|
|
444
|
-
else
|
|
445
|
-
soundPlay(w.hitSound, 1.0)
|
|
446
463
|
|
|
447
464
|
export fun draw(w: World) =
|
|
448
|
-
fit(w, screenWidth(), screenHeight())
|
|
449
|
-
clear(Color.rgb(0.04, 0.05, 0.1))
|
|
450
|
-
w.
|
|
465
|
+
w.draw()
|
|
451
466
|
|
|
452
467
|
# --- tests ---
|
|
453
468
|
|
|
454
469
|
fun near(a: Float, b: Float) -> Bool =
|
|
455
470
|
{a - b}.abs() < 0.01
|
|
456
471
|
|
|
457
|
-
# A world with the orbs and mines moved out of the way.
|
|
458
|
-
fun emptyWorld() -> World =
|
|
459
|
-
w := newWorld(7, -1)
|
|
460
|
-
for i := range w.orbList.length()
|
|
461
|
-
w.orbList.at(i).place(Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0))
|
|
462
|
-
for i := range w.mineList.length()
|
|
463
|
-
w.mineList.at(i).place(Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0))
|
|
464
|
-
w
|
|
465
|
-
|
|
466
472
|
test "steering accelerates and drag slows the ship"
|
|
467
473
|
ship := Ship(x: 400.0, y: 300.0, tex: -1)
|
|
468
474
|
ship.ax = 1.0
|
|
@@ -498,7 +504,7 @@ test "a mine spins as it drifts"
|
|
|
498
504
|
assert near(mine.x, 405.0)
|
|
499
505
|
|
|
500
506
|
test "the scene has a backdrop, stars, mines, orbs, ship and HUD layers"
|
|
501
|
-
w :=
|
|
507
|
+
w := World.create(7, -1)
|
|
502
508
|
assert w.stage.root.children.length() == 6
|
|
503
509
|
assert w.stage.root.children.at(1).children.length() == 40
|
|
504
510
|
assert w.mines.children.length() == mineCount(0)
|
|
@@ -508,10 +514,10 @@ test "the scene has a backdrop, stars, mines, orbs, ship and HUD layers"
|
|
|
508
514
|
assert same(w.stage.root.children.at(3), w.orbs)
|
|
509
515
|
|
|
510
516
|
test "collecting an orb scores and respawns it elsewhere, still in the orbs layer"
|
|
511
|
-
w :=
|
|
517
|
+
w := World.empty()
|
|
512
518
|
orb := w.orbList.at(0)
|
|
513
519
|
orb.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
|
|
514
|
-
step(
|
|
520
|
+
w.step(0.0, 0.0, 0.016)
|
|
515
521
|
assert w.score == Rules.OrbPoints
|
|
516
522
|
assert Math.hypot(orb.x - w.ship.x, orb.y - w.ship.y) >= 120.0
|
|
517
523
|
assert orb.x < 5000.0
|
|
@@ -520,10 +526,10 @@ test "collecting an orb scores and respawns it elsewhere, still in the orbs laye
|
|
|
520
526
|
assert w.scoreText.text == "Score 10"
|
|
521
527
|
|
|
522
528
|
test "a mine costs one life and makes the ship flash through its own act"
|
|
523
|
-
w :=
|
|
529
|
+
w := World.empty()
|
|
524
530
|
mine := w.mineList.at(0)
|
|
525
531
|
mine.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
|
|
526
|
-
step(
|
|
532
|
+
w.step(0.0, 0.0, 0.016)
|
|
527
533
|
assert w.lives == Rules.StartLives - 1
|
|
528
534
|
assert w.ship.hurt > 0.0
|
|
529
535
|
assert !w.lifeIcons.at(2).visible
|
|
@@ -532,7 +538,7 @@ test "a mine costs one life and makes the ship flash through its own act"
|
|
|
532
538
|
hidden := 0
|
|
533
539
|
shown := 0
|
|
534
540
|
for i := range 100
|
|
535
|
-
step(
|
|
541
|
+
w.step(0.0, 0.0, 0.016)
|
|
536
542
|
if w.ship.visible
|
|
537
543
|
shown = shown + 1
|
|
538
544
|
else
|
|
@@ -544,35 +550,35 @@ test "a mine costs one life and makes the ship flash through its own act"
|
|
|
544
550
|
assert w.lives == Rules.StartLives - 1
|
|
545
551
|
|
|
546
552
|
test "the ship is briefly safe after a hit"
|
|
547
|
-
w :=
|
|
553
|
+
w := World.empty()
|
|
548
554
|
mine := w.mineList.at(0)
|
|
549
555
|
mine.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
|
|
550
|
-
step(
|
|
556
|
+
w.step(0.0, 0.0, 0.016)
|
|
551
|
-
step(
|
|
557
|
+
w.step(0.0, 0.0, 0.016)
|
|
552
558
|
assert w.lives == Rules.StartLives - 1
|
|
553
559
|
|
|
554
560
|
test "the game ends at zero lives and then ignores steps"
|
|
555
|
-
w :=
|
|
561
|
+
w := World.empty()
|
|
556
562
|
w.lives = 1
|
|
557
563
|
w.score = 40
|
|
558
564
|
mine := w.mineList.at(0)
|
|
559
565
|
mine.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
|
|
560
|
-
step(
|
|
566
|
+
w.step(0.0, 0.0, 0.016)
|
|
561
567
|
assert w.over
|
|
562
568
|
assert w.lives == 0
|
|
563
569
|
assert w.best == 40
|
|
564
570
|
assert w.overlay.visible
|
|
565
571
|
assert !w.ship.visible
|
|
566
572
|
x := w.ship.x
|
|
567
|
-
step(
|
|
573
|
+
w.step(1.0, 0.0, 0.5)
|
|
568
574
|
assert w.ship.x == x
|
|
569
575
|
|
|
570
576
|
test "after game over nothing moves but the animations keep running"
|
|
571
|
-
w :=
|
|
577
|
+
w := World.empty()
|
|
572
578
|
w.lives = 1
|
|
573
579
|
mine := w.mineList.at(0)
|
|
574
580
|
mine.place(Motion(x: w.ship.x, y: w.ship.y, vx: 10.0, vy: 0.0))
|
|
575
|
-
step(
|
|
581
|
+
w.step(0.0, 0.0, 0.016)
|
|
576
582
|
assert w.over
|
|
577
583
|
x := mine.x
|
|
578
584
|
rot := mine.rotation
|
|
@@ -581,7 +587,7 @@ test "after game over nothing moves but the animations keep running"
|
|
|
581
587
|
star := Star.create(0)
|
|
582
588
|
w.stage.root.children.at(1).add(star)
|
|
583
589
|
for i := range 10
|
|
584
|
-
step(
|
|
590
|
+
w.step(1.0, 0.0, 0.1)
|
|
585
591
|
assert mine.x == x
|
|
586
592
|
assert mine.rotation == rot
|
|
587
593
|
assert w.ship.x == Rules.ArenaW / 2.0
|
|
@@ -592,12 +598,12 @@ test "after game over nothing moves but the animations keep running"
|
|
|
592
598
|
assert w.score == 0
|
|
593
599
|
|
|
594
600
|
test "restart resets the run and keeps the best score"
|
|
595
|
-
w :=
|
|
601
|
+
w := World.empty()
|
|
596
602
|
w.score = 90
|
|
597
603
|
w.lives = 0
|
|
598
604
|
w.over = true
|
|
599
605
|
w.overlay.visible = true
|
|
600
|
-
restart(
|
|
606
|
+
w.restart()
|
|
601
607
|
assert !w.over
|
|
602
608
|
assert !w.overlay.visible
|
|
603
609
|
assert w.ship.visible
|
|
@@ -612,29 +618,29 @@ test "more mines appear as the score grows and restart removes them from the lay
|
|
|
612
618
|
assert mineCount(0) == 3
|
|
613
619
|
assert mineCount(100) == 5
|
|
614
620
|
assert mineCount(10000) == 8
|
|
615
|
-
w :=
|
|
621
|
+
w := World.empty()
|
|
616
622
|
w.score = 100
|
|
617
|
-
step(
|
|
623
|
+
w.step(0.0, 0.0, 0.016)
|
|
618
624
|
assert w.mineList.length() == 5
|
|
619
625
|
assert w.mines.children.length() == 5
|
|
620
|
-
restart(
|
|
626
|
+
w.restart()
|
|
621
627
|
assert w.mineList.length() == 3
|
|
622
628
|
assert w.mines.children.length() == 3
|
|
623
629
|
|
|
624
630
|
test "the same seed plays out the same way"
|
|
625
|
-
a :=
|
|
631
|
+
a := World.create(11, -1)
|
|
626
|
-
b :=
|
|
632
|
+
b := World.create(11, -1)
|
|
627
633
|
for i := range 120
|
|
628
|
-
step(
|
|
634
|
+
a.step(1.0, -1.0, 0.016)
|
|
629
|
-
step(
|
|
635
|
+
b.step(1.0, -1.0, 0.016)
|
|
630
636
|
assert a.ship.x == b.ship.x
|
|
631
637
|
assert a.score == b.score
|
|
632
638
|
assert a.mineList.at(0).x == b.mineList.at(0).x
|
|
633
|
-
assert
|
|
639
|
+
assert World.create(12, -1).orbList.at(0).x != a.orbList.at(0).x
|
|
634
640
|
|
|
635
641
|
test "fit centres the arena on the screen"
|
|
636
|
-
w :=
|
|
642
|
+
w := World.create(7, -1)
|
|
637
|
-
fit(
|
|
643
|
+
w.fit(1600.0, 600.0)
|
|
638
644
|
assert near(w.stage.camScale, 1.0)
|
|
639
645
|
assert near(w.stage.camX, -400.0)
|
|
640
646
|
assert near(w.stage.camY, 0.0)
|