plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
3ac7d50
— Peter John
2026-11-02T14:42:36+05:30
Add Orb Run example game with generated sprite sheet; generic Games docs
- README.md +14 -12
- docs/game.png +0 -0
- docs/plans/2026-10-26-game-engine.md +2 -0
- plum-examples/assets/make_sprites.py +85 -0
- plum-examples/assets/sprites.png +0 -0
- plum-examples/game.plum +373 -0
README.md
CHANGED
|
@@ -1027,7 +1027,11 @@ Every module that imports `std/Str` also exports four helpers for hosts that can
|
|
|
1027
1027
|
|
|
1028
1028
|
## Games
|
|
1029
1029
|
|
|
1030
|
-
`plum game file.plum` compiles a game and runs it in a native window (macroquad, with the module under wasmtime). `plum game --web file.plum [-o dist] [--
|
|
1030
|
+
`plum game file.plum` compiles a game and runs it in a native window (macroquad, with the module under wasmtime). `plum game --web file.plum [-o dist] [--assets DIR]` writes a static web build instead: `index.html`, `mq_js_bundle.js`, `plum_bridge.js`, `plum-game.wasm`, `plum.wasm`, `preload.txt` and an `assets/` copy of the whole asset directory (every file in it is listed in `preload.txt` and fetched before the first frame; `dist` defaults to `dist` beside the source file). Serve it from any static host; the browser needs wasm GC support. The web build needs `rustup target add wasm32-unknown-unknown`.
|
|
1031
|
+
|
|
1032
|
+
`plum game plum-examples/game.plum` runs the example, a small arcade game: steer a ship with the arrow keys or WASD, collect orbs, dodge mines (Enter restarts). It draws shapes, text and an animated sprite sheet (`plum-examples/assets/sprites.png`, generated by `make_sprites.py` beside it). Its rules are plain functions with `test` blocks, so `plum test plum-examples` covers them without a window.
|
|
1033
|
+
|
|
1034
|
+

|
|
1031
1035
|
|
|
1032
1036
|
A game declares three exports. `G` is any record and holds all game state (there are no globals):
|
|
1033
1037
|
|
|
@@ -1041,29 +1045,27 @@ The host calls `init` once after the window exists, then `update` and `draw` eve
|
|
|
1041
1045
|
|
|
1042
1046
|
**Headless check:** `PLUM_GAME_CAPTURE=out.png plum-game game.wasm` runs `PLUM_GAME_FRAMES` frames (default 30), saves the framebuffer as an upright PNG and exits 0.
|
|
1043
1047
|
|
|
1044
|
-
**
|
|
1048
|
+
**Asset root:** `plum-game <wasm> [--assets DIR]` reads textures and files relative to `DIR`, default `assets` beside the wasm (`$PLUM_GAME_ASSETS` overrides that default). `plum game file.plum` passes `assets` beside the source file. A path that escapes the root (`..` or absolute) fails like a missing file: `-1`, `""` or `false`.
|
|
1045
1049
|
|
|
1046
|
-
**Host API** (`std/Game`;
|
|
1050
|
+
**Host API** (`std/Game`; texture handles are `-1` on failure; coordinates are world units once a camera is set):
|
|
1047
1051
|
|
|
1048
1052
|
| function | meaning |
|
|
1049
1053
|
|---|---|
|
|
1050
1054
|
| `screenWidth()`, `screenHeight()`, `frameTime()` | window size in pixels, last frame time in seconds |
|
|
1051
1055
|
| `clear(c)` | fill the screen |
|
|
1052
|
-
| `drawRect(x, y, w, h, c)`, `drawLine(x1, y1, x2, y2, thickness, c)`, `drawText(s, x, y, size, c)` | shapes and text (`x, y` is the text's top-left) |
|
|
1056
|
+
| `drawRect(x, y, w, h, c)`, `drawCircle(x, y, r, c)`, `drawLine(x1, y1, x2, y2, thickness, c)`, `drawText(s, x, y, size, c)` | shapes and text (`x, y` is the text's top-left, a circle's centre) |
|
|
1053
1057
|
| `setCamera(x, y, scale)`, `resetCamera()` | world point `(x, y)` maps to the screen's top-left, one world unit is `scale` pixels |
|
|
1054
|
-
| `
|
|
1055
|
-
| `
|
|
1056
|
-
| `
|
|
1057
|
-
| `
|
|
1058
|
+
| `textureLoad(path)` | load an image (PNG at least) relative to the asset root |
|
|
1059
|
+
| `textureWidth(t)`, `textureHeight(t)` | size in pixels (0 for a bad handle) |
|
|
1060
|
+
| `textureSetSmooth(t, smooth)` | linear (`true`) or nearest (`false`, the default) filtering |
|
|
1061
|
+
| `drawTexture(t, x, y, tint)` | the texture at its natural size, top-left at `(x, y)` |
|
|
1062
|
+
| `drawTextureEx(t, sx, sy, sw, sh, x, y, w, h, rotation, flipX, tint)` | source rect `(sx, sy, sw, sh)` in texture pixels drawn into dest rect `(x, y, w, h)`, rotated `rotation` radians clockwise about the dest centre |
|
|
1063
|
+
| `fileExists(path)`, `fileLoad(path)` | whole file as a `Str` (`""` if missing), relative to the asset root |
|
|
1058
1064
|
| `isKeyDown(k)`, `isKeyPressed(k)`, `isMouseDown(b)`, `isMousePressed(b)` | input; `k` is a `Key` constant, `b` a `Mouse` constant |
|
|
1059
1065
|
| `mousePos()`, `mouseWheel()` | pointer in screen pixels; wheel direction this frame (+ is up) |
|
|
1060
|
-
| `mapLoad(path)`, `mapWidth(m)`, `mapHeight(m)` | load a KaM `.map` relative to the asset root |
|
|
1061
|
-
| `mapTerrain(m, x, y)`, `mapRotation`, `mapElevation`, `mapObject` | per-tile data; row 0 is the map's first row. The Tileset sprite for a terrain id is `id + 1`; `mapObject` is a `Trees_a` sprite index (0 for none) |
|
|
1062
1066
|
|
|
1063
1067
|
`Key` constants use GLFW numbering: `Key.Space`, `Key.KeyA` to `Key.KeyZ`, `Key.Num0` to `Key.Num9`, `Key.Escape`, `Key.Enter`, `Key.Tab`, `Key.Backspace`, `Key.Right`, `Key.Left`, `Key.Down`, `Key.Up`, `Key.LeftShift`, `Key.LeftControl`. `Mouse` has `Mouse.Left`, `Mouse.Right` and `Mouse.Middle`. `Color` holds RGBA channels in 0..1 with the constants `White`, `Black`, `Red`, `Green`, `Blue`, `Yellow` and `Transparent`, plus `Color.rgb(r, g, b)` and `withAlpha(a)`.
|
|
1064
1068
|
|
|
1065
|
-
`plum-game/kam/Main.plum` is a Knights and Merchants map viewer built on this API: `plum game plum-game/kam/Main.plum`.
|
|
1066
|
-
|
|
1067
1069
|
## Standard library highlights
|
|
1068
1070
|
|
|
1069
1071
|
- **`Bool`** — `true`/`false`, `if`, `&&`/`||`/`!` and `==` are built in (no import); `toStr` (also used by `"{b}"` interpolation) and `Bool.parse("true")` need `import std/Bool`
|
docs/game.png
ADDED
|
Binary file
|
docs/plans/2026-10-26-game-engine.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Plum for games: browser-first macroquad host, port of a simplified KaM
|
|
2
2
|
|
|
3
|
+
> The engine is generic (textures, files, shapes, input); the KaM port lives in `../kam_plum`, not in this repository.
|
|
4
|
+
|
|
3
5
|
**Status:** draft. Planned only and not run; the user has more ideas to fold in first.
|
|
4
6
|
**Depends on:** `2026-10-26-zig-generics-and-numbers.md`, which supplies duck-typed per-type-copy generics, no traits (enums plus duck-typed operator methods), native typed arrays, and `Int`/`Float` kept as i64/f64 with `Int` narrowed to wasm i32 at `extern fun` boundaries (no JS BigInt in the browser bridge). That plan runs first.
|
|
5
7
|
**Goal:** Port `../kam_macroquad`, a 98k-line Rust/macroquad remake of Knights and Merchants that already runs in the browser via wasm, to Plum as a **simplified** game. There is no mission scripting (rhai) and the rules are simpler, but animations and sounds stay identical. It runs in the browser first.
|
plum-examples/assets/make_sprites.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Writes sprites.png beside this script: a 64x48 sheet of 16x16 frames.
|
|
3
|
+
|
|
4
|
+
Row 0: the ship with four engine-flame frames (pointing up).
|
|
5
|
+
Row 1: an orb spinning (four frames, squeezed and widened).
|
|
6
|
+
Row 2: a mine whose light blinks (four frames).
|
|
7
|
+
Standard library only (zlib + struct).
|
|
8
|
+
"""
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import struct
|
|
12
|
+
import zlib
|
|
13
|
+
|
|
14
|
+
CELL, COLS, ROWS = 16, 4, 3
|
|
15
|
+
W, H = CELL * COLS, CELL * ROWS
|
|
16
|
+
pix = [[(0, 0, 0, 0)] * W for _ in range(H)]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def put(col, row, x, y, rgba):
|
|
20
|
+
if 0 <= x < CELL and 0 <= y < CELL:
|
|
21
|
+
pix[row * CELL + y][col * CELL + x] = rgba
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ship(col):
|
|
25
|
+
hull, edge, glass = (120, 200, 255, 255), (30, 90, 160, 255), (255, 255, 255, 255)
|
|
26
|
+
for y in range(2, 13):
|
|
27
|
+
half = (y - 2) * 5 // 10 + 1
|
|
28
|
+
for x in range(8 - half, 8 + half):
|
|
29
|
+
put(col, 0, x, y, edge if x in (8 - half, 7 + half) or y == 12 else hull)
|
|
30
|
+
for y in (5, 6):
|
|
31
|
+
put(col, 0, 7, y, glass)
|
|
32
|
+
put(col, 0, 8, y, glass)
|
|
33
|
+
flame = (255, 190, 60, 255) if col % 2 == 0 else (255, 90, 40, 255)
|
|
34
|
+
for y in range(13, 13 + 1 + (col % 4)):
|
|
35
|
+
put(col, 0, 7, y, flame)
|
|
36
|
+
put(col, 0, 8, y, flame)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def orb(col):
|
|
40
|
+
half_w = (7, 5, 2, 5)[col]
|
|
41
|
+
for y in range(16):
|
|
42
|
+
for x in range(16):
|
|
43
|
+
dx, dy = (x - 7.5) / max(half_w, 1), (y - 7.5) / 7
|
|
44
|
+
d = dx * dx + dy * dy
|
|
45
|
+
if d <= 1.0:
|
|
46
|
+
shade = (255, 215, 70, 255) if d < 0.55 else (230, 150, 20, 255)
|
|
47
|
+
if dx < -0.2 and dy < -0.2 and d < 0.5:
|
|
48
|
+
shade = (255, 250, 200, 255)
|
|
49
|
+
put(col, 1, x, y, shade)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def mine(col):
|
|
53
|
+
body, spike = (190, 60, 70, 255), (110, 25, 40, 255)
|
|
54
|
+
for y in range(16):
|
|
55
|
+
for x in range(16):
|
|
56
|
+
d = math.hypot(x - 7.5, y - 7.5)
|
|
57
|
+
if d <= 5.2:
|
|
58
|
+
put(col, 2, x, y, body)
|
|
59
|
+
for k in range(8):
|
|
60
|
+
a = k * math.pi / 4
|
|
61
|
+
for r in (6, 7):
|
|
62
|
+
put(col, 2, int(round(7.5 + math.cos(a) * r)), int(round(7.5 + math.sin(a) * r)), spike)
|
|
63
|
+
light = (255, 255, 255, 255) if col % 2 == 0 else (90, 20, 30, 255)
|
|
64
|
+
for x, y in ((7, 7), (8, 7), (7, 8), (8, 8)):
|
|
65
|
+
put(col, 2, x, y, light)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
for c in range(COLS):
|
|
69
|
+
ship(c)
|
|
70
|
+
orb(c)
|
|
71
|
+
mine(c)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def chunk(tag, data):
|
|
75
|
+
body = tag + data
|
|
76
|
+
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
raw = b"".join(b"\x00" + b"".join(bytes(p) for p in row) for row in pix)
|
|
80
|
+
png = b"\x89PNG\r\n\x1a\n"
|
|
81
|
+
png += chunk(b"IHDR", struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0))
|
|
82
|
+
png += chunk(b"IDAT", zlib.compress(raw, 9))
|
|
83
|
+
png += chunk(b"IEND", b"")
|
|
84
|
+
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "sprites.png"), "wb") as f:
|
|
85
|
+
f.write(png)
|
plum-examples/assets/sprites.png
ADDED
|
Binary file
|
plum-examples/game.plum
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# Orb Run: a small arcade game for the game host (`plum game plum-examples/game.plum`).
|
|
2
|
+
# Steer the ship with the arrow keys or WASD, collect the drifting orbs and avoid
|
|
3
|
+
# the mines. Three lives; Enter or R restarts after the game is over.
|
|
4
|
+
#
|
|
5
|
+
# All rules are plain functions over `World` that never call the host, so the
|
|
6
|
+
# `test` blocks below run under `plum test`; only `init`, `update` and `draw`
|
|
7
|
+
# talk to the host.
|
|
8
|
+
module game
|
|
9
|
+
import std/Str
|
|
10
|
+
import std/Int
|
|
11
|
+
import std/Float
|
|
12
|
+
import std/List
|
|
13
|
+
import std/Math
|
|
14
|
+
import std/Random
|
|
15
|
+
import std/Vec2
|
|
16
|
+
import std/Color
|
|
17
|
+
import std/Game
|
|
18
|
+
|
|
19
|
+
primitive Rules =
|
|
20
|
+
ArenaW = 800.0
|
|
21
|
+
ArenaH = 600.0
|
|
22
|
+
ShipRadius = 10.0
|
|
23
|
+
OrbRadius = 10.0
|
|
24
|
+
MineRadius = 11.0
|
|
25
|
+
Thrust = 900.0
|
|
26
|
+
Drag = 2.2
|
|
27
|
+
OrbCount = 3
|
|
28
|
+
StartLives = 3
|
|
29
|
+
HurtTime = 1.5
|
|
30
|
+
OrbPoints = 10
|
|
31
|
+
Cell = 16.0
|
|
32
|
+
Zoom = 3.0
|
|
33
|
+
|
|
34
|
+
# A moving circle. `angle` is the ship's heading or a mine's spin, in radians clockwise.
|
|
35
|
+
record Body(x: Float, y: Float, vx: Float, vy: Float, angle: Float)
|
|
36
|
+
|
|
37
|
+
record World(
|
|
38
|
+
ship: Body,
|
|
39
|
+
orbs: List[Body],
|
|
40
|
+
mines: List[Body],
|
|
41
|
+
rng: Random,
|
|
42
|
+
score: Int,
|
|
43
|
+
best: Int,
|
|
44
|
+
lives: Int,
|
|
45
|
+
hurt: Float,
|
|
46
|
+
clock: Float,
|
|
47
|
+
over: Bool,
|
|
48
|
+
sheet: Int)
|
|
49
|
+
|
|
50
|
+
fun distance(a: Body, b: Body) -> Float =
|
|
51
|
+
Math.hypot(a.x - b.x, a.y - b.y)
|
|
52
|
+
|
|
53
|
+
fun overlaps(a: Body, ra: Float, b: Body, rb: Float) -> Bool =
|
|
54
|
+
distance(a, b) < ra + rb
|
|
55
|
+
|
|
56
|
+
# Which of `count` animation frames is showing after `clock` seconds at `fps` frames per second.
|
|
57
|
+
fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
|
|
58
|
+
Int(clock * fps) % count
|
|
59
|
+
|
|
60
|
+
# A body at a random spot at least `gap` away from `avoid`, drifting at up to `speed`.
|
|
61
|
+
fun spawn(rng: Random, avoid: Body, gap: Float, speed: Float) -> Body =
|
|
62
|
+
margin := 30.0
|
|
63
|
+
b := Body(x: 0.0, y: 0.0, vx: 0.0, vy: 0.0, angle: 0.0)
|
|
64
|
+
tries := 0
|
|
65
|
+
while tries < 20
|
|
66
|
+
b.x = margin + rng.nextFloat() * {Rules.ArenaW - 2.0 * margin}
|
|
67
|
+
b.y = margin + rng.nextFloat() * {Rules.ArenaH - 2.0 * margin}
|
|
68
|
+
if distance(b, avoid) >= gap
|
|
69
|
+
break
|
|
70
|
+
tries = tries + 1
|
|
71
|
+
heading := rng.nextFloat() * Float.Tau
|
|
72
|
+
pace := speed * {0.4 + 0.6 * rng.nextFloat()}
|
|
73
|
+
b.vx = Math.cos(heading) * pace
|
|
74
|
+
b.vy = Math.sin(heading) * pace
|
|
75
|
+
b
|
|
76
|
+
|
|
77
|
+
# How many mines are out: more as the score grows.
|
|
78
|
+
fun mineCount(score: Int) -> Int =
|
|
79
|
+
{3 + score / 50}.min(8)
|
|
80
|
+
|
|
81
|
+
fun newWorld(seed: Int, sheet: Int) -> World =
|
|
82
|
+
rng := Random(seed: seed)
|
|
83
|
+
ship := Body(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, angle: 0.0)
|
|
84
|
+
w := World(
|
|
85
|
+
ship: ship, orbs: List[Body](), mines: List[Body](), rng: rng, score: 0, best: 0,
|
|
86
|
+
lives: Rules.StartLives, hurt: 0.0, clock: 0.0, over: false, sheet: sheet)
|
|
87
|
+
for i := range Rules.OrbCount
|
|
88
|
+
w.orbs.push(spawn(rng, ship, 120.0, 40.0))
|
|
89
|
+
for i := range mineCount(0)
|
|
90
|
+
w.mines.push(spawn(rng, ship, 220.0, 90.0))
|
|
91
|
+
w
|
|
92
|
+
|
|
93
|
+
# Starts a fresh game, keeping the best score and the random stream.
|
|
94
|
+
fun restart(w: World) =
|
|
95
|
+
w.best = w.best.max(w.score)
|
|
96
|
+
w.score = 0
|
|
97
|
+
w.lives = Rules.StartLives
|
|
98
|
+
w.hurt = 0.0
|
|
99
|
+
w.over = false
|
|
100
|
+
w.ship.x = Rules.ArenaW / 2.0
|
|
101
|
+
w.ship.y = Rules.ArenaH / 2.0
|
|
102
|
+
w.ship.vx = 0.0
|
|
103
|
+
w.ship.vy = 0.0
|
|
104
|
+
for i := range w.orbs.length()
|
|
105
|
+
moved := spawn(w.rng, w.ship, 120.0, 40.0)
|
|
106
|
+
w.orbs.at(i).x = moved.x
|
|
107
|
+
w.orbs.at(i).y = moved.y
|
|
108
|
+
w.orbs.at(i).vx = moved.vx
|
|
109
|
+
w.orbs.at(i).vy = moved.vy
|
|
110
|
+
while w.mines.length() > mineCount(0)
|
|
111
|
+
_ := w.mines.pop()!
|
|
112
|
+
for i := range w.mines.length()
|
|
113
|
+
moved := spawn(w.rng, w.ship, 220.0, 90.0)
|
|
114
|
+
w.mines.at(i).x = moved.x
|
|
115
|
+
w.mines.at(i).y = moved.y
|
|
116
|
+
w.mines.at(i).vx = moved.vx
|
|
117
|
+
w.mines.at(i).vy = moved.vy
|
|
118
|
+
|
|
119
|
+
# Accelerates the ship by input axes (-1..1), applies drag and keeps it in the arena.
|
|
120
|
+
fun steer(ship: Body, ax: Float, ay: Float, dt: Float) =
|
|
121
|
+
ship.vx = ship.vx + ax * Rules.Thrust * dt
|
|
122
|
+
ship.vy = ship.vy + ay * Rules.Thrust * dt
|
|
123
|
+
keep := {1.0 - Rules.Drag * dt}.max(0.0)
|
|
124
|
+
ship.vx = ship.vx * keep
|
|
125
|
+
ship.vy = ship.vy * keep
|
|
126
|
+
ship.x = ship.x + ship.vx * dt
|
|
127
|
+
ship.y = ship.y + ship.vy * dt
|
|
128
|
+
r := Rules.ShipRadius
|
|
129
|
+
if ship.x < r
|
|
130
|
+
ship.x = r
|
|
131
|
+
ship.vx = 0.0
|
|
132
|
+
if ship.x > Rules.ArenaW - r
|
|
133
|
+
ship.x = Rules.ArenaW - r
|
|
134
|
+
ship.vx = 0.0
|
|
135
|
+
if ship.y < r
|
|
136
|
+
ship.y = r
|
|
137
|
+
ship.vy = 0.0
|
|
138
|
+
if ship.y > Rules.ArenaH - r
|
|
139
|
+
ship.y = Rules.ArenaH - r
|
|
140
|
+
ship.vy = 0.0
|
|
141
|
+
if ship.vx * ship.vx + ship.vy * ship.vy > 400.0
|
|
142
|
+
ship.angle = Math.atan2(ship.vx, 0.0 - ship.vy)
|
|
143
|
+
|
|
144
|
+
# Moves a drifter and bounces it off the arena walls.
|
|
145
|
+
fun drift(b: Body, r: Float, dt: Float) =
|
|
146
|
+
b.x = b.x + b.vx * dt
|
|
147
|
+
b.y = b.y + b.vy * dt
|
|
148
|
+
if b.x < r || b.x > Rules.ArenaW - r
|
|
149
|
+
b.vx = 0.0 - b.vx
|
|
150
|
+
b.x = b.x.max(r).min(Rules.ArenaW - r)
|
|
151
|
+
if b.y < r || b.y > Rules.ArenaH - r
|
|
152
|
+
b.vy = 0.0 - b.vy
|
|
153
|
+
b.y = b.y.max(r).min(Rules.ArenaH - r)
|
|
154
|
+
|
|
155
|
+
# One simulation step. Does nothing once the game is over.
|
|
156
|
+
fun step(w: World, ax: Float, ay: Float, dt: Float) =
|
|
157
|
+
w.clock = w.clock + dt
|
|
158
|
+
if w.over
|
|
159
|
+
return
|
|
160
|
+
w.hurt = {w.hurt - dt}.max(0.0)
|
|
161
|
+
steer(w.ship, ax, ay, dt)
|
|
162
|
+
for i := range w.orbs.length()
|
|
163
|
+
orb := w.orbs.at(i)
|
|
164
|
+
drift(orb, Rules.OrbRadius, dt)
|
|
165
|
+
if overlaps(w.ship, Rules.ShipRadius, orb, Rules.OrbRadius)
|
|
166
|
+
w.score = w.score + Rules.OrbPoints
|
|
167
|
+
moved := spawn(w.rng, w.ship, 120.0, 40.0)
|
|
168
|
+
orb.x = moved.x
|
|
169
|
+
orb.y = moved.y
|
|
170
|
+
orb.vx = moved.vx
|
|
171
|
+
orb.vy = moved.vy
|
|
172
|
+
while w.mines.length() < mineCount(w.score)
|
|
173
|
+
w.mines.push(spawn(w.rng, w.ship, 220.0, 90.0))
|
|
174
|
+
for i := range w.mines.length()
|
|
175
|
+
mine := w.mines.at(i)
|
|
176
|
+
drift(mine, Rules.MineRadius, dt)
|
|
177
|
+
mine.angle = mine.angle + 2.0 * dt
|
|
178
|
+
if w.hurt <= 0.0 && overlaps(w.ship, Rules.ShipRadius, mine, Rules.MineRadius)
|
|
179
|
+
w.lives = w.lives - 1
|
|
180
|
+
w.hurt = Rules.HurtTime
|
|
181
|
+
if w.lives <= 0
|
|
182
|
+
w.over = true
|
|
183
|
+
w.best = w.best.max(w.score)
|
|
184
|
+
|
|
185
|
+
# --- host glue: input, drawing ---
|
|
186
|
+
|
|
187
|
+
export fun init() -> World =
|
|
188
|
+
newWorld(2024, textureLoad("sprites.png"))
|
|
189
|
+
|
|
190
|
+
fun axis(negative: Bool, positive: Bool) -> Float =
|
|
191
|
+
if negative && !positive
|
|
192
|
+
-1.0
|
|
193
|
+
else if positive && !negative
|
|
194
|
+
1.0
|
|
195
|
+
else
|
|
196
|
+
0.0
|
|
197
|
+
|
|
198
|
+
export fun update(w: World, dt: Float) =
|
|
199
|
+
if w.over && {isKeyPressed(Key.Enter) || isKeyPressed(Key.KeyR)}
|
|
200
|
+
restart(w)
|
|
201
|
+
left := isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
|
|
202
|
+
right := isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
|
|
203
|
+
up := isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
|
|
204
|
+
down := isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
|
|
205
|
+
step(w, axis(left, right), axis(up, down), dt)
|
|
206
|
+
|
|
207
|
+
# Draws frame `frame` of sheet row `row`, centred on (x, y), at `Rules.Zoom` times its size.
|
|
208
|
+
fun sprite(w: World, row: Int, frame: Int, x: Float, y: Float, angle: Float, tint: Color) =
|
|
209
|
+
size := Rules.Cell * Rules.Zoom
|
|
210
|
+
drawTextureEx(
|
|
211
|
+
w.sheet, Float(frame) * Rules.Cell, Float(row) * Rules.Cell, Rules.Cell, Rules.Cell,
|
|
212
|
+
x - size / 2.0, y - size / 2.0, size, size, angle, false, tint)
|
|
213
|
+
|
|
214
|
+
fun drawStars(w: World) =
|
|
215
|
+
for i := range 40
|
|
216
|
+
sx := Float({i * 7919} % 800)
|
|
217
|
+
sy := Float({i * 104729} % 600)
|
|
218
|
+
twinkle := 0.25 + 0.2 * Math.sin(w.clock * 2.0 + Float(i))
|
|
219
|
+
drawCircle(sx, sy, 1.0 + Float(i % 3) * 0.5, Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
|
|
220
|
+
|
|
221
|
+
export fun draw(w: World) =
|
|
222
|
+
sw := screenWidth()
|
|
223
|
+
sh := screenHeight()
|
|
224
|
+
zoom := {sw / Rules.ArenaW}.min(sh / Rules.ArenaH)
|
|
225
|
+
clear(Color.rgb(0.04, 0.05, 0.1))
|
|
226
|
+
setCamera(0.0 - {sw / zoom - Rules.ArenaW} / 2.0, 0.0 - {sh / zoom - Rules.ArenaH} / 2.0, zoom)
|
|
227
|
+
drawRect(0.0, 0.0, Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16))
|
|
228
|
+
drawStars(w)
|
|
229
|
+
for i := range w.orbs.length()
|
|
230
|
+
orb := w.orbs.at(i)
|
|
231
|
+
drawCircle(orb.x, orb.y, 22.0, Color(r: 1.0, g: 0.8, b: 0.2, a: 0.18))
|
|
232
|
+
sprite(w, 1, frameOf(w.clock + Float(i) * 0.1, 4, 8.0), orb.x, orb.y, 0.0, Color.White)
|
|
233
|
+
for i := range w.mines.length()
|
|
234
|
+
mine := w.mines.at(i)
|
|
235
|
+
sprite(w, 2, frameOf(w.clock, 4, 4.0), mine.x, mine.y, mine.angle, Color.White)
|
|
236
|
+
blink := w.hurt > 0.0 && frameOf(w.clock, 2, 10.0) == 0
|
|
237
|
+
if !w.over && !blink
|
|
238
|
+
flame := frameOf(w.clock, 4, 14.0)
|
|
239
|
+
drawCircle(w.ship.x, w.ship.y, 17.0, Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12))
|
|
240
|
+
sprite(w, 0, flame, w.ship.x, w.ship.y, w.ship.angle, Color.White)
|
|
241
|
+
drawText("Score {w.score}", 16.0, 12.0, 28.0, Color.White)
|
|
242
|
+
drawText("Best {w.best.max(w.score)}", 16.0, 44.0, 20.0, Color.Yellow)
|
|
243
|
+
for i := range w.lives
|
|
244
|
+
drawTextureEx(
|
|
245
|
+
w.sheet, 0.0, 0.0, Rules.Cell, Rules.Cell,
|
|
246
|
+
Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 32.0, 0.0, false, Color.White)
|
|
247
|
+
if w.over
|
|
248
|
+
drawRect(0.0, 240.0, Rules.ArenaW, 120.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6))
|
|
249
|
+
drawText("Game over", 300.0, 258.0, 48.0, Color.Red)
|
|
250
|
+
drawText("Press Enter to play again", 270.0, 316.0, 24.0, Color.White)
|
|
251
|
+
resetCamera()
|
|
252
|
+
|
|
253
|
+
# --- tests ---
|
|
254
|
+
|
|
255
|
+
fun still(x: Float, y: Float) -> Body =
|
|
256
|
+
Body(x: x, y: y, vx: 0.0, vy: 0.0, angle: 0.0)
|
|
257
|
+
|
|
258
|
+
# A world with the orbs and mines moved out of the way.
|
|
259
|
+
fun emptyWorld() -> World =
|
|
260
|
+
w := newWorld(7, -1)
|
|
261
|
+
for i := range w.orbs.length()
|
|
262
|
+
w.orbs.at(i).x = 5000.0
|
|
263
|
+
w.orbs.at(i).vx = 0.0
|
|
264
|
+
w.orbs.at(i).vy = 0.0
|
|
265
|
+
for i := range w.mines.length()
|
|
266
|
+
w.mines.at(i).x = 5000.0
|
|
267
|
+
w.mines.at(i).vx = 0.0
|
|
268
|
+
w.mines.at(i).vy = 0.0
|
|
269
|
+
w
|
|
270
|
+
|
|
271
|
+
test "steering accelerates and drag slows the ship"
|
|
272
|
+
ship := still(400.0, 300.0)
|
|
273
|
+
steer(ship, 1.0, 0.0, 0.1)
|
|
274
|
+
assert ship.vx > 0.0
|
|
275
|
+
assert ship.x > 400.0
|
|
276
|
+
fast := ship.vx
|
|
277
|
+
for i := range 30
|
|
278
|
+
steer(ship, 0.0, 0.0, 0.1)
|
|
279
|
+
assert ship.vx < fast / 4.0
|
|
280
|
+
|
|
281
|
+
test "the ship stops at the arena walls"
|
|
282
|
+
ship := still(20.0, 20.0)
|
|
283
|
+
for i := range 20
|
|
284
|
+
steer(ship, -1.0, -1.0, 0.1)
|
|
285
|
+
assert ship.x == Rules.ShipRadius
|
|
286
|
+
assert ship.y == Rules.ShipRadius
|
|
287
|
+
assert ship.vx == 0.0
|
|
288
|
+
|
|
289
|
+
test "drifters bounce off the walls"
|
|
290
|
+
b := Body(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0, angle: 0.0)
|
|
291
|
+
drift(b, 10.0, 0.1)
|
|
292
|
+
assert b.vx == -100.0
|
|
293
|
+
assert b.x == Rules.ArenaW - 10.0
|
|
294
|
+
|
|
295
|
+
test "collecting an orb scores and respawns it elsewhere"
|
|
296
|
+
w := emptyWorld()
|
|
297
|
+
orb := w.orbs.at(0)
|
|
298
|
+
orb.x = w.ship.x
|
|
299
|
+
orb.y = w.ship.y
|
|
300
|
+
step(w, 0.0, 0.0, 0.016)
|
|
301
|
+
assert w.score == Rules.OrbPoints
|
|
302
|
+
assert distance(orb, w.ship) >= 120.0
|
|
303
|
+
assert orb.x < 5000.0
|
|
304
|
+
|
|
305
|
+
test "a mine costs one life, then the ship is briefly safe"
|
|
306
|
+
w := emptyWorld()
|
|
307
|
+
mine := w.mines.at(0)
|
|
308
|
+
mine.x = w.ship.x
|
|
309
|
+
mine.y = w.ship.y
|
|
310
|
+
step(w, 0.0, 0.0, 0.016)
|
|
311
|
+
assert w.lives == Rules.StartLives - 1
|
|
312
|
+
assert w.hurt > 0.0
|
|
313
|
+
step(w, 0.0, 0.0, 0.016)
|
|
314
|
+
assert w.lives == Rules.StartLives - 1
|
|
315
|
+
mine.x = 5000.0
|
|
316
|
+
for i := range 100
|
|
317
|
+
step(w, 0.0, 0.0, 0.016)
|
|
318
|
+
assert w.hurt == 0.0
|
|
319
|
+
|
|
320
|
+
test "the game ends at zero lives and then ignores steps"
|
|
321
|
+
w := emptyWorld()
|
|
322
|
+
w.lives = 1
|
|
323
|
+
w.score = 40
|
|
324
|
+
mine := w.mines.at(0)
|
|
325
|
+
mine.x = w.ship.x
|
|
326
|
+
mine.y = w.ship.y
|
|
327
|
+
step(w, 0.0, 0.0, 0.016)
|
|
328
|
+
assert w.over
|
|
329
|
+
assert w.lives == 0
|
|
330
|
+
assert w.best == 40
|
|
331
|
+
x := w.ship.x
|
|
332
|
+
step(w, 1.0, 0.0, 0.5)
|
|
333
|
+
assert w.ship.x == x
|
|
334
|
+
|
|
335
|
+
test "restart resets the run and keeps the best score"
|
|
336
|
+
w := emptyWorld()
|
|
337
|
+
w.score = 90
|
|
338
|
+
w.lives = 0
|
|
339
|
+
w.over = true
|
|
340
|
+
restart(w)
|
|
341
|
+
assert !w.over
|
|
342
|
+
assert w.score == 0
|
|
343
|
+
assert w.best == 90
|
|
344
|
+
assert w.lives == Rules.StartLives
|
|
345
|
+
assert w.ship.x == Rules.ArenaW / 2.0
|
|
346
|
+
assert w.mines.length() == mineCount(0)
|
|
347
|
+
|
|
348
|
+
test "more mines appear as the score grows"
|
|
349
|
+
assert mineCount(0) == 3
|
|
350
|
+
assert mineCount(100) == 5
|
|
351
|
+
assert mineCount(10000) == 8
|
|
352
|
+
w := emptyWorld()
|
|
353
|
+
w.score = 100
|
|
354
|
+
step(w, 0.0, 0.0, 0.016)
|
|
355
|
+
assert w.mines.length() == 5
|
|
356
|
+
|
|
357
|
+
test "the same seed plays out the same way"
|
|
358
|
+
a := newWorld(11, -1)
|
|
359
|
+
b := newWorld(11, -1)
|
|
360
|
+
for i := range 120
|
|
361
|
+
step(a, 1.0, -1.0, 0.016)
|
|
362
|
+
step(b, 1.0, -1.0, 0.016)
|
|
363
|
+
assert a.ship.x == b.ship.x
|
|
364
|
+
assert a.score == b.score
|
|
365
|
+
assert a.mines.at(0).x == b.mines.at(0).x
|
|
366
|
+
assert newWorld(12, -1).orbs.at(0).x != a.orbs.at(0).x
|
|
367
|
+
|
|
368
|
+
test "animation frames cycle"
|
|
369
|
+
assert frameOf(0.0, 4, 8.0) == 0
|
|
370
|
+
assert frameOf(0.13, 4, 8.0) == 1
|
|
371
|
+
assert frameOf(0.5, 4, 8.0) == 0
|
|
372
|
+
assert axis(true, false) == -1.0
|
|
373
|
+
assert axis(true, true) == 0.0
|