HordeGun.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
-- HORDE MODE: the handgun. -- -- A voxel model in the player's right hand, authored here in METRES the -- way lib/Pokedex authors the device in the left one -- because the VR -- mapping's scale is what turns metres into world pixels, a mesh built -- this way is the right size in the hand at every scale the mod has, and -- the same mesh serves the flat screen's view model. -- -- IN VR the gun rides the tracked right hand through VRRig.propMatrix, -- pointed by the runtime's AIM pose where one exists (the pose a runtime -- defines as "where the user is pointing") and by the grip pose where it -- does not. You aim it by pointing it. The iron sights are real geometry, -- and lining them up is how you shoot accurately, because the shot is -- traced down the model's own barrel axis. -- -- ON THE FLAT SCREEN there is no hand to track, so the gun is carried by -- the camera: a model matrix built from the first-person eye and its yaw -- and pitch, with the gun hanging at the hip until the player aims. AIM -- DOWN SIGHTS slides it to the centre of the screen with the sight line -- ON the eye axis -- the model is authored with its rear notch at the -- origin precisely so that offset is (0, 0, forward) -- and narrows the -- field of view, which is the whole of what aiming does here. -- -- THE SHOT IS A RAY, traced the same way in both modes: march it in world -- pixels, let terrain height stop it (a wall is a tall cell, so a cell -- whose ground is above the ray's height is a wall the bullet hits), and -- test every live mob against it as a standing cylinder. Nearest wins, -- and a hit above the shoulder line counts double. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... local Mat4 = V.require("Mat4") local Voxel3D = V.require("Voxel3D") local VRRig = V.require("VRRig") local FirstPerson = V.require("FirstPerson") local Horde = V.require("Horde") local HordeSfx = V.require("HordeSfx") local HordeGun = {} -- ------- tuning HordeGun.MAG = 8 HordeGun.RELOAD_TIME = 1.5 HordeGun.FIRE_COOLDOWN = 0.17 -- semi-auto, and it fits the reload clicks HordeGun.RANGE = 220 -- world pixels: about fourteen cells HordeGun.HIT_RADIUS = 6 -- a person is about twelve pixels wide HordeGun.ADS_TIME = 0.13 HordeGun.ADS_FOV = math.rad(40) -- Where the gun sits relative to the EYE, in metres, hip and aimed. The -- model's own origin is its rear sight notch, so the aimed offset is a -- pure push forward: nothing to line up, it is already lined up. HordeGun.HIP = { -0.115, -0.125, 0.30 } HordeGun.ADS = { 0, -0.002, 0.34 } -- Where it sits relative to the tracked hand, in METRES and in the POSE's -- own axes -- so with the barrel pointed away from the player (see below) -- -Z is forward, and this nudges the gun a little down and forward of the -- pose origin so the hand is behind it rather than inside it. HordeGun.HAND_OFFSET = { 0, -0.012, -0.02 } -- THE BARREL, AND WHICH WAY IS FORWARD. -- -- OpenXR's AIM pose -- the one this rides where the runtime offers it -- -- is defined with its **-Z axis pointing the way the user is aiming**. -- The model below is authored with its barrel along **+Z**, because that -- is what the flat screen's view model wants (Ry(yaw)*Rx(pitch) carries -- +Z onto the look direction). Half a turn about Y is what reconciles -- them, and it is the whole of the attachment. -- -- Getting this wrong does not read as "slightly off": the first cut -- copied the Pokedex's quarter-turn about X, which lays a flat slab along -- the controller's body and is exactly right for a slab -- on a gun it -- pointed the muzzle at the player's own face. HordeGun.HAND_YAW = math.pi -- AND A PITCH, because a hand is not a tripod. A controller held the way -- you hold a pistol -- fist closed, wrist cocked -- has its own aim axis -- running up and forward out of the top of your fist, well above the line -- your hand FEELS like it is pointing along. A model laid flat on that -- axis reads as a gun held by somebody with a broken wrist. -- -- So the gun tips its muzzle down 45 degrees off the pose, which puts the -- barrel back on the line the grip implies. The shot follows: the ray is -- read off the finished matrix's own +Z column (see place), so it comes -- out of the barrel as drawn rather than off the pose it was hung on -- -- point the gun, hit the thing. HordeGun.HAND_PITCH = math.rad(45) -- ------- the model -- -- One voxel is 8mm, so the pistol below comes out about 18cm long -- a -- compact service automatic. Authored around the REAR SIGHT NOTCH at the -- origin, barrel down +Z, up +Y. (+X is the viewer's LEFT: the world runs -- +X east and +Z south, so a body facing +Z has its right hand toward -- -X, which is why the hip offset's x is negative.) local VOX = 0.008 local COLORS = { { 60, 62, 72 }, -- 1 slide { 30, 31, 38 }, -- 2 frame / shadowed { 46, 40, 40 }, -- 3 grip { 104, 108, 122 }, -- 4 highlight { 248, 240, 176 }, -- 5 sight dot { 18, 18, 22 }, -- 6 bore { 132, 136, 148 }, -- 7 trigger { 255, 246, 196 }, -- 8 flash core { 255, 168, 56 }, -- 9 flash edge } local paletteTex, bodyMesh, flashMesh = nil, nil, nil local function palette() if paletteTex then return paletteTex end if not (love.image and love.image.newImageData and love.graphics and love.graphics.newImage) then return nil end local ok, data = pcall(love.image.newImageData, #COLORS, 1) if not (ok and data) then return nil end for i, c in ipairs(COLORS) do pcall(data.setPixel, data, i - 1, 0, c[1] / 255, c[2] / 255, c[3] / 255, 1) end local built, img = pcall(love.graphics.newImage, data) if not built then return nil end pcall(img.setFilter, img, "nearest", "nearest") paletteTex = img return img end -- one solid box, in voxels, straight into the shared vertex format local function box(verts, indices, x, y, z, w, h, d, color) local u = (color - 0.5) / #COLORS local ox, oy, oz = x * VOX, y * VOX, z * VOX local sx, sy, sz = w * VOX, h * VOX, d * VOX for face = 1, 6 do local corners = Voxel3D.FACE_CORNERS[face] local shade = Voxel3D.FACE_SHADE[face] local n = #verts / 4 for _, c in ipairs(corners) do verts[#verts + 1] = { ox + c[1] * sx, oy + c[2] * sy, oz + c[3] * sz, u, 0.5, shade } end Voxel3D.pushQuad(indices, n) end end local function buildBody() if bodyMesh then return bodyMesh end local v, i = {}, {} -- slide, and the bore's dark eye at the end of it box(v, i, -2, -5, -1, 4, 4, 18, 1) box(v, i, -2, -2, -1, 4, 0.6, 18, 4) -- the light along the top edge box(v, i, -1, -4, 16.6, 2, 2, 0.6, 6) -- frame under the slide, and the dust cover forward of the guard box(v, i, -1.8, -8, 0.5, 3.6, 3.2, 12, 2) -- the grip, three blocks stepping back: a raked butt without a hull box(v, i, -1.8, -11, -1.2, 3.6, 3.2, 5, 3) box(v, i, -1.8, -14, -2.6, 3.6, 3.2, 5, 3) box(v, i, -1.8, -16.8, -3.8, 3.6, 3, 5, 2) -- trigger guard: the bar under, the post in front box(v, i, -1.4, -11.4, 3.6, 2.8, 1, 4.4, 2) box(v, i, -1.4, -11.4, 7.4, 2.8, 3.4, 1, 2) box(v, i, -0.9, -10.8, 4.6, 1.8, 2, 1, 7) -- the trigger itself -- IRON SIGHTS. Two rear posts with a notch between them at the origin, -- one front post at the muzzle: look through the gap, put the front -- post's dot in it, and the barrel is pointing where you are looking. box(v, i, -2, -1, -0.2, 0.9, 1.3, 1.4, 2) box(v, i, 1.1, -1, -0.2, 0.9, 1.3, 1.4, 2) box(v, i, -1.95, -0.2, 0.3, 0.5, 0.5, 0.5, 5) box(v, i, 1.45, -0.2, 0.3, 0.5, 0.5, 0.5, 5) box(v, i, -0.45, -1, 15.2, 0.9, 1.5, 1, 2) box(v, i, -0.3, 0.1, 15.4, 0.6, 0.6, 0.6, 5) bodyMesh = Voxel3D.newMesh(v, i) return bodyMesh end -- the muzzle flash: a bright cross of boxes off the bore, drawn for two -- frames after a shot and never lit by anything local function buildFlash() if flashMesh then return flashMesh end local v, i = {}, {} box(v, i, -1.6, -4.6, 17.4, 3.2, 3.2, 2.6, 8) box(v, i, -3.4, -3.8, 17.6, 6.8, 1.6, 1.8, 9) box(v, i, -0.9, -6.4, 17.6, 1.8, 6.4, 1.8, 9) box(v, i, -1.1, -4.1, 19.6, 2.2, 2.2, 2.2, 9) flashMesh = Voxel3D.newMesh(v, i) return flashMesh end -- ------- state local gun = { ammo = HordeGun.MAG, reloading = false, reloadT = 0, reloadStage = 0, cooldown = 0, ads = false, adsBlend = 0, kick = 0, flash = 0, frame = nil, -- the VR hand's model matrix for this frame ray = nil, -- the VR aim ray in world space, if there is one } HordeGun.state = gun function HordeGun.reset() gun.ammo = HordeGun.MAG gun.reloading, gun.reloadT, gun.reloadStage = false, 0, 0 gun.cooldown, gun.kick, gun.flash = 0, 0, 0 gun.ads, gun.adsBlend = false, 0 gun.frame, gun.ray = nil, nil end -- how far into the aim the sights are, 0..1 -- read by the HUD (the -- crosshair goes away) and by the camera (the field of view narrows) function HordeGun.adsBlend() return gun.adsBlend end function HordeGun.ammo() return gun.ammo, HordeGun.MAG, gun.reloading end function HordeGun.setAds(on) gun.ads = on and true or false end -- ------- the shot -- The eye and the direction it is looking, in world pixels. In VR this is -- the gun's own barrel (set by the VR frame); on the flat screen it is -- the camera, because the gun follows the camera exactly. local function ray(G) if gun.ray then return gun.ray end local ow = G and G.overworld if not (ow and ow.player and ow.map) then return nil end local p = ow.player local gh = 0 pcall(function() gh = V.require("VoxelScene").groundAt(ow.map, p.cellX, p.cellY) or 0 end) local cp = math.cos(FirstPerson.pitch) return { p.px + 8, gh + FirstPerson.EYE_HEIGHT, p.py + 8, math.sin(FirstPerson.yaw) * cp, -math.sin(FirstPerson.pitch), math.cos(FirstPerson.yaw) * cp, } end -- How far the ray travels before terrain stops it. A wall in this world -- is a cell whose ground stands taller than the ray does where it crosses -- it, which is the same test for a fence you can shoot over, a building -- you cannot, and a doorway you can shoot through. local function occlusion(map, r) local VoxelScene = V.require("VoxelScene") local step = 3 local t = step while t <= HordeGun.RANGE do local x = r[1] + r[4] * t local y = r[2] + r[5] * t local z = r[3] + r[6] * t local cx, cy = math.floor(x / 16), math.floor(z / 16) if not map:inBounds(cx, cy) then return t end local gh = 0 local ok, got = pcall(VoxelScene.groundAt, map, cx, cy) if ok and got then gh = got end if y < gh - 0.5 then return t end if y < 0 then return t end t = t + step end return HordeGun.RANGE end -- The nearest mob the ray reaches, and whether it caught the head. local function pick(G, r, maxT) local Mobs = V.require("HordeMobs") local VoxelScene = V.require("VoxelScene") local ow = G and G.overworld if not (ow and ow.map) then return nil end local flat = r[4] * r[4] + r[6] * r[6] if flat < 1e-6 then return nil end local best, bestT, bestHead = nil, maxT, false for _, e in ipairs(Mobs.list()) do local npc = e.npc if npc and not e.dead and e.mapId == ow.map.id then local mx, mz = npc.px + 8, npc.py + 8 local t = ((mx - r[1]) * r[4] + (mz - r[3]) * r[6]) / flat if t > 0 and t < bestT then local hx = r[1] + r[4] * t - mx local hz = r[3] + r[6] * t - mz if hx * hx + hz * hz <= HordeGun.HIT_RADIUS * HordeGun.HIT_RADIUS then local gh = 0 local ok, got = pcall(VoxelScene.groundAt, ow.map, npc.cellX, npc.cellY) if ok and got then gh = got end local y = r[2] + r[5] * t if y >= gh - 2 and y <= gh + 17 then best, bestT, bestHead = e, t, y >= gh + 11 end end end end end return best, bestHead end -- Pull the trigger. Every input device funnels here (see Horde.install, -- FirstPerson's mouse and touch wraps, and VR.driveControls), so the -- cooldown below is also what keeps two devices reporting the same press -- from spending two rounds. function HordeGun.fire() if not Horde.playing() then return false end if gun.cooldown > 0 or gun.reloading then return false end if gun.ammo <= 0 then gun.cooldown = 0.35 HordeSfx.play(HordeSfx.DRY) HordeGun.reload() return false end local G = require("src.core.Game") gun.ammo = gun.ammo - 1 gun.cooldown = HordeGun.FIRE_COOLDOWN gun.kick = 1 gun.flash = 0.05 HordeSfx.shot() local r = ray(G) if r then local ow = G.overworld local maxT = ow and ow.map and occlusion(ow.map, r) or HordeGun.RANGE local hit, head = pick(G, r, maxT) if hit then local Mobs = V.require("HordeMobs") local result = Mobs.hit(hit, head and 2 or 1) local s = Horde.session if s then s.hitMarker = 1 if result == "kill" and head then Horde.addScore(50) end end end end if gun.ammo <= 0 then HordeGun.reload() end return true end function HordeGun.reload() if gun.reloading or gun.ammo >= HordeGun.MAG then return false end gun.reloading = true gun.reloadT = 0 gun.reloadStage = 0 return true end -- ------- the frame function HordeGun.update(dt, live) if not Horde.active then FirstPerson.fovScale = 1 -- give the lens back on the way out return end gun.cooldown = math.max(0, gun.cooldown - dt) gun.kick = math.max(0, gun.kick - dt * 7) gun.flash = math.max(0, gun.flash - dt) local target = (gun.ads and live) and 1 or 0 local astep = dt / HordeGun.ADS_TIME if gun.adsBlend < target then gun.adsBlend = math.min(target, gun.adsBlend + astep) else gun.adsBlend = math.max(target, gun.adsBlend - astep) end -- the lens narrows with the sights. Half of what aiming does here is -- the model coming to the centre of the screen; the other half is this local e = gun.adsBlend * gun.adsBlend * (3 - 2 * gun.adsBlend) FirstPerson.fovScale = 1 - (1 - HordeGun.ADS_FOV / FirstPerson.FOV) * e if gun.reloading then local was = gun.reloadT gun.reloadT = gun.reloadT + dt -- three clicks on their own clock: the magazine out, the fresh one -- in, the slide home. Staged by time rather than animated frames so -- the sound and the dip below stay in step at any frame rate. local marks = { { 0.10, HordeSfx.MAG_OUT }, { 0.62, HordeSfx.MAG_IN }, { 1.15, HordeSfx.RACK } } for _, m in ipairs(marks) do if was < m[1] and gun.reloadT >= m[1] then HordeSfx.play(m[2]) end end if gun.reloadT >= HordeGun.RELOAD_TIME then gun.reloading = false gun.reloadT = 0 gun.ammo = HordeGun.MAG end end end -- ------- VR placement -- -- Called from the VR frame with the same mapping the eyes got. `pose` is -- the tracked right hand -- the runtime's aim pose where it has one. function HordeGun.place(pose, pivot, anchor, scale, yaw) if not (Horde.active and pose) then HordeGun.clear() return end local m = VRRig.propMatrix(pose, pivot, anchor, scale, yaw) m = Mat4.mul(m, Mat4.translate(HordeGun.HAND_OFFSET[1], HordeGun.HAND_OFFSET[2], HordeGun.HAND_OFFSET[3])) m = Mat4.mul(m, Mat4.rotateY(HordeGun.HAND_YAW)) m = Mat4.mul(m, Mat4.rotateX(HordeGun.HAND_PITCH)) -- the recoil, up and back along the gun's own axes local k = gun.kick if k > 0 then m = Mat4.mul(m, Mat4.translate(0, 0, -0.05 * k)) m = Mat4.mul(m, Mat4.rotateX(-0.30 * k)) end gun.frame = m -- the barrel, in world pixels: the shot goes where the gun points, so -- lining the sights up with an eye is what aims it local o = { m[4], m[8], m[12] } local dx, dy, dz = m[3], m[7], m[11] -- the model's +Z column local len = math.sqrt(dx * dx + dy * dy + dz * dz) if len > 1e-6 then gun.ray = { o[1], o[2], o[3], dx / len, dy / len, dz / len } else gun.ray = nil end end function HordeGun.clear() gun.frame, gun.ray = nil, nil end -- ------- drawing -- -- Runs inside VoxelScene's drawScene, once per eye in VR and once per -- frame flat, after the world -- so the gun composites with real depth -- and leaning it into a wall occludes honestly. -- Should the gun be drawn at all this frame? Keyed on the first-person -- rig's own IDENTITY rather than on the rung's number, because a staged -- VR battle places a camera through the same seam and the gun has no -- business in it. function HordeGun.visible() if not Horde.active then return false end if gun.frame then return true end return FirstPerson.cardBlend() > 0.35 end -- The flat screen's view model matrix: carried by the camera, offset to -- the hip or the sight line, with the recoil on top. local function flatModel() local cam = Voxel3D.camera local eye = cam and cam.eye if not eye then return nil end local a = gun.adsBlend a = a * a * (3 - 2 * a) local hip, ads = HordeGun.HIP, HordeGun.ADS local ox = hip[1] + (ads[1] - hip[1]) * a local oy = hip[2] + (ads[2] - hip[2]) * a local oz = hip[3] + (ads[3] - hip[3]) * a -- the reload dip: the gun swings down and out of the shot while the -- hands are busy, easing back as the slide comes home if gun.reloading then local t = math.min(1, gun.reloadT / HordeGun.RELOAD_TIME) local dip = math.sin(math.min(1, t * 1.15) * math.pi) oy = oy - 0.09 * dip ox = ox - 0.03 * dip end local k = gun.kick oz = oz - 0.045 * k local m = Mat4.translate(eye[1], eye[2], eye[3]) m = Mat4.mul(m, Mat4.rotateY(FirstPerson.yaw)) m = Mat4.mul(m, Mat4.rotateX(FirstPerson.pitch - 0.34 * k)) m = Mat4.mul(m, Mat4.scale(VRRig.FP_SCALE, VRRig.FP_SCALE, VRRig.FP_SCALE)) m = Mat4.mul(m, Mat4.translate(ox, oy, oz)) if gun.reloading then local t = math.min(1, gun.reloadT / HordeGun.RELOAD_TIME) m = Mat4.mul(m, Mat4.rotateX(-0.55 * math.sin(math.min(1, t * 1.15) * math.pi))) end return m end function HordeGun.draw() if not HordeGun.visible() then return end local model = gun.frame or flatModel() if not model then return end local body, pal = buildBody(), palette() if not (body and pal) then return end Voxel3D.draw(body, pal, model) if gun.flash > 0 then local flash = buildFlash() if flash then Voxel3D.draw(flash, pal, model) end end end function HordeGun.invalidate() paletteTex, bodyMesh, flashMesh = nil, nil, nil end return HordeGun