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

Horde.lua

lib/Horde.lua25.3 KB100644a9dff23543
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 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 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