mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:10:49 +02:00
+41
-1
@@ -102,6 +102,15 @@ FirstPerson.yaw = 0
|
||||
FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
|
||||
FirstPerson.blend = 0
|
||||
|
||||
-- A multiplier on the first-person field of view, for anything that wants
|
||||
-- to narrow the lens without owning the rig: 1 is the ordinary 65
|
||||
-- degrees, and horde mode's iron sights ease it down toward 40 while the
|
||||
-- player is looking down them (lib/HordeGun). Kept here rather than in
|
||||
-- the caller because the fov is folded into the orbit blend below, and
|
||||
-- because signature() has to know -- a lens that narrows while the player
|
||||
-- stands still still has to re-fit the shadow box.
|
||||
FirstPerson.fovScale = 1
|
||||
|
||||
local wasEngaged = false
|
||||
local stick = { x = 0, y = 0 } -- right stick, latest event values
|
||||
local mouseDX, mouseDY = 0, 0 -- relative counts since last update
|
||||
@@ -464,7 +473,7 @@ function FirstPerson.frame(me, cx, cy, vw, vh)
|
||||
rig = {
|
||||
eye = mix(oEye, head),
|
||||
focus = mix(oFocus, fpFocus),
|
||||
fov = oFov + (FirstPerson.FOV - oFov) * e,
|
||||
fov = oFov + (FirstPerson.FOV * FirstPerson.fovScale - oFov) * e,
|
||||
up = up,
|
||||
curve = k,
|
||||
}
|
||||
@@ -503,6 +512,7 @@ function FirstPerson.signature()
|
||||
math.floor(b * 64),
|
||||
math.floor(FirstPerson.yaw * 64),
|
||||
math.floor(FirstPerson.pitch * 64),
|
||||
math.floor(FirstPerson.fovScale * 64),
|
||||
}, ",")
|
||||
end
|
||||
|
||||
@@ -595,11 +605,30 @@ function FirstPerson.install()
|
||||
-- pressed is remembered per button, so the release always reaches the
|
||||
-- overlay even if the capture ended while the button was down --
|
||||
-- otherwise a click that outlives the rung strands A held forever.
|
||||
--
|
||||
-- HORDE MODE re-reads the same two buttons as a weapon: left fires,
|
||||
-- right holds the sights. Claimed BEFORE the A/B mapping below rather
|
||||
-- than on top of it, so a click during the mode never also lands as a
|
||||
-- GB button -- otherwise the A that ends the GAME OVER card would be
|
||||
-- spent by the shot that ended the run.
|
||||
local mouseHeld = {}
|
||||
local MOUSE_BTN = { [1] = "a", [2] = "b" }
|
||||
local function hordeMouse(button, down)
|
||||
local Horde = V.require("Horde")
|
||||
if not Horde.playing() then return false end
|
||||
if button == 1 then
|
||||
if down then V.require("HordeGun").fire() end
|
||||
return true
|
||||
elseif button == 2 then
|
||||
V.require("HordeGun").setAds(down)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
do
|
||||
local inner = love.mousepressed
|
||||
love.mousepressed = function(x, y, button, istouch, presses)
|
||||
if captured and not istouch and hordeMouse(button, true) then return end
|
||||
if captured and not istouch and MOUSE_BTN[button] then
|
||||
local Input = require("src.core.Input")
|
||||
mouseHeld[button] = true
|
||||
@@ -612,6 +641,9 @@ function FirstPerson.install()
|
||||
do
|
||||
local inner = love.mousereleased
|
||||
love.mousereleased = function(x, y, button, istouch, presses)
|
||||
-- a release always reaches whoever owns the press: the horde's
|
||||
-- aim-hold has to let go even if the mode ended mid-click
|
||||
if not mouseHeld[button] and hordeMouse(button, false) then return end
|
||||
if mouseHeld[button] then
|
||||
local Input = require("src.core.Input")
|
||||
mouseHeld[button] = nil
|
||||
@@ -651,6 +683,14 @@ function FirstPerson.install()
|
||||
local onControl = nil
|
||||
pcall(function() onControl = TouchControls:hitTest(x, y) end)
|
||||
if not onControl and not lookTouch then
|
||||
-- HORDE MODE: a tap on open screen is a SHOT, fired on the press
|
||||
-- rather than on a release that turned out not to be a drag --
|
||||
-- a shooter that waits to find out whether you meant it is a
|
||||
-- shooter that misses. The same finger still becomes the look
|
||||
-- drag below, so aiming and firing are one gesture.
|
||||
if V.require("Horde").playing() then
|
||||
V.require("HordeGun").fire()
|
||||
end
|
||||
lookTouch = { id = id, x = x, y = y }
|
||||
return
|
||||
end
|
||||
|
||||
+25
-17
@@ -177,12 +177,21 @@ end
|
||||
-- ------- the blocked push
|
||||
--
|
||||
-- The grid game's blocked step is where half its verbs live: the map-edge
|
||||
-- crossing, the ledge hop, the boulder shove, the route-gate warp fired
|
||||
-- by collision, and the honest bonk. Hand the engine the quantised
|
||||
-- direction and let its own handlers decide -- each one validates itself
|
||||
-- (checkLedgeHop matches the tile pair, checkEdgeExit checks the bounds),
|
||||
-- so calling them on every firm push is safe. Returns true when one of
|
||||
-- them took the frame over.
|
||||
-- crossing, the ledge hop, the boulder shove, and the route-gate warp
|
||||
-- fired by collision. Hand the engine the quantised direction and let its
|
||||
-- own handlers decide -- each one validates itself (checkLedgeHop matches
|
||||
-- the tile pair, checkEdgeExit checks the bounds), so calling them on
|
||||
-- every firm push is safe. Returns true when one of them took the frame
|
||||
-- over.
|
||||
--
|
||||
-- The one verb NOT restated here is the bonk. On the grid a blocked step
|
||||
-- is a discrete event -- you pressed a direction, the game refused, and
|
||||
-- the bump answers you once. A free walk has no such moment: the body
|
||||
-- SLIDES along whatever it grazes, so a player walking a fence line or
|
||||
-- rounding a doorframe is blocked on one axis continuously, and the same
|
||||
-- sound comes out as a rattle for as long as they keep walking. It is
|
||||
-- feedback for a refusal that is not happening. The grid walk keeps its
|
||||
-- own bump (the engine's, in OverworldController) untouched.
|
||||
local function pushSpecials(state, dir, why)
|
||||
local p = state.player
|
||||
p.facing = dir -- the handlers read the push off the facing
|
||||
@@ -199,13 +208,6 @@ local function pushSpecials(state, dir, why)
|
||||
return true
|
||||
end
|
||||
end
|
||||
if why ~= "entity" then
|
||||
if (state.bumpCooldown or 0) <= 0 then
|
||||
local Game = require("src.core.Game")
|
||||
require("src.core.Sound").play(Game.data, "Collision")
|
||||
state.bumpCooldown = 16
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -234,11 +236,19 @@ function FreeMove.tick(state)
|
||||
-- which way a bonk points
|
||||
p.facing = FirstPerson.compassFacing()
|
||||
|
||||
if input:wasPressed("a") then
|
||||
-- HORDE MODE takes both of these away for as long as it runs: there is
|
||||
-- no pausing (START), and nobody stops to read a sign with the horde
|
||||
-- coming (A, which is also the button the mode's own GAME OVER card
|
||||
-- wants left unspent). Everything below -- the walk, the wall slide and
|
||||
-- the blocked-push verbs, warps included -- keeps working, because the
|
||||
-- crowd has to be able to follow the player through a door.
|
||||
local suppressed = V.require("Horde").suppressWorldInput()
|
||||
|
||||
if not suppressed and input:wasPressed("a") then
|
||||
state:interact()
|
||||
return
|
||||
end
|
||||
if input:wasPressed("start") then
|
||||
if not suppressed and input:wasPressed("start") then
|
||||
require("src.core.Sound").play(Game.data, "Start_Menu")
|
||||
require("src.ui.Screens").push(Game, "StartMenu")
|
||||
return
|
||||
@@ -266,8 +276,6 @@ function FreeMove.tick(state)
|
||||
|
||||
if not moving then return end
|
||||
|
||||
state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1)
|
||||
|
||||
local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE
|
||||
or FreeMove.WALK
|
||||
local dx, dz = wx * speed, wz * speed
|
||||
|
||||
+707
@@ -0,0 +1,707 @@
|
||||
-- HORDE MODE: the code, the dark, and the way back.
|
||||
--
|
||||
-- Up Up Down Down Left Right Left Right B A, standing in the overworld,
|
||||
-- and Kanto turns on you: the sky goes to a starless violet night, the
|
||||
-- Lavender Town theme comes up, the camera locks into the player's own
|
||||
-- head, a handgun appears in their right hand, and waves of people walk
|
||||
-- out of the dark to kill them. Score goes up per kill; when the health
|
||||
-- runs out a GAME OVER screen offers a score and PRESS A, and pressing it
|
||||
-- puts everything back exactly as it was.
|
||||
--
|
||||
-- WHAT THIS FILE OWNS: the code detector, the state machine, the snapshot
|
||||
-- and its restore, and every hook that holds the world still while the
|
||||
-- mode runs. The gun is lib/HordeGun, the crowd is lib/HordeMobs, the
|
||||
-- readout is lib/HordeHud, the sounds are lib/HordeSfx and the ending is
|
||||
-- lib/HordeGameOver.
|
||||
--
|
||||
-- IT IS NOT A STACK STATE, and that is the load-bearing decision. Pushing
|
||||
-- a state over the overworld stops StateStack ticking the overworld,
|
||||
-- which stops OverworldState:handleInput, which stops FreeMove -- the
|
||||
-- player would be unable to walk. So horde mode is a MODE FLAG driven
|
||||
-- from the voxel pipeline's update hook, exactly as lib/OverworldBattle
|
||||
-- rides it: the one tick that keeps running through menus, transitions
|
||||
-- and battles. The GAME OVER screen IS a pushed state, because by then
|
||||
-- the walking is over and freezing the world under it is the point.
|
||||
--
|
||||
-- THE CODE IS READ OFF GAME BOY BUTTONS, not off keys. Every input device
|
||||
-- the engine has -- keyboard, gamepad, raw joystick, the touch overlay,
|
||||
-- and the VR controllers (lib/VR.driveControls feeds Input:overlayPressed
|
||||
-- and the stick path) -- lands in src/core/Input as one of eight buttons.
|
||||
-- One detector on that abstraction is therefore a detector on ALL of
|
||||
-- them, which is why the code works on a headset with no keyboard in the
|
||||
-- room. It reads Input.pressQueue from the `input.step` hook, the fixed
|
||||
-- step's own boundary, so it sees every edge exactly once whatever the
|
||||
-- frame rate did.
|
||||
--
|
||||
-- THE DARK is not a new renderer. DayNight is pinned to NIGHT and then
|
||||
-- its two public colour functions are WRAPPED and multiplied down toward
|
||||
-- violet -- so the sky bands, the world tint, the flat 2D world (DayTint
|
||||
-- paints the same multiply), the water's reflection and the shadow rig
|
||||
-- all darken together, because every one of them already reads those two
|
||||
-- functions. Wrapped rather than edited in place because both memoise
|
||||
-- into file-local caches this module cannot reach.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local DayNight = V.require("DayNight")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local HordeSfx = V.require("HordeSfx")
|
||||
|
||||
local Horde = {}
|
||||
|
||||
-- lib modules that require THIS one back (the mobs read the session, the
|
||||
-- gun reports kills). Loaded on first use rather than at the top, so the
|
||||
-- require cycle never closes.
|
||||
local Mobs, Gun, Hud
|
||||
local function parts()
|
||||
Mobs = Mobs or V.require("HordeMobs")
|
||||
Gun = Gun or V.require("HordeGun")
|
||||
Hud = Hud or V.require("HordeHud")
|
||||
return Mobs, Gun, Hud
|
||||
end
|
||||
|
||||
-- ------- tuning
|
||||
--
|
||||
-- Every number the mode is balanced on, in one place.
|
||||
|
||||
Horde.MAX_HP = 100
|
||||
Horde.CONTACT_DAMAGE = 9 -- one mob's touch
|
||||
Horde.INTRO_TIME = 3.6 -- the beat before the first wave
|
||||
Horde.DYING_TIME = 1.1 -- from the last hit to the GAME OVER card
|
||||
Horde.SONG = "Music_Lavender"
|
||||
|
||||
-- how far down NIGHT is dragged. The sky's bands and the world tint are
|
||||
-- multiplied by these; the third is how much of the colour is pulled out
|
||||
-- on the way (1 keeps it, 0 is greyscale) -- a little desaturation is
|
||||
-- what turns "dark" into "grim".
|
||||
Horde.GLOOM_SKY = { 0.34, 0.30, 0.46 }
|
||||
Horde.GLOOM_WORLD = { 0.42, 0.38, 0.56 }
|
||||
Horde.GLOOM_INDOOR = { 0.55, 0.50, 0.68 }
|
||||
Horde.GLOOM_SAT = 0.72
|
||||
Horde.SHADOW_BOOST = 1.45 -- the moon presses harder than it should
|
||||
|
||||
-- ------- state
|
||||
|
||||
Horde.active = false -- every hook in this file gates on it
|
||||
Horde.state = "idle" -- idle | intro | active | dying | gameover
|
||||
Horde.session = nil
|
||||
|
||||
-- Whether the combat is live: mobs move, the gun fires, damage lands.
|
||||
-- False during the intro beat, the death fade, the GAME OVER card -- and
|
||||
-- while ANYTHING is on the stack above the overworld, which is what
|
||||
-- stops the trigger from firing into a world the player has stopped
|
||||
-- looking at while the exit prompt asks them a question.
|
||||
function Horde.playing()
|
||||
if not (Horde.active and Horde.state == "active") then return false end
|
||||
local ok, live = pcall(function()
|
||||
local G = require("src.core.Game")
|
||||
local ow = G.overworld
|
||||
return G.stack and ow and G.stack:top() == ow and not ow.transitioning
|
||||
end)
|
||||
return ok and live == true
|
||||
end
|
||||
|
||||
-- Whether the mode owns the camera rung right now, which is the whole of
|
||||
-- what "locked to first person" means: main.lua's cycleVoxel refuses
|
||||
-- while this is true, and that one function is what the 3 key, the pad's
|
||||
-- SELECT and the VR stick click all call.
|
||||
function Horde.viewLocked()
|
||||
return Horde.active
|
||||
end
|
||||
|
||||
-- Whether the free walk should skip its A (talk) and START (menu)
|
||||
-- branches. Nobody stops to read a sign mid-firefight, and START has a
|
||||
-- different job here (see askExit).
|
||||
function Horde.suppressWorldInput()
|
||||
return Horde.active
|
||||
end
|
||||
|
||||
local function game()
|
||||
local ok, G = pcall(require, "src.core.Game")
|
||||
return ok and G or nil
|
||||
end
|
||||
|
||||
local function overworld(G)
|
||||
G = G or game()
|
||||
return G and G.overworld or nil
|
||||
end
|
||||
|
||||
-- The way out, on demand. START -- the pad's, the keyboard's ESCAPE, the
|
||||
-- touch overlay's -- and the VR left stick click all ask this, and it
|
||||
-- asks the player. Nothing here ends the mode; the prompt does that
|
||||
-- through Horde.finish if the answer is yes.
|
||||
--
|
||||
-- Refused while anything is already on top of the overworld, so the
|
||||
-- question cannot stack on itself or arrive over the GAME OVER card.
|
||||
--
|
||||
-- BELOW the two helpers above, deliberately: a Lua local is only in
|
||||
-- scope after its declaration, so a function written above them captures
|
||||
-- the GLOBAL of that name instead -- which is nil, and only says so when
|
||||
-- somebody presses the button.
|
||||
function Horde.askExit(G)
|
||||
G = G or game()
|
||||
if not (Horde.active and Horde.state ~= "gameover") then return false end
|
||||
local ow = overworld(G)
|
||||
if not (G and G.stack and ow and G.stack:top() == ow) then return false end
|
||||
if ow.transitioning then return false end
|
||||
local pushed = false
|
||||
pcall(function()
|
||||
require("src.ui.Screens").push(G, "HordeExitPrompt")
|
||||
pushed = true
|
||||
end)
|
||||
return pushed
|
||||
end
|
||||
|
||||
-- ------- the code
|
||||
--
|
||||
-- Advance on the expected button; on a wrong one, fall back to the
|
||||
-- longest run already entered that is still a valid start of the code,
|
||||
-- and try again from there. That fallback is why this is a table rather
|
||||
-- than a counter: the code STARTS with a repeat, so a player who presses
|
||||
-- Up three times has, on the third, still entered "Up Up" -- and a naive
|
||||
-- "wrong button, back to the beginning" rule would throw one of them
|
||||
-- away and refuse a code that was in fact typed correctly. (It is the
|
||||
-- prefix function from Knuth-Morris-Pratt, over ten buttons.)
|
||||
--
|
||||
-- The timeout is in fixed steps, 60 to the second: a code is a deliberate
|
||||
-- act, and a stray Up a minute ago should not be half of one.
|
||||
|
||||
local SEQUENCE = { "up", "up", "down", "down",
|
||||
"left", "right", "left", "right", "b", "a" }
|
||||
local IDLE_STEPS = 150 -- two and a half seconds between buttons
|
||||
|
||||
-- FALLBACK[n] = how much of the code is still entered after n matched
|
||||
-- buttons and then a wrong one
|
||||
local FALLBACK = { [0] = 0, [1] = 0 }
|
||||
do
|
||||
local k = 0
|
||||
for i = 2, #SEQUENCE do
|
||||
while k > 0 and SEQUENCE[k + 1] ~= SEQUENCE[i] do k = FALLBACK[k] end
|
||||
if SEQUENCE[k + 1] == SEQUENCE[i] then k = k + 1 end
|
||||
FALLBACK[i] = k
|
||||
end
|
||||
end
|
||||
|
||||
local progress = 0
|
||||
local sinceLast = 0
|
||||
|
||||
-- Named for the suite: how far into the code the detector has got.
|
||||
function Horde._progress()
|
||||
return progress
|
||||
end
|
||||
|
||||
local function resetCode()
|
||||
progress, sinceLast = 0, 0
|
||||
end
|
||||
|
||||
-- Can the mode start from where the player is standing? The overworld has
|
||||
-- to be the live state (not a menu, not a battle, not a transition wipe),
|
||||
-- the 3D pass has to exist to put a camera inside, and the world has to be
|
||||
-- free-roaming rather than mid-cutscene.
|
||||
--
|
||||
-- MID-STEP IS ALLOWED, and that is not an oversight. Six of the code's ten
|
||||
-- buttons are directions, so entering it on a d-pad walks the player four
|
||||
-- cells across the map -- and at the moment the closing A lands they are
|
||||
-- very often still animating the last of those steps. Refusing a code for
|
||||
-- being mid-step would refuse most of the codes anyone actually enters.
|
||||
-- The snapshot records the cell the step began from, which is where the
|
||||
-- restore puts them back.
|
||||
function Horde.canStart(G)
|
||||
G = G or game()
|
||||
if not G or Horde.active then return false end
|
||||
local ow = overworld(G)
|
||||
if not (ow and ow.map and ow.player) then return false end
|
||||
if not (G.stack and G.stack:top() == ow) then return false end
|
||||
if ow.transitioning or ow.scripted or ow.engaging then return false end
|
||||
if ow.player.inputLocked then return false end
|
||||
if not Voxel3D.available() then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- One fixed step of the detector, over the edges about to be promoted.
|
||||
-- Separated from the hook so the suite can drive it with a plain list.
|
||||
function Horde.feed(queue)
|
||||
if Horde.active then
|
||||
resetCode()
|
||||
return false
|
||||
end
|
||||
sinceLast = sinceLast + 1
|
||||
if progress > 0 and sinceLast > IDLE_STEPS then resetCode() end
|
||||
local fired = false
|
||||
for _, btn in ipairs(queue or {}) do
|
||||
sinceLast = 0
|
||||
while progress > 0 and SEQUENCE[progress + 1] ~= btn do
|
||||
progress = FALLBACK[progress]
|
||||
end
|
||||
if SEQUENCE[progress + 1] == btn then
|
||||
progress = progress + 1
|
||||
if progress >= #SEQUENCE then
|
||||
resetCode()
|
||||
fired = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return fired
|
||||
end
|
||||
|
||||
-- ------- the snapshot
|
||||
--
|
||||
-- Everything the mode changes, read back before it changes any of it.
|
||||
-- Presentational settings included: the rung, the two engine FX levels the
|
||||
-- rung clearing would zero, and the clock -- a player who was watching a
|
||||
-- CYCLE sunset gets their sunset back.
|
||||
|
||||
local function snapshot(G)
|
||||
local ow = overworld(G)
|
||||
local p = ow.player
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local opts = G.save and G.save.options or {}
|
||||
local snap = {
|
||||
mapId = ow.map.id,
|
||||
cellX = p.cellX, cellY = p.cellY,
|
||||
px = p.px, py = p.py,
|
||||
facing = p.facing,
|
||||
viewLevel = Pipelines.level("voxel"),
|
||||
tilt = opts.tilt or 0,
|
||||
gbcfx = opts.gbcfx or 0,
|
||||
fpYaw = FirstPerson.yaw,
|
||||
fpPitch = FirstPerson.pitch,
|
||||
dayIndex = DayNight.setting:read(),
|
||||
dayClock = DayNight.clock,
|
||||
}
|
||||
return snap
|
||||
end
|
||||
|
||||
-- ------- the gloom
|
||||
--
|
||||
-- Installed once and inert while the mode is off: each wrapper calls
|
||||
-- through and returns the base answer untouched unless Horde.active.
|
||||
|
||||
local gloomInstalled = false
|
||||
|
||||
local function desaturate(r, g, b, keep)
|
||||
local lum = 0.30 * r + 0.59 * g + 0.11 * b
|
||||
return lum + (r - lum) * keep,
|
||||
lum + (g - lum) * keep,
|
||||
lum + (b - lum) * keep
|
||||
end
|
||||
|
||||
local function installGloom()
|
||||
if gloomInstalled then return end
|
||||
gloomInstalled = true
|
||||
|
||||
-- The sky's bands. Sky.bands caches BY COLOUR VALUE, so darkening what
|
||||
-- this returns rebuilds the band ramp on its own -- and puts it back the
|
||||
-- same way when the mode ends.
|
||||
do
|
||||
local base = DayNight.palette
|
||||
local cacheIn, cacheOut = nil, nil
|
||||
DayNight.palette = function(t)
|
||||
local pal = base(t)
|
||||
if not Horde.active then return pal end
|
||||
if cacheIn == pal then return cacheOut end
|
||||
local k = Horde.GLOOM_SKY
|
||||
local out = {}
|
||||
for i, c in ipairs(pal) do
|
||||
local r, g, b = c[1] * k[1], c[2] * k[2], c[3] * k[3]
|
||||
r, g, b = desaturate(r, g, b, Horde.GLOOM_SAT)
|
||||
out[i] = { math.floor(r), math.floor(g), math.floor(b) }
|
||||
end
|
||||
cacheIn, cacheOut = pal, out
|
||||
return out
|
||||
end
|
||||
end
|
||||
|
||||
-- The world multiply -- the voxel shader's tint uniform AND, through
|
||||
-- DayTint, the flat 2D world. Indoors normally returns neutral white;
|
||||
-- under the horde it does not, because a Pokemon Centre with the horde
|
||||
-- in it should not look like a Pokemon Centre.
|
||||
do
|
||||
local base = DayNight.tint
|
||||
local cacheIn, cacheOut, cacheOutdoor = nil, nil, nil
|
||||
DayNight.tint = function(outdoor, t)
|
||||
local c = base(outdoor, t)
|
||||
if not Horde.active then return c end
|
||||
if cacheIn == c and cacheOutdoor == outdoor then return cacheOut end
|
||||
local k = outdoor and Horde.GLOOM_WORLD or Horde.GLOOM_INDOOR
|
||||
local r, g, b = c[1] * k[1], c[2] * k[2], c[3] * k[3]
|
||||
r, g, b = desaturate(r, g, b, Horde.GLOOM_SAT)
|
||||
cacheIn, cacheOutdoor, cacheOut = c, outdoor, { r, g, b }
|
||||
return cacheOut
|
||||
end
|
||||
end
|
||||
|
||||
-- and the shadows press harder: applyRig writes SHADOW_ALPHA from the
|
||||
-- hour, so the boost goes on after it has had its say
|
||||
do
|
||||
local base = DayNight.applyRig
|
||||
DayNight.applyRig = function(outdoor)
|
||||
local t = base(outdoor)
|
||||
if Horde.active then
|
||||
Voxel3D.SHADOW_ALPHA = math.min(0.75,
|
||||
(Voxel3D.SHADOW_ALPHA or 0) * Horde.SHADOW_BOOST)
|
||||
end
|
||||
return t
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- starting
|
||||
|
||||
-- The banner over the world: text, and how long it holds before fading.
|
||||
function Horde.banner(text, hold)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
s.bannerText = text
|
||||
s.bannerT = 0
|
||||
s.bannerHold = hold or 2.2
|
||||
end
|
||||
|
||||
function Horde.begin(G)
|
||||
G = G or game()
|
||||
if not Horde.canStart(G) then return false end
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local mobs, gun = parts()
|
||||
|
||||
local snap = snapshot(G)
|
||||
Horde.session = {
|
||||
hp = Horde.MAX_HP, maxHp = Horde.MAX_HP,
|
||||
score = 0, wave = 0, kills = 0,
|
||||
t = 0, introT = Horde.INTRO_TIME, dyingT = 0,
|
||||
damageFlash = 0, hitMarker = 0, hurtCooldown = 0,
|
||||
bannerText = nil, bannerT = 0, bannerHold = 0,
|
||||
snapshot = snap,
|
||||
spawned = {}, -- mapId -> { [objIndex] = true }, for the scrub
|
||||
mobs = {},
|
||||
waveRemaining = 0, waveGap = 0, spawnGap = 0, followQueue = 0,
|
||||
startedAt = os and os.time and os.time() or 0,
|
||||
}
|
||||
Horde.active = true
|
||||
Horde.state = "intro"
|
||||
|
||||
-- the rung, forced and then held: FP_LEVEL is the one rung with a camera
|
||||
-- inside the world, and cycleVoxel refuses to leave it while active
|
||||
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
|
||||
Pipelines.syncOptions(G.save.options)
|
||||
G.save.options.tilt, G.save.options.gbcfx = 0, 0
|
||||
pcall(function() require("src.render.Tilt").setLevel(0) end)
|
||||
pcall(function() require("src.render.GBCFX").setLevel(0) end)
|
||||
pcall(G.writeOptions, G)
|
||||
|
||||
-- night, pinned; the gloom wrappers do the rest on top of it
|
||||
local nightIndex = 3 -- DayNight.setting values: sync/day/NIGHT/...
|
||||
for i, v in ipairs(DayNight.setting.values) do
|
||||
if v == "night" then nightIndex = i end
|
||||
end
|
||||
DayNight.setting:setIndex(nightIndex, G)
|
||||
|
||||
pcall(function()
|
||||
require("src.core.Music").play(G.data, Horde.SONG, true,
|
||||
{ reason = "horde" })
|
||||
end)
|
||||
|
||||
gun.reset()
|
||||
mobs.begin(G)
|
||||
Horde.banner("A DARKNESS APPROACHES", 2.6)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- damage and score
|
||||
|
||||
function Horde.addScore(n)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
s.score = s.score + (n or 0)
|
||||
end
|
||||
|
||||
-- A mob reached the player. Returns true when the hit landed (it is on a
|
||||
-- cooldown, so a crowd of six does not delete the player in one frame).
|
||||
function Horde.damage(n)
|
||||
local s = Horde.session
|
||||
if not (s and Horde.playing()) then return false end
|
||||
if s.hurtCooldown > 0 then return false end
|
||||
s.hurtCooldown = 0.55
|
||||
s.hp = math.max(0, s.hp - (n or Horde.CONTACT_DAMAGE))
|
||||
s.damageFlash = 1
|
||||
HordeSfx.play(HordeSfx.HURT)
|
||||
if s.hp <= 0 then
|
||||
Horde.state = "dying"
|
||||
s.dyingT = Horde.DYING_TIME
|
||||
pcall(function() require("src.core.Sound").stopLoop("Low_Health_Alarm") end)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- the ending
|
||||
|
||||
local function pushGameOver(G)
|
||||
Horde.state = "gameover"
|
||||
local s = Horde.session
|
||||
local best = 0
|
||||
pcall(function() best = V.mod.save:get("hordeBest", 0) or 0 end)
|
||||
if s.score > best then
|
||||
best = s.score
|
||||
pcall(function() V.mod.save:set("hordeBest", best) end)
|
||||
end
|
||||
s.best = best
|
||||
pcall(function() require("src.core.Music").stop() end)
|
||||
pcall(function()
|
||||
require("src.ui.Screens").push(G, "HordeGameOver")
|
||||
end)
|
||||
end
|
||||
|
||||
-- Put everything back. Called from the GAME OVER card's A press.
|
||||
--
|
||||
-- Order matters: active goes false FIRST, so the music hook, the gloom
|
||||
-- wrappers and the mob spawner have all stood down before anything is
|
||||
-- restored under them. The warp home is taken even when the player never
|
||||
-- left the map they started on -- setMap rebuilds the cast from the map
|
||||
-- record, which is what puts every NPC the horde ate back on its feet.
|
||||
function Horde.finish(G)
|
||||
G = G or game()
|
||||
local s = Horde.session
|
||||
if not s then return false end
|
||||
local mobs = parts()
|
||||
local snap = s.snapshot or {}
|
||||
|
||||
Horde.active = false
|
||||
Horde.state = "idle"
|
||||
resetCode()
|
||||
|
||||
mobs.cleanup(G)
|
||||
pcall(function() require("src.core.Sound").stopLoop("Low_Health_Alarm") end)
|
||||
|
||||
-- the clock, back to the hour and the setting the player kept
|
||||
if snap.dayIndex then DayNight.setting:setIndex(snap.dayIndex, G) end
|
||||
if snap.dayClock then DayNight.clock = snap.dayClock end
|
||||
|
||||
-- the rung and the two FX levels the rung clearing zeroed
|
||||
pcall(function()
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
Pipelines.setLevel("voxel", snap.viewLevel or 0)
|
||||
Pipelines.syncOptions(G.save.options)
|
||||
G.save.options.tilt = snap.tilt or 0
|
||||
G.save.options.gbcfx = snap.gbcfx or 0
|
||||
require("src.render.Tilt").setLevel(snap.tilt or 0)
|
||||
require("src.render.GBCFX").setLevel(snap.gbcfx or 0)
|
||||
G:writeOptions()
|
||||
end)
|
||||
|
||||
if snap.fpYaw then FirstPerson.yaw = snap.fpYaw end
|
||||
if snap.fpPitch then FirstPerson.pitch = snap.fpPitch end
|
||||
|
||||
Horde.session = nil
|
||||
|
||||
-- home, through the engine's own warp: a fade, a setMap, and the map's
|
||||
-- own music coming back up on the other side (the hook that was forcing
|
||||
-- Lavender is inert now)
|
||||
local ow = overworld(G)
|
||||
if ow and snap.mapId then
|
||||
pcall(function()
|
||||
ow:startWarpTo(snap.mapId, snap.cellX, snap.cellY, snap.facing or "down",
|
||||
function()
|
||||
-- the pixel position and the facing, restated on
|
||||
-- the far side of the fade. setMap already placed
|
||||
-- both, but the free walk owns them while the rung
|
||||
-- is still easing out of the head, and the head was
|
||||
-- looking wherever the last shot was aimed
|
||||
local p = overworld(G) and overworld(G).player
|
||||
if not p then return end
|
||||
if snap.px then p.px, p.py = snap.px, snap.py end
|
||||
if snap.facing then p.facing = snap.facing end
|
||||
end,
|
||||
{ via = "warp" })
|
||||
end)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- the tick
|
||||
--
|
||||
-- Rides the voxel pipeline's update hook, which Game:update calls every
|
||||
-- frame whatever the level and whatever is on the stack -- so the mode
|
||||
-- keeps thinking through a warp's transition wipe and under the GAME OVER
|
||||
-- card, which is exactly what a mode that owns the whole screen needs.
|
||||
|
||||
function Horde.update(dt)
|
||||
if not Horde.active then return end
|
||||
local s = Horde.session
|
||||
if not s then
|
||||
Horde.active = false
|
||||
return
|
||||
end
|
||||
dt = math.min(dt or 0, 0.1) -- a hitch must not teleport the wave
|
||||
local G = game()
|
||||
local mobs, gun, hud = parts()
|
||||
|
||||
s.t = s.t + dt
|
||||
s.damageFlash = math.max(0, s.damageFlash - dt * 2.2)
|
||||
s.hitMarker = math.max(0, s.hitMarker - dt * 4)
|
||||
s.hurtCooldown = math.max(0, s.hurtCooldown - dt)
|
||||
if s.bannerText then
|
||||
s.bannerT = s.bannerT + dt
|
||||
if s.bannerT > s.bannerHold + 1.1 then s.bannerText = nil end
|
||||
end
|
||||
hud.update(dt)
|
||||
|
||||
if Horde.state == "intro" then
|
||||
s.introT = s.introT - dt
|
||||
if s.introT <= 0 then
|
||||
Horde.state = "active"
|
||||
mobs.nextWave(G)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if Horde.state == "dying" then
|
||||
s.dyingT = s.dyingT - dt
|
||||
mobs.update(dt, G) -- the crowd keeps coming while you fall
|
||||
if s.dyingT <= 0 then pushGameOver(G) end
|
||||
return
|
||||
end
|
||||
|
||||
if Horde.state ~= "active" then return end
|
||||
|
||||
-- the world only ticks while the overworld is actually the live state:
|
||||
-- during a warp's wipe there is no map under the mobs to walk on
|
||||
local ow = overworld(G)
|
||||
local live = G and G.stack and ow and G.stack:top() == ow
|
||||
and not ow.transitioning
|
||||
gun.update(dt, live)
|
||||
if live then mobs.update(dt, G) end
|
||||
|
||||
-- the siren the game already owns, for the last third of the health bar
|
||||
local low = s.hp <= s.maxHp * 0.3
|
||||
if low ~= s.alarmOn then
|
||||
s.alarmOn = low
|
||||
pcall(function()
|
||||
local Sound = require("src.core.Sound")
|
||||
if low then Sound.startLoop(G.data, "Low_Health_Alarm")
|
||||
else Sound.stopLoop("Low_Health_Alarm") end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the seams
|
||||
--
|
||||
-- Every engine and mod hook the mode needs, installed once. main.lua
|
||||
-- calls this AFTER FreeMove.install and the SELECT wrap, so the
|
||||
-- handleInput wrap this adds sits outside both of theirs.
|
||||
|
||||
local installed = false
|
||||
|
||||
function Horde.install()
|
||||
if installed then return end
|
||||
installed = true
|
||||
local mod = V.mod
|
||||
|
||||
installGloom()
|
||||
|
||||
-- THE CODE. `input.step` runs once per fixed step, immediately before
|
||||
-- Input:step promotes the queue into this step's edges -- so pressQueue
|
||||
-- is exactly "the buttons that were pressed since last time", in order,
|
||||
-- from every device at once. Read, never consumed: the game still gets
|
||||
-- every one of them.
|
||||
mod.hooks:wrap("input.step", function(next, G, dt)
|
||||
local inp = G and G.input
|
||||
if inp and inp.pressQueue and Horde.feed(inp.pressQueue) then
|
||||
pcall(Horde.begin, G)
|
||||
elseif Horde.playing() and inp and inp.pressQueue then
|
||||
-- B is a trigger while the horde is up (the pad's B, the keyboard's,
|
||||
-- the touch overlay's). Read here rather than in the frame tick
|
||||
-- because THIS is the boundary that sees each press exactly once.
|
||||
for _, btn in ipairs(inp.pressQueue) do
|
||||
if btn == "b" then
|
||||
local _, gun = parts()
|
||||
gun.fire()
|
||||
end
|
||||
end
|
||||
end
|
||||
return next(G, dt)
|
||||
end)
|
||||
|
||||
-- Lavender, and it stays Lavender. Every song choice in the engine goes
|
||||
-- through this hook, so a door into a building cannot change the record.
|
||||
mod.hooks:wrap("music.select", function(next, chosen, ctx)
|
||||
if Horde.active and Horde.state ~= "gameover" then
|
||||
return next(Horde.SONG, ctx)
|
||||
end
|
||||
return next(chosen, ctx)
|
||||
end)
|
||||
|
||||
-- no wild encounters: returning nil from this hook suppresses the roll
|
||||
-- outright, which is the documented way to do it
|
||||
mod.hooks:wrap("encounter.roll", function(next, encDef, ctx)
|
||||
if Horde.active then return nil end
|
||||
return next(encDef, ctx)
|
||||
end)
|
||||
|
||||
-- and no trainer walking up to talk. Wrapped rather than set through
|
||||
-- self.engaging, which would also freeze the player's own input.
|
||||
do
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
if not OverworldState.dramaticShapeHordeSight then
|
||||
local inner = OverworldState.checkTrainerSight
|
||||
function OverworldState:checkTrainerSight(...)
|
||||
if Horde.active then return end
|
||||
return inner(self, ...)
|
||||
end
|
||||
OverworldState.dramaticShapeHordeSight = true
|
||||
end
|
||||
end
|
||||
|
||||
-- THE BUTTONS THE WORLD MAY NOT HAVE. A, START, SELECT and B are the
|
||||
-- mode's, and this wrap is where they are taken -- the OUTERMOST wrap on
|
||||
-- handleInput, installed after FreeMove's and after the SELECT hook, so
|
||||
-- the edges are gone before either of them looks.
|
||||
--
|
||||
-- It has to be here rather than inside the free walk, because the free
|
||||
-- walk is not always the one reading: the rung is forced to 1ST at the
|
||||
-- moment the code completes, but the camera takes a few frames to blend
|
||||
-- into the head, and until it does the GRID walk still owns the frame.
|
||||
-- That is not a corner case -- it is the very first frame of every run,
|
||||
-- and the code's own closing A was landing in it and opening a dialogue
|
||||
-- with whoever the player happened to be standing next to.
|
||||
--
|
||||
-- The EDGE is cleared, not the hold: pressed[] is rebuilt from scratch
|
||||
-- every fixed step, so this reaches exactly this step's presses and
|
||||
-- nothing downstream of it can revive one.
|
||||
do
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
if not OverworldState.dramaticShapeHordeInput then
|
||||
local inner = OverworldState.handleInput
|
||||
function OverworldState:handleInput(...)
|
||||
if Horde.active then
|
||||
local G = game()
|
||||
local inp = G and G.input
|
||||
if inp and inp.pressed then
|
||||
-- START is the way out, and it is asked rather than taken:
|
||||
-- read here, BEFORE the edge is cleared, so the engine's own
|
||||
-- START menu never sees it
|
||||
if inp.pressed.start then Horde.askExit(G) end
|
||||
inp.pressed.a = nil -- no talking
|
||||
inp.pressed.b = nil -- the trigger, already read
|
||||
inp.pressed.start = nil -- and no start menu
|
||||
inp.pressed.select = nil -- no changing the view
|
||||
end
|
||||
end
|
||||
return inner(self, ...)
|
||||
end
|
||||
OverworldState.dramaticShapeHordeInput = true
|
||||
end
|
||||
end
|
||||
|
||||
-- the crowd follows the player through the door: a warp lands a new map
|
||||
-- with none of the old one's actors on it, so the roster is re-seeded on
|
||||
-- the far side (lib/HordeMobs)
|
||||
mod.events:on("map.entered", function(payload)
|
||||
if not Horde.active then return end
|
||||
local mobs = parts()
|
||||
pcall(mobs.onMapEntered, payload)
|
||||
end)
|
||||
end
|
||||
|
||||
return Horde
|
||||
@@ -0,0 +1,94 @@
|
||||
-- HORDE MODE: the way out.
|
||||
--
|
||||
-- START (the pad's, the keyboard's ESCAPE, the touch overlay's) and the
|
||||
-- VR left stick click all land here: a plain yes/no over the frozen
|
||||
-- world, asking whether to leave. YES hands over to Horde.finish, which
|
||||
-- is the same restore the GAME OVER card runs -- the map, the cell, the
|
||||
-- facing, the camera rung, the hour, the music and every NPC put back
|
||||
-- exactly as they were. NO drops the player straight back into the
|
||||
-- firefight.
|
||||
--
|
||||
-- Pushing a state is what pauses the mode, and it is the only thing that
|
||||
-- can: horde mode rides the pipeline's update hook rather than the state
|
||||
-- stack precisely so that nothing on the stack stops it, but the combat
|
||||
-- inside Horde.update is gated on the overworld actually being the live
|
||||
-- state, so this prompt freezes the crowd and the gun for as long as it
|
||||
-- is up. That is the correct behaviour for a confirmation and it is why
|
||||
-- "no pausing" does not extend to this one.
|
||||
--
|
||||
-- Drawn the way the game draws a yes/no: a white bordered box with black
|
||||
-- text and the filled arrow beside the row (see Theme.choiceBox, which
|
||||
-- is where the original's own YES_NO_MENU sits). Black on white because
|
||||
-- that is what the font IS -- the sheets are black glyphs on transparent
|
||||
-- and no colour can lighten one.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Horde = V.require("Horde")
|
||||
|
||||
local HordeExitPrompt = {}
|
||||
HordeExitPrompt.__index = HordeExitPrompt
|
||||
|
||||
-- the question's box, and the choice box under it, in 8px tiles
|
||||
local ASK = { tx = 1, ty = 6, tw = 18, th = 4 }
|
||||
local PICK = { tx = 13, ty = 10, tw = 6, th = 6 }
|
||||
|
||||
function HordeExitPrompt.new(game)
|
||||
local self = setmetatable({}, HordeExitPrompt)
|
||||
self.game = game
|
||||
-- NOT opaque: the horde is still standing out there behind this, which
|
||||
-- is most of what makes the question feel like a decision
|
||||
self.isOpaque = false
|
||||
self.index = 2 -- NO, the way every dangerous prompt starts
|
||||
self.done = false
|
||||
return self
|
||||
end
|
||||
|
||||
local function sfx(game, name)
|
||||
pcall(function()
|
||||
require("src.core.Sound").play(game.data, name)
|
||||
end)
|
||||
end
|
||||
|
||||
function HordeExitPrompt:update()
|
||||
if self.done then return end
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
self.index = self.index == 1 and 2 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
self.done = true
|
||||
sfx(self.game, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
if self.index == 1 then pcall(Horde.finish, self.game) end
|
||||
elseif input:wasPressed("b") or input:wasPressed("start") then
|
||||
-- B and START both mean "no": the button that opened this closes it,
|
||||
-- which is the one thing a player who opened it by accident will try
|
||||
self.done = true
|
||||
sfx(self.game, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
end
|
||||
end
|
||||
|
||||
function HordeExitPrompt:draw()
|
||||
local ok, Font = pcall(require, "src.render.Font")
|
||||
if not ok then return end
|
||||
local okT, Theme = pcall(require, "src.ui.Theme")
|
||||
|
||||
Font.drawBox(ASK.tx, ASK.ty, ASK.tw, ASK.th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("EXIT MINI GAME?", (ASK.tx + 2) * 8, (ASK.ty + 2) * 8)
|
||||
|
||||
Font.drawBox(PICK.tx, PICK.ty, PICK.tw, PICK.th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("YES", (PICK.tx + 2) * 8, (PICK.ty + 2) * 8)
|
||||
Font.draw("NO", (PICK.tx + 2) * 8, (PICK.ty + 4) * 8)
|
||||
local cursor = okT and Theme.cursor or 0xED
|
||||
Font.drawCode(cursor, (PICK.tx + 1) * 8,
|
||||
(PICK.ty + 2 + (self.index - 1) * 2) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return HordeExitPrompt
|
||||
@@ -0,0 +1,107 @@
|
||||
-- HORDE MODE: the card at the end.
|
||||
--
|
||||
-- A stack state, unlike the mode itself -- and for the opposite reason.
|
||||
-- Horde mode cannot be a pushed state because pushing one stops the
|
||||
-- overworld ticking and the player could not walk; the GAME OVER card
|
||||
-- WANTS exactly that. Pushed, it freezes the world underneath, takes the
|
||||
-- buttons, and stands there until A.
|
||||
--
|
||||
-- It draws in the engine's own 160x144 UI canvas with the game's own
|
||||
-- font, which is what makes it work in VR for free: with a headset live
|
||||
-- and something other than the overworld on top of the stack, lib/VR
|
||||
-- already puts the flat screen on the floating panel (or on the Pokedex
|
||||
-- in the player's left hand). A card drawn the way the game draws cards
|
||||
-- arrives there with no VR code at all.
|
||||
--
|
||||
-- A pops it and hands over to Horde.finish, which is what puts the world
|
||||
-- back: the map, the cell, the facing, the camera rung, the hour, the
|
||||
-- music, and every NPC the horde had turned into a mob.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Horde = V.require("Horde")
|
||||
|
||||
local W, H = 160, 144
|
||||
|
||||
local HordeGameOver = {}
|
||||
HordeGameOver.__index = HordeGameOver
|
||||
|
||||
function HordeGameOver.new(game)
|
||||
local self = setmetatable({}, HordeGameOver)
|
||||
self.game = game
|
||||
self.isOpaque = true
|
||||
self.t = 0
|
||||
-- the session is read ONCE, here: Horde.finish clears it, and this card
|
||||
-- outlives that by a frame or two while the warp home fades
|
||||
local s = Horde.session or {}
|
||||
self.score = math.floor(s.score or 0)
|
||||
self.best = math.floor(s.best or 0)
|
||||
self.wave = math.max(1, s.wave or 1)
|
||||
self.kills = s.kills or 0
|
||||
self.done = false
|
||||
return self
|
||||
end
|
||||
|
||||
function HordeGameOver:update(dt)
|
||||
self.t = self.t + (dt or 0)
|
||||
if self.done then return end
|
||||
-- a beat of dead air before the prompt takes input, so the button that
|
||||
-- was being mashed at the moment of death does not dismiss the card
|
||||
if self.t < 0.6 then return end
|
||||
local input = self.game and self.game.input
|
||||
if input and input:wasPressed("a") then
|
||||
self.done = true
|
||||
pcall(function()
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end)
|
||||
self.game.stack:pop()
|
||||
pcall(Horde.finish, self.game)
|
||||
end
|
||||
end
|
||||
|
||||
-- THE CARD IS DRAWN THE WAY THE GAME DRAWS CARDS: a bordered white box
|
||||
-- with black text in it (Font.drawBox then setColor(0,0,0)), exactly as
|
||||
-- HallOfFame and every menu do. That is not decoration -- the UI canvas
|
||||
-- is a FOUR-SHADE Game Boy screen, and an arbitrary colour drawn into it
|
||||
-- has nowhere to land. A first cut of this card painted a dark red on
|
||||
-- near-black and composited as a rectangle of pure black, with the score
|
||||
-- in it and invisible.
|
||||
local function centred(Font, str, y, scale)
|
||||
scale = scale or 1
|
||||
local w = Font.width(str) * scale
|
||||
love.graphics.push()
|
||||
love.graphics.translate(math.floor((W - w) / 2), y)
|
||||
love.graphics.scale(scale, scale)
|
||||
Font.draw(str, 0, 0)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
function HordeGameOver:draw()
|
||||
local ok, Font = pcall(require, "src.render.Font")
|
||||
if not ok then return end
|
||||
|
||||
-- the whole screen as one box: isOpaque keeps the stack from drawing
|
||||
-- the world under it, but the canvas still holds whatever was there
|
||||
Font.drawBox(0, 0, 20, 18)
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
centred(Font, "GAME OVER", 3 * 8, 2)
|
||||
|
||||
centred(Font, ("SCORE %d"):format(self.score), 8 * 8)
|
||||
centred(Font, ("WAVE %d"):format(self.wave), 10 * 8)
|
||||
centred(Font, ("KILLS %d"):format(self.kills), 11 * 8)
|
||||
|
||||
if self.best > 0 then
|
||||
centred(Font, (self.score >= self.best) and "NEW BEST!"
|
||||
or ("BEST %d"):format(self.best), 13 * 8)
|
||||
end
|
||||
|
||||
-- the prompt blinks the way every "press a button" in this game blinks
|
||||
if self.t > 0.6 and (self.t % 1.0) < 0.62 then
|
||||
centred(Font, "PRESS A", 15 * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return HordeGameOver
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,484 @@
|
||||
-- HORDE MODE: the readout.
|
||||
--
|
||||
-- Health, ammunition, score, wave, the crosshair, the hit marker, the red
|
||||
-- that closes in when something reaches you, and the banners -- "A
|
||||
-- DARKNESS APPROACHES", then "WAVE 1" and every wave after it.
|
||||
--
|
||||
-- IT IS DRAWN TWICE, INTO TWO DIFFERENT PLACES, and that is not
|
||||
-- duplication for its own sake. The flat screen's HUD goes into the SCENE
|
||||
-- canvas through Voxel3D.beginOverlay -- the same seam the overworld's FX
|
||||
-- bubbles use -- because that canvas is what the window composites. A
|
||||
-- headset never sees that canvas: with VR live the window's world pass
|
||||
-- short-circuits to the mirror, and the eyes are rendered on their own in
|
||||
-- lib/VR. So the eye canvases get their own pass, at the same instant the
|
||||
-- VR frame paints its fade over them, in the same 2D idiom.
|
||||
--
|
||||
-- Both call the same draw with a different scale and a different safe
|
||||
-- area: a headset wants everything well inside the lens rather than
|
||||
-- pinned to the corners, because the corners of a VR frame are off the
|
||||
-- edge of the visible world.
|
||||
--
|
||||
-- EVERY WORD IS ON A WHITE PLATE, and that is not a style choice -- it is
|
||||
-- what the font is. The Game Boy font sheets are BLACK glyphs on
|
||||
-- transparent, so setColor cannot make a letter pale: multiplying black
|
||||
-- by white is still black. That is why every box in the game is drawn
|
||||
-- white first and its text black on top (Font.drawBox, then
|
||||
-- setColor(0,0,0)), and it is why a first cut of this HUD -- pale text,
|
||||
-- straight onto the night -- composited as black letters on a black
|
||||
-- street and could not be read at all. Plates also happen to be the right
|
||||
-- answer aesthetically: the game already talks to the player in white
|
||||
-- boxes, and a horde mode that shouts in the same voice belongs to it.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Horde = V.require("Horde")
|
||||
|
||||
local HordeHud = {}
|
||||
|
||||
local Font = nil
|
||||
local function font()
|
||||
if Font then return Font end
|
||||
local ok, F = pcall(require, "src.render.Font")
|
||||
if ok then Font = F end
|
||||
return Font
|
||||
end
|
||||
|
||||
-- the pulse under the low-health plate and the banner's own breathing
|
||||
local blink = 0
|
||||
|
||||
function HordeHud.update(dt)
|
||||
blink = (blink + (dt or 0)) % 1.0
|
||||
end
|
||||
|
||||
-- named for the suite: the banner's line breaking, which is the part with
|
||||
-- an answer worth pinning
|
||||
HordeHud._layout = nil -- assigned below, once `layout` exists
|
||||
|
||||
-- ------- pieces
|
||||
--
|
||||
-- Every helper takes a scale `s` and draws in GB pixels multiplied by it,
|
||||
-- so one layout serves a 4x window and a headset's eye buffer alike.
|
||||
|
||||
local PAD = 3 -- plate padding, in GB pixels
|
||||
|
||||
-- A white plate with a dark edge: the surface a black glyph can be read
|
||||
-- on. Returns the interior origin, so a caller lays text out from there.
|
||||
local function plate(x, y, w, h, s, alpha)
|
||||
love.graphics.setColor(0.06, 0.05, 0.09, (alpha or 1) * 0.92)
|
||||
love.graphics.rectangle("fill", x - s, y - s, w + 2 * s, h + 2 * s)
|
||||
love.graphics.setColor(0.93, 0.94, 0.90, alpha or 1)
|
||||
love.graphics.rectangle("fill", x, y, w, h)
|
||||
return x + PAD * s, y + PAD * s
|
||||
end
|
||||
|
||||
local function textWidth(str, s)
|
||||
local F = font()
|
||||
if not F then return 0 end
|
||||
return F.width(str) * s
|
||||
end
|
||||
|
||||
-- Black glyphs at `s` times their size. Black because that is the only
|
||||
-- colour the font has (see the header).
|
||||
local function text(str, x, y, s)
|
||||
local F = font()
|
||||
if not F then return 0 end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.push()
|
||||
love.graphics.translate(math.floor(x), math.floor(y))
|
||||
love.graphics.scale(s, s)
|
||||
F.draw(str, 0, 0)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- One line of text on its own plate, anchored left or right.
|
||||
local function label(str, x, y, s, align)
|
||||
local tw = textWidth(str, s)
|
||||
local pw, ph = tw + PAD * 2 * s, 8 * s + PAD * 2 * s
|
||||
local px = (align == "right") and (x - pw) or x
|
||||
local ix, iy = plate(px, y, pw, ph, s)
|
||||
text(str, ix, iy, s)
|
||||
return pw, ph
|
||||
end
|
||||
|
||||
-- The health bar: a plate with a red bar inside it, so the red reads
|
||||
-- against white rather than against a night street.
|
||||
local function healthBar(x, y, w, h, s, fill, flash)
|
||||
local ix, iy = plate(x, y, w, h, s)
|
||||
local iw, ih = w - PAD * 2 * s, h - PAD * 2 * s
|
||||
love.graphics.setColor(0.80, 0.80, 0.78, 1)
|
||||
love.graphics.rectangle("fill", ix, iy, iw, ih)
|
||||
local r, g, b = 0.78, 0.12, 0.16
|
||||
if flash then r, g, b = 1, 0.45, 0.35 end
|
||||
love.graphics.setColor(r, g, b, 1)
|
||||
love.graphics.rectangle("fill", ix, iy, math.max(0, iw * fill), ih)
|
||||
end
|
||||
|
||||
-- The crosshair: four ticks around a gap that opens as the gun kicks, and
|
||||
-- gone entirely down the sights, where the iron sights ARE the crosshair.
|
||||
-- Drawn as a dark pair under a light pair so it survives both a white
|
||||
-- wall and a black doorway.
|
||||
local function crosshair(cx, cy, s, spread, alpha)
|
||||
local gap = (3 + spread * 4) * s
|
||||
local len = 4 * s
|
||||
local t = math.max(1, s)
|
||||
local function ticks(o, thick, r, g, b, a)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
love.graphics.rectangle("fill", cx - gap - len - o, cy - thick / 2 - o,
|
||||
len + 2 * o, thick + 2 * o)
|
||||
love.graphics.rectangle("fill", cx + gap - o, cy - thick / 2 - o,
|
||||
len + 2 * o, thick + 2 * o)
|
||||
love.graphics.rectangle("fill", cx - thick / 2 - o, cy - gap - len - o,
|
||||
thick + 2 * o, len + 2 * o)
|
||||
love.graphics.rectangle("fill", cx - thick / 2 - o, cy + gap - o,
|
||||
thick + 2 * o, len + 2 * o)
|
||||
end
|
||||
ticks(math.max(1, s * 0.5), t, 0, 0, 0, alpha * 0.85)
|
||||
ticks(0, t, 0.98, 0.98, 1, alpha)
|
||||
end
|
||||
|
||||
local function hitMarker(cx, cy, s, amount)
|
||||
if amount <= 0 then return end
|
||||
love.graphics.setColor(1, 0.30, 0.26, amount)
|
||||
local o = 5 * s
|
||||
local len = 5 * s
|
||||
local t = math.max(1, s)
|
||||
for _, d in ipairs({ { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } }) do
|
||||
love.graphics.push()
|
||||
love.graphics.translate(cx + d[1] * o, cy + d[2] * o)
|
||||
love.graphics.rotate(math.pi / 4 * (d[1] * d[2] > 0 and 1 or -1))
|
||||
love.graphics.rectangle("fill", -t / 2, -len / 2, t, len)
|
||||
love.graphics.pop()
|
||||
end
|
||||
end
|
||||
|
||||
-- How wide a run of glyphs comes out at `bs` pixels per font pixel, with
|
||||
-- `track` of air after each one.
|
||||
local function runWidth(F, codes, bs, track)
|
||||
local total = 0
|
||||
for _, code in ipairs(codes) do
|
||||
total = total + F.advanceOf(code) * bs + track
|
||||
end
|
||||
return total - track
|
||||
end
|
||||
|
||||
-- The banner's lines, and the size to draw them at.
|
||||
--
|
||||
-- THE SCALE IS NEGOTIATED, not assumed. The caller's scale comes from the
|
||||
-- window's own zoom, so a player zoomed well in gets a large `s` -- and
|
||||
-- "A DARKNESS APPROACHES" at twice a large scale is wider than the
|
||||
-- screen, which is how the words ran off both edges. So: shrink until the
|
||||
-- longest single WORD fits, then wrap the words into as many lines as
|
||||
-- that leaves. Wrapping first and shrinking only when a word alone cannot
|
||||
-- fit keeps the announcement as big as the frame can carry it.
|
||||
local function layout(F, str, scale, maxW)
|
||||
local words = {}
|
||||
for word in tostring(str):gmatch("%S+") do words[#words + 1] = word end
|
||||
if #words == 0 then return nil end
|
||||
|
||||
local bs = math.max(1, scale * 2)
|
||||
local function track(size) return math.max(1, math.floor(size / 2)) end
|
||||
while bs > 1 do
|
||||
local widest = 0
|
||||
for _, word in ipairs(words) do
|
||||
local ww = runWidth(F, F.encode(word), bs, track(bs))
|
||||
if ww > widest then widest = ww end
|
||||
end
|
||||
if widest <= maxW then break end
|
||||
bs = bs - 1
|
||||
end
|
||||
|
||||
local tr = track(bs)
|
||||
local spaceW = runWidth(F, F.encode(" "), bs, tr) + tr
|
||||
local lines, line, lineW = {}, nil, 0
|
||||
for _, word in ipairs(words) do
|
||||
local ww = runWidth(F, F.encode(word), bs, tr)
|
||||
if not line then
|
||||
line, lineW = word, ww
|
||||
elseif lineW + spaceW + ww <= maxW then
|
||||
line, lineW = line .. " " .. word, lineW + spaceW + ww
|
||||
else
|
||||
lines[#lines + 1] = { text = line, width = lineW }
|
||||
line, lineW = word, ww
|
||||
end
|
||||
end
|
||||
lines[#lines + 1] = { text = line, width = lineW }
|
||||
return lines, bs, tr
|
||||
end
|
||||
|
||||
HordeHud._layout = layout
|
||||
|
||||
-- The banner: a plate across the middle of the frame with the words on
|
||||
-- it, as big as the frame can carry. It fades in and out rather than
|
||||
-- cutting -- an announcement, not a notification -- and the plate fades
|
||||
-- with it.
|
||||
local function banner(w, h, scale)
|
||||
local sess = Horde.session
|
||||
if not (sess and sess.bannerText) then return end
|
||||
local t, hold = sess.bannerT, sess.bannerHold
|
||||
local alpha
|
||||
if t < 0.4 then alpha = t / 0.4
|
||||
elseif t < hold then alpha = 1
|
||||
else alpha = math.max(0, 1 - (t - hold) / 1.1) end
|
||||
if alpha <= 0 then return end
|
||||
|
||||
local F = font()
|
||||
if not F then return end
|
||||
local margin = 6 * scale
|
||||
local lines, bs, tr = layout(F, sess.bannerText, scale, w - margin * 2)
|
||||
if not lines then return end
|
||||
|
||||
local lineH = 8 * bs
|
||||
local gap = math.max(1, math.floor(bs * 0.4))
|
||||
local pad = PAD * 2 * scale
|
||||
local ph = #lines * lineH + (#lines - 1) * gap + pad * 2
|
||||
local y = math.floor(h * 0.30 - ph / 2)
|
||||
-- the plate runs the full width: a band across the world, which reads
|
||||
-- as the game interrupting itself rather than as a label on it
|
||||
plate(0, y, w, ph, scale, alpha)
|
||||
local iy = y + pad
|
||||
|
||||
love.graphics.setColor(0, 0, 0, alpha)
|
||||
for i, line in ipairs(lines) do
|
||||
local pen = math.floor((w - line.width) / 2)
|
||||
local ly = iy + (i - 1) * (lineH + gap)
|
||||
for _, code in ipairs(F.encode(line.text)) do
|
||||
love.graphics.push()
|
||||
love.graphics.translate(pen, ly)
|
||||
love.graphics.scale(bs, bs)
|
||||
F.drawCode(code, 0, 0)
|
||||
love.graphics.pop()
|
||||
pen = pen + F.advanceOf(code) * bs + tr
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the whole thing
|
||||
--
|
||||
-- `inset` is how far off the edges the corners sit, which is the one real
|
||||
-- difference between a window and a headset.
|
||||
|
||||
local function draw(w, h, s, inset)
|
||||
local sess = Horde.session
|
||||
if not sess then return end
|
||||
local Gun = V.require("HordeGun")
|
||||
local ammo, mag, reloading = Gun.ammo()
|
||||
local ads = Gun.adsBlend()
|
||||
|
||||
love.graphics.push("all")
|
||||
love.graphics.setBlendMode("alpha")
|
||||
|
||||
-- The red. A VIGNETTE rather than a wash over everything: a full-screen
|
||||
-- fill strong enough to register at a glance also hides the thing that
|
||||
-- just hit you, which in a mode about being surrounded is the one thing
|
||||
-- it must not do. Bands closing in from the edges instead, so the
|
||||
-- middle of the frame stays readable and the alarm arrives in the
|
||||
-- corner of the eye.
|
||||
local hurt = sess.damageFlash
|
||||
local low = 1 - math.min(1, sess.hp / (sess.maxHp * 0.35))
|
||||
local wash = math.max(hurt * 0.9, low * 0.6
|
||||
* (0.7 + 0.3 * math.sin(blink * math.pi * 2)))
|
||||
if wash > 0 then
|
||||
local band = math.min(w, h) * 0.38
|
||||
local steps = 8
|
||||
for i = 1, steps do
|
||||
local t = i / steps
|
||||
local d = band * t
|
||||
love.graphics.setColor(0.60, 0.02, 0.06, wash * 0.13)
|
||||
love.graphics.rectangle("fill", 0, 0, w, d)
|
||||
love.graphics.rectangle("fill", 0, h - d, w, d)
|
||||
love.graphics.rectangle("fill", 0, 0, d, h)
|
||||
love.graphics.rectangle("fill", w - d, 0, d, h)
|
||||
end
|
||||
end
|
||||
|
||||
-- health, top left
|
||||
local barW, barH = 60 * s, 8 * s + PAD * 2 * s
|
||||
healthBar(inset, inset, barW, barH, s, sess.hp / sess.maxHp, hurt > 0.3)
|
||||
label(("%d"):format(math.ceil(sess.hp)), inset, inset + barH + 3 * s, s)
|
||||
|
||||
-- score and wave, top right
|
||||
label(("SCORE %d"):format(math.floor(sess.score)), w - inset, inset, s,
|
||||
"right")
|
||||
label(("WAVE %d"):format(math.max(1, sess.wave)),
|
||||
w - inset, inset + (8 * s + PAD * 2 * s) + 3 * s, s, "right")
|
||||
|
||||
-- ammunition, bottom right: the rounds as pips over the count, which
|
||||
-- reads at a glance in a firefight where a number does not
|
||||
local ammoStr = reloading and "RELOADING" or ("%d / %d"):format(ammo, mag)
|
||||
local _, ah = label(ammoStr, w - inset, h - inset - (8 * s + PAD * 2 * s), s,
|
||||
"right")
|
||||
local pipW, pipH, pipGap = 3 * s, 7 * s, 2 * s
|
||||
local pipsW = mag * (pipW + pipGap) - pipGap
|
||||
local px = w - inset - pipsW
|
||||
local py = h - inset - ah - pipH - 5 * s
|
||||
love.graphics.setColor(0.06, 0.05, 0.09, 0.85)
|
||||
love.graphics.rectangle("fill", px - 2 * s, py - 2 * s,
|
||||
pipsW + 4 * s, pipH + 4 * s)
|
||||
for i = 1, mag do
|
||||
if i <= ammo and not reloading then
|
||||
love.graphics.setColor(0.98, 0.86, 0.36, 1)
|
||||
else
|
||||
love.graphics.setColor(0.32, 0.30, 0.36, 1)
|
||||
end
|
||||
love.graphics.rectangle("fill", px + (i - 1) * (pipW + pipGap), py,
|
||||
pipW, pipH)
|
||||
end
|
||||
if reloading then
|
||||
local t = math.min(1, Gun.state.reloadT / Gun.RELOAD_TIME)
|
||||
love.graphics.setColor(0.55, 0.78, 0.98, 1)
|
||||
love.graphics.rectangle("fill", px, py + pipH + 1 * s, pipsW * t, 2 * s)
|
||||
end
|
||||
|
||||
-- the sight picture
|
||||
local cx, cy = w / 2, h / 2
|
||||
if ads < 0.6 then
|
||||
crosshair(cx, cy, s, Gun.state.kick, (1 - ads / 0.6) * 0.9)
|
||||
end
|
||||
hitMarker(cx, cy, s, sess.hitMarker)
|
||||
|
||||
banner(w, h, s)
|
||||
|
||||
love.graphics.pop()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- ------- the two callers
|
||||
|
||||
-- The flat window. Called from the voxel pipeline's overlay block, into
|
||||
-- the scene canvas -- which is at the window's PIXEL size and may be
|
||||
-- supersampled on top of that, so the caller's scale carries both.
|
||||
--
|
||||
-- The caller's scale is CAPPED against the canvas rather than taken as
|
||||
-- given, because that scale is the world's zoom: zoom in far enough and
|
||||
-- the health bar was a metre wide and half of it off the top of the
|
||||
-- screen. A readout is not part of the world and should not zoom with
|
||||
-- it -- so it sizes off the frame it is drawn in, which keeps its
|
||||
-- apparent size the same at every zoom and grows it honestly on a bigger
|
||||
-- display (and with supersampling, which is in both numbers).
|
||||
function HordeHud.drawFlat(w, h, scale)
|
||||
if not Horde.active then return end
|
||||
local cap = math.max(1, math.floor(h / 260))
|
||||
local s = math.max(1, math.min(math.floor((scale or 1) + 0.5), cap))
|
||||
draw(w, h, s, 8 * s)
|
||||
end
|
||||
|
||||
-- ------- and the headset's, which is not a screen overlay at all
|
||||
--
|
||||
-- A VR eye gets NO 2D overlay. An earlier cut drew this same HUD into
|
||||
-- both eye canvases and it came out torn down the middle: the eye frusta
|
||||
-- are ASYMMETRIC, so the same canvas pixel is a different ANGLE in each
|
||||
-- eye, and the two images never fuse. Nor is there a crosshair to draw --
|
||||
-- the gun is a real object with real sights and the shot goes down its
|
||||
-- barrel, so a dot painted at the centre of the frame would be pointing
|
||||
-- at something else entirely.
|
||||
--
|
||||
-- What the headset gets instead is this: the readout as a TEXTURE, which
|
||||
-- lib/VR puts on the POKEDEX in the player's left hand -- already
|
||||
-- tracked, already lit, and already the surface this mod shows
|
||||
-- information on. Geometry in the world, so both eyes see it from their
|
||||
-- own position and the stereo is correct by construction. (It rode the
|
||||
-- gun for one revision and that was worse: a screen on the slide sits
|
||||
-- exactly where the iron sights have to be looked through.)
|
||||
--
|
||||
-- Sized to the device's own screen, which is the GB frame's 10:9.
|
||||
|
||||
local panelCanvas = nil
|
||||
local PANEL_W, PANEL_H = 160, 144
|
||||
|
||||
function HordeHud.panelTexture()
|
||||
if not Horde.active then return nil end
|
||||
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||
local sess = Horde.session
|
||||
if not sess then return nil end
|
||||
local F = font()
|
||||
if not F then return nil end
|
||||
|
||||
if not panelCanvas then
|
||||
local ok, c = pcall(love.graphics.newCanvas, PANEL_W, PANEL_H)
|
||||
if not ok then return nil end
|
||||
panelCanvas = c
|
||||
pcall(panelCanvas.setFilter, panelCanvas, "nearest", "nearest")
|
||||
end
|
||||
|
||||
local Gun = V.require("HordeGun")
|
||||
local ammo, mag, reloading = Gun.ammo()
|
||||
|
||||
local ok = pcall(function()
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(panelCanvas)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
love.graphics.clear(0.93, 0.94, 0.90, 1)
|
||||
|
||||
-- the health bar, framed, across the top
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 8, 8, PANEL_W - 16, 20)
|
||||
love.graphics.setColor(0.80, 0.80, 0.78, 1)
|
||||
love.graphics.rectangle("fill", 11, 11, PANEL_W - 22, 14)
|
||||
love.graphics.setColor(0.78, 0.12, 0.16, 1)
|
||||
love.graphics.rectangle("fill", 11, 11,
|
||||
(PANEL_W - 22) * math.max(0, sess.hp / sess.maxHp),
|
||||
14)
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
F.draw(("HP %d"):format(math.ceil(sess.hp)), 8, 34)
|
||||
F.draw(reloading and "RELOADING" or ("AMMO %d/%d"):format(ammo, mag),
|
||||
8, 50)
|
||||
|
||||
-- the round pips, so ammunition reads without counting digits
|
||||
local pipW, gap = 9, 5
|
||||
for i = 1, mag do
|
||||
if i <= ammo and not reloading then
|
||||
love.graphics.setColor(0.85, 0.65, 0.10, 1)
|
||||
else
|
||||
love.graphics.setColor(0.72, 0.73, 0.70, 1)
|
||||
end
|
||||
love.graphics.rectangle("fill", 8 + (i - 1) * (pipW + gap), 66, pipW, 12)
|
||||
end
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
F.draw(("WAVE %d"):format(math.max(1, sess.wave)), 8, 86)
|
||||
F.draw(("%d"):format(math.floor(sess.score)), 8, 102)
|
||||
|
||||
-- and the banner, wrapped to the panel rather than to the frame.
|
||||
-- BLACK on the panel's own white, like everything else here: the font
|
||||
-- sheets are black glyphs on transparent, so a pale letter is not a
|
||||
-- thing that can be drawn (see the header).
|
||||
if sess.bannerText then
|
||||
local lines, bs, tr = layout(F, sess.bannerText, 1, PANEL_W - 8)
|
||||
if lines then
|
||||
local top = PANEL_H - 8 * bs * #lines - 5
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 0, top - 2, PANEL_W, 1)
|
||||
for i, line in ipairs(lines) do
|
||||
local pen = math.floor((PANEL_W - line.width) / 2)
|
||||
local ly = PANEL_H - 8 * bs * (#lines - i + 1) - 3
|
||||
for _, code in ipairs(F.encode(line.text)) do
|
||||
love.graphics.push()
|
||||
love.graphics.translate(pen, ly)
|
||||
love.graphics.scale(bs, bs)
|
||||
F.drawCode(code, 0, 0)
|
||||
love.graphics.pop()
|
||||
pen = pen + F.advanceOf(code) * bs + tr
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.pop()
|
||||
end)
|
||||
pcall(love.graphics.setCanvas)
|
||||
if not ok then return nil end
|
||||
return panelCanvas
|
||||
end
|
||||
|
||||
-- window resize / hot reload
|
||||
function HordeHud.invalidate()
|
||||
if panelCanvas and panelCanvas.release then
|
||||
pcall(panelCanvas.release, panelCanvas)
|
||||
end
|
||||
panelCanvas = nil
|
||||
end
|
||||
|
||||
return HordeHud
|
||||
@@ -0,0 +1,553 @@
|
||||
-- HORDE MODE: the crowd.
|
||||
--
|
||||
-- Waves of people who want to touch you, walking the same grid the game
|
||||
-- walks, wearing the overworld's own character sheets. Every mob IS a
|
||||
-- real engine NPC (OverworldState:addRuntimeObject), which is what buys
|
||||
-- the whole feature for nothing: the engine interpolates their steps,
|
||||
-- the collision system lets them jostle, the voxel pass billboards them
|
||||
-- with the right frame for the angle you see them from, and the flat 2D
|
||||
-- path draws them too. Nothing here draws a character.
|
||||
--
|
||||
-- THEY ARE DRIVEN, NOT SCRIPTED. OverworldState:scriptMove would be the
|
||||
-- obvious way to walk one, and it is a trap: a queued script move sets
|
||||
-- `scripted` on the state, which blocks the PLAYER's input for as long as
|
||||
-- it runs. So mobs are spawned with movement = "STAY" (which leaves
|
||||
-- NPC:update's wander branch inert) and this file writes facing / target /
|
||||
-- moving / progress directly, once per step. NPC:update then does the
|
||||
-- pixel interpolation and the cell commit exactly as it does for a
|
||||
-- wandering shopkeeper.
|
||||
--
|
||||
-- PATHING IS A FLOW FIELD, not A* per mob. One breadth-first sweep out
|
||||
-- from the player's cell, over the map's walkable cells, gives EVERY mob
|
||||
-- its next step at once -- and gives it correctly through doorways and
|
||||
-- around buildings, which is what "gang up on the player" actually
|
||||
-- requires. Rebuilt a few times a second rather than per frame; between
|
||||
-- rebuilds a mob just walks downhill on the numbers. It also answers two
|
||||
-- other questions for free: how far a cell is from the player (so a spawn
|
||||
-- point can be picked at a fair distance and be guaranteed REACHABLE),
|
||||
-- and whether a mob is adjacent enough to swing.
|
||||
--
|
||||
-- The sweep ignores entity occupancy on purpose. Mobs are solid to each
|
||||
-- other, so a pack funnelling down a corridor will jam -- and the fix for
|
||||
-- that is not a cleverer path, it is that a mob whose downhill step is
|
||||
-- occupied tries its second choice and otherwise waits. That is what
|
||||
-- makes them pool around the player instead of forming a queue.
|
||||
--
|
||||
-- FOLLOWING THROUGH DOORS. A warp tears down every NPC on the old map, so
|
||||
-- the roster cannot survive one. What survives is the COUNT: the number
|
||||
-- still alive when the player ran, re-spawned on the far side over the
|
||||
-- next few seconds, from the cells nearest the door they came in by. From
|
||||
-- the player's chair that is the horde coming through the door after them.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Horde = V.require("Horde")
|
||||
local HordeSfx = V.require("HordeSfx")
|
||||
|
||||
local HordeMobs = {}
|
||||
|
||||
-- ------- tuning
|
||||
|
||||
-- Wave n throws this many at you, and no more than CAP stand at once.
|
||||
local function waveSize(n) return 4 + 3 * n end
|
||||
local CAP = 14
|
||||
local SPAWN_INTERVAL = 0.75 -- seconds between arrivals inside a wave
|
||||
local WAVE_GAP = 4.0 -- the breather, and the banner's window
|
||||
local FOLLOW_INTERVAL = 0.55 -- how fast they pour through a door
|
||||
|
||||
-- Frames per cell. The engine's own walk is 16; the horde is quicker than
|
||||
-- a shopkeeper and gets quicker as the waves stack, floored so it never
|
||||
-- outruns the player's own free walk.
|
||||
local function stepFrames(n)
|
||||
return math.max(9, 15 - math.floor(n / 2))
|
||||
end
|
||||
|
||||
local function mobHp(n) return math.min(4, 1 + math.floor(n / 3)) end
|
||||
local function killScore(n) return 100 + 25 * (n - 1) end
|
||||
local function waveBonus(n) return 250 * n end
|
||||
|
||||
-- How close a mob comes before it stops walking and starts swinging, in
|
||||
-- CELLS, and how close it has to be to land the hit, in world pixels.
|
||||
--
|
||||
-- The standoff is the difference between a horde and a wall. Nothing
|
||||
-- stops a mob taking the cell next to the player -- and when it does, a
|
||||
-- sixteen-pixel figure a cell away fills a sixty-five-degree lens edge to
|
||||
-- edge, so being surrounded looks like a texture rather than like people.
|
||||
-- Two cells back they read as figures closing in, the ring holds a dozen
|
||||
-- of them, and the player can still see what they are shooting at.
|
||||
local STANDOFF = 2
|
||||
local REACH = 40
|
||||
|
||||
-- The cast. Overworld sprite sheets that read as a threat coming out of
|
||||
-- the dark; anything missing from the loaded game is dropped at spawn.
|
||||
local CAST = {
|
||||
"SPRITE_ROCKET", "SPRITE_CHANNELER", "SPRITE_SCIENTIST", "SPRITE_BIKER",
|
||||
"SPRITE_GUARD", "SPRITE_SUPER_NERD", "SPRITE_HIKER", "SPRITE_SWIMMER",
|
||||
"SPRITE_GYM_GUIDE", "SPRITE_BLACK_HAIR_BOY_1", "SPRITE_GIRL",
|
||||
"SPRITE_MIDDLE_AGED_MAN", "SPRITE_FISHER", "SPRITE_GAMBLER",
|
||||
}
|
||||
|
||||
local OWNER = "DRAMATIC_SHAPE"
|
||||
|
||||
-- ------- the flow field
|
||||
--
|
||||
-- dist[cy * w + cx] = steps from the player, over walkable cells only.
|
||||
-- Nil where the sweep never reached, which is the same answer as "no way
|
||||
-- there from here" -- an island across water, a room behind a locked door.
|
||||
|
||||
local field = { mapId = nil, w = 0, h = 0, dist = nil, at = nil, age = 0 }
|
||||
|
||||
local REBUILD_EVERY = 0.28
|
||||
|
||||
local function passable(map, cx, cy)
|
||||
if not map:inBounds(cx, cy) then return false end
|
||||
if not map:isWalkableCell(cx, cy) then return false end
|
||||
-- a warp cell is walkable but standing on one takes the warp; mobs may
|
||||
-- cross them (that IS the door they follow you through) so they stay in
|
||||
return true
|
||||
end
|
||||
|
||||
-- fixed order, so a tie between two equally good steps always breaks the
|
||||
-- same way -- a mob that dithers between two cells reads as broken, and a
|
||||
-- pairs() walk over a hash would give a different answer every run
|
||||
local DIRS = { "right", "left", "down", "up" }
|
||||
local DX = { right = 1, left = -1, down = 0, up = 0 }
|
||||
local DY = { right = 0, left = 0, down = 1, up = -1 }
|
||||
|
||||
local function rebuildField(map, px, py)
|
||||
local w, h = map.widthCells, map.heightCells
|
||||
local dist = {}
|
||||
-- a plain array queue: BFS on a grid never revisits a cell, so no heap
|
||||
-- and no priority is needed and the whole sweep is one pass
|
||||
local qx, qy = { px }, { py }
|
||||
local head = 1
|
||||
dist[py * w + px] = 0
|
||||
while head <= #qx do
|
||||
local cx, cy = qx[head], qy[head]
|
||||
head = head + 1
|
||||
local d = dist[cy * w + cx] + 1
|
||||
for i = 1, 4 do
|
||||
local dir = DIRS[i]
|
||||
local nx, ny = cx + DX[dir], cy + DY[dir]
|
||||
local key = ny * w + nx
|
||||
if dist[key] == nil and passable(map, nx, ny) then
|
||||
dist[key] = d
|
||||
qx[#qx + 1], qy[#qy + 1] = nx, ny
|
||||
end
|
||||
end
|
||||
end
|
||||
field.mapId, field.w, field.h, field.dist = map.id, w, h, dist
|
||||
field.at = { px, py }
|
||||
field.age = 0
|
||||
end
|
||||
|
||||
local function distAt(cx, cy)
|
||||
if not field.dist then return nil end
|
||||
if cx < 0 or cy < 0 or cx >= field.w or cy >= field.h then return nil end
|
||||
return field.dist[cy * field.w + cx]
|
||||
end
|
||||
|
||||
-- named for the suite: the sweep, and the distance it wrote to a cell
|
||||
HordeMobs._dist = distAt
|
||||
HordeMobs._rebuild = rebuildField
|
||||
|
||||
-- ------- spawning
|
||||
|
||||
local function liveSprites(G)
|
||||
local out = {}
|
||||
local sprites = G and G.data and G.data.sprites
|
||||
for _, key in ipairs(CAST) do
|
||||
if sprites and sprites[key] then out[#out + 1] = key end
|
||||
end
|
||||
if #out == 0 and sprites then
|
||||
-- a total conversion with none of the vanilla sheets: take whatever
|
||||
-- walker it does have rather than spawning nothing at all
|
||||
local keys = {}
|
||||
for key, def in pairs(sprites) do
|
||||
if def and def.walker then keys[#keys + 1] = key end
|
||||
end
|
||||
table.sort(keys)
|
||||
for i = 1, math.min(6, #keys) do out[i] = keys[i] end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Cells at a fair distance from the player that the flow field says are
|
||||
-- actually reachable, preferring the far end of the band so the horde
|
||||
-- arrives from off in the dark rather than on top of you.
|
||||
local function spawnCells(map, near, far, want)
|
||||
local out = {}
|
||||
if not field.dist then return out end
|
||||
for key, d in pairs(field.dist) do
|
||||
if d >= near and d <= far then
|
||||
local cy = math.floor(key / field.w)
|
||||
local cx = key - cy * field.w
|
||||
out[#out + 1] = { cx, cy, d }
|
||||
end
|
||||
end
|
||||
-- shuffle, then bias toward distance: sorting outright would file every
|
||||
-- mob in from the same corner
|
||||
for i = #out, 2, -1 do
|
||||
local j = love.math.random(i)
|
||||
out[i], out[j] = out[j], out[i]
|
||||
end
|
||||
table.sort(out, function(a, b) return a[3] > b[3] end)
|
||||
while #out > (want or 16) do table.remove(out) end
|
||||
return out
|
||||
end
|
||||
|
||||
local function occupiedCell(state, cx, cy)
|
||||
local Collision = require("src.world.Collision")
|
||||
return Collision.occupied(state.entities, cx, cy, nil) ~= nil
|
||||
end
|
||||
|
||||
-- One mob, on a cell, on the live map. Returns the roster entry or nil.
|
||||
local function spawnAt(G, state, cx, cy, wave)
|
||||
local s = Horde.session
|
||||
if not s then return nil end
|
||||
local sprites = liveSprites(G)
|
||||
if #sprites == 0 then return nil end
|
||||
local def = {
|
||||
x = cx, y = cy,
|
||||
sprite = sprites[love.math.random(#sprites)],
|
||||
movement = "STAY",
|
||||
range = "DOWN",
|
||||
name = "HORDE",
|
||||
hordeMob = true,
|
||||
}
|
||||
local mapId = state.map.id
|
||||
local okAdd, npcId = pcall(state.addRuntimeObject, state, mapId, def, OWNER)
|
||||
if not (okAdd and npcId) then return nil end
|
||||
s.spawned[mapId] = s.spawned[mapId] or {}
|
||||
s.spawned[mapId][def.index] = true
|
||||
|
||||
local npc = nil
|
||||
for _, e in ipairs(state.npcs) do
|
||||
if e.id == npcId then npc = e break end
|
||||
end
|
||||
if not npc then return nil end
|
||||
npc.wanders = false
|
||||
npc.stepFrames = stepFrames(wave)
|
||||
local entry = {
|
||||
npc = npc, id = npcId, mapId = mapId,
|
||||
hp = mobHp(wave), attackT = 0,
|
||||
}
|
||||
s.mobs[#s.mobs + 1] = entry
|
||||
return entry
|
||||
end
|
||||
|
||||
-- ------- removal
|
||||
--
|
||||
-- Targeted, because the engine's own removeRuntimeObject walks every map
|
||||
-- in the game to find one object and a firefight calls this several times
|
||||
-- a second.
|
||||
|
||||
local function dropNpc(state, npcId)
|
||||
for _, list in ipairs({ state.npcs or {}, state.entities or {} }) do
|
||||
for i = #list, 1, -1 do
|
||||
if list[i].id == npcId then table.remove(list, i) end
|
||||
end
|
||||
end
|
||||
if state.npcPool then state.npcPool[npcId] = nil end
|
||||
end
|
||||
|
||||
-- Take this mode's objects back out of a map record. Runtime objects live
|
||||
-- in Game.data.maps[id].objects until removed, and setMap respawns from
|
||||
-- that list -- so a def left behind is a mob waiting on the far side of a
|
||||
-- door long after the mode ended.
|
||||
local function scrubMap(G, mapId, indices)
|
||||
local def = G and G.data and G.data.maps and G.data.maps[mapId]
|
||||
if not def or not def.objects then return end
|
||||
for i = #def.objects, 1, -1 do
|
||||
local obj = def.objects[i]
|
||||
if obj and obj.hordeMob and (not indices or indices[obj.index]) then
|
||||
table.remove(def.objects, i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the roster's own step
|
||||
|
||||
local function faceToward(npc, cx, cy)
|
||||
local dx, dy = cx - npc.cellX, cy - npc.cellY
|
||||
if math.abs(dx) > math.abs(dy) then
|
||||
return dx > 0 and "right" or "left"
|
||||
end
|
||||
return dy > 0 and "down" or "up"
|
||||
end
|
||||
|
||||
-- Walk one mob downhill on the flow field. The best neighbour is the one
|
||||
-- with the lowest distance; when it is taken, the second best is tried,
|
||||
-- and when both are taken the mob waits a beat -- which is what makes a
|
||||
-- pack pool around the player instead of queueing behind one another.
|
||||
local function stepMob(state, entry)
|
||||
local npc = entry.npc
|
||||
if npc.moving then return end
|
||||
local here = distAt(npc.cellX, npc.cellY)
|
||||
-- close enough: stand and swing rather than crowding into the lens
|
||||
if here and here <= STANDOFF then
|
||||
local p = state.player
|
||||
npc.facing = faceToward(npc, p.cellX, p.cellY)
|
||||
return
|
||||
end
|
||||
local best, bestD, second, secondD = nil, nil, nil, nil
|
||||
for i = 1, 4 do
|
||||
local dir = DIRS[i]
|
||||
local tx, ty = npc.cellX + DX[dir], npc.cellY + DY[dir]
|
||||
local d = distAt(tx, ty)
|
||||
-- the standoff is enforced on the cell being ENTERED, not the one
|
||||
-- being stood on: a mob that checked only where it was would still
|
||||
-- finish the step it was already taking and end up in the lens
|
||||
if d and d < STANDOFF then d = nil end
|
||||
if d and (not here or d < here) then
|
||||
if not bestD or d < bestD then
|
||||
second, secondD = best, bestD
|
||||
best, bestD = { dir, tx, ty }, d
|
||||
elseif not secondD or d < secondD then
|
||||
second, secondD = { dir, tx, ty }, d
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, pick in ipairs({ best, second }) do
|
||||
if pick then
|
||||
local dir, tx, ty = pick[1], pick[2], pick[3]
|
||||
if not occupiedCell(state, tx, ty) then
|
||||
npc.facing = dir
|
||||
npc.targetX, npc.targetY = tx, ty
|
||||
npc.moving = true
|
||||
npc.progress = 0
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
-- boxed in: keep facing the player so the pack still reads as a threat
|
||||
local p = state.player
|
||||
npc.facing = faceToward(npc, p.cellX, p.cellY)
|
||||
end
|
||||
|
||||
-- ------- the public surface
|
||||
|
||||
function HordeMobs.begin(G)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
local state = G and G.overworld
|
||||
if not (state and state.map) then return end
|
||||
field.mapId = nil
|
||||
s.wave, s.waveRemaining, s.waveGap, s.spawnGap = 0, 0, 0, 0
|
||||
HordeMobs.convertLocals(state)
|
||||
end
|
||||
|
||||
-- Everyone already standing on the map joins in. Their sprite, their
|
||||
-- position, their business -- now walking at the player. Nothing is
|
||||
-- stored to undo it, because the restore warps through setMap, which
|
||||
-- rebuilds every one of them from the map record (see Horde.finish).
|
||||
function HordeMobs.convertLocals(state)
|
||||
local s = Horde.session
|
||||
if not (s and state and state.npcs) then return end
|
||||
local known = {}
|
||||
for _, e in ipairs(s.mobs) do known[e.npc] = true end
|
||||
for _, npc in ipairs(state.npcs) do
|
||||
if not known[npc] and not npc.passable then
|
||||
npc.wanders = false
|
||||
npc.frozen = false
|
||||
npc.stepFrames = stepFrames(math.max(1, s.wave))
|
||||
s.mobs[#s.mobs + 1] = {
|
||||
npc = npc, id = npc.id, mapId = state.map.id,
|
||||
hp = mobHp(math.max(1, s.wave)), attackT = 0, local_ = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function HordeMobs.nextWave(G)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
s.wave = s.wave + 1
|
||||
s.waveRemaining = waveSize(s.wave)
|
||||
s.spawnGap = 0
|
||||
Horde.banner(("WAVE %d"):format(s.wave), 1.6)
|
||||
HordeSfx.play(HordeSfx.WAVE)
|
||||
for _, e in ipairs(s.mobs) do
|
||||
e.npc.stepFrames = stepFrames(s.wave)
|
||||
end
|
||||
end
|
||||
|
||||
-- A mob took a bullet. Returns "kill", "hit", or nil.
|
||||
function HordeMobs.hit(entry, damage)
|
||||
local s = Horde.session
|
||||
if not (s and entry) then return nil end
|
||||
entry.hp = entry.hp - (damage or 1)
|
||||
if entry.hp > 0 then
|
||||
HordeSfx.play(HordeSfx.HIT)
|
||||
return "hit"
|
||||
end
|
||||
entry.dead = true
|
||||
s.kills = s.kills + 1
|
||||
Horde.addScore(killScore(math.max(1, s.wave)))
|
||||
HordeSfx.randomCry()
|
||||
return "kill"
|
||||
end
|
||||
|
||||
-- Every live mob, for the gun's ray to test against.
|
||||
function HordeMobs.list()
|
||||
local s = Horde.session
|
||||
return s and s.mobs or {}
|
||||
end
|
||||
|
||||
function HordeMobs.update(dt, G)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
local state = G and G.overworld
|
||||
if not (state and state.map and state.player) then return end
|
||||
local p = state.player
|
||||
|
||||
-- the flow field, rebuilt on a clock and whenever the player changes
|
||||
-- cell far enough that the old numbers point at where they used to be
|
||||
field.age = field.age + dt
|
||||
local moved = field.at
|
||||
and (math.abs(field.at[1] - p.cellX) + math.abs(field.at[2] - p.cellY)) or 99
|
||||
if field.mapId ~= state.map.id or field.age >= REBUILD_EVERY or moved >= 2 then
|
||||
rebuildField(state.map, p.cellX, p.cellY)
|
||||
end
|
||||
|
||||
-- the dead, collected before anything walks
|
||||
for i = #s.mobs, 1, -1 do
|
||||
local e = s.mobs[i]
|
||||
if e.dead or not e.npc then
|
||||
if e.npc then dropNpc(state, e.id) end
|
||||
table.remove(s.mobs, i)
|
||||
end
|
||||
end
|
||||
|
||||
-- the living
|
||||
local pcx, pcy = p.px + 8, p.py + 8
|
||||
for _, e in ipairs(s.mobs) do
|
||||
local npc = e.npc
|
||||
e.attackT = math.max(0, e.attackT - dt)
|
||||
stepMob(state, e)
|
||||
local dx, dz = (npc.px + 8) - pcx, (npc.py + 8) - pcy
|
||||
if dx * dx + dz * dz <= REACH * REACH then
|
||||
if e.attackT <= 0 then
|
||||
e.attackT = 0.8
|
||||
npc.facing = faceToward(npc, p.cellX, p.cellY)
|
||||
Horde.damage()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not Horde.playing() then return end
|
||||
|
||||
-- the crowd that followed the player through a door, arriving
|
||||
if s.followQueue > 0 then
|
||||
s.spawnGap = s.spawnGap - dt
|
||||
if s.spawnGap <= 0 and #s.mobs < CAP then
|
||||
s.spawnGap = FOLLOW_INTERVAL
|
||||
local cells = spawnCells(state.map, 2, 9, 8)
|
||||
local cell = cells[1]
|
||||
if cell and spawnAt(G, state, cell[1], cell[2], s.wave) then
|
||||
s.followQueue = s.followQueue - 1
|
||||
else
|
||||
s.followQueue = s.followQueue - 1 -- nowhere to put them; let it go
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- the wave itself
|
||||
if s.waveRemaining > 0 then
|
||||
s.spawnGap = s.spawnGap - dt
|
||||
if s.spawnGap <= 0 and #s.mobs < CAP then
|
||||
s.spawnGap = SPAWN_INTERVAL
|
||||
local cells = spawnCells(state.map, 7, 18, 10)
|
||||
if #cells == 0 then cells = spawnCells(state.map, 3, 30, 10) end
|
||||
local cell = cells[1]
|
||||
if cell and spawnAt(G, state, cell[1], cell[2], s.wave) then
|
||||
s.waveRemaining = s.waveRemaining - 1
|
||||
else
|
||||
s.spawnGap = 1.5 -- no room right now; try again shortly
|
||||
end
|
||||
end
|
||||
elseif #s.mobs == 0 then
|
||||
s.waveGap = s.waveGap + dt
|
||||
if s.waveGap == dt then
|
||||
Horde.addScore(waveBonus(s.wave))
|
||||
Horde.banner(("WAVE %d CLEAR"):format(s.wave), 1.8)
|
||||
end
|
||||
if s.waveGap >= WAVE_GAP then
|
||||
s.waveGap = 0
|
||||
HordeMobs.nextWave(G)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the door
|
||||
--
|
||||
-- map.entered fires after setMap has rebuilt the world, which means every
|
||||
-- mob instance from the old map is already gone. What is left to do is
|
||||
-- take our defs off the old map (or they respawn if the player ever comes
|
||||
-- back), remember how many were chasing, and let update() walk them in.
|
||||
|
||||
function HordeMobs.onMapEntered(payload)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
local G = require("src.core.Game")
|
||||
local state = G.overworld
|
||||
if not (state and state.map) then return end
|
||||
local newId = state.map.id
|
||||
|
||||
local following = 0
|
||||
for _, e in ipairs(s.mobs) do
|
||||
if e.mapId ~= newId and not e.local_ then following = following + 1 end
|
||||
end
|
||||
-- the old map's records, and any instance the pool kept
|
||||
for mapId, indices in pairs(s.spawned) do
|
||||
if mapId ~= newId then
|
||||
scrubMap(G, mapId, indices)
|
||||
s.spawned[mapId] = nil
|
||||
end
|
||||
end
|
||||
for i = #s.mobs, 1, -1 do
|
||||
if s.mobs[i].mapId ~= newId then table.remove(s.mobs, i) end
|
||||
end
|
||||
|
||||
field.mapId = nil
|
||||
s.followQueue = math.max(s.followQueue, following)
|
||||
s.spawnGap = math.min(s.spawnGap, 0.4)
|
||||
HordeMobs.convertLocals(state)
|
||||
end
|
||||
|
||||
-- ------- the end
|
||||
--
|
||||
-- Every def this mode wrote, off every map it wrote one to. The live
|
||||
-- instances go too, though the restore's own warp would have taken them:
|
||||
-- cleanup has to leave a consistent world even when it is called from a
|
||||
-- path that never warps.
|
||||
|
||||
function HordeMobs.cleanup(G)
|
||||
G = G or require("src.core.Game")
|
||||
local s = Horde.session
|
||||
local state = G.overworld
|
||||
if s then
|
||||
for _, e in ipairs(s.mobs) do
|
||||
if state and not e.local_ then dropNpc(state, e.id) end
|
||||
end
|
||||
for mapId, indices in pairs(s.spawned) do
|
||||
scrubMap(G, mapId, indices)
|
||||
end
|
||||
s.mobs, s.spawned = {}, {}
|
||||
s.followQueue, s.waveRemaining = 0, 0
|
||||
else
|
||||
-- a session that vanished under us (a reload mid-mode): sweep every
|
||||
-- map for this mode's marker rather than leaving actors behind
|
||||
for mapId in pairs((G.data and G.data.maps) or {}) do
|
||||
scrubMap(G, mapId, nil)
|
||||
end
|
||||
end
|
||||
field.mapId, field.dist, field.at = nil, nil, nil
|
||||
end
|
||||
|
||||
-- named for the suite, down here because the walk is defined above it
|
||||
HordeMobs._stepMob = stepMob
|
||||
|
||||
return HordeMobs
|
||||
@@ -0,0 +1,262 @@
|
||||
-- HORDE MODE: the gun, in Game Boy hardware.
|
||||
--
|
||||
-- Every sound this mode makes is SYNTHESIZED on the same emulated APU the
|
||||
-- rest of the game speaks through -- no sample files ship with the mod.
|
||||
-- That is a deliberate aesthetic choice as much as a legal one: Lavender
|
||||
-- Town is playing, the cries are the real cries, and a 44kHz foley
|
||||
-- gunshot dropped on top would read as a different program running in the
|
||||
-- same window. Authored here with ChipAsm (src/audio/ChipAsm.lua), which
|
||||
-- assembles note tables into the channel bytecode ChipAudio interprets.
|
||||
--
|
||||
-- WHAT A GUNSHOT IS, on this hardware. Channel 4 is a noise generator
|
||||
-- whose `parameter` byte is NR43: the high nibble is the shift clock (LOW
|
||||
-- values are BRIGHT, high values are low rumble), bit 3 picks the short
|
||||
-- 7-bit LFSR (metallic and pitched) over the long 15-bit one (white
|
||||
-- hiss), and the low three bits divide. A real gunshot is a bright crack
|
||||
-- collapsing into a body and then a room tail, so each sound here is a
|
||||
-- STAGED program: three or four noise notes marching down the parameter
|
||||
-- byte, each shorter-lived than the last. `len` is in frames of 1/60s,
|
||||
-- `volume` is 0-15, and `fade` is the envelope period -- 1 decays fastest,
|
||||
-- 7 slowest, 0 holds for the note's whole length.
|
||||
--
|
||||
-- The shot also gets two frames of channel 1 underneath it: a square note
|
||||
-- swept hard downward, which is the only way to put a low thump on this
|
||||
-- chip. It costs the music its lead channel for 1/30s per shot, which is
|
||||
-- inaudible as interference and is most of what makes the shot feel like
|
||||
-- it has weight.
|
||||
--
|
||||
-- THREE SHOT VARIANTS, round-robined. Sound.play caches ONE Source per
|
||||
-- registered name and restarts it (stop then play), so firing twice on
|
||||
-- one name cuts the first shot's tail off. Three names means three
|
||||
-- Sources, so a fast trigger finger overlaps its own echoes the way a
|
||||
-- real one does -- and the variants differ slightly in their tails, which
|
||||
-- takes the machine-gun sameness off a repeated sound.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local HordeSfx = {}
|
||||
|
||||
-- the registered names, in the shape the rest of the mode asks for them
|
||||
HordeSfx.SHOTS = { "DS_HORDE_SHOT_1", "DS_HORDE_SHOT_2", "DS_HORDE_SHOT_3" }
|
||||
HordeSfx.DRY = "DS_HORDE_DRY"
|
||||
HordeSfx.MAG_OUT = "DS_HORDE_MAG_OUT"
|
||||
HordeSfx.MAG_IN = "DS_HORDE_MAG_IN"
|
||||
HordeSfx.RACK = "DS_HORDE_RACK"
|
||||
HordeSfx.HIT = "DS_HORDE_HIT"
|
||||
HordeSfx.HURT = "DS_HORDE_HURT"
|
||||
HordeSfx.WAVE = "DS_HORDE_WAVE"
|
||||
|
||||
-- ------- the programs
|
||||
|
||||
-- The shot's noise stage list: bright crack, body, tail, room. `tail`
|
||||
-- lets the three variants differ in how the last stage rings out without
|
||||
-- restating the whole program.
|
||||
local function shotNoise(tail)
|
||||
return {
|
||||
-- the crack: one frame, full volume, brightest parameter the chip has
|
||||
{ noiseNote = { len = 1, volume = 15, fade = 1, parameter = 0x00 } },
|
||||
-- the body: the shift clock drops, the 7-bit LFSR gives it a metallic
|
||||
-- edge -- this is the part that reads as "a mechanism did that"
|
||||
{ noiseNote = { len = 2, volume = 13, fade = 2, parameter = 0x2C } },
|
||||
-- the tail: lower, softer, longer
|
||||
{ noiseNote = { len = 3, volume = 8, fade = 3, parameter = tail[1] } },
|
||||
-- the room: a low breath of noise fading under everything
|
||||
{ noiseNote = { len = tail[2], volume = 4, fade = 4, parameter = tail[3] } },
|
||||
}
|
||||
end
|
||||
|
||||
-- The thump under the crack: channel 1's frequency register swept down
|
||||
-- hard. 0x600 is around 250Hz; the sweep drags it into the floor over the
|
||||
-- two frames it lives, which is a kick drum by another name.
|
||||
local THUMP = {
|
||||
{ pitchSweep = { pace = 2, subtract = true, shift = 3 } },
|
||||
{ squareNote = { len = 2, volume = 12, fade = 2, frequency = 0x600 } },
|
||||
}
|
||||
|
||||
local function shot(tail)
|
||||
return {
|
||||
channels = {
|
||||
{ hw = 1, program = THUMP },
|
||||
{ hw = 4, program = shotNoise(tail) },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- The reload, in three separate sounds the gun fires on its own clock:
|
||||
-- the magazine dropping out, the fresh one seating, and the slide coming
|
||||
-- back and going home. Noise only -- these are mechanical clicks, and
|
||||
-- keeping them off the tone channels leaves the music alone.
|
||||
local PROGRAMS = {
|
||||
[HordeSfx.SHOTS[1]] = shot({ 0x55, 5, 0x76 }),
|
||||
[HordeSfx.SHOTS[2]] = shot({ 0x54, 6, 0x77 }),
|
||||
[HordeSfx.SHOTS[3]] = shot({ 0x65, 4, 0x86 }),
|
||||
|
||||
-- the hammer falling on nothing: one dull tick, no tail
|
||||
[HordeSfx.DRY] = {
|
||||
channels = {
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 1, volume = 7, fade = 1, parameter = 0x38 } },
|
||||
{ noiseNote = { len = 1, volume = 3, fade = 1, parameter = 0x54 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- the magazine leaving: a click and a soft drop away from it
|
||||
[HordeSfx.MAG_OUT] = {
|
||||
channels = {
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 1, volume = 10, fade = 1, parameter = 0x1A } },
|
||||
{ noiseNote = { len = 2, volume = 5, fade = 2, parameter = 0x58 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- the fresh magazine seating: a firmer, lower clack with a bit of body
|
||||
[HordeSfx.MAG_IN] = {
|
||||
channels = {
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 1, volume = 13, fade = 1, parameter = 0x18 } },
|
||||
{ noiseNote = { len = 2, volume = 8, fade = 2, parameter = 0x46 } },
|
||||
{ noiseNote = { len = 2, volume = 3, fade = 3, parameter = 0x67 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- the slide: back (bright scrape), a frame of nothing, then home (hard)
|
||||
[HordeSfx.RACK] = {
|
||||
channels = {
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 2, volume = 9, fade = 2, parameter = 0x25 } },
|
||||
{ rest = 1 },
|
||||
{ noiseNote = { len = 1, volume = 14, fade = 1, parameter = 0x11 } },
|
||||
{ noiseNote = { len = 2, volume = 6, fade = 2, parameter = 0x44 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- a bullet arriving: short, bright, gone -- the hit marker's own sound
|
||||
[HordeSfx.HIT] = {
|
||||
channels = {
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 1, volume = 11, fade = 1, parameter = 0x14 } },
|
||||
{ noiseNote = { len = 1, volume = 5, fade = 2, parameter = 0x42 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- being hit: a low ugly thud on the noise channel with a square groan
|
||||
-- under it, sweeping DOWN -- the sound of losing something
|
||||
[HordeSfx.HURT] = {
|
||||
channels = {
|
||||
{ hw = 1, program = {
|
||||
{ pitchSweep = { pace = 3, subtract = true, shift = 4 } },
|
||||
{ squareNote = { len = 6, volume = 11, fade = 3, frequency = 0x480 } },
|
||||
} },
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 2, volume = 12, fade = 2, parameter = 0x66 } },
|
||||
{ noiseNote = { len = 4, volume = 6, fade = 3, parameter = 0x78 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- a wave arriving: two rising square stabs, deliberately not a fanfare
|
||||
[HordeSfx.WAVE] = {
|
||||
channels = {
|
||||
{ hw = 1, program = {
|
||||
{ squareNote = { len = 3, volume = 10, fade = 2, frequency = 0x5C0 } },
|
||||
{ rest = 1 },
|
||||
{ squareNote = { len = 6, volume = 12, fade = 3, frequency = 0x680 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- ------- registration
|
||||
|
||||
-- Assemble every program and put it in the sfx registry. Called once from
|
||||
-- main.lua at load. A malformed note table raises inside ChipAsm; each is
|
||||
-- assembled under pcall so one bad program is one missing sound rather
|
||||
-- than a mod that fails to load.
|
||||
function HordeSfx.register(mod)
|
||||
local ok, ChipAsm = pcall(require, "src.audio.ChipAsm")
|
||||
if not (ok and ChipAsm) then return false end
|
||||
local n = 0
|
||||
for name, spec in pairs(PROGRAMS) do
|
||||
local built, out = pcall(ChipAsm.sfx, spec)
|
||||
if built and out and out.chip then
|
||||
local reg = pcall(function()
|
||||
mod.content.sfx:register(name, { chip = out.chip })
|
||||
end)
|
||||
if reg then n = n + 1 end
|
||||
elseif mod.log then
|
||||
mod.log:error("horde: sfx %s did not assemble: %s", name, tostring(out))
|
||||
end
|
||||
end
|
||||
return n > 0
|
||||
end
|
||||
|
||||
-- ------- playback
|
||||
--
|
||||
-- One indirection so callers never touch Sound directly and a headless
|
||||
-- run (no love.audio) costs a pcall rather than an error.
|
||||
|
||||
local function play(name)
|
||||
pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
require("src.core.Sound").play(Game.data, name)
|
||||
end)
|
||||
end
|
||||
|
||||
HordeSfx.play = play
|
||||
|
||||
local shotIndex = 0
|
||||
|
||||
-- The next shot in the round-robin, so consecutive rounds overlap rather
|
||||
-- than cutting each other off (see the header).
|
||||
function HordeSfx.shot()
|
||||
shotIndex = shotIndex % #HordeSfx.SHOTS + 1
|
||||
play(HordeSfx.SHOTS[shotIndex])
|
||||
end
|
||||
|
||||
-- ------- the cries
|
||||
--
|
||||
-- Every mob that dies screams as something from the national dex. The
|
||||
-- list is built once from the live cry registry -- whatever the game and
|
||||
-- whatever mods are loaded have between them -- so this needs no data of
|
||||
-- its own and picks up a total conversion's roster for free.
|
||||
|
||||
local cryList = nil
|
||||
|
||||
local function cries()
|
||||
if cryList then return cryList end
|
||||
local out = {}
|
||||
pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
local table_ = Game.data and Game.data.audio and Game.data.audio.cries
|
||||
for species in pairs(table_ or {}) do out[#out + 1] = species end
|
||||
end)
|
||||
table.sort(out) -- love.math.random over a stable order, not hash order
|
||||
cryList = out
|
||||
return out
|
||||
end
|
||||
|
||||
-- A random cry, at a random-ish pitch. Nothing is more Pokemon than the
|
||||
-- wrong animal noise coming out of a man in a suit.
|
||||
function HordeSfx.randomCry()
|
||||
local list = cries()
|
||||
if #list == 0 then return nil end
|
||||
local species = list[love.math.random(#list)]
|
||||
pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
require("src.core.Sound").playCry(Game.data, species)
|
||||
end)
|
||||
return species
|
||||
end
|
||||
|
||||
-- a fresh boot (or a hot reload) rebuilds the species list
|
||||
function HordeSfx.invalidate()
|
||||
cryList = nil
|
||||
end
|
||||
|
||||
return HordeSfx
|
||||
+109
-9
@@ -59,6 +59,25 @@ local VR = {}
|
||||
-- spoken for, and a headset is not something to toggle by accident.
|
||||
VR.setting = ModSetting.new("vr", "VR", { false, true }, { "OFF", "ON" })
|
||||
|
||||
-- How the right stick turns you in first person. OFF is the 45-degree
|
||||
-- SNAP this mod shipped with and the reason for it is comfort, not
|
||||
-- taste: a software turn moves the world past a head that did not move,
|
||||
-- which is vection with no vestibular signal to match it, and it is the
|
||||
-- single most reliable way to make somebody ill in a headset. A snap
|
||||
-- gives the inner ear nothing to disagree with.
|
||||
--
|
||||
-- But snap turning is not free either -- it costs continuity, and the
|
||||
-- players who have their sea legs generally want the stick. So it is a
|
||||
-- row rather than a decision: OFF by default, on for anyone who asks,
|
||||
-- and the row only exists while there is a headset to use it in.
|
||||
VR.smoothTurn = ModSetting.new("smoothturn", "SMOOTH TURN",
|
||||
{ false, true }, { "OFF", "ON" })
|
||||
|
||||
-- radians per second at full deflection, with a squared response so the
|
||||
-- first half of the throw aims and the rest turns -- the same curve
|
||||
-- FirstPerson gives the flat screen's right stick
|
||||
VR.SMOOTH_TURN_RATE = 2.2
|
||||
|
||||
-- Where the diorama's UI panel floats vs first person's. These are the
|
||||
-- FALLBACK screens: wherever the pokedex is up and lit -- first
|
||||
-- person's menus, a battle's 2D scene -- no quad is submitted at all
|
||||
@@ -181,6 +200,11 @@ local function shutdown(reason)
|
||||
BattleCam.still = false
|
||||
VoxelScene.spriteLean = nil
|
||||
Pokedex.clear()
|
||||
-- the horde's gun too: its VR frame is a matrix built from a hand pose,
|
||||
-- and a stale one left behind would pin the model to wherever the
|
||||
-- controller was when the session died -- on the FLAT screen, where the
|
||||
-- view model should have taken over
|
||||
V.require("HordeGun").clear()
|
||||
zoom, heightOff = 1, 0
|
||||
fpYawOff, snapArmed = 0, true
|
||||
camMode, fadeAlpha = "explore", 0
|
||||
@@ -311,11 +335,46 @@ local function renderWorld(views, ctl)
|
||||
if scr then
|
||||
Pokedex.screen(scr[1], scr[2], scr[3], scr[4], scr[5])
|
||||
end
|
||||
elseif V.require("Horde").active then
|
||||
-- HORDE MODE's readout, on the device already in the player's left
|
||||
-- hand. It cannot be a flat overlay: the eye buffers have
|
||||
-- ASYMMETRIC frusta, so the same canvas pixel is a different ANGLE
|
||||
-- in each eye and a 2D HUD drawn into both tears down the middle.
|
||||
-- The Pokedex is real geometry both eyes see from their own
|
||||
-- position, so the stereo is correct by construction -- and it is
|
||||
-- already tracked, already lit, and already the thing this mod
|
||||
-- puts information on. (The gun wore it briefly and that was
|
||||
-- worse: a screen on the slide sits exactly where the iron sights
|
||||
-- need to be looked through.)
|
||||
--
|
||||
-- The UV rect goes over the usual way up: v = 0 at the TOP, which
|
||||
-- is how the device's screen quad reads every other texture it
|
||||
-- wears. An inverted rect was tried first, on the theory that a
|
||||
-- self-drawn canvas samples from the bottom -- it does not here,
|
||||
-- and it stood the readout on its head.
|
||||
local tex = V.require("HordeHud").panelTexture()
|
||||
if tex then Pokedex.screen(tex, 0, 0, 1, 1) end
|
||||
end
|
||||
else
|
||||
Pokedex.clear()
|
||||
end
|
||||
|
||||
-- and the horde's gun on the tracked RIGHT hand, under the same
|
||||
-- mapping. The AIM pose where the runtime offers one -- the barrel
|
||||
-- should point where the player is pointing, not along their wrist --
|
||||
-- and the grip pose as the fallback. Placed here rather than in the
|
||||
-- draw because the shot is traced down the model's own axis, so the
|
||||
-- matrix has to exist before anything can be hit with it.
|
||||
do
|
||||
local HordeGun = V.require("HordeGun")
|
||||
local right = ctl and (ctl.aimr or ctl.handr) or nil
|
||||
if right and fp and not battle and V.require("Horde").active then
|
||||
HordeGun.place(right, pivot, anchor, scale, mountYaw)
|
||||
else
|
||||
HordeGun.clear()
|
||||
end
|
||||
end
|
||||
|
||||
local eyes = {}
|
||||
for i = 1, 2 do
|
||||
local v = views[i]
|
||||
@@ -339,7 +398,10 @@ local function renderWorld(views, ctl)
|
||||
end
|
||||
|
||||
-- the snap's fade, over the finished eyes: plain black at this moment's
|
||||
-- strength, drawn before the blit so the headset never sees the swap
|
||||
-- strength, drawn before the blit so the headset never sees the swap.
|
||||
-- A full-frame fill is the ONE 2D thing that is safe to draw into an
|
||||
-- eye buffer -- it covers everything, so it does not matter that the
|
||||
-- two frusta disagree about where any given pixel points.
|
||||
if fadeAlpha > 0 then
|
||||
pcall(function()
|
||||
for i = 1, 2 do
|
||||
@@ -501,9 +563,25 @@ local function driveControls(ctl, dt, fp)
|
||||
if not (ok and Game.input) then return end
|
||||
local inp = Game.input
|
||||
|
||||
setGB(inp, "a", ctl.a)
|
||||
setGB(inp, "b", ctl.b)
|
||||
setGB(inp, "start", ctl.start)
|
||||
-- HORDE MODE re-reads the right hand as a weapon: the trigger fires
|
||||
-- (its own OpenXR action, suggested alongside START on the same input
|
||||
-- -- see VRXR.setupInput), and B reloads. START is dropped rather than
|
||||
-- forwarded, because the mode does not pause. Everything else -- the
|
||||
-- stick's walk, the snap turn, A -- keeps working, so the player can
|
||||
-- still move and look while they are being chased.
|
||||
local Horde = V.require("Horde")
|
||||
if Horde.playing() then
|
||||
local Gun = V.require("HordeGun")
|
||||
if ctl.fireChanged and ctl.fire then Gun.fire() end
|
||||
if ctl.bChanged and ctl.b then Gun.reload() end
|
||||
setGB(inp, "a", ctl.a)
|
||||
setGB(inp, "b", false)
|
||||
setGB(inp, "start", false)
|
||||
else
|
||||
setGB(inp, "a", ctl.a)
|
||||
setGB(inp, "b", ctl.b)
|
||||
setGB(inp, "start", ctl.start)
|
||||
end
|
||||
|
||||
-- the left stick, through the engine's OWN stick handler: it quantises
|
||||
-- to the grid d-pad for the diorama, and FirstPerson.moveVector reads
|
||||
@@ -512,12 +590,32 @@ local function driveControls(ctl, dt, fp)
|
||||
inp:gamepadaxis(nil, "leftx", ctl.moveX or 0)
|
||||
inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0))
|
||||
|
||||
if ctl.toggleChanged and ctl.toggle then VR.stepView() end
|
||||
-- the left stick click: the VOXEL ladder ordinarily, and the way out of
|
||||
-- horde mode while it runs (the rung is locked there, so the click has
|
||||
-- nothing else to do, and a headset has no ESCAPE key)
|
||||
if ctl.toggleChanged and ctl.toggle then
|
||||
if Horde.active then Horde.askExit() else VR.stepView() end
|
||||
end
|
||||
|
||||
-- first person's snap turn: a flick of the right stick steps the view
|
||||
-- 45 degrees, once per flick -- it re-arms only when the stick comes
|
||||
-- back toward centre, so holding it turns exactly once
|
||||
if fp and camMode ~= "battle" then
|
||||
-- first person's turn on the right stick. SMOOTH TURN ON makes it a
|
||||
-- rate -- hold and the world rotates under you -- and OFF (the
|
||||
-- default) makes it a 45-degree snap per flick: see the row's own
|
||||
-- reasoning where it is declared. Either way the offset turns the
|
||||
-- MAPPING, so the eyes, the walk direction, the pokedex and the gun
|
||||
-- all agree about which way the world now faces.
|
||||
if fp and camMode ~= "battle" and VR.smoothTurn:get() == true then
|
||||
local sx = ctl.lookX or 0
|
||||
local a = math.abs(sx)
|
||||
if a > 0.2 then
|
||||
a = (a - 0.2) / 0.8
|
||||
-- increasing yaw turns LEFT in this mod's compass, so a stick
|
||||
-- pushed right subtracts -- the same sign the snap below uses
|
||||
fpYawOff = wrapPi(fpYawOff
|
||||
- (sx > 0 and 1 or -1) * a * a
|
||||
* VR.SMOOTH_TURN_RATE * (dt or 0))
|
||||
end
|
||||
snapArmed = true -- so a switch back to snap mid-flick re-arms
|
||||
elseif fp and camMode ~= "battle" then
|
||||
local sx = ctl.lookX or 0
|
||||
if math.abs(sx) > 0.65 then
|
||||
if snapArmed then
|
||||
@@ -675,6 +773,8 @@ function VR.invalidate()
|
||||
if dexCanvas and dexCanvas.release then pcall(dexCanvas.release, dexCanvas) end
|
||||
dexCanvas = nil
|
||||
Pokedex.invalidate()
|
||||
V.require("HordeGun").invalidate()
|
||||
V.require("HordeHud").invalidate()
|
||||
for k in pairs(fboCache) do fboCache[k] = nil end
|
||||
end
|
||||
|
||||
|
||||
@@ -528,6 +528,18 @@ local function setupInput()
|
||||
gripr = makeAction(set, "grip_r", XR.ACTION_TYPE_FLOAT, "Right Grip"),
|
||||
handl = makeAction(set, "hand_l", XR.ACTION_TYPE_POSE, "Left Hand"),
|
||||
handr = makeAction(set, "hand_r", XR.ACTION_TYPE_POSE, "Right Hand"),
|
||||
-- HORDE MODE's two. `fire` is bound ALONGSIDE start on the right
|
||||
-- trigger rather than instead of it: bindings are suggested once,
|
||||
-- before the session attaches its action sets, so they cannot be
|
||||
-- swapped when the mode starts -- and OpenXR is happy for two actions
|
||||
-- to share an input. Outside the mode nothing reads `fire`, so the
|
||||
-- trigger is START exactly as it always was; inside it, lib/VR reads
|
||||
-- `fire` and drops START on the floor (there is no pausing anyway).
|
||||
-- `aimr` is the pose a runtime defines as "where the user is
|
||||
-- pointing", which is what a gun wants -- the grip pose points along
|
||||
-- the controller's own body, which is a wrist, not a barrel.
|
||||
fire = makeAction(set, "fire", XR.ACTION_TYPE_BOOLEAN, "Fire"),
|
||||
aimr = makeAction(set, "aim_r", XR.ACTION_TYPE_POSE, "Right Aim"),
|
||||
}
|
||||
|
||||
local function suggest(profile, list)
|
||||
@@ -560,6 +572,8 @@ local function setupInput()
|
||||
{ A.gripr, "/user/hand/right/input/squeeze/value" },
|
||||
{ A.handl, "/user/hand/left/input/grip/pose" },
|
||||
{ A.handr, "/user/hand/right/input/grip/pose" },
|
||||
{ A.fire, "/user/hand/right/input/trigger/value" },
|
||||
{ A.aimr, "/user/hand/right/input/aim/pose" },
|
||||
}
|
||||
-- Index has X/Y on no hand -- its A/B exist on BOTH -- so the two X/Y
|
||||
-- rows are swapped for left A/B there
|
||||
@@ -582,6 +596,8 @@ local function setupInput()
|
||||
{ A.gripr, "/user/hand/right/input/squeeze/click" },
|
||||
{ A.handl, "/user/hand/left/input/grip/pose" },
|
||||
{ A.handr, "/user/hand/right/input/grip/pose" },
|
||||
{ A.fire, "/user/hand/right/input/trigger/value" },
|
||||
{ A.aimr, "/user/hand/right/input/aim/pose" },
|
||||
})
|
||||
pcall(suggest, "/interaction_profiles/khr/simple_controller", {
|
||||
{ A.a, "/user/hand/right/input/select/click" },
|
||||
@@ -599,7 +615,11 @@ local function setupInput()
|
||||
check(xr.xrCreateActionSpace(session, info, out), "xrCreateActionSpace")
|
||||
return out[0]
|
||||
end
|
||||
-- the aim pose gets a space of its own; a runtime that refused the
|
||||
-- binding simply never locates it, and the gun falls back to the grip
|
||||
local spaces = { handl = handSpace(A.handl), handr = handSpace(A.handr) }
|
||||
local okAim, aimSpace = pcall(handSpace, A.aimr)
|
||||
if okAim and aimSpace then spaces.aimr = aimSpace end
|
||||
|
||||
local sets = ffi.new("XrActionSet[1]")
|
||||
sets[0] = set
|
||||
@@ -660,6 +680,7 @@ function VRXR.input(time)
|
||||
o.b, o.bChanged = readBool(A.b)
|
||||
o.start, o.startChanged = readBool(A.start)
|
||||
o.toggle, o.toggleChanged = readBool(A.toggle)
|
||||
o.fire, o.fireChanged = readBool(A.fire)
|
||||
o.gripL = readFloat(A.gripl)
|
||||
o.gripR = readFloat(A.gripr)
|
||||
|
||||
|
||||
@@ -1130,6 +1130,23 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
Voxel3D.glass(true)
|
||||
end
|
||||
|
||||
-- HORDE MODE's handgun, in the same slot and for the same reasons: a
|
||||
-- prop over the world with real depth, no wireframe and no glass. In VR
|
||||
-- it rides the tracked right hand (lib/VR placed it this frame); on the
|
||||
-- flat screen it is carried by the camera, which is why it draws here
|
||||
-- rather than in the overlay -- a view model that is 2D cannot be
|
||||
-- occluded by the wall the player just backed into.
|
||||
do
|
||||
local HordeGun = V.require("HordeGun")
|
||||
if HordeGun.visible() then
|
||||
Voxel3D.glass(false)
|
||||
Voxel3D.seams(false)
|
||||
HordeGun.draw()
|
||||
Voxel3D.seams(true)
|
||||
Voxel3D.glass(true)
|
||||
end
|
||||
end
|
||||
|
||||
end -- drawScene
|
||||
|
||||
if not eyes then
|
||||
|
||||
Reference in New Issue
Block a user