-- 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