Resumed to project. INIT
@@ -0,0 +1,7 @@
|
||||
# Rust build artifacts
|
||||
/target
|
||||
|
||||
# Editor / OS cruft
|
||||
*.swp
|
||||
*~
|
||||
.DS_Store
|
||||
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["shared", "client", "server"]
|
||||
resolver = "2"
|
||||
@@ -0,0 +1,260 @@
|
||||
GIMP Palette
|
||||
Name: RGB332
|
||||
Columns: 16
|
||||
#
|
||||
0 0 0 Index 0
|
||||
255 0 0 Index 1
|
||||
0 0 170 Index 2
|
||||
0 0 255 Index 3
|
||||
0 36 0 Index 4
|
||||
0 36 85 Index 5
|
||||
0 36 170 Index 6
|
||||
0 36 255 Index 7
|
||||
0 72 0 Index 8
|
||||
0 72 85 Index 9
|
||||
0 72 170 Index 10
|
||||
0 72 255 Index 11
|
||||
0 109 0 Index 12
|
||||
0 109 85 Index 13
|
||||
0 109 170 Index 14
|
||||
0 109 255 Index 15
|
||||
0 145 0 Index 16
|
||||
0 145 85 Index 17
|
||||
0 145 170 Index 18
|
||||
0 145 255 Index 19
|
||||
0 182 0 Index 20
|
||||
0 182 85 Index 21
|
||||
0 182 170 Index 22
|
||||
0 182 255 Index 23
|
||||
0 218 0 Index 24
|
||||
0 218 85 Index 25
|
||||
0 218 170 Index 26
|
||||
0 218 255 Index 27
|
||||
0 255 0 Index 28
|
||||
0 255 85 Index 29
|
||||
0 255 170 Index 30
|
||||
0 255 255 Index 31
|
||||
36 0 0 Index 32
|
||||
36 0 85 Index 33
|
||||
36 0 170 Index 34
|
||||
36 0 255 Index 35
|
||||
36 36 0 Index 36
|
||||
36 36 85 Index 37
|
||||
36 36 170 Index 38
|
||||
36 36 255 Index 39
|
||||
36 72 0 Index 40
|
||||
36 72 85 Index 41
|
||||
36 72 170 Index 42
|
||||
36 72 255 Index 43
|
||||
36 109 0 Index 44
|
||||
36 109 85 Index 45
|
||||
36 109 170 Index 46
|
||||
36 109 255 Index 47
|
||||
36 145 0 Index 48
|
||||
36 145 85 Index 49
|
||||
36 145 170 Index 50
|
||||
36 145 255 Index 51
|
||||
36 182 0 Index 52
|
||||
36 182 85 Index 53
|
||||
36 182 170 Index 54
|
||||
36 182 255 Index 55
|
||||
36 218 0 Index 56
|
||||
36 218 85 Index 57
|
||||
36 218 170 Index 58
|
||||
36 218 255 Index 59
|
||||
36 255 0 Index 60
|
||||
36 255 85 Index 61
|
||||
36 255 170 Index 62
|
||||
36 255 255 Index 63
|
||||
72 0 0 Index 64
|
||||
72 0 85 Index 65
|
||||
72 0 170 Index 66
|
||||
72 0 255 Index 67
|
||||
72 36 0 Index 68
|
||||
72 36 85 Index 69
|
||||
72 36 170 Index 70
|
||||
72 36 255 Index 71
|
||||
72 72 0 Index 72
|
||||
72 72 85 Index 73
|
||||
72 72 170 Index 74
|
||||
72 72 255 Index 75
|
||||
72 109 0 Index 76
|
||||
72 109 85 Index 77
|
||||
72 109 170 Index 78
|
||||
72 109 255 Index 79
|
||||
72 145 0 Index 80
|
||||
72 145 85 Index 81
|
||||
72 145 170 Index 82
|
||||
72 145 255 Index 83
|
||||
72 182 0 Index 84
|
||||
72 182 85 Index 85
|
||||
72 182 170 Index 86
|
||||
72 182 255 Index 87
|
||||
72 218 0 Index 88
|
||||
72 218 85 Index 89
|
||||
72 218 170 Index 90
|
||||
72 218 255 Index 91
|
||||
72 255 0 Index 92
|
||||
72 255 85 Index 93
|
||||
72 255 170 Index 94
|
||||
72 255 255 Index 95
|
||||
109 0 0 Index 96
|
||||
109 0 85 Index 97
|
||||
109 0 170 Index 98
|
||||
109 0 255 Index 99
|
||||
109 36 0 Index 100
|
||||
109 36 85 Index 101
|
||||
109 36 170 Index 102
|
||||
109 36 255 Index 103
|
||||
109 72 0 Index 104
|
||||
109 72 85 Index 105
|
||||
109 72 170 Index 106
|
||||
109 72 255 Index 107
|
||||
109 109 0 Index 108
|
||||
109 109 85 Index 109
|
||||
109 109 170 Index 110
|
||||
109 109 255 Index 111
|
||||
109 145 0 Index 112
|
||||
109 145 85 Index 113
|
||||
109 145 170 Index 114
|
||||
109 145 255 Index 115
|
||||
109 182 0 Index 116
|
||||
109 182 85 Index 117
|
||||
109 182 170 Index 118
|
||||
109 182 255 Index 119
|
||||
109 218 0 Index 120
|
||||
109 218 85 Index 121
|
||||
109 218 170 Index 122
|
||||
109 218 255 Index 123
|
||||
109 255 0 Index 124
|
||||
109 255 85 Index 125
|
||||
109 255 170 Index 126
|
||||
109 255 255 Index 127
|
||||
145 0 0 Index 128
|
||||
145 0 85 Index 129
|
||||
145 0 170 Index 130
|
||||
145 0 255 Index 131
|
||||
145 36 0 Index 132
|
||||
145 36 85 Index 133
|
||||
145 36 170 Index 134
|
||||
145 36 255 Index 135
|
||||
145 72 0 Index 136
|
||||
145 72 85 Index 137
|
||||
145 72 170 Index 138
|
||||
145 72 255 Index 139
|
||||
145 109 0 Index 140
|
||||
145 109 85 Index 141
|
||||
145 109 170 Index 142
|
||||
145 109 255 Index 143
|
||||
145 145 0 Index 144
|
||||
145 145 85 Index 145
|
||||
145 145 170 Index 146
|
||||
145 145 255 Index 147
|
||||
145 182 0 Index 148
|
||||
145 182 85 Index 149
|
||||
145 182 170 Index 150
|
||||
145 182 255 Index 151
|
||||
145 218 0 Index 152
|
||||
145 218 85 Index 153
|
||||
145 218 170 Index 154
|
||||
145 218 255 Index 155
|
||||
145 255 0 Index 156
|
||||
145 255 85 Index 157
|
||||
145 255 170 Index 158
|
||||
145 255 255 Index 159
|
||||
182 0 0 Index 160
|
||||
182 0 85 Index 161
|
||||
182 0 170 Index 162
|
||||
182 0 255 Index 163
|
||||
182 36 0 Index 164
|
||||
182 36 85 Index 165
|
||||
182 36 170 Index 166
|
||||
182 36 255 Index 167
|
||||
182 72 0 Index 168
|
||||
182 72 85 Index 169
|
||||
182 72 170 Index 170
|
||||
182 72 255 Index 171
|
||||
182 109 0 Index 172
|
||||
182 109 85 Index 173
|
||||
182 109 170 Index 174
|
||||
182 109 255 Index 175
|
||||
182 145 0 Index 176
|
||||
182 145 85 Index 177
|
||||
182 145 170 Index 178
|
||||
182 145 255 Index 179
|
||||
182 182 0 Index 180
|
||||
182 182 85 Index 181
|
||||
182 182 170 Index 182
|
||||
182 182 255 Index 183
|
||||
182 218 0 Index 184
|
||||
182 218 85 Index 185
|
||||
182 218 170 Index 186
|
||||
182 218 255 Index 187
|
||||
182 255 0 Index 188
|
||||
182 255 85 Index 189
|
||||
182 255 170 Index 190
|
||||
182 255 255 Index 191
|
||||
218 0 0 Index 192
|
||||
218 0 85 Index 193
|
||||
218 0 170 Index 194
|
||||
218 0 255 Index 195
|
||||
218 36 0 Index 196
|
||||
218 36 85 Index 197
|
||||
218 36 170 Index 198
|
||||
218 36 255 Index 199
|
||||
218 72 0 Index 200
|
||||
218 72 85 Index 201
|
||||
218 72 170 Index 202
|
||||
218 72 255 Index 203
|
||||
218 109 0 Index 204
|
||||
218 109 85 Index 205
|
||||
218 109 170 Index 206
|
||||
218 109 255 Index 207
|
||||
218 145 0 Index 208
|
||||
218 145 85 Index 209
|
||||
218 145 170 Index 210
|
||||
218 145 255 Index 211
|
||||
218 182 0 Index 212
|
||||
218 182 85 Index 213
|
||||
218 182 170 Index 214
|
||||
218 182 255 Index 215
|
||||
218 218 0 Index 216
|
||||
218 218 85 Index 217
|
||||
218 218 170 Index 218
|
||||
218 218 255 Index 219
|
||||
218 255 0 Index 220
|
||||
218 255 85 Index 221
|
||||
218 255 170 Index 222
|
||||
218 255 255 Index 223
|
||||
255 0 0 Index 224
|
||||
255 0 85 Index 225
|
||||
255 0 170 Index 226
|
||||
255 0 255 Index 227
|
||||
255 36 0 Index 228
|
||||
255 36 85 Index 229
|
||||
255 36 170 Index 230
|
||||
255 36 255 Index 231
|
||||
255 72 0 Index 232
|
||||
255 72 85 Index 233
|
||||
255 72 170 Index 234
|
||||
255 72 255 Index 235
|
||||
255 109 0 Index 236
|
||||
255 109 85 Index 237
|
||||
255 109 170 Index 238
|
||||
255 109 255 Index 239
|
||||
255 145 0 Index 240
|
||||
255 145 85 Index 241
|
||||
255 145 170 Index 242
|
||||
255 145 255 Index 243
|
||||
255 182 0 Index 244
|
||||
255 182 85 Index 245
|
||||
255 182 170 Index 246
|
||||
255 182 255 Index 247
|
||||
255 218 0 Index 248
|
||||
255 218 85 Index 249
|
||||
255 218 170 Index 250
|
||||
255 218 255 Index 251
|
||||
255 255 0 Index 252
|
||||
255 255 85 Index 253
|
||||
255 255 170 Index 254
|
||||
255 255 255 Index 255
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,17 @@
|
||||
Mini Medieval v2.3.1
|
||||
--------------------
|
||||
|
||||
|
||||
Enjoy my assets? Keep up to date by following me on itch or x.
|
||||
|
||||
https://v3x3d.itch.io/
|
||||
https://x.com/_V3X3D
|
||||
|
||||
You can also support me on Patreon for just a $1 each month.
|
||||
Your support helps me keep making assets and provides you with rewards.
|
||||
|
||||
https://www.patreon.com/V3X3D
|
||||
|
||||
|
||||
Cheers,
|
||||
-- VEXED
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,7 @@
|
||||
with open("rgb332.gpl", "w") as f:
|
||||
f.write("GIMP Palette\nName: RGB332\nColumns: 16\n#\n")
|
||||
for i in range(256):
|
||||
r = ((i >> 5) * 255) // 7
|
||||
g = (((i >> 2) & 0x7) * 255) // 7
|
||||
b = ((i & 0x3) * 255) // 3
|
||||
f.write(f"{r:3d} {g:3d} {b:3d} Index {i}\n")
|
||||
@@ -0,0 +1,260 @@
|
||||
GIMP Palette
|
||||
Name: RGB332
|
||||
Columns: 16
|
||||
#
|
||||
0 0 0 Index 0
|
||||
0 0 0 Index 1
|
||||
0 0 170 Index 2
|
||||
0 0 255 Index 3
|
||||
0 36 0 Index 4
|
||||
0 36 85 Index 5
|
||||
0 36 170 Index 6
|
||||
0 36 255 Index 7
|
||||
0 72 0 Index 8
|
||||
0 72 85 Index 9
|
||||
0 72 170 Index 10
|
||||
0 72 255 Index 11
|
||||
0 109 0 Index 12
|
||||
0 109 85 Index 13
|
||||
0 109 170 Index 14
|
||||
0 109 255 Index 15
|
||||
0 145 0 Index 16
|
||||
0 145 85 Index 17
|
||||
0 145 170 Index 18
|
||||
0 145 255 Index 19
|
||||
0 182 0 Index 20
|
||||
0 182 85 Index 21
|
||||
0 182 170 Index 22
|
||||
0 182 255 Index 23
|
||||
0 218 0 Index 24
|
||||
0 218 85 Index 25
|
||||
0 218 170 Index 26
|
||||
0 218 255 Index 27
|
||||
0 255 0 Index 28
|
||||
0 255 85 Index 29
|
||||
0 255 170 Index 30
|
||||
0 255 255 Index 31
|
||||
36 0 0 Index 32
|
||||
36 0 85 Index 33
|
||||
36 0 170 Index 34
|
||||
36 0 255 Index 35
|
||||
36 36 0 Index 36
|
||||
36 36 85 Index 37
|
||||
36 36 170 Index 38
|
||||
36 36 255 Index 39
|
||||
36 72 0 Index 40
|
||||
36 72 85 Index 41
|
||||
36 72 170 Index 42
|
||||
36 72 255 Index 43
|
||||
36 109 0 Index 44
|
||||
36 109 85 Index 45
|
||||
36 109 170 Index 46
|
||||
36 109 255 Index 47
|
||||
36 145 0 Index 48
|
||||
36 145 85 Index 49
|
||||
36 145 170 Index 50
|
||||
36 145 255 Index 51
|
||||
36 182 0 Index 52
|
||||
36 182 85 Index 53
|
||||
36 182 170 Index 54
|
||||
36 182 255 Index 55
|
||||
36 218 0 Index 56
|
||||
36 218 85 Index 57
|
||||
36 218 170 Index 58
|
||||
36 218 255 Index 59
|
||||
36 255 0 Index 60
|
||||
36 255 85 Index 61
|
||||
36 255 170 Index 62
|
||||
36 255 255 Index 63
|
||||
72 0 0 Index 64
|
||||
72 0 85 Index 65
|
||||
72 0 170 Index 66
|
||||
72 0 255 Index 67
|
||||
72 36 0 Index 68
|
||||
72 36 85 Index 69
|
||||
72 36 170 Index 70
|
||||
72 36 255 Index 71
|
||||
72 72 0 Index 72
|
||||
72 72 85 Index 73
|
||||
72 72 170 Index 74
|
||||
72 72 255 Index 75
|
||||
72 109 0 Index 76
|
||||
72 109 85 Index 77
|
||||
72 109 170 Index 78
|
||||
72 109 255 Index 79
|
||||
72 145 0 Index 80
|
||||
72 145 85 Index 81
|
||||
72 145 170 Index 82
|
||||
72 145 255 Index 83
|
||||
72 182 0 Index 84
|
||||
72 182 85 Index 85
|
||||
72 182 170 Index 86
|
||||
72 182 255 Index 87
|
||||
72 218 0 Index 88
|
||||
72 218 85 Index 89
|
||||
72 218 170 Index 90
|
||||
72 218 255 Index 91
|
||||
72 255 0 Index 92
|
||||
72 255 85 Index 93
|
||||
72 255 170 Index 94
|
||||
72 255 255 Index 95
|
||||
109 0 0 Index 96
|
||||
109 0 85 Index 97
|
||||
109 0 170 Index 98
|
||||
109 0 255 Index 99
|
||||
109 36 0 Index 100
|
||||
109 36 85 Index 101
|
||||
109 36 170 Index 102
|
||||
109 36 255 Index 103
|
||||
109 72 0 Index 104
|
||||
109 72 85 Index 105
|
||||
109 72 170 Index 106
|
||||
109 72 255 Index 107
|
||||
109 109 0 Index 108
|
||||
109 109 85 Index 109
|
||||
109 109 170 Index 110
|
||||
109 109 255 Index 111
|
||||
109 145 0 Index 112
|
||||
109 145 85 Index 113
|
||||
109 145 170 Index 114
|
||||
109 145 255 Index 115
|
||||
109 182 0 Index 116
|
||||
109 182 85 Index 117
|
||||
109 182 170 Index 118
|
||||
109 182 255 Index 119
|
||||
109 218 0 Index 120
|
||||
109 218 85 Index 121
|
||||
109 218 170 Index 122
|
||||
109 218 255 Index 123
|
||||
109 255 0 Index 124
|
||||
109 255 85 Index 125
|
||||
109 255 170 Index 126
|
||||
109 255 255 Index 127
|
||||
145 0 0 Index 128
|
||||
145 0 85 Index 129
|
||||
145 0 170 Index 130
|
||||
145 0 255 Index 131
|
||||
145 36 0 Index 132
|
||||
145 36 85 Index 133
|
||||
145 36 170 Index 134
|
||||
145 36 255 Index 135
|
||||
145 72 0 Index 136
|
||||
145 72 85 Index 137
|
||||
145 72 170 Index 138
|
||||
145 72 255 Index 139
|
||||
145 109 0 Index 140
|
||||
145 109 85 Index 141
|
||||
145 109 170 Index 142
|
||||
145 109 255 Index 143
|
||||
145 145 0 Index 144
|
||||
145 145 85 Index 145
|
||||
145 145 170 Index 146
|
||||
145 145 255 Index 147
|
||||
145 182 0 Index 148
|
||||
145 182 85 Index 149
|
||||
145 182 170 Index 150
|
||||
145 182 255 Index 151
|
||||
145 218 0 Index 152
|
||||
145 218 85 Index 153
|
||||
145 218 170 Index 154
|
||||
145 218 255 Index 155
|
||||
145 255 0 Index 156
|
||||
145 255 85 Index 157
|
||||
145 255 170 Index 158
|
||||
145 255 255 Index 159
|
||||
182 0 0 Index 160
|
||||
182 0 85 Index 161
|
||||
182 0 170 Index 162
|
||||
182 0 255 Index 163
|
||||
182 36 0 Index 164
|
||||
182 36 85 Index 165
|
||||
182 36 170 Index 166
|
||||
182 36 255 Index 167
|
||||
182 72 0 Index 168
|
||||
182 72 85 Index 169
|
||||
182 72 170 Index 170
|
||||
182 72 255 Index 171
|
||||
182 109 0 Index 172
|
||||
182 109 85 Index 173
|
||||
182 109 170 Index 174
|
||||
182 109 255 Index 175
|
||||
182 145 0 Index 176
|
||||
182 145 85 Index 177
|
||||
182 145 170 Index 178
|
||||
182 145 255 Index 179
|
||||
182 182 0 Index 180
|
||||
182 182 85 Index 181
|
||||
182 182 170 Index 182
|
||||
182 182 255 Index 183
|
||||
182 218 0 Index 184
|
||||
182 218 85 Index 185
|
||||
182 218 170 Index 186
|
||||
182 218 255 Index 187
|
||||
182 255 0 Index 188
|
||||
182 255 85 Index 189
|
||||
182 255 170 Index 190
|
||||
182 255 255 Index 191
|
||||
218 0 0 Index 192
|
||||
218 0 85 Index 193
|
||||
218 0 170 Index 194
|
||||
218 0 255 Index 195
|
||||
218 36 0 Index 196
|
||||
218 36 85 Index 197
|
||||
218 36 170 Index 198
|
||||
218 36 255 Index 199
|
||||
218 72 0 Index 200
|
||||
218 72 85 Index 201
|
||||
218 72 170 Index 202
|
||||
218 72 255 Index 203
|
||||
218 109 0 Index 204
|
||||
218 109 85 Index 205
|
||||
218 109 170 Index 206
|
||||
218 109 255 Index 207
|
||||
218 145 0 Index 208
|
||||
218 145 85 Index 209
|
||||
218 145 170 Index 210
|
||||
218 145 255 Index 211
|
||||
218 182 0 Index 212
|
||||
218 182 85 Index 213
|
||||
218 182 170 Index 214
|
||||
218 182 255 Index 215
|
||||
218 218 0 Index 216
|
||||
218 218 85 Index 217
|
||||
218 218 170 Index 218
|
||||
218 218 255 Index 219
|
||||
218 255 0 Index 220
|
||||
218 255 85 Index 221
|
||||
218 255 170 Index 222
|
||||
218 255 255 Index 223
|
||||
255 0 0 Index 224
|
||||
255 0 85 Index 225
|
||||
255 0 170 Index 226
|
||||
255 0 255 Index 227
|
||||
255 36 0 Index 228
|
||||
255 36 85 Index 229
|
||||
255 36 170 Index 230
|
||||
255 36 255 Index 231
|
||||
255 72 0 Index 232
|
||||
255 72 85 Index 233
|
||||
255 72 170 Index 234
|
||||
255 72 255 Index 235
|
||||
255 109 0 Index 236
|
||||
255 109 85 Index 237
|
||||
255 109 170 Index 238
|
||||
255 109 255 Index 239
|
||||
255 145 0 Index 240
|
||||
255 145 85 Index 241
|
||||
255 145 170 Index 242
|
||||
255 145 255 Index 243
|
||||
255 182 0 Index 244
|
||||
255 182 85 Index 245
|
||||
255 182 170 Index 246
|
||||
255 182 255 Index 247
|
||||
255 218 0 Index 248
|
||||
255 218 85 Index 249
|
||||
255 218 170 Index 250
|
||||
255 218 255 Index 251
|
||||
255 255 0 Index 252
|
||||
255 255 85 Index 253
|
||||
255 255 170 Index 254
|
||||
255 255 255 Index 255
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "client"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
shared = { path = "../shared" }
|
||||
pbio = { git = "https://codeberg.org/irrlicht/rust-pbio.git" }
|
||||
bytemuck = { version = "1.24.0", features = ["derive"] }
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Load an 8-bit indexed TGA (type 1 or type 9) from disk.
|
||||
pub fn from_tga(path: &str) -> Self {
|
||||
let mut file = File::open(path).unwrap();
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data).unwrap();
|
||||
|
||||
let image_type = data[2];
|
||||
let width = u16::from_le_bytes([data[12], data[13]]) as u32;
|
||||
let height = u16::from_le_bytes([data[14], data[15]]) as u32;
|
||||
let colormap_entries = u16::from_le_bytes([data[5], data[6]]) as usize;
|
||||
let colormap_entry_bytes = data[7] as usize / 8;
|
||||
let pixel_offset = 18 + colormap_entries * colormap_entry_bytes;
|
||||
|
||||
let pixel_count = (width * height) as usize;
|
||||
let mut pixels = vec![0u8; pixel_count];
|
||||
match image_type {
|
||||
1 => pixels.copy_from_slice(&data[pixel_offset..pixel_offset + pixel_count]),
|
||||
9 => decode_rle(&data[pixel_offset..], &mut pixels),
|
||||
_ => panic!("unsupported TGA image type: {}", image_type),
|
||||
}
|
||||
|
||||
Image { width, height, pixels }
|
||||
}
|
||||
|
||||
/// Split this image into 8×8 tiles, row-major. Tile ID = flat index into the Vec.
|
||||
pub fn to_tileset(&self) -> Vec<[u8; 64]> {
|
||||
let w = self.width as usize;
|
||||
let cols = w / 8;
|
||||
let rows = self.height as usize / 8;
|
||||
let mut tiles = Vec::with_capacity(rows * cols);
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let mut tile = [0u8; 64];
|
||||
for tr in 0..8 {
|
||||
for tc in 0..8 {
|
||||
tile[tr * 8 + tc] = self.pixels[w * (row * 8 + tr) + col * 8 + tc];
|
||||
}
|
||||
}
|
||||
tiles.push(tile);
|
||||
}
|
||||
}
|
||||
tiles
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_rle(src: &[u8], dst: &mut [u8]) {
|
||||
let mut si = 0;
|
||||
let mut di = 0;
|
||||
while di < dst.len() {
|
||||
let header = src[si];
|
||||
si += 1;
|
||||
let count = (header & 0x7f) as usize + 1;
|
||||
if header & 0x80 != 0 {
|
||||
dst[di..di + count].fill(src[si]);
|
||||
si += 1;
|
||||
} else {
|
||||
dst[di..di + count].copy_from_slice(&src[si..si + count]);
|
||||
si += count;
|
||||
}
|
||||
di += count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
mod pixelhelper;
|
||||
|
||||
use crate::assets::Image;
|
||||
use crate::input::{GameAction, InputState};
|
||||
use crate::net::{EntityInfo, NetClient, NetEvent};
|
||||
use shared::{chunk_id, player_action};
|
||||
|
||||
pub enum GameSignal {
|
||||
Quit,
|
||||
}
|
||||
|
||||
pub struct Game {
|
||||
#[allow(dead_code)]
|
||||
tileset: Vec<[u8; 64]>,
|
||||
entity_tileset: Vec<[u8; 64]>,
|
||||
net: NetClient,
|
||||
player_entity_id: u32,
|
||||
player_pos: (i32, i32),
|
||||
entities: Vec<EntityInfo>,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub fn start() -> Self {
|
||||
let image = Image::from_tga("assets/tilesets/overworld.tga");
|
||||
let tileset = image.to_tileset();
|
||||
let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset();
|
||||
|
||||
let server_addr = "127.0.0.1:7777".parse().unwrap();
|
||||
Game {
|
||||
tileset,
|
||||
entity_tileset,
|
||||
net: NetClient::new(server_addr),
|
||||
player_entity_id: 0,
|
||||
player_pos: (16, 16),
|
||||
entities: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, render_frame: &mut [u8], _dt: usize, input: &InputState)
|
||||
-> Option<GameSignal>
|
||||
{
|
||||
if input.button_held(GameAction::Up) { self.net.send_action(player_action::NORTH); }
|
||||
if input.button_held(GameAction::Down) { self.net.send_action(player_action::SOUTH); }
|
||||
if input.button_held(GameAction::Left) { self.net.send_action(player_action::WEST); }
|
||||
if input.button_held(GameAction::Right) { self.net.send_action(player_action::EAST); }
|
||||
|
||||
if input.button_pressed(GameAction::Confirm) {
|
||||
self.net.send_ping();
|
||||
println!("ping sent");
|
||||
}
|
||||
|
||||
for event in self.net.poll() {
|
||||
match event {
|
||||
NetEvent::Pong { rtt_ms } => println!("pong! rtt = {rtt_ms} ms"),
|
||||
NetEvent::State { tick, player_entity_id } => {
|
||||
println!("state tick={tick} player_entity_id={player_entity_id}");
|
||||
self.player_entity_id = player_entity_id;
|
||||
}
|
||||
NetEvent::Chunk { chunk_id } => println!("chunk cached id={chunk_id}"),
|
||||
NetEvent::Entity { entities } => {
|
||||
self.player_pos = entities.iter()
|
||||
.find(|e| e.id == self.player_entity_id)
|
||||
.map(|e| (e.pos_x as i32, e.pos_y as i32))
|
||||
.unwrap_or(self.player_pos);
|
||||
self.entities = entities;
|
||||
}
|
||||
NetEvent::Disconnected => println!("disconnected from server"),
|
||||
}
|
||||
}
|
||||
|
||||
if input.button_pressed(GameAction::Cancel) {
|
||||
println!("Goodbye!");
|
||||
return Some(GameSignal::Quit);
|
||||
}
|
||||
|
||||
self.render_viewport(render_frame);
|
||||
None
|
||||
}
|
||||
|
||||
fn render_viewport(&self, frame: &mut [u8]) {
|
||||
const WALL: u8 = 0x00;
|
||||
const W: usize = 320;
|
||||
|
||||
frame.fill(0);
|
||||
|
||||
let ox = self.player_pos.0 - 15;
|
||||
let oy = self.player_pos.1 - 15;
|
||||
|
||||
for vy in 0..30i32 {
|
||||
for vx in 0..30i32 {
|
||||
let wx = ox + vx;
|
||||
let wy = oy + vy;
|
||||
let cid = chunk_id(wx.div_euclid(32) as i16, wy.div_euclid(32) as i16);
|
||||
let tile_idx = wy.rem_euclid(32) as usize * 32 + wx.rem_euclid(32) as usize;
|
||||
let tile_id = self.net.chunk_cache.get(&cid)
|
||||
.map(|c| c.tiles[tile_idx])
|
||||
.unwrap_or(0);
|
||||
let px = vx * 8;
|
||||
let py = vy * 8;
|
||||
if let Some(tile) = self.tileset.get(tile_id as usize) {
|
||||
pixelhelper::blit_tile(frame, W, px, py, tile);
|
||||
} else {
|
||||
for dy in 0..8 {
|
||||
for dx in 0..8 {
|
||||
pixelhelper::set_pixel(frame, W, px + dx, py + dy, WALL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entity pass
|
||||
let ox = self.player_pos.0 - 15;
|
||||
let oy = self.player_pos.1 - 15;
|
||||
for e in &self.entities {
|
||||
let vx = e.pos_x as i32 - ox;
|
||||
let vy = e.pos_y as i32 - oy;
|
||||
if vx < 0 || vx >= 30 || vy < 0 || vy >= 30 { continue; }
|
||||
if let Some(tile) = self.entity_tileset.get(e.type_id as usize) {
|
||||
pixelhelper::blit_tile(frame, W, vx * 8, vy * 8, tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/// Write a single palette index. Out-of-bounds coordinates are silently ignored.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_pixel(dst: &mut [u8], dst_w: usize, x: i32, y: i32, color: u8) {
|
||||
let dst_h = (dst.len() / dst_w) as i32;
|
||||
if x < 0 || x >= dst_w as i32 || y < 0 || y >= dst_h {
|
||||
return;
|
||||
}
|
||||
dst[dst_w * y as usize + x as usize] = color;
|
||||
}
|
||||
|
||||
/// Copy a rectangle out of `src` into a new Vec (for building sprites/glyphs).
|
||||
#[allow(dead_code)]
|
||||
pub fn extract(src: &[u8], src_w: usize, x: usize, y: usize, w: usize, h: usize) -> Vec<u8> {
|
||||
let mut out = vec![0u8; w * h];
|
||||
for row in 0..h {
|
||||
let s = src_w * (y + row) + x;
|
||||
let d = w * row;
|
||||
out[d..d + w].copy_from_slice(&src[s..s + w]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Blit `src` (width `src_w`, height derived from src.len()) into `dst` at (dx, dy).
|
||||
/// Clips against all four edges of `dst`; the destination position may be negative.
|
||||
#[allow(dead_code)]
|
||||
pub fn blit(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, src: &[u8], src_w: usize) {
|
||||
let dst_w_i = dst_w as i32;
|
||||
let dst_h = (dst.len() / dst_w) as i32;
|
||||
let src_h = (src.len() / src_w) as i32;
|
||||
|
||||
for row in 0..src_h {
|
||||
let y = dy + row;
|
||||
if y < 0 || y >= dst_h {
|
||||
continue;
|
||||
}
|
||||
// Clip the horizontal span once per row, then copy it in one shot.
|
||||
let x0 = dx.max(0);
|
||||
let x1 = (dx + src_w as i32).min(dst_w_i);
|
||||
if x0 >= x1 {
|
||||
continue;
|
||||
}
|
||||
let d = dst_w * y as usize + x0 as usize;
|
||||
let s = (row * src_w as i32 + (x0 - dx)) as usize;
|
||||
let len = (x1 - x0) as usize;
|
||||
dst[d..d + len].copy_from_slice(&src[s..s + len]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Blit an 8×8 tile with transparency: index 0 is skipped.
|
||||
/// Clips against all four edges of `dst`; the destination position may be negative.
|
||||
pub fn blit_tile(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, tile: &[u8; 64]) {
|
||||
let dst_w_i = dst_w as i32;
|
||||
let dst_h = (dst.len() / dst_w) as i32;
|
||||
|
||||
for row in 0..8i32 {
|
||||
let y = dy + row;
|
||||
if y < 0 || y >= dst_h {
|
||||
continue;
|
||||
}
|
||||
for col in 0..8i32 {
|
||||
let x = dx + col;
|
||||
if x < 0 || x >= dst_w_i {
|
||||
continue;
|
||||
}
|
||||
let px = tile[(row * 8 + col) as usize];
|
||||
if px != 0 {
|
||||
dst[dst_w * y as usize + x as usize] = px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use pbio::Key;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GameAction {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Confirm,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub struct InputMap {
|
||||
bindings: Vec<(Key, GameAction)>,
|
||||
}
|
||||
|
||||
impl InputMap {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
bindings: vec![
|
||||
(Key::Up, GameAction::Up),
|
||||
(Key::Down, GameAction::Down),
|
||||
(Key::Left, GameAction::Left),
|
||||
(Key::Right, GameAction::Right),
|
||||
(Key::W, GameAction::Up),
|
||||
(Key::S, GameAction::Down),
|
||||
(Key::A, GameAction::Left),
|
||||
(Key::D, GameAction::Right),
|
||||
(Key::Enter, GameAction::Confirm),
|
||||
(Key::Escape, GameAction::Cancel),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn translate(&self, key: Key) -> Option<GameAction> {
|
||||
self.bindings.iter().find(|(k, _)| *k == key).map(|(_, a)| *a)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InputState {
|
||||
pressed: Vec<GameAction>,
|
||||
held: Vec<GameAction>,
|
||||
released: Vec<GameAction>,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
pub fn new() -> Self {
|
||||
Self { pressed: Vec::new(), held: Vec::new(), released: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn push(&mut self, action: GameAction) {
|
||||
self.pressed.push(action);
|
||||
if !self.held.contains(&action) {
|
||||
self.held.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn release(&mut self, action: GameAction) {
|
||||
self.held.retain(|a| *a != action);
|
||||
if !self.released.contains(&action) {
|
||||
self.released.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn button_pressed(&self, action: GameAction) -> bool {
|
||||
self.pressed.contains(&action)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn button_held(&self, action: GameAction) -> bool {
|
||||
self.held.contains(&action)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn button_released(&self, action: GameAction) -> bool {
|
||||
self.released.contains(&action)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.pressed.clear();
|
||||
self.released.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use pbio::{Event, Platform, PlatformConfig};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod assets;
|
||||
mod game;
|
||||
mod input;
|
||||
mod net;
|
||||
|
||||
fn main() {
|
||||
let mut plat = Platform::new(PlatformConfig {
|
||||
title: "Roguelike".into(),
|
||||
window_size: (800, 600),
|
||||
framebuffer_size: (320, 240),
|
||||
aspect_ratio: Some(4.0 / 3.0),
|
||||
palette: pbio::palette::RGB332,
|
||||
vsync: true,
|
||||
mouse_capture: false,
|
||||
mouse_visible: true,
|
||||
});
|
||||
|
||||
let input_map = input::InputMap::new();
|
||||
let mut input_state = input::InputState::new();
|
||||
let mut last_update = Instant::now();
|
||||
let mut game = game::Game::start();
|
||||
|
||||
while !plat.should_close() {
|
||||
plat.poll_events(Some(Duration::from_millis(20)));
|
||||
|
||||
let events: Vec<Event> = plat.drain_events().collect();
|
||||
for ev in events {
|
||||
match ev {
|
||||
Event::Key { key, pressed: true, .. } => {
|
||||
if let Some(action) = input_map.translate(key) {
|
||||
input_state.push(action);
|
||||
}
|
||||
}
|
||||
Event::Key { key, pressed: false, .. } => {
|
||||
if let Some(action) = input_map.translate(key) {
|
||||
input_state.release(action);
|
||||
}
|
||||
}
|
||||
Event::CloseRequested => plat.request_close(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let dt = last_update.elapsed().as_millis();
|
||||
if dt < 16 { continue; }
|
||||
last_update = Instant::now();
|
||||
|
||||
if let Some(game::GameSignal::Quit) =
|
||||
game.update(plat.framebuffer_mut(), dt as usize, &input_state)
|
||||
{
|
||||
plat.request_close();
|
||||
}
|
||||
input_state.clear();
|
||||
|
||||
plat.present();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use std::collections::HashMap;
|
||||
use std::mem::size_of;
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::time::{Duration, Instant};
|
||||
use bytemuck::bytes_of;
|
||||
use shared::{ActionPacket, ChunkEntry, ChunkPacket, EntityPacket,
|
||||
Header, PingPacket, PongPacket, StatePacket,
|
||||
MAGIC, VERSION, packet_type};
|
||||
|
||||
pub struct CachedChunk {
|
||||
pub version: u16,
|
||||
pub tiles: Box<[u16; 1024]>, // decoded global tile IDs
|
||||
}
|
||||
|
||||
pub struct EntityInfo {
|
||||
pub id: u32,
|
||||
pub type_id: u16,
|
||||
pub pos_x: i16,
|
||||
pub pos_y: i16,
|
||||
pub hp: u16,
|
||||
pub hp_max: u16,
|
||||
pub flags: u8,
|
||||
}
|
||||
|
||||
pub enum NetEvent {
|
||||
Pong { rtt_ms: u64 },
|
||||
State { tick: u32, player_entity_id: u32 },
|
||||
Chunk { chunk_id: u32 },
|
||||
Entity { entities: Vec<EntityInfo> },
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
const FNV_BASIS: u32 = 2_166_136_261;
|
||||
const FNV_PRIME: u32 = 16_777_619;
|
||||
|
||||
pub struct NetClient {
|
||||
socket: UdpSocket,
|
||||
ping_sent_at: Option<Instant>,
|
||||
action_sequence: u32,
|
||||
last_action_sent: Instant,
|
||||
last_state_received: Option<Instant>,
|
||||
pub chunk_cache: HashMap<u32, CachedChunk>,
|
||||
last_state_chunks: Option<[ChunkEntry; 9]>,
|
||||
pending_entities: Vec<EntityInfo>,
|
||||
last_entity_checksum: u32,
|
||||
last_state_tick: u32,
|
||||
entity_checksum_accum: u32,
|
||||
}
|
||||
|
||||
impl NetClient {
|
||||
pub fn new(server_addr: SocketAddr) -> Self {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").expect("bind failed");
|
||||
socket.connect(server_addr).expect("connect failed");
|
||||
socket.set_nonblocking(true).expect("set_nonblocking failed");
|
||||
Self {
|
||||
socket,
|
||||
ping_sent_at: None,
|
||||
action_sequence: 0,
|
||||
last_action_sent: Instant::now(),
|
||||
last_state_received: None,
|
||||
chunk_cache: HashMap::new(),
|
||||
last_state_chunks: None,
|
||||
pending_entities: Vec::new(),
|
||||
last_entity_checksum: 0,
|
||||
last_state_tick: 0,
|
||||
entity_checksum_accum: FNV_BASIS,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_action(&mut self, player_action: u16) {
|
||||
self.action_sequence += 1;
|
||||
let packet = ActionPacket {
|
||||
header: Header::new(packet_type::ACTION),
|
||||
auth_token: 0,
|
||||
sequence: self.action_sequence,
|
||||
cache: {
|
||||
let mut cache = [ChunkEntry { chunk_id: 0, version: 0 }; 9];
|
||||
if let Some(slots) = self.last_state_chunks {
|
||||
for (i, slot) in slots.iter().enumerate() {
|
||||
let cid = slot.chunk_id;
|
||||
let ver = self.chunk_cache.get(&cid)
|
||||
.map(|c| c.version).unwrap_or(0);
|
||||
cache[i] = ChunkEntry { chunk_id: cid, version: ver };
|
||||
}
|
||||
}
|
||||
cache
|
||||
},
|
||||
player_action,
|
||||
};
|
||||
self.socket.send(bytes_of(&packet)).ok();
|
||||
self.last_action_sent = Instant::now();
|
||||
}
|
||||
|
||||
pub fn send_ping(&mut self) {
|
||||
let ping = PingPacket { header: Header::new(packet_type::PING), timestamp_ms: 0 };
|
||||
self.ping_sent_at = Some(Instant::now());
|
||||
self.socket.send(bytes_of(&ping)).ok();
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) -> Vec<NetEvent> {
|
||||
const KEEPALIVE: Duration = Duration::from_secs(2);
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
if self.last_action_sent.elapsed() >= KEEPALIVE {
|
||||
self.send_action(shared::player_action::NOOP);
|
||||
}
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut buf = [0u8; 1200];
|
||||
|
||||
loop {
|
||||
match self.socket.recv(&mut buf) {
|
||||
Ok(n) => {
|
||||
if n < size_of::<Header>() { continue; }
|
||||
let header: Header = *bytemuck::from_bytes(&buf[..size_of::<Header>()]);
|
||||
if header.magic != MAGIC || header.version != VERSION { continue; }
|
||||
|
||||
match header.packet_type {
|
||||
packet_type::PONG if n >= size_of::<PongPacket>() => {
|
||||
if let Some(sent_at) = self.ping_sent_at.take() {
|
||||
let rtt_ms = sent_at.elapsed().as_millis() as u64;
|
||||
events.push(NetEvent::Pong { rtt_ms });
|
||||
}
|
||||
}
|
||||
packet_type::STATE if n >= size_of::<StatePacket>() => {
|
||||
let pkt: StatePacket = *bytemuck::from_bytes(&buf[..size_of::<StatePacket>()]);
|
||||
self.last_state_received = Some(Instant::now());
|
||||
self.last_state_chunks = Some(pkt.chunks);
|
||||
self.last_entity_checksum = pkt.entity_checksum;
|
||||
self.last_state_tick = pkt.tick;
|
||||
self.pending_entities.clear();
|
||||
self.entity_checksum_accum = FNV_BASIS;
|
||||
events.push(NetEvent::State { tick: pkt.tick, player_entity_id: pkt.player_entity_id });
|
||||
}
|
||||
packet_type::ENTITY if n >= size_of::<EntityPacket>() => {
|
||||
let pkt: EntityPacket = *bytemuck::from_bytes(&buf[..size_of::<EntityPacket>()]);
|
||||
if pkt.tick != self.last_state_tick {
|
||||
// stale datagram from a previous state cycle
|
||||
} else {
|
||||
for i in 0..pkt.entity_count as usize {
|
||||
let e = pkt.entities[i];
|
||||
for byte in bytemuck::bytes_of(&e) {
|
||||
self.entity_checksum_accum ^= *byte as u32;
|
||||
self.entity_checksum_accum =
|
||||
self.entity_checksum_accum.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
self.pending_entities.push(EntityInfo {
|
||||
id: e.id,
|
||||
type_id: e.type_id,
|
||||
pos_x: e.pos_x,
|
||||
pos_y: e.pos_y,
|
||||
hp: e.hp,
|
||||
hp_max: e.hp_max,
|
||||
flags: e.entity_flags,
|
||||
});
|
||||
}
|
||||
if pkt.packet_flags & 1 == 0 {
|
||||
// last datagram in sequence
|
||||
if self.entity_checksum_accum != self.last_entity_checksum {
|
||||
self.send_action(shared::player_action::NOOP);
|
||||
}
|
||||
events.push(NetEvent::Entity {
|
||||
entities: std::mem::take(&mut self.pending_entities),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
packet_type::CHUNK if n >= size_of::<ChunkPacket>() => {
|
||||
let pkt: ChunkPacket = *bytemuck::from_bytes(&buf[..size_of::<ChunkPacket>()]);
|
||||
let id = pkt.chunk.chunk_id;
|
||||
let version = pkt.chunk.version;
|
||||
let palette = pkt.palette;
|
||||
let raw = pkt.tiles;
|
||||
let mut tiles = Box::new([0u16; 1024]);
|
||||
for i in 0..256usize {
|
||||
let b0 = raw[i * 3];
|
||||
let b1 = raw[i * 3 + 1];
|
||||
let b2 = raw[i * 3 + 2];
|
||||
let ia = (b0 & 0x3F) as usize;
|
||||
let ib = ((b0 >> 6) | ((b1 & 0x0F) << 2)) as usize;
|
||||
let ic = ((b1 >> 4) | ((b2 & 0x03) << 4)) as usize;
|
||||
let id_ = (b2 >> 2) as usize;
|
||||
tiles[i * 4] = palette[ia];
|
||||
tiles[i * 4 + 1] = palette[ib];
|
||||
tiles[i * 4 + 2] = palette[ic];
|
||||
tiles[i * 4 + 3] = palette[id_];
|
||||
}
|
||||
self.chunk_cache.insert(id, CachedChunk { version, tiles });
|
||||
events.push(NetEvent::Chunk { chunk_id: id });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(t) = self.last_state_received {
|
||||
if t.elapsed() >= TIMEOUT {
|
||||
self.last_state_received = None;
|
||||
events.push(NetEvent::Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Input Manager
|
||||
|
||||
## Current state
|
||||
|
||||
Input is fully decoupled from the windowing backend. `client/src/input.rs` owns all input
|
||||
types. `main.rs` translates `pbio::Event::Key` events into `GameAction` values each frame and
|
||||
stores them in an `InputState`. `game::update()` receives a `&InputState` and queries it with
|
||||
three distinct semantics. (The platform/windowing layer lives in the external `pbio` crate;
|
||||
game code only ever sees `pbio::Key`, never a raw backend type.)
|
||||
|
||||
## Architecture
|
||||
|
||||
### `GameAction` — `client/src/input.rs`
|
||||
|
||||
Logical actions the game cares about. Game code never sees backend key types.
|
||||
|
||||
```rust
|
||||
pub enum GameAction { Up, Down, Left, Right, Confirm, Cancel }
|
||||
```
|
||||
|
||||
### `InputMap` — `client/src/input.rs`
|
||||
|
||||
Translates a `pbio::Key` to an `Option<GameAction>`. All key bindings live here.
|
||||
|
||||
| Key | Action |
|
||||
|------------------|---------|
|
||||
| Up / W | Up |
|
||||
| Down / S | Down |
|
||||
| Left / A | Left |
|
||||
| Right / D | Right |
|
||||
| Enter | Confirm |
|
||||
| Escape | Cancel |
|
||||
|
||||
### `InputState` — `client/src/input.rs`
|
||||
|
||||
Three internal buffers, populated from `pbio::Event::Key` events in `main.rs`:
|
||||
|
||||
| Buffer | Lifetime | Populated by |
|
||||
|------------|----------------|---------------------------|
|
||||
| `pressed` | current frame | key-down event |
|
||||
| `held` | until released | key-down; cleared on up |
|
||||
| `released` | current frame | key-up event |
|
||||
|
||||
`clear()` is called after `game::update()` each frame. It clears `pressed` and `released`;
|
||||
`held` persists until the corresponding key-up event arrives.
|
||||
|
||||
#### Query API
|
||||
|
||||
```rust
|
||||
input.button_pressed(action) // true only on the frame the key went down
|
||||
input.button_held(action) // true every frame the key is physically held
|
||||
input.button_released(action) // true only on the frame the key was released
|
||||
```
|
||||
|
||||
## Data flow per frame
|
||||
|
||||
```
|
||||
pbio::Event::Key
|
||||
→ InputMap::translate(key)
|
||||
→ InputState::push / InputState::release
|
||||
→ game.update(&mut framebuffer, dt, &input_state) -> Option<GameSignal>
|
||||
→ input_state.clear()
|
||||
```
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should modifier keys (Shift, Ctrl) produce distinct actions, or be handled in the mapping?
|
||||
- How do we prepare for key rebinding?
|
||||
@@ -0,0 +1,35 @@
|
||||
# Asset Loader
|
||||
|
||||
## Current state
|
||||
|
||||
TGA loading is implemented in `client/src/assets.rs`. `Image::from_tga(path)` reads the file,
|
||||
parses the 18-byte header, skips the colormap block, and returns an `Image` — supporting
|
||||
type 1 (uncompressed) and type 9 (RLE) via the private `decode_rle()` function in the same
|
||||
module. `game.rs` uses it directly: `Image::from_tga("assets/tilesets/overworld.tga")`.
|
||||
|
||||
`Image` also provides `to_tileset() -> Vec<[u8; 64]>`, which splits the image into 8×8
|
||||
tiles row-major. Every tile is emitted; tile ID is the flat row-major index into the Vec,
|
||||
so tiles are never skipped or reordered. (Transparency is a per-pixel concern handled at
|
||||
blit time by `blit_tile`, not by the splitter.)
|
||||
|
||||
`AssetStore` (a named registry) is **not yet implemented**. Assets are currently loaded
|
||||
inline in `Game::start()`.
|
||||
|
||||
## Goal
|
||||
|
||||
Add an `AssetStore` so that game code can ask for an asset by name rather than loading
|
||||
files directly at the call site.
|
||||
|
||||
## Design notes
|
||||
|
||||
- `Image` struct: `width: u32`, `height: u32`, `pixels: Vec<u8>` — done.
|
||||
- A simple `AssetStore` could be a `HashMap<&'static str, Image>` loaded at startup.
|
||||
- No streaming needed — the whole game is small enough to load everything upfront in `start()`.
|
||||
- Palette index 0 is transparent when blitting sprites. This convention is enforced by
|
||||
`blit_tile()` in `pixelhelper.rs`; the loader itself does not need to handle it.
|
||||
- Pixel assets are stored as flat `Vec<u8>` of palette indices, row-major, width × height bytes.
|
||||
- Future: consider embedding assets with `include_bytes!` to produce a single binary.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should `AssetStore` hold pre-split tilesets too, or just raw `Image` values?
|
||||
@@ -0,0 +1,31 @@
|
||||
# Text Renderer
|
||||
|
||||
## Current state
|
||||
|
||||
No text rendering exists yet. No font loading or glyph blitting code exists.
|
||||
|
||||
## Goal
|
||||
|
||||
Draw ASCII text into the `u8` framebuffer using a bitmap font, respecting the palette-index
|
||||
color model (one u8 per pixel, no RGBA).
|
||||
|
||||
## Design notes
|
||||
|
||||
- A bitmap font is the right fit: each glyph is a small tile of palette indices, blitted
|
||||
directly into the framebuffer using the existing blit primitive in `pixelhelper.rs`.
|
||||
- Likely approach: a fixed 8×8 or similar glyph grid packed into a TGA or raw binary,
|
||||
one glyph per ASCII codepoint starting at 0x20 (space).
|
||||
- The font image itself should be indexed: foreground pixels carry a nonzero palette index
|
||||
(caller supplies the color), background pixels are index 0 (transparent/skip).
|
||||
- API sketch:
|
||||
```rust
|
||||
fn draw_text(frame: &mut [u8], font: &Font, x: i32, y: i32, color: u8, text: &str);
|
||||
```
|
||||
- `Font` holds the glyph sheet as an `Image` plus glyph width/height.
|
||||
|
||||
## Open questions
|
||||
|
||||
- How do we handle proportional spacing of different characters?
|
||||
- How do we store font data in memory?
|
||||
- How do we handle font color rendering?
|
||||
- How do we handle special characters like Ä, Ö, Ü, ß, etc.?
|
||||
@@ -0,0 +1,36 @@
|
||||
# UI Renderer
|
||||
|
||||
## Current state
|
||||
|
||||
No UI primitives exist. The framebuffer is currently filled by the game world only
|
||||
(`render_viewport` in `game.rs`: tile pass + entity sprite pass). `pixelhelper.rs` has
|
||||
low-level pixel/blit helpers but nothing higher-level.
|
||||
|
||||
## Goal
|
||||
|
||||
Draw common roguelike UI elements (panels, borders, HUD bars, etc.) procedurally into the
|
||||
`u8` framebuffer, composited on top of the game world.
|
||||
|
||||
## Design notes
|
||||
|
||||
The virtual resolution is 320×240, so UI layout should be designed in those pixel units.
|
||||
|
||||
Likely primitives needed (built on top of `pixelhelper.rs`):
|
||||
|
||||
- `fill_rect(frame, x, y, w, h, color)` — solid filled rectangle
|
||||
- `draw_rect(frame, x, y, w, h, color)` — 1-pixel border rectangle
|
||||
- `draw_border_box(frame, x, y, w, h, tileset)` — box drawn with corner/edge tiles from
|
||||
a tileset (classic roguelike panel look)
|
||||
- `draw_hbar(frame, x, y, w, value, max, fg, bg)` — horizontal progress/HP bar
|
||||
|
||||
Panels are typically fixed regions of the screen (e.g. a status bar at the bottom 40px,
|
||||
a message log on the right). Hard-coding these regions first is fine; extract to a layout
|
||||
system only if needed.
|
||||
|
||||
The UI layer draws after the world layer so it always appears on top. Draw order within
|
||||
the UI should be back-to-front (backgrounds before text).
|
||||
|
||||
## Open questions
|
||||
|
||||
- Tileset-based borders vs. line-drawing: which look are we going for?
|
||||
- Does the message log need scrolling? Probably not at first.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Hybrid Tick Loop
|
||||
|
||||
Single 24 Hz base tick; subsystems self-select frequency via stride scheduling. Replaces the old
|
||||
3-tier (20/10/1 Hz buckets) description from roadmap item 06.
|
||||
|
||||
---
|
||||
|
||||
## Stride scheduling
|
||||
|
||||
All subsystems hang off one monotonic `tick: u32` counter. Each subsystem fires when its
|
||||
stride condition is met. **Current implementation:**
|
||||
|
||||
```
|
||||
tick % 1 == 0 → input processing, inbound recv (main.rs)
|
||||
tick % 2 == send_phase → broadcast state to clients, phase-staggered (net.rs, 12 Hz/client)
|
||||
tick % 4 == 0 → entity_tick() — movement / collision (sim.rs, 6 Hz)
|
||||
tick % 8 == 0 → (reserved) slower AI routines, pathfinding refresh
|
||||
tick % 192 == 0 → (reserved) world simulation (8-sec cycle: weather, daylight, respawns)
|
||||
```
|
||||
|
||||
24 Hz is highly composite (divisors: 1, 2, 3, 4, 6, 8, 12, 24), giving flexible stride
|
||||
options without resorting to co-prime tricks. `tick >> 3` yields 3 Hz pulses; world-sim
|
||||
at `% 192` is exactly an 8-second cycle.
|
||||
|
||||
**Why `entity_tick` runs at 6 Hz, not 12 Hz:** without the action-point model (below), one
|
||||
`entity_tick` = one tile of movement, so the tick rate *is* the movement rate. 6 Hz yields the
|
||||
target ~6 tiles/sec. Once `energy`/`speed` are reintroduced, `entity_tick` can move up to 12 Hz
|
||||
(`% 2`) and per-entity `speed` sets the effective movement rate instead — decoupling sim rate
|
||||
from movement rate. The broadcast rate (12 Hz/client) is deliberately higher than the movement
|
||||
rate: it keeps position latency low and adds redundancy against packet loss.
|
||||
|
||||
---
|
||||
|
||||
## Action-point model (entity AI)
|
||||
|
||||
> **Status: not yet implemented.** Removed from `entity.rs` as premature (no NPCs need it yet).
|
||||
> Until it returns, `entity_tick` runs at 6 Hz to set the movement rate directly (see above).
|
||||
> Reintroduce alongside roadmap item 08 (NPCs).
|
||||
|
||||
Layered on top of `entity_tick()`. Replaces fixed per-entity cooldown timers.
|
||||
|
||||
Each entity carries:
|
||||
- `energy: i32` — accumulates each tick
|
||||
- `speed: u8` — added to energy every `entity_tick()`
|
||||
|
||||
An entity acts when `energy >= ACTION_COST`, then pays the cost:
|
||||
|
||||
```rust
|
||||
// inside entity_tick()
|
||||
entity.energy += entity.speed as i32;
|
||||
if entity.energy >= ACTION_COST {
|
||||
entity.act(&mut world);
|
||||
entity.energy -= ACTION_COST;
|
||||
}
|
||||
```
|
||||
|
||||
Fast entities (`speed >= ACTION_COST`) act every tick. Slow entities act every N ticks
|
||||
naturally without any scheduler involvement. Haste and slow effects become simple `speed`
|
||||
modifiers — no special-case scheduling needed.
|
||||
|
||||
---
|
||||
|
||||
## Simulation rate vs network rate
|
||||
|
||||
Sim and net rates are independent. The current targets:
|
||||
|
||||
| Layer | Rate | Stride |
|
||||
|-------|------|--------|
|
||||
| Base loop | 24 Hz | every tick |
|
||||
| Network broadcast (per client, phase-staggered) | 12 Hz | `tick % 2 == send_phase` |
|
||||
| Entity tick (movement / collision) | 6 Hz | `tick % 4 == 0` |
|
||||
|
||||
Clients receive a fresh `EntityPacket` every ~83 ms. For tile-based movement at 5–6 tiles/sec
|
||||
this is sufficient with comfortable headroom.
|
||||
|
||||
Rough bandwidth per client at 12 Hz:
|
||||
- `StatePacket` 72 B × 12 = 864 B/s
|
||||
- `EntityPacket` ~1200 B × 12 = 14 400 B/s
|
||||
- Total: ~15 KB/s outbound per client
|
||||
|
||||
---
|
||||
|
||||
## Tick-offset broadcasting (phase staggering)
|
||||
|
||||
Rather than flushing all clients on the same tick, assign each client a `send_phase` at
|
||||
connection time and send only when the client's phase matches:
|
||||
|
||||
```rust
|
||||
// ClientState gains:
|
||||
send_phase: u8, // assigned as entity_id % BROADCAST_STRIDE at creation
|
||||
|
||||
// broadcast() skips clients whose phase doesn't match current tick:
|
||||
if tick % BROADCAST_STRIDE as u32 != cs.send_phase as u32 { continue; }
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Outbound NIC load is flat across ticks instead of spiking every 2nd tick
|
||||
- Scales with player count without architectural changes
|
||||
- Each client still receives state at the same effective rate
|
||||
|
||||
---
|
||||
|
||||
## Why higher tick rate doesn't help high-ping players
|
||||
|
||||
```
|
||||
Perceived latency ≈ RTT + tick_processing_delay + state_interval
|
||||
|
||||
LTE @ 100ms RTT, 24Hz sim, 12Hz net:
|
||||
worst case: 100 + 42 + 83 = 225ms ← RTT-dominated
|
||||
|
||||
Same at 64Hz sim, 20Hz net:
|
||||
worst case: 100 + 15 + 50 = 165ms ← 60ms gain, negligible for roguelike
|
||||
```
|
||||
|
||||
RTT is the dominant term. Higher tick rates yield diminishing returns and increase server
|
||||
CPU load for minimal perceived benefit. 24 Hz is sufficient for deliberate tile-based input
|
||||
and remains acceptable at LTE latencies (~100ms RTT) and remote locations (~200ms RTT).
|
||||
@@ -0,0 +1,310 @@
|
||||
# Protocol
|
||||
Wire format for client-server communication.
|
||||
|
||||
## Transport
|
||||
|
||||
- UDP (`std::net::UdpSocket`, non-blocking)
|
||||
- Hard limit: **1200 bytes per datagram** (safe internet MTU; avoids IP fragmentation)
|
||||
- All integers: little-endian
|
||||
- Server streams `StatePackets` continuously; client sends `ActionPackets` only when needed
|
||||
|
||||
---
|
||||
|
||||
## Header
|
||||
|
||||
Every datagram begins with a 6-byte universal header.
|
||||
|
||||
| Offset | Size | Type | Field | Notes |
|
||||
|--------|------|------|---------------|-----------------------------------------|
|
||||
| 0 | 2 | u16 | `magic` | Fixed: `0x524C` ("RL") — rejects noise |
|
||||
| 2 | 1 | u8 | `version` | Protocol version — server rejects mismatch |
|
||||
| 3 | 1 | u8 | `packet_type` | See packet type table below |
|
||||
| 4 | 2 | u16 | `client_type` | 0 = official client; others registered |
|
||||
|
||||
`client_type` allows the server to distinguish official clients, registered bots, and
|
||||
forks for analytics and access control, without affecting the protocol logic.
|
||||
|
||||
---
|
||||
|
||||
## Packet types
|
||||
|
||||
| Packet type | id | Flow | Size |
|
||||
|---------------|----|------------------|------------------|
|
||||
| State | 0 | server → client | 72 bytes |
|
||||
| Action | 1 | client → server | min. 74 bytes |
|
||||
| Chunk Data | 2 | server → client | max. 909 bytes |
|
||||
| Entity | 3 | server → client | max. 1200 bytes |
|
||||
| Entity Query | 4 | client → server | 18 bytes |
|
||||
| Entity Detail | 5 | server → client | max. 1200 bytes |
|
||||
| Ping | 6 | client → server | 14 bytes |
|
||||
| Pong | 7 | server → client | 14 bytes |
|
||||
|
||||
---
|
||||
|
||||
### StatePacket — server → client
|
||||
|
||||
Sent by the server at 10 Hz regardless of client activity.
|
||||
Currently carries the chunk manifest for the 3×3 neighbourhood around the player.
|
||||
|
||||
| Offset | Size | Type | Field | Notes |
|
||||
|--------|------|------------|---------------------|-------------------------------------------------|
|
||||
| 0 | 6 | Header | `header` | packet_type = 0 |
|
||||
| 6 | 4 | u32 | `tick` | Server tick counter |
|
||||
| 10 | 54 | ChunkEntry | `chunks[9]` | 9 chunk entries |
|
||||
| 64 | 4 | u32 | `player_entity_id` | Global entity ID of the receiving client |
|
||||
| 68 | 4 | u32 | `entity_checksum` | FNV-1a over all EntityPacket payloads this tick |
|
||||
|
||||
**Total: 72 bytes**
|
||||
|
||||
The `entity_checksum` lets the client detect a lost `EntityPacket` without a dedicated
|
||||
ACK: if the checksum differs from the one computed over the last received entity update,
|
||||
the client knows to retransmit an `ActionPacket` (sequence preserved, no-op action) to
|
||||
prompt the server to re-send the current entity state.
|
||||
|
||||
---
|
||||
|
||||
### ActionPacket — client → server
|
||||
|
||||
Sent by the client on player action or on a chunk cache miss.
|
||||
|
||||
| Offset | Size | Type | Field | Notes |
|
||||
|--------|------|--------------|-----------------|--------------------------------|
|
||||
| 0 | 6 | Header | `header` | packet_type = 1 |
|
||||
| 6 | 8 | u64 | `auth_token` | Token of the current session |
|
||||
| 14 | 4 | u32 | `sequence` | Monotonically increasing |
|
||||
| 18 | 54 | ChunkEntry | `cache[9]` | Versions client currently holds |
|
||||
| 72 | 2 | PlayerAction | `player_action` | Derived from user input |
|
||||
| 74 | ? | ActionData | `action_data` | Dependent on PlayerAction |
|
||||
|
||||
**Minimum: 74 bytes** (no ActionData)
|
||||
|
||||
**ChunkEntry (6 bytes)**
|
||||
|
||||
| Offset | Size | Type | Field |
|
||||
|--------|------|------|------------|
|
||||
| 0 | 4 | u32 | `chunk_id` |
|
||||
| 4 | 2 | u16 | `version` |
|
||||
|
||||
`chunk_id` encodes the chunk grid position: `(x as u16) | ((y as u16) << 16)`,
|
||||
where x and y are signed chunk coordinates (i16 each).
|
||||
|
||||
**PlayerAction (u16)**
|
||||
|
||||
| Value | Action |
|
||||
|-------|---------|
|
||||
| 0 | No-Op (cache update only) |
|
||||
| 1 | North |
|
||||
| 2 | East |
|
||||
| 3 | South |
|
||||
| 4 | West |
|
||||
|
||||
$TODO — additional actions (interact, etc.)
|
||||
|
||||
---
|
||||
|
||||
### ChunkPacket — server → client
|
||||
|
||||
Sent by the server for each chunk the client is missing or has stale.
|
||||
One chunk per datagram.
|
||||
|
||||
The world is divided into **32×32 tile chunks**.
|
||||
- At most **4 chunks (2×2)** are visible at once.
|
||||
- The 3×3 neighbourhood (9 chunks) covers all prefetch needs.
|
||||
- Each chunk carries a **local palette** of up to 64 tile types.
|
||||
- Tiles are encoded as 6-bit palette indices (4 tiles per 3 bytes, no padding).
|
||||
|
||||
| Offset | Size | Type | Field | Notes |
|
||||
|-------------------|----------|--------------|-------------|---------------------------------|
|
||||
| 0 | 6 | Header | `header` | packet_type = 2 |
|
||||
| 6 | 6 | ChunkEntry | `chunk` | ID and version of this chunk |
|
||||
| 12 | 1 | u8 | `pal_count` | Number of palette entries (≤64) |
|
||||
| 13 | max. 128 | u16[] | `palette` | Global tile IDs, pal_count × 2 |
|
||||
| 13 + pal_count×2 | 768 | packed u6[] | `tiles` | 1024 tiles, 6-bit indices |
|
||||
|
||||
**Maximum: 6 + 6 + 1 + 128 + 768 = 909 bytes**
|
||||
|
||||
**Palette** — up to 64 entries, each a global tile ID (u16) mapping local 6-bit index → world tile type.
|
||||
|
||||
**Tiles** — 1024 tiles packed as 256 groups of 3 bytes (4 tiles × 6 bits = 24 bits per group).
|
||||
|
||||
---
|
||||
|
||||
### EntityPacket — server → client
|
||||
|
||||
Sent by the server each tick, immediately after the `StatePacket`.
|
||||
Contains the bulk entity update for all entities within the visible **30×30 tile viewport**.
|
||||
Positions are **absolute world tile coordinates**.
|
||||
|
||||
If the entity count exceeds what fits in one datagram, the server sends multiple
|
||||
`EntityPacket`s on the same tick; `packet_flags` bit 0 signals that more follow.
|
||||
|
||||
| Offset | Size | Type | Field | Notes |
|
||||
|--------|----------------|-------------|----------------|--------------------------------|
|
||||
| 0 | 6 | Header | `header` | packet_type = 3 |
|
||||
| 6 | 4 | u32 | `tick` | Matches the `StatePacket` tick |
|
||||
| 10 | 1 | u8 | `entity_count` | Entities in this datagram |
|
||||
| 11 | 1 | u8 | `packet_flags` | bit 0: more packets follow |
|
||||
| 12 | entity_count×… | EntityEntry | `entities` | Variable-length entries |
|
||||
|
||||
**Maximum: 1200 bytes** — without metadata: ⌊(1200 − 12) / 18⌋ = **66 entities per datagram**.
|
||||
|
||||
---
|
||||
|
||||
#### EntityEntry
|
||||
|
||||
| Offset | Size | Type | Field | Notes |
|
||||
|--------|------------|------|-----------------|-----------------------------------------|
|
||||
| 0 | 4 | u32 | `id` | Server-assigned entity ID (persistent) |
|
||||
| 4 | 2 | u16 | `type_id` | Entity type; 0 = player |
|
||||
| 6 | 2 | i16 | `pos_x` | Absolute world tile X |
|
||||
| 8 | 2 | i16 | `pos_y` | Absolute world tile Y |
|
||||
| 10 | 2 | u16 | `hp` | Current HP |
|
||||
| 12 | 2 | u16 | `hp_max` | Max HP |
|
||||
| 14 | 2 | u16 | `elo` | Elo rating (acts as level) |
|
||||
| 16 | 1 | u8 | `entity_flags` | See flag table below |
|
||||
| 17 | 1 | u8 | `meta_len` | Byte length of the TLV metadata block |
|
||||
| 18 | `meta_len` | u8[] | `meta` | TLV metadata (0 bytes if none) |
|
||||
|
||||
**Base size: 18 bytes** (plus `meta_len` bytes of metadata).
|
||||
|
||||
**entity_flags:**
|
||||
|
||||
| Bit | Meaning |
|
||||
|-----|-------------------------------------------------------|
|
||||
| 0 | `is_static` — does not move (chest, sign, item stack) |
|
||||
| 1 | `is_hostile` |
|
||||
| 2 | `is_interactable` — show interaction prompt |
|
||||
| 3–7 | reserved |
|
||||
|
||||
---
|
||||
|
||||
#### Inline metadata — TLV format
|
||||
|
||||
Inline metadata is a sequence of TLV (type–length–value) fields appended directly after
|
||||
the fixed `EntityEntry` fields. `meta_len = 0` means no metadata is present.
|
||||
|
||||
Each TLV field:
|
||||
|
||||
| Offset | Size | Type | Field |
|
||||
|--------|------|------|---------|
|
||||
| 0 | 1 | u8 | `tag` |
|
||||
| 1 | 1 | u8 | `len` |
|
||||
| 2 | len | u8[] | `value` |
|
||||
|
||||
**Defined tags:**
|
||||
|
||||
| Tag | Name | Value format | Notes |
|
||||
|------|------------------|--------------|--------------------------------|
|
||||
| 0x01 | `status_effects` | u16 bitmask | Active status effects |
|
||||
| 0x02 | `weapon` | u16 type_id | Currently equipped weapon |
|
||||
| 0x03 | `armor` | u16 type_id | Currently equipped armor |
|
||||
|
||||
Large metadata (chest inventory, sign text, dialogue trees) is not included inline.
|
||||
Request it via `EntityQueryPacket` (type 4) on player interaction.
|
||||
|
||||
---
|
||||
|
||||
### EntityQueryPacket — client → server
|
||||
|
||||
Sent when the client needs detailed metadata for a specific entity (on interaction,
|
||||
hover, or similar trigger). The server responds with an `EntityDetailPacket`.
|
||||
|
||||
| Offset | Size | Type | Field | Notes |
|
||||
|--------|------|--------|--------------|------------------------------|
|
||||
| 0 | 6 | Header | `header` | packet_type = 4 |
|
||||
| 6 | 8 | u64 | `auth_token` | Token of the current session |
|
||||
| 14 | 4 | u32 | `entity_id` | Entity to query |
|
||||
|
||||
**Total: 18 bytes**
|
||||
|
||||
---
|
||||
|
||||
### EntityDetailPacket — server → client
|
||||
|
||||
Full metadata response for a queried entity. Sent in reply to an `EntityQueryPacket`.
|
||||
|
||||
| Offset | Size | Type | Field | Notes |
|
||||
|--------|------------|--------|--------------|------------------------------|
|
||||
| 0 | 6 | Header | `header` | packet_type = 5 |
|
||||
| 6 | 4 | u32 | `tick` | Server tick at time of query |
|
||||
| 10 | 4 | u32 | `entity_id` | Entity being described |
|
||||
| 14 | 2 | u16 | `meta_len` | Byte length of metadata |
|
||||
| 16 | `meta_len` | u8[] | `meta` | Full TLV metadata block |
|
||||
|
||||
**Maximum: 1200 bytes** (up to 1184 bytes of metadata).
|
||||
|
||||
---
|
||||
|
||||
### PingPacket / PongPacket — round-trip measurement
|
||||
|
||||
Liveness and RTT probe. The client sends a `PingPacket`; the server echoes the payload back
|
||||
unchanged as a `PongPacket`. Both share the same layout.
|
||||
|
||||
| Offset | Size | Type | Field | Notes |
|
||||
|--------|------|--------|----------------|--------------------------------------|
|
||||
| 0 | 6 | Header | `header` | packet_type = 6 (Ping) / 7 (Pong) |
|
||||
| 6 | 8 | u64 | `timestamp_ms` | Echoed verbatim by the server |
|
||||
|
||||
**Total: 14 bytes**
|
||||
|
||||
The client currently measures RTT locally via the elapsed time since the ping was sent, so
|
||||
`timestamp_ms` is sent as 0; the field is reserved for server-stamped timing if needed later.
|
||||
|
||||
---
|
||||
|
||||
## Protocol flow
|
||||
|
||||
```
|
||||
── every tick (10 Hz) ────────────────────────────────────────────────────────
|
||||
|
||||
server → client StatePacket (tick N, chunk manifest, entity_checksum)
|
||||
server → client EntityPacket (tick N, entities 0–73)
|
||||
server → client EntityPacket (tick N, entities 74–N, more=0) ← if needed
|
||||
|
||||
↓ client verifies entity_checksum
|
||||
↓ checksum mismatch → retransmit ActionPacket (no-op)
|
||||
↓ chunk cache miss on chunk X →
|
||||
|
||||
client → server ActionPacket (auth_token, seq, cache state, player_action)
|
||||
|
||||
server → client ChunkPacket (chunk X, full data)
|
||||
server → client ChunkPacket (chunk Y, full data) ← if multiple misses
|
||||
|
||||
── on player interaction ─────────────────────────────────────────────────────
|
||||
|
||||
client → server EntityQueryPacket (auth_token, entity_id)
|
||||
server → client EntityDetailPacket (entity_id, full TLV metadata)
|
||||
```
|
||||
|
||||
The server reads the `ActionPacket`'s cache list, computes the diff against current
|
||||
chunk versions, and sends one `ChunkPacket` per missing or stale chunk.
|
||||
|
||||
---
|
||||
|
||||
## Reliability
|
||||
|
||||
UDP is unreliable. The protocol handles loss without a dedicated ACK mechanism:
|
||||
|
||||
**Lost ActionPacket** — the server never learns of the cache miss and keeps streaming
|
||||
StatePackets. The client retransmits the ActionPacket after **200–300 ms** if no
|
||||
ChunkPacket has arrived.
|
||||
|
||||
**Lost ChunkPacket** — the server has already processed the ActionPacket and will not
|
||||
retransmit spontaneously. The client's timeout fires (200–300 ms), it resends the
|
||||
ActionPacket with its updated cache state (listing only still-missing chunks), and
|
||||
the server sends the missing chunks again.
|
||||
|
||||
**Duplicate ActionPackets** — the server handles these idempotently. The cache list is
|
||||
self-describing state; the server reads it, diffs against current versions, and sends
|
||||
whatever is still missing. No deduplication logic is required.
|
||||
|
||||
**Lost StatePacket** — the next tick delivers the same chunk manifest. No retry needed;
|
||||
the client simply waits one tick (~100 ms).
|
||||
|
||||
**Lost EntityPacket** — detected via the `entity_checksum` in the next `StatePacket`.
|
||||
The client retransmits a no-op `ActionPacket` (same sequence number, no-op action,
|
||||
current cache state); the server treats this as a normal diff request and re-sends the
|
||||
full entity update for the tick.
|
||||
|
||||
**Lost EntityQueryPacket / EntityDetailPacket** — the client retransmits the query
|
||||
after a 200–300 ms timeout if no `EntityDetailPacket` has arrived.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Roadmap
|
||||
|
||||
Workspace layout: `shared/` (wire types), `server/` (authoritative sim), `client/` (render
|
||||
terminal). The platform layer — window, GPU, input, RGB332 palette — lives in the external
|
||||
`pbio` crate (git dependency), not in this repo.
|
||||
|
||||
---
|
||||
|
||||
## Achieved milestones
|
||||
|
||||
- [x] TGA loader: `Image`, `from_tga`, `to_tileset`, `decode_rle` in `client/src/assets.rs`
|
||||
- [x] Pixel helpers: `set_pixel`, `extract`, `blit`, `blit_tile` in `client/src/game/pixelhelper.rs`
|
||||
— signed `i32` offsets, clip against all four framebuffer edges, index 0 = transparent
|
||||
- [x] Input system: `GameAction`, `InputMap`, `InputState` in `client/src/input.rs` (over `pbio::Key`)
|
||||
- [x] Protocol spec: `notes/protocol.md` — Header, State, Action, Chunk, Entity, EntityQuery,
|
||||
EntityDetail, Ping/Pong; reliability model
|
||||
- [x] Shared crate: packet structs as `bytemuck::Pod` with compile-time size assertions,
|
||||
`player_action` / `packet_type` constants, `chunk_id` / `chunk_coords`
|
||||
- [x] Server binary: 24 Hz tick loop, 3×3-chunk test world (`build_world`), wall collision,
|
||||
per-client action queue, `ChunkPacket` dispatch on cache miss, stale-client eviction
|
||||
- [x] Client net: `NetClient` in `client/src/net.rs`, non-blocking UDP recv,
|
||||
`StatePacket` / `EntityPacket` / `ChunkPacket` / `PongPacket` dispatch, entity-checksum retransmit
|
||||
- [x] End-to-end loop: player moves on server, position reflected in `EntityPacket`, rendered on client
|
||||
- [x] Tile rendering: chunk-based world rendering from received `ChunkPacket` data (6-bit unpack)
|
||||
- [x] Tick-based movement: movement resolved in `entity_tick`, currently every 4th tick (`sim.rs`)
|
||||
- [x] Sprite-based entity rendering: entity `type_id` → tile in `entities.tga`, blitted with transparency
|
||||
- [x] Phase-staggered broadcast: per-client `send_phase`, flat outbound load across ticks
|
||||
- [x] Ping/Pong RTT measurement (`PingPacket` / `PongPacket`)
|
||||
- [x] Connection timeout: client emits `Disconnected` after 10 s without a `StatePacket`;
|
||||
server evicts clients unseen for 10 s
|
||||
|
||||
---
|
||||
|
||||
## Upcoming work
|
||||
|
||||
### 06 — Hybrid tick loop *(partially done)*
|
||||
|
||||
Design of record: **`notes/06-hybrid-tick-loop.md`** (24 Hz base tick + stride scheduling).
|
||||
This supersedes the original 20/10/1 Hz three-tier sketch.
|
||||
|
||||
- [x] 24 Hz base loop (`server/src/main.rs`)
|
||||
- [x] Phase-staggered broadcast at 12 Hz/client (`send_phase` in `server/src/net.rs`)
|
||||
- [x] Stride layout settled: `entity_tick` at `%4` (6 Hz, sets movement rate), broadcast at
|
||||
`%2` (12 Hz/client). The `06` note now documents this and why.
|
||||
- [ ] Action-point model (`energy` / `speed` per entity, act when `energy >= ACTION_COST`).
|
||||
Removed from `entity.rs` for now as premature — reintroduce when NPCs (08) actually need it.
|
||||
Then `entity_tick` can move to `%2` (12 Hz) and `speed` sets the effective movement rate.
|
||||
|
||||
---
|
||||
|
||||
### 07 — Tiled map loader
|
||||
|
||||
Replace the hardcoded `build_world` wall loop with real level data.
|
||||
|
||||
- Parse Tiled TMX (XML) or JSON export — only the subset actually used
|
||||
- Define the tile vocabulary: `TileKind` variants map 1:1 to Tiled tile IDs
|
||||
- Load a hand-authored starting area as the first real level
|
||||
- Procedural generation comes later; hand-authored first
|
||||
|
||||
---
|
||||
|
||||
### 08 — Basic NPC entity + AI budget
|
||||
|
||||
One dumb wandering enemy. Validates the simulation architecture before complexity accumulates.
|
||||
Depends on the action-point model from 06 being reintroduced.
|
||||
|
||||
- Add `EntityKind::Npc` with a `think() -> u32` method returning budget cost
|
||||
- Per sim-tick: distribute `think_budget = BASE / (clients + 1)` across entities ordered by player proximity
|
||||
- Complex entities consume more budget; simple ones less. Loop breaks at zero — natural load shedding.
|
||||
- No framework. No trait objects yet. A match on `EntityKind` is fine.
|
||||
|
||||
```rust
|
||||
let mut budget: u32 = BASE_BUDGET / (client_count + 1).max(1);
|
||||
for entity in entities_by_player_proximity() {
|
||||
if budget == 0 { break; }
|
||||
budget = budget.saturating_sub(entity.think(&mut world));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 09 — Client camera *(sprite rendering already done)*
|
||||
|
||||
Sprite rendering already landed (see Achieved). What remains is the camera:
|
||||
|
||||
- Smooth camera: lerp between last known and current server position; do not snap
|
||||
(currently `player_pos` snaps hard to the server position in `game.rs`)
|
||||
- Store previous + current position per entity, interpolate on render
|
||||
- Camera math is architectural — affects how entity state is stored. Do it before UI.
|
||||
|
||||
---
|
||||
|
||||
### Later — UI / HUD
|
||||
|
||||
- Bitmap font renderer: 8×8 glyph sheet TGA, `draw_text(frame, font, x, y, color, text)` — see `notes/04`
|
||||
- UI primitives in `client/src/ui.rs`: `fill_rect`, `draw_rect`, `draw_hbar` — see `notes/05`
|
||||
- HUD layout (320×240): HP bar + player name bottom 16px, message log right panel
|
||||
- A real clip-rect on the blit primitives belongs here — once the viewport and HUD panels are
|
||||
two distinct regions, the abstraction earns its keep. Not before.
|
||||
|
||||
---
|
||||
|
||||
### Later — Robustness + auth
|
||||
|
||||
- Auth token handshake: replace source-address identity (`auth_token` field exists but is unused)
|
||||
- Multi-datagram `EntityPacket`: server currently truncates at 66 entities (`net.rs` TODO)
|
||||
- Asset embedding: `include_bytes!` for single-binary distribution
|
||||
|
||||
---
|
||||
|
||||
### Later — World depth
|
||||
|
||||
Once the simulation architecture is stable and a real level exists:
|
||||
|
||||
- Doors, interactive objects (server-authoritative state)
|
||||
- Combat: melee range check, HP, death, respawn
|
||||
- Inventory: on-demand via `EntityQueryPacket` / `EntityDetailPacket`
|
||||
- Day/night and weather on the slow tick — affect visibility, spawns
|
||||
- Procedural dungeon generation (BSP or cellular automata feeding into the same tile format)
|
||||
|
||||
---
|
||||
</content>
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
shared = { path = "../shared" }
|
||||
bytemuck = "1.24.0"
|
||||
@@ -0,0 +1,102 @@
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
|
||||
pub struct TileFlags(pub u8);
|
||||
|
||||
impl TileFlags {
|
||||
pub const COLLIDABLE: u8 = 1 << 0;
|
||||
pub const OPAQUE: u8 = 1 << 1;
|
||||
pub const LIQUID: u8 = 1 << 2;
|
||||
|
||||
pub fn collidable(self) -> bool { self.0 & Self::COLLIDABLE != 0 }
|
||||
pub fn opaque(self) -> bool { self.0 & Self::OPAQUE != 0 }
|
||||
pub fn liquid(self) -> bool { self.0 & Self::LIQUID != 0 }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
|
||||
pub struct TileDef { pub tile_id: u16, pub flags: TileFlags }
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PaletteFullError;
|
||||
|
||||
pub struct Chunk {
|
||||
pub palette: [TileDef; 64],
|
||||
pub pal_count: u8,
|
||||
pub tiles: Box<[u8; 1024]>,
|
||||
pub version: u16,
|
||||
}
|
||||
|
||||
impl Chunk {
|
||||
/// Runtime use: all tiles empty, pal_count=1, version=1.
|
||||
/// Clients with version=0 will always request this chunk.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
palette: [TileDef::default(); 64],
|
||||
pal_count: 1,
|
||||
tiles: Box::new([0u8; 1024]),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find existing palette entry or insert a new one.
|
||||
/// Index 0 is always the null sentinel; this short-circuits for default TileDef.
|
||||
/// Returns (palette_index, was_new). Does NOT bump version.
|
||||
fn find_or_insert_raw(&mut self, def: TileDef) -> Result<(u8, bool), PaletteFullError> {
|
||||
if def == TileDef::default() {
|
||||
return Ok((0, false));
|
||||
}
|
||||
for i in 1..self.pal_count as usize {
|
||||
if self.palette[i] == def {
|
||||
return Ok((i as u8, false));
|
||||
}
|
||||
}
|
||||
if self.pal_count >= 64 {
|
||||
return Err(PaletteFullError);
|
||||
}
|
||||
let idx = self.pal_count;
|
||||
self.palette[idx as usize] = def;
|
||||
self.pal_count += 1;
|
||||
Ok((idx, true))
|
||||
}
|
||||
|
||||
/// World gen path. Calls the closure for every tile coordinate.
|
||||
/// version=0 so the first StatePacket causes all clients to request it.
|
||||
pub fn generate(f: impl Fn(u8, u8) -> TileDef) -> Result<Self, PaletteFullError> {
|
||||
let mut chunk = Self {
|
||||
palette: [TileDef::default(); 64],
|
||||
pal_count: 1,
|
||||
tiles: Box::new([0u8; 1024]),
|
||||
version: 0,
|
||||
};
|
||||
for ly in 0u8..32 {
|
||||
for lx in 0u8..32 {
|
||||
let def = f(lx, ly);
|
||||
let (idx, _) = chunk.find_or_insert_raw(def)?;
|
||||
chunk.tiles[ly as usize * 32 + lx as usize] = idx;
|
||||
}
|
||||
}
|
||||
chunk.version = 1;
|
||||
Ok(chunk)
|
||||
}
|
||||
|
||||
/// Set a tile at local coords. Bumps version if tile index or palette changed.
|
||||
pub fn set_tile(&mut self, lx: u8, ly: u8, def: TileDef) -> Result<(), PaletteFullError> {
|
||||
let (idx, was_new) = self.find_or_insert_raw(def)?;
|
||||
let slot = &mut self.tiles[ly as usize * 32 + lx as usize];
|
||||
let changed = *slot != idx;
|
||||
*slot = idx;
|
||||
if changed || was_new {
|
||||
self.version = self.version.wrapping_add(1);
|
||||
if self.version == 0 {
|
||||
self.version = 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_tile(&self, lx: u8, ly: u8) -> TileDef {
|
||||
self.palette[self.tiles[ly as usize * 32 + lx as usize] as usize]
|
||||
}
|
||||
|
||||
pub fn tile_flags(&self, lx: u8, ly: u8) -> TileFlags {
|
||||
self.get_tile(lx, ly).flags
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
pub struct Entity {
|
||||
pub id: u32,
|
||||
pub type_id: u16, // 0 = player
|
||||
pub pos: (i16, i16),
|
||||
pub hp: u16,
|
||||
pub hp_max: u16,
|
||||
}
|
||||
|
||||
impl Entity {
|
||||
pub fn new(id: u32, type_id: u16, pos: (i16, i16), hp: u16) -> Self {
|
||||
Self {
|
||||
id,
|
||||
type_id,
|
||||
pos,
|
||||
hp,
|
||||
hp_max: hp,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
mod chunk;
|
||||
mod entity;
|
||||
mod world;
|
||||
mod sim;
|
||||
mod net;
|
||||
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chunk::{Chunk, TileDef, TileFlags};
|
||||
use world::World;
|
||||
use sim::Sim;
|
||||
use net::Net;
|
||||
|
||||
const TICK_HZ: u32 = 24;
|
||||
const TICK_DURATION: Duration = Duration::from_micros(1_000_000 / TICK_HZ as u64);
|
||||
|
||||
fn build_world() -> World {
|
||||
let mut w = World::new();
|
||||
for cy in -1i16..=1 {
|
||||
for cx in -1i16..=1 {
|
||||
let chunk = Chunk::generate(|lx, ly| {
|
||||
let (wx, wy) = world::local_to_tile(cx, cy, lx, ly);
|
||||
let border = wx == -32 || wx == 31 || wy == -32 || wy == 31;
|
||||
if border {
|
||||
TileDef { tile_id: 1, flags: TileFlags(TileFlags::COLLIDABLE | TileFlags::OPAQUE) }
|
||||
} else {
|
||||
TileDef::default()
|
||||
}
|
||||
}).expect("palette overflow during world gen");
|
||||
w.set_chunk(cx, cy, chunk);
|
||||
}
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let world = build_world();
|
||||
let mut sim = Sim::new(world);
|
||||
|
||||
let mut net = Net::new("127.0.0.1:7777").expect("failed to bind UDP socket");
|
||||
|
||||
let mut next_tick = Instant::now() + TICK_DURATION;
|
||||
let mut tick = 0u32;
|
||||
loop {
|
||||
net.drain_recv();
|
||||
net.evict_stale();
|
||||
|
||||
for addr in net.drain_new_clients() {
|
||||
let entity_id = sim.world.spawn_entity(0, (0, 0), 100);
|
||||
net.add_client(addr, entity_id);
|
||||
println!("client connected: {addr} → entity {entity_id}");
|
||||
}
|
||||
|
||||
let actions = net.drain_actions();
|
||||
sim.tick(tick, &actions);
|
||||
net.broadcast(&sim.world, tick);
|
||||
thread::sleep(next_tick.saturating_duration_since(Instant::now()));
|
||||
next_tick += TICK_DURATION;
|
||||
tick = tick.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use std::collections::HashMap;
|
||||
use std::mem::size_of;
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::sync::mpsc::{self, Receiver, SyncSender};
|
||||
use std::thread;
|
||||
use std::time::Instant;
|
||||
|
||||
use bytemuck::bytes_of;
|
||||
use shared::{
|
||||
ActionPacket, ChunkEntry, ChunkPacket, EntityEntry, EntityPacket,
|
||||
Header, PingPacket, PongPacket, StatePacket,
|
||||
MAGIC, VERSION, packet_type,
|
||||
};
|
||||
|
||||
use crate::world::World;
|
||||
|
||||
pub struct NetClient {
|
||||
pub entity_id: u32,
|
||||
pub last_seen: Instant,
|
||||
pub send_phase: u8,
|
||||
pub pending_action: Option<u16>,
|
||||
pub last_cache: [ChunkEntry; 9],
|
||||
}
|
||||
|
||||
pub struct Net {
|
||||
clients: HashMap<SocketAddr, NetClient>,
|
||||
new_addrs: Vec<SocketAddr>,
|
||||
recv_rx: Receiver<(SocketAddr, Vec<u8>)>,
|
||||
send_tx: SyncSender<(SocketAddr, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl Net {
|
||||
pub fn new(addr: &str) -> std::io::Result<Self> {
|
||||
let socket = UdpSocket::bind(addr)?;
|
||||
|
||||
let (recv_tx, recv_rx) = mpsc::sync_channel::<(SocketAddr, Vec<u8>)>(256);
|
||||
let (send_tx, send_rx) = mpsc::sync_channel::<(SocketAddr, Vec<u8>)>(256);
|
||||
|
||||
let recv_sock = socket.try_clone()?;
|
||||
thread::spawn(move || {
|
||||
let mut buf = [0u8; 1200];
|
||||
loop {
|
||||
match recv_sock.recv_from(&mut buf) {
|
||||
Ok((n, addr)) => {
|
||||
if recv_tx.send((addr, buf[..n].to_vec())).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let send_sock = socket.try_clone()?;
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
match send_rx.recv() {
|
||||
Ok((addr, data)) => { let _ = send_sock.send_to(&data, addr); }
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// socket is dropped here; send/recv threads own their copies
|
||||
drop(socket);
|
||||
|
||||
Ok(Net { clients: HashMap::new(), new_addrs: Vec::new(), recv_rx, send_tx })
|
||||
}
|
||||
|
||||
pub fn add_client(&mut self, addr: SocketAddr, entity_id: u32) {
|
||||
self.clients.insert(addr, NetClient {
|
||||
entity_id,
|
||||
last_seen: Instant::now(),
|
||||
send_phase: (entity_id % 2) as u8,
|
||||
pending_action: None,
|
||||
last_cache: [ChunkEntry { chunk_id: 0, version: 0 }; 9],
|
||||
});
|
||||
}
|
||||
|
||||
pub fn drain_recv(&mut self) {
|
||||
loop {
|
||||
match self.recv_rx.try_recv() {
|
||||
Ok((addr, data)) => self.handle_packet(addr, data),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_packet(&mut self, addr: SocketAddr, data: Vec<u8>) {
|
||||
if data.len() < size_of::<Header>() {
|
||||
return;
|
||||
}
|
||||
let header: Header = bytemuck::pod_read_unaligned(&data[..size_of::<Header>()]);
|
||||
if header.magic != MAGIC || header.version != VERSION {
|
||||
return;
|
||||
}
|
||||
|
||||
if header.packet_type == packet_type::ACTION && data.len() == size_of::<ActionPacket>() {
|
||||
let pkt: ActionPacket = bytemuck::pod_read_unaligned(&data);
|
||||
if let Some(client) = self.clients.get_mut(&addr) {
|
||||
client.pending_action = Some(pkt.player_action);
|
||||
client.last_seen = Instant::now();
|
||||
client.last_cache = pkt.cache;
|
||||
} else if !self.new_addrs.contains(&addr) {
|
||||
self.new_addrs.push(addr);
|
||||
}
|
||||
} else if header.packet_type == packet_type::PING && data.len() == size_of::<PingPacket>() {
|
||||
let pkt: PingPacket = bytemuck::pod_read_unaligned(&data);
|
||||
let pong = PongPacket {
|
||||
header: Header::new(packet_type::PONG),
|
||||
timestamp_ms: pkt.timestamp_ms,
|
||||
};
|
||||
let _ = self.send_tx.send((addr, bytes_of(&pong).to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drain_new_clients(&mut self) -> Vec<SocketAddr> {
|
||||
std::mem::take(&mut self.new_addrs)
|
||||
}
|
||||
|
||||
pub fn evict_stale(&mut self) {
|
||||
self.clients.retain(|_, c| c.last_seen.elapsed().as_secs() < 10);
|
||||
}
|
||||
|
||||
pub fn drain_actions(&mut self) -> Vec<(u32, u16)> {
|
||||
self.clients.values_mut()
|
||||
.filter_map(|c| {
|
||||
let action = c.pending_action.take()?;
|
||||
Some((c.entity_id, action))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn broadcast(&self, world: &World, tick: u32) {
|
||||
for (addr, client) in &self.clients {
|
||||
if tick % 2 != client.send_phase as u32 { continue; }
|
||||
|
||||
// Step 1 — resolve player position
|
||||
let Some(entity) = world.entities.get(&client.entity_id) else { continue };
|
||||
let pos = entity.pos;
|
||||
let (player_cx, player_cy) = crate::world::tile_to_chunk(pos.0, pos.1);
|
||||
|
||||
// Step 2 — build 9-slot chunk manifest
|
||||
let mut chunks = [ChunkEntry { chunk_id: 0, version: 0 }; 9];
|
||||
for dy in -1i16..=1 {
|
||||
for dx in -1i16..=1 {
|
||||
let slot = ((dy + 1) * 3 + (dx + 1)) as usize;
|
||||
let cx = player_cx + dx;
|
||||
let cy = player_cy + dy;
|
||||
chunks[slot] = ChunkEntry {
|
||||
chunk_id: shared::chunk_id(cx, cy),
|
||||
version: world.chunk(cx, cy).map_or(0, |c| c.version),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — build EntityEntry list + checksum
|
||||
let viewport_entities = world.entities_in_viewport(pos);
|
||||
let mut entries: Vec<EntityEntry> = viewport_entities.iter().map(|e| EntityEntry {
|
||||
id: e.id,
|
||||
type_id: e.type_id,
|
||||
pos_x: e.pos.0,
|
||||
pos_y: e.pos.1,
|
||||
hp: e.hp,
|
||||
hp_max: e.hp_max,
|
||||
elo: 0,
|
||||
entity_flags: 0,
|
||||
meta_len: 0,
|
||||
}).collect();
|
||||
if entries.len() > 66 {
|
||||
entries.truncate(66); // TODO: multi-datagram
|
||||
}
|
||||
let checksum = fnv1a_entities(&entries);
|
||||
|
||||
// Step 4 — send StatePacket (72 bytes)
|
||||
let state = StatePacket {
|
||||
header: Header::new(packet_type::STATE),
|
||||
tick,
|
||||
chunks,
|
||||
player_entity_id: client.entity_id,
|
||||
entity_checksum: checksum,
|
||||
};
|
||||
let _ = self.send_tx.send((*addr, bytes_of(&state).to_vec()));
|
||||
|
||||
// Step 5 — send EntityPacket (1200 bytes)
|
||||
let mut pkt = <EntityPacket as bytemuck::Zeroable>::zeroed();
|
||||
pkt.header = Header::new(packet_type::ENTITY);
|
||||
pkt.tick = tick;
|
||||
pkt.entity_count = entries.len() as u8;
|
||||
pkt.packet_flags = 0; // no more-flag; single datagram
|
||||
for (i, e) in entries.iter().enumerate() { pkt.entities[i] = *e; }
|
||||
let _ = self.send_tx.send((*addr, bytes_of(&pkt).to_vec()));
|
||||
|
||||
// Step 6 — send ChunkPackets for cache misses
|
||||
for slot in 0..9 {
|
||||
let current = chunks[slot];
|
||||
let cached = client.last_cache[slot];
|
||||
let miss = cached.chunk_id != current.chunk_id || cached.version != current.version;
|
||||
if miss {
|
||||
let (cx, cy) = shared::chunk_coords(current.chunk_id);
|
||||
if let Some(chunk) = world.chunk(cx, cy) {
|
||||
let mut cpkt = <ChunkPacket as bytemuck::Zeroable>::zeroed();
|
||||
cpkt.header = Header::new(packet_type::CHUNK);
|
||||
cpkt.chunk = current;
|
||||
cpkt.pal_count = chunk.pal_count;
|
||||
for i in 0..64 { cpkt.palette[i] = chunk.palette[i].tile_id; }
|
||||
cpkt.tiles = pack_tiles(&chunk.tiles);
|
||||
let _ = self.send_tx.send((*addr, bytes_of(&cpkt).to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fnv1a_entities(entries: &[EntityEntry]) -> u32 {
|
||||
const FNV_BASIS: u32 = 2_166_136_261;
|
||||
const FNV_PRIME: u32 = 16_777_619;
|
||||
let mut h = FNV_BASIS;
|
||||
for e in entries {
|
||||
for &byte in bytes_of(e) {
|
||||
h ^= byte as u32;
|
||||
h = h.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn pack_tiles(tiles: &[u8; 1024]) -> [u8; 768] {
|
||||
let mut raw = [0u8; 768];
|
||||
for i in 0..256 {
|
||||
let ia = tiles[i*4] & 0x3F;
|
||||
let ib = tiles[i*4+1] & 0x3F;
|
||||
let ic = tiles[i*4+2] & 0x3F;
|
||||
let id = tiles[i*4+3] & 0x3F;
|
||||
raw[i*3] = ia | (ib << 6);
|
||||
raw[i*3+1] = (ib >> 2) | (ic << 4);
|
||||
raw[i*3+2] = (ic >> 4) | (id << 2);
|
||||
}
|
||||
raw
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use std::collections::HashMap;
|
||||
use shared::player_action;
|
||||
use crate::world::World;
|
||||
|
||||
pub struct Sim {
|
||||
pub world: World,
|
||||
pending: HashMap<u32, u16>,
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
pub fn new(world: World) -> Self {
|
||||
Self { world, pending: HashMap::new() }
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, tick: u32, actions: &[(u32, u16)]) {
|
||||
for &(entity_id, action) in actions {
|
||||
self.pending.insert(entity_id, action);
|
||||
}
|
||||
if tick.is_multiple_of(4) {
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
self.entity_tick(&pending);
|
||||
}
|
||||
}
|
||||
|
||||
fn entity_tick(&mut self, actions: &HashMap<u32, u16>) {
|
||||
for (&entity_id, &action) in actions {
|
||||
if action == player_action::NOOP {
|
||||
continue;
|
||||
}
|
||||
let pos = match self.world.entities.get(&entity_id) {
|
||||
Some(e) => e.pos,
|
||||
None => continue,
|
||||
};
|
||||
let (nx, ny) = match action {
|
||||
player_action::NORTH => (pos.0, pos.1 - 1),
|
||||
player_action::EAST => (pos.0 + 1, pos.1),
|
||||
player_action::SOUTH => (pos.0, pos.1 + 1),
|
||||
player_action::WEST => (pos.0 - 1, pos.1),
|
||||
_ => continue,
|
||||
};
|
||||
let blocked = self.world.tile_flags(nx, ny).map_or(true, |f| f.collidable());
|
||||
if !blocked {
|
||||
self.world.move_entity(entity_id, (nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::collections::HashMap;
|
||||
use crate::chunk::{Chunk, TileDef, TileFlags, PaletteFullError};
|
||||
use crate::entity::Entity;
|
||||
|
||||
pub fn tile_to_chunk(tx: i16, ty: i16) -> (i16, i16) {
|
||||
(tx >> 5, ty >> 5)
|
||||
}
|
||||
|
||||
pub fn tile_to_local(tx: i16, ty: i16) -> (u8, u8) {
|
||||
((tx & 31) as u8, (ty & 31) as u8)
|
||||
}
|
||||
|
||||
pub fn local_to_tile(cx: i16, cy: i16, lx: u8, ly: u8) -> (i16, i16) {
|
||||
((cx << 5) | lx as i16, (cy << 5) | ly as i16)
|
||||
}
|
||||
|
||||
pub struct World {
|
||||
chunks: HashMap<(i16, i16), Chunk>,
|
||||
pub entities: HashMap<u32, Entity>,
|
||||
chunk_entities: HashMap<(i16, i16), Vec<u32>>,
|
||||
next_entity_id: u32,
|
||||
}
|
||||
|
||||
impl World {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
chunks: HashMap::new(),
|
||||
entities: HashMap::new(),
|
||||
chunk_entities: HashMap::new(),
|
||||
next_entity_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chunk(&self, cx: i16, cy: i16) -> Option<&Chunk> {
|
||||
self.chunks.get(&(cx, cy))
|
||||
}
|
||||
|
||||
pub fn set_chunk(&mut self, cx: i16, cy: i16, chunk: Chunk) {
|
||||
self.chunks.insert((cx, cy), chunk);
|
||||
}
|
||||
|
||||
pub fn tile_flags(&self, wx: i16, wy: i16) -> Option<TileFlags> {
|
||||
let (cx, cy) = tile_to_chunk(wx, wy);
|
||||
let (lx, ly) = tile_to_local(wx, wy);
|
||||
self.chunks.get(&(cx, cy)).map(|c| c.tile_flags(lx, ly))
|
||||
}
|
||||
|
||||
pub fn set_tile(&mut self, wx: i16, wy: i16, def: TileDef) -> Result<(), PaletteFullError> {
|
||||
let (cx, cy) = tile_to_chunk(wx, wy);
|
||||
let (lx, ly) = tile_to_local(wx, wy);
|
||||
let chunk = self.chunks.entry((cx, cy)).or_insert_with(Chunk::new);
|
||||
chunk.set_tile(lx, ly, def)
|
||||
}
|
||||
|
||||
pub fn spawn_entity(&mut self, type_id: u16, pos: (i16, i16), hp: u16) -> u32 {
|
||||
let id = self.next_entity_id;
|
||||
self.next_entity_id += 1;
|
||||
let entity = Entity::new(id, type_id, pos, hp);
|
||||
self.add_entity(entity);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn add_entity(&mut self, entity: Entity) {
|
||||
let chunk_key = tile_to_chunk(entity.pos.0, entity.pos.1);
|
||||
let id = entity.id;
|
||||
self.entities.insert(id, entity);
|
||||
self.chunk_entities.entry(chunk_key).or_default().push(id);
|
||||
}
|
||||
|
||||
pub fn remove_entity(&mut self, id: u32) {
|
||||
if let Some(entity) = self.entities.remove(&id) {
|
||||
let chunk_key = tile_to_chunk(entity.pos.0, entity.pos.1);
|
||||
if let Some(list) = self.chunk_entities.get_mut(&chunk_key) {
|
||||
list.retain(|&eid| eid != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_entity(&mut self, id: u32, new_pos: (i16, i16)) {
|
||||
if let Some(entity) = self.entities.get_mut(&id) {
|
||||
let old_chunk = tile_to_chunk(entity.pos.0, entity.pos.1);
|
||||
let new_chunk = tile_to_chunk(new_pos.0, new_pos.1);
|
||||
entity.pos = new_pos;
|
||||
if old_chunk != new_chunk {
|
||||
if let Some(list) = self.chunk_entities.get_mut(&old_chunk) {
|
||||
list.retain(|&eid| eid != id);
|
||||
}
|
||||
self.chunk_entities.entry(new_chunk).or_default().push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entities_in_chunk(&self, cx: i16, cy: i16) -> &[u32] {
|
||||
self.chunk_entities.get(&(cx, cy)).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Returns all entities within a 30×30 tile viewport centered on `center`.
|
||||
pub fn entities_in_viewport(&self, center: (i16, i16)) -> Vec<&Entity> {
|
||||
let half: i16 = 15;
|
||||
let min_x = center.0 - half;
|
||||
let max_x = center.0 + half - 1;
|
||||
let min_y = center.1 - half;
|
||||
let max_y = center.1 + half - 1;
|
||||
|
||||
let min_cx = tile_to_chunk(min_x, min_y).0;
|
||||
let max_cx = tile_to_chunk(max_x, max_y).0;
|
||||
let min_cy = tile_to_chunk(min_x, min_y).1;
|
||||
let max_cy = tile_to_chunk(max_x, max_y).1;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for cy in min_cy..=max_cy {
|
||||
for cx in min_cx..=max_cx {
|
||||
for &eid in self.entities_in_chunk(cx, cy) {
|
||||
if let Some(e) = self.entities.get(&eid) {
|
||||
if e.pos.0 >= min_x && e.pos.0 <= max_x
|
||||
&& e.pos.1 >= min_y && e.pos.1 <= max_y
|
||||
{
|
||||
result.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bytemuck = { version = "1.24.0", features = ["derive", "min_const_generics"] }
|
||||
@@ -0,0 +1,136 @@
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
|
||||
pub mod player_action {
|
||||
pub const NOOP: u16 = 0;
|
||||
pub const NORTH: u16 = 1;
|
||||
pub const EAST: u16 = 2;
|
||||
pub const SOUTH: u16 = 3;
|
||||
pub const WEST: u16 = 4;
|
||||
}
|
||||
|
||||
pub const MAGIC: u16 = 0x524C;
|
||||
pub const VERSION: u8 = 1;
|
||||
|
||||
pub mod packet_type {
|
||||
pub const STATE: u8 = 0;
|
||||
pub const ACTION: u8 = 1;
|
||||
pub const CHUNK: u8 = 2;
|
||||
pub const ENTITY: u8 = 3;
|
||||
pub const ENTITY_QUERY: u8 = 4;
|
||||
pub const ENTITY_DETAIL: u8 = 5;
|
||||
pub const PING: u8 = 6;
|
||||
pub const PONG: u8 = 7;
|
||||
}
|
||||
|
||||
/// Encodes signed chunk grid coordinates into the wire chunk_id format.
|
||||
pub fn chunk_id(cx: i16, cy: i16) -> u32 {
|
||||
(cx as u16 as u32) | ((cy as u16 as u32) << 16)
|
||||
}
|
||||
|
||||
/// Decodes a wire chunk_id back to signed chunk grid coordinates.
|
||||
pub fn chunk_coords(id: u32) -> (i16, i16) {
|
||||
(id as u16 as i16, (id >> 16) as u16 as i16)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
#[repr(C, packed)]
|
||||
pub struct Header {
|
||||
pub magic: u16,
|
||||
pub version: u8,
|
||||
pub packet_type: u8,
|
||||
pub client_type: u16,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
pub fn new(packet_type: u8) -> Self {
|
||||
Self { magic: MAGIC, version: VERSION, packet_type, client_type: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
#[repr(C, packed)]
|
||||
pub struct ChunkEntry {
|
||||
pub chunk_id: u32,
|
||||
pub version: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
#[repr(C, packed)]
|
||||
pub struct ActionPacket {
|
||||
pub header: Header,
|
||||
pub auth_token: u64,
|
||||
pub sequence: u32,
|
||||
/// Slot index = (dy+1)*3 + (dx+1), dx/dy ∈ {-1, 0, 1}.
|
||||
/// Slot 4 is always the player's current chunk.
|
||||
pub cache: [ChunkEntry; 9],
|
||||
pub player_action: u16,
|
||||
}
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<ChunkEntry>() == 6);
|
||||
const _: () = assert!(std::mem::size_of::<ActionPacket>() == 74);
|
||||
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
#[repr(C, packed)]
|
||||
pub struct StatePacket {
|
||||
pub header: Header, // 6 (offset 0)
|
||||
pub tick: u32, // 4 (offset 6)
|
||||
/// Slot index = (dy+1)*3 + (dx+1), dx/dy ∈ {-1, 0, 1}.
|
||||
/// Slot 4 is always the player's current chunk.
|
||||
pub chunks: [ChunkEntry; 9], // 54 (offset 10)
|
||||
pub player_entity_id: u32, // 4 (offset 64)
|
||||
pub entity_checksum: u32, // 4 (offset 68)
|
||||
} // total: 72 bytes
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<StatePacket>() == 72);
|
||||
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
#[repr(C, packed)]
|
||||
pub struct PingPacket {
|
||||
pub header: Header,
|
||||
pub timestamp_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
#[repr(C, packed)]
|
||||
pub struct PongPacket {
|
||||
pub header: Header,
|
||||
pub timestamp_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
#[repr(C, packed)]
|
||||
pub struct ChunkPacket {
|
||||
pub header: Header, // 6 bytes
|
||||
pub chunk: ChunkEntry, // 6 bytes
|
||||
pub pal_count: u8, // 1 byte
|
||||
pub palette: [u16; 64], // 128 bytes
|
||||
pub tiles: [u8; 768], // 768 bytes — 1024 tiles, 6-bit packed
|
||||
} // total: 909 bytes
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<ChunkPacket>() == 909);
|
||||
|
||||
#[repr(C, packed)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct EntityEntry {
|
||||
pub id: u32,
|
||||
pub type_id: u16,
|
||||
pub pos_x: i16,
|
||||
pub pos_y: i16,
|
||||
pub hp: u16,
|
||||
pub hp_max: u16,
|
||||
pub elo: u16,
|
||||
pub entity_flags: u8,
|
||||
pub meta_len: u8,
|
||||
}
|
||||
const _: () = assert!(std::mem::size_of::<EntityEntry>() == 18);
|
||||
|
||||
#[repr(C, packed)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct EntityPacket {
|
||||
pub header: Header,
|
||||
pub tick: u32,
|
||||
pub entity_count: u8,
|
||||
pub packet_flags: u8,
|
||||
pub entities: [EntityEntry; 66],
|
||||
}
|
||||
const _: () = assert!(std::mem::size_of::<EntityPacket>() == 1200);
|
||||