Files
DramaticShapeVoxelMod/lib/HordeGun.lua
2026-08-02 22:42:46 -04:00

512 lines
18 KiB
Lua

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