mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:00:50 +02:00
horde mode initial commit
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
-- 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 }
|
||||
|
||||
-- and where it sits relative to the tracked hand, in metres
|
||||
HordeGun.HAND_OFFSET = { 0, 0.02, 0.03 }
|
||||
HordeGun.HAND_TILT = -math.pi / 2 -- lay the barrel along the controller
|
||||
|
||||
-- ------- 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.rotateX(HordeGun.HAND_TILT))
|
||||
-- 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
|
||||
Reference in New Issue
Block a user