This repository was archived by the owner on Aug 14, 2026. It is now read-only.
forked from TeJota1337/DramaticShapeVoxelMod
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathHorde.lua
More file actions
707 lines (635 loc) · 25.3 KB
/
Copy pathHorde.lua
File metadata and controls
707 lines (635 loc) · 25.3 KB
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