DramaticShapeVoxelMod archival mirror · read-only
lib/HordeMobs.lua

HordeMobs.lua

lib/HordeMobs.lua18.9 KB1006442dbc4eae9a
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 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