plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
f689ac7
— Peter John
2026-11-02T13:51:37+05:30
T6: KaM map viewer (P1)
- plum-game/kam/Main.plum +258 -0
plum-game/kam/Main.plum
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# A Knights and Merchants map viewer: terrain tiles and map objects drawn through
|
|
2
|
+
# the game host, a camera that pans (arrows / WASD) and zooms about the mouse
|
|
3
|
+
# (wheel), and a minimap in the bottom-right corner that centres the camera on click.
|
|
4
|
+
module main
|
|
5
|
+
import std/Str
|
|
6
|
+
import std/Int
|
|
7
|
+
import std/Float
|
|
8
|
+
import std/Bool
|
|
9
|
+
import std/Array
|
|
10
|
+
import std/Grid
|
|
11
|
+
import std/Vec2
|
|
12
|
+
import std/Color
|
|
13
|
+
import std/Game
|
|
14
|
+
|
|
15
|
+
primitive View =
|
|
16
|
+
TileSize = 32.0 # world units per tile
|
|
17
|
+
MinScale = 0.25
|
|
18
|
+
MaxScale = 4.0
|
|
19
|
+
PanSpeed = 600.0 # screen pixels per second
|
|
20
|
+
MiniSize = 160.0 # minimap's longest side in screen pixels
|
|
21
|
+
MiniMargin = 8.0
|
|
22
|
+
MapPath = "Maps/A new Beginning/A new Beginning.map"
|
|
23
|
+
|
|
24
|
+
# The inclusive tile columns and rows to draw.
|
|
25
|
+
struct TileRange(x0: Int, y0: Int, x1: Int, y1: Int)
|
|
26
|
+
|
|
27
|
+
# The minimap's placement on screen: top-left corner and pixels per tile.
|
|
28
|
+
struct MiniRect(x: Float, y: Float, cell: Float)
|
|
29
|
+
|
|
30
|
+
# World point (x, y) sits at the screen's top-left; one world unit is `scale` pixels.
|
|
31
|
+
record Camera(x: Float, y: Float, scale: Float) =
|
|
32
|
+
|
|
33
|
+
fun screenToWorld(self, p: Vec2) -> Vec2 =
|
|
34
|
+
Vec2(x: self.x + p.x / self.scale, y: self.y + p.y / self.scale)
|
|
35
|
+
|
|
36
|
+
fun worldToScreen(self, p: Vec2) -> Vec2 =
|
|
37
|
+
Vec2(x: {p.x - self.x} * self.scale, y: {p.y - self.y} * self.scale)
|
|
38
|
+
|
|
39
|
+
# Keeps the view inside a `w` x `h` world on a `sw` x `sh` screen; an axis the
|
|
40
|
+
# world doesn't fill is centred.
|
|
41
|
+
fun clampTo(self, w: Float, h: Float, sw: Float, sh: Float) =
|
|
42
|
+
vw := sw / self.scale
|
|
43
|
+
vh := sh / self.scale
|
|
44
|
+
if vw >= w
|
|
45
|
+
self.x = {w - vw} / 2.0
|
|
46
|
+
else
|
|
47
|
+
self.x = self.x.max(0.0).min(w - vw)
|
|
48
|
+
if vh >= h
|
|
49
|
+
self.y = {h - vh} / 2.0
|
|
50
|
+
else
|
|
51
|
+
self.y = self.y.max(0.0).min(h - vh)
|
|
52
|
+
|
|
53
|
+
# Multiplies the zoom by `factor` (scale stays within MinScale..MaxScale) keeping the
|
|
54
|
+
# world point under `screen` fixed.
|
|
55
|
+
fun zoomAt(self, screen: Vec2, factor: Float) =
|
|
56
|
+
anchor := self.screenToWorld(screen)
|
|
57
|
+
self.scale = {self.scale * factor}.max(View.MinScale).min(View.MaxScale)
|
|
58
|
+
self.x = anchor.x - screen.x / self.scale
|
|
59
|
+
self.y = anchor.y - screen.y / self.scale
|
|
60
|
+
|
|
61
|
+
# Moves the view so world point `p` is at the screen's centre.
|
|
62
|
+
fun centerOn(self, p: Vec2, sw: Float, sh: Float) =
|
|
63
|
+
self.x = p.x - sw / self.scale / 2.0
|
|
64
|
+
self.y = p.y - sh / self.scale / 2.0
|
|
65
|
+
|
|
66
|
+
# Tiles touching the screen, including partial ones, clamped to a `mapW` x `mapH` map.
|
|
67
|
+
fun visibleTiles(self, mapW: Int, mapH: Int, sw: Float, sh: Float) -> TileRange =
|
|
68
|
+
left := self.x.max(0.0)
|
|
69
|
+
top := self.y.max(0.0)
|
|
70
|
+
right := {self.x + sw / self.scale}.max(0.0)
|
|
71
|
+
bottom := {self.y + sh / self.scale}.max(0.0)
|
|
72
|
+
TileRange(
|
|
73
|
+
x0: Int(left / View.TileSize).min(mapW - 1),
|
|
74
|
+
y0: Int(top / View.TileSize).min(mapH - 1),
|
|
75
|
+
x1: Int(right / View.TileSize).min(mapW - 1),
|
|
76
|
+
y1: Int(bottom / View.TileSize).min(mapH - 1))
|
|
77
|
+
|
|
78
|
+
fun miniRect(sw: Float, sh: Float, mapW: Int, mapH: Int) -> MiniRect =
|
|
79
|
+
longest := Float(mapW.max(mapH))
|
|
80
|
+
cell := View.MiniSize / longest
|
|
81
|
+
MiniRect(
|
|
82
|
+
x: sw - View.MiniMargin - cell * Float(mapW),
|
|
83
|
+
y: sh - View.MiniMargin - cell * Float(mapH),
|
|
84
|
+
cell: cell)
|
|
85
|
+
|
|
86
|
+
# World point under screen point `p` of the minimap, or null when `p` is outside it.
|
|
87
|
+
fun miniToWorld(m: MiniRect, mapW: Int, mapH: Int, p: Vec2) -> Vec2? =
|
|
88
|
+
tx := {p.x - m.x} / m.cell
|
|
89
|
+
ty := {p.y - m.y} / m.cell
|
|
90
|
+
if tx < 0.0 || ty < 0.0 || tx >= Float(mapW) || ty >= Float(mapH)
|
|
91
|
+
return null
|
|
92
|
+
Vec2(x: tx * View.TileSize, y: ty * View.TileSize)
|
|
93
|
+
|
|
94
|
+
record Viewer(
|
|
95
|
+
cam: Camera,
|
|
96
|
+
ok: Bool,
|
|
97
|
+
mapW: Int,
|
|
98
|
+
mapH: Int,
|
|
99
|
+
tileset: Int,
|
|
100
|
+
trees: Int,
|
|
101
|
+
terrain: Grid[Int],
|
|
102
|
+
rotation: Grid[Int],
|
|
103
|
+
objects: Grid[Int],
|
|
104
|
+
mini: Array[Color])
|
|
105
|
+
|
|
106
|
+
export fun init() -> Viewer =
|
|
107
|
+
tileset := atlasLoad("Tileset")
|
|
108
|
+
trees := atlasLoad("Trees_a")
|
|
109
|
+
m := mapLoad(View.MapPath)
|
|
110
|
+
cam := Camera(x: 0.0, y: 0.0, scale: 1.0)
|
|
111
|
+
if tileset < 0 || trees < 0 || m < 0
|
|
112
|
+
return Viewer(
|
|
113
|
+
cam: cam, ok: false, mapW: 1, mapH: 1, tileset: tileset, trees: trees,
|
|
114
|
+
terrain: Grid.filled(1, 1, 0), rotation: Grid.filled(1, 1, 0),
|
|
115
|
+
objects: Grid.filled(1, 1, 0), mini: Array[Color]())
|
|
116
|
+
w := mapWidth(m)
|
|
117
|
+
h := mapHeight(m)
|
|
118
|
+
terrain := Grid.filled(w, h, 0)
|
|
119
|
+
rotation := Grid.filled(w, h, 0)
|
|
120
|
+
objects := Grid.filled(w, h, 0)
|
|
121
|
+
mini := Array[Color]()
|
|
122
|
+
for y := range h
|
|
123
|
+
for x := range w
|
|
124
|
+
t := mapTerrain(m, x, y)
|
|
125
|
+
terrain.set(x, y, t)
|
|
126
|
+
rotation.set(x, y, mapRotation(m, x, y))
|
|
127
|
+
objects.set(x, y, mapObject(m, x, y))
|
|
128
|
+
mini.push(spriteColor(tileset, t + 1))
|
|
129
|
+
Viewer(
|
|
130
|
+
cam: cam, ok: true, mapW: w, mapH: h, tileset: tileset, trees: trees,
|
|
131
|
+
terrain: terrain, rotation: rotation, objects: objects, mini: mini)
|
|
132
|
+
|
|
133
|
+
fun worldW(v: Viewer) -> Float =
|
|
134
|
+
Float(v.mapW) * View.TileSize
|
|
135
|
+
|
|
136
|
+
fun worldH(v: Viewer) -> Float =
|
|
137
|
+
Float(v.mapH) * View.TileSize
|
|
138
|
+
|
|
139
|
+
export fun update(v: Viewer, dt: Float) =
|
|
140
|
+
if !v.ok
|
|
141
|
+
return
|
|
142
|
+
sw := screenWidth()
|
|
143
|
+
sh := screenHeight()
|
|
144
|
+
step := View.PanSpeed * dt / v.cam.scale
|
|
145
|
+
if isKeyDown(Key.Left) || isKeyDown(Key.KeyA)
|
|
146
|
+
v.cam.x = v.cam.x - step
|
|
147
|
+
if isKeyDown(Key.Right) || isKeyDown(Key.KeyD)
|
|
148
|
+
v.cam.x = v.cam.x + step
|
|
149
|
+
if isKeyDown(Key.Up) || isKeyDown(Key.KeyW)
|
|
150
|
+
v.cam.y = v.cam.y - step
|
|
151
|
+
if isKeyDown(Key.Down) || isKeyDown(Key.KeyS)
|
|
152
|
+
v.cam.y = v.cam.y + step
|
|
153
|
+
wheel := mouseWheel()
|
|
154
|
+
if wheel != 0.0
|
|
155
|
+
v.cam.zoomAt(mousePos(), Float(1.1).pow(wheel))
|
|
156
|
+
if isMouseDown(Mouse.Left)
|
|
157
|
+
target := miniToWorld(miniRect(sw, sh, v.mapW, v.mapH), v.mapW, v.mapH, mousePos())
|
|
158
|
+
if t := target
|
|
159
|
+
v.cam.centerOn(t, sw, sh)
|
|
160
|
+
v.cam.clampTo(worldW(v), worldH(v), sw, sh)
|
|
161
|
+
|
|
162
|
+
export fun draw(v: Viewer) =
|
|
163
|
+
clear(Color.Black)
|
|
164
|
+
sw := screenWidth()
|
|
165
|
+
sh := screenHeight()
|
|
166
|
+
if !v.ok
|
|
167
|
+
drawText("assets not found", 20.0, 40.0, 24.0, Color.White)
|
|
168
|
+
return
|
|
169
|
+
setCamera(v.cam.x, v.cam.y, v.cam.scale)
|
|
170
|
+
r := v.cam.visibleTiles(v.mapW, v.mapH, sw, sh)
|
|
171
|
+
for y := range r.y1 - r.y0 + 1
|
|
172
|
+
for x := range r.x1 - r.x0 + 1
|
|
173
|
+
tx := r.x0 + x
|
|
174
|
+
ty := r.y0 + y
|
|
175
|
+
drawTile(v.tileset, v.terrain.get(tx, ty) + 1, Float(tx) * View.TileSize,
|
|
176
|
+
Float(ty) * View.TileSize, View.TileSize, v.rotation.get(tx, ty), Color.White)
|
|
177
|
+
for y := range r.y1 - r.y0 + 1
|
|
178
|
+
for x := range r.x1 - r.x0 + 1
|
|
179
|
+
tx := r.x0 + x
|
|
180
|
+
ty := r.y0 + y
|
|
181
|
+
o := v.objects.get(tx, ty)
|
|
182
|
+
if o != 0
|
|
183
|
+
drawSprite(v.trees, o, Float(tx) * View.TileSize, Float(ty) * View.TileSize,
|
|
184
|
+
false, Color.White)
|
|
185
|
+
resetCamera()
|
|
186
|
+
drawMinimap(v, sw, sh)
|
|
187
|
+
drawText("FPS {Int(1.0 / frameTime().max(0.0001))}", 8.0, 20.0, 20.0, Color.Yellow)
|
|
188
|
+
|
|
189
|
+
fun drawMinimap(v: Viewer, sw: Float, sh: Float) =
|
|
190
|
+
m := miniRect(sw, sh, v.mapW, v.mapH)
|
|
191
|
+
for y := range v.mapH
|
|
192
|
+
for x := range v.mapW
|
|
193
|
+
drawRect(m.x + Float(x) * m.cell, m.y + Float(y) * m.cell, m.cell, m.cell,
|
|
194
|
+
v.mini.get(y * v.mapW + x))
|
|
195
|
+
vx := m.x + v.cam.x * m.cell / View.TileSize
|
|
196
|
+
vy := m.y + v.cam.y * m.cell / View.TileSize
|
|
197
|
+
vw := sw / v.cam.scale * m.cell / View.TileSize
|
|
198
|
+
vh := sh / v.cam.scale * m.cell / View.TileSize
|
|
199
|
+
drawLine(vx, vy, vx + vw, vy, 1.0, Color.White)
|
|
200
|
+
drawLine(vx + vw, vy, vx + vw, vy + vh, 1.0, Color.White)
|
|
201
|
+
drawLine(vx + vw, vy + vh, vx, vy + vh, 1.0, Color.White)
|
|
202
|
+
drawLine(vx, vy + vh, vx, vy, 1.0, Color.White)
|
|
203
|
+
|
|
204
|
+
test "screenToWorld and worldToScreen are inverses"
|
|
205
|
+
c := Camera(x: 100.0, y: 50.0, scale: 2.0)
|
|
206
|
+
assert c.screenToWorld(Vec2(x: 20.0, y: 10.0)) == Vec2(x: 110.0, y: 55.0)
|
|
207
|
+
assert c.worldToScreen(Vec2(x: 110.0, y: 55.0)) == Vec2(x: 20.0, y: 10.0)
|
|
208
|
+
|
|
209
|
+
test "zoomAt keeps the world point under the mouse fixed and clamps the scale"
|
|
210
|
+
c := Camera(x: 100.0, y: 50.0, scale: 2.0)
|
|
211
|
+
m := Vec2(x: 300.0, y: 200.0)
|
|
212
|
+
before := c.screenToWorld(m)
|
|
213
|
+
c.zoomAt(m, 1.5)
|
|
214
|
+
assert c.scale == 3.0
|
|
215
|
+
assert c.screenToWorld(m).distance(before) < 0.000001
|
|
216
|
+
c.zoomAt(m, 100.0)
|
|
217
|
+
assert c.scale == 4.0
|
|
218
|
+
c.zoomAt(m, 0.0001)
|
|
219
|
+
assert c.scale == 0.25
|
|
220
|
+
|
|
221
|
+
test "visibleTiles covers the partial tile and clamps to the map"
|
|
222
|
+
c := Camera(x: 0.0, y: 0.0, scale: 1.0)
|
|
223
|
+
r := c.visibleTiles(64, 64, 800.0, 600.0)
|
|
224
|
+
assert r.x0 == 0
|
|
225
|
+
assert r.y0 == 0
|
|
226
|
+
assert r.x1 == 25
|
|
227
|
+
assert r.y1 == 18
|
|
228
|
+
small := c.visibleTiles(10, 8, 800.0, 600.0)
|
|
229
|
+
assert small.x1 == 9
|
|
230
|
+
assert small.y1 == 7
|
|
231
|
+
far := Camera(x: 3000.0, y: 3000.0, scale: 1.0).visibleTiles(64, 64, 800.0, 600.0)
|
|
232
|
+
assert far.x1 == 63
|
|
233
|
+
assert far.y1 == 63
|
|
234
|
+
|
|
235
|
+
test "clampTo keeps the view in the map and centres a small map"
|
|
236
|
+
c := Camera(x: -50.0, y: 5000.0, scale: 1.0)
|
|
237
|
+
c.clampTo(2048.0, 2048.0, 800.0, 600.0)
|
|
238
|
+
assert c.x == 0.0
|
|
239
|
+
assert c.y == 1448.0
|
|
240
|
+
c.x = 5000.0
|
|
241
|
+
c.clampTo(2048.0, 2048.0, 800.0, 600.0)
|
|
242
|
+
assert c.x == 1248.0
|
|
243
|
+
s := Camera(x: 99.0, y: 99.0, scale: 1.0)
|
|
244
|
+
s.clampTo(320.0, 240.0, 800.0, 600.0)
|
|
245
|
+
assert s.x == -240.0
|
|
246
|
+
assert s.y == -180.0
|
|
247
|
+
|
|
248
|
+
test "a minimap click centres the camera"
|
|
249
|
+
m := miniRect(800.0, 600.0, 64, 64)
|
|
250
|
+
assert m.cell == 2.5
|
|
251
|
+
assert m.x == 632.0
|
|
252
|
+
assert m.y == 432.0
|
|
253
|
+
w := miniToWorld(m, 64, 64, Vec2(x: 632.0 + 25.0, y: 432.0 + 50.0)) ?: Vec2.Zero
|
|
254
|
+
assert w == Vec2(x: 320.0, y: 640.0)
|
|
255
|
+
assert miniToWorld(m, 64, 64, Vec2(x: 10.0, y: 10.0)) == null
|
|
256
|
+
c := Camera(x: 0.0, y: 0.0, scale: 2.0)
|
|
257
|
+
c.centerOn(w, 800.0, 600.0)
|
|
258
|
+
assert c.screenToWorld(Vec2(x: 400.0, y: 300.0)) == w
|