mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 12:50:50 +02:00
554 lines
19 KiB
Lua
554 lines
19 KiB
Lua
-- 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
|