horde mode initial commit

This commit is contained in:
DramaticShape
2026-08-02 21:01:11 -04:00
parent 2ce44586c2
commit c0c180fd01
18 changed files with 3184 additions and 12 deletions
+235
View File
@@ -4151,6 +4151,241 @@ vrRigSection()
Pipelines.setLevel("voxel", 0)
end)()
-- ------- HORDE MODE
--
-- The parts that can be judged without a screen: the code detector (which
-- reads Game Boy buttons, so this is the same test the pad, the touch
-- overlay and the VR controllers would pass), the registrations, the
-- gloom's arithmetic, and the flow field the crowd chases along.
;(function()
local lib = run.loader.exports.DRAMATIC_SHAPE.lib
local Horde = lib.require("Horde")
local Mobs = lib.require("HordeMobs")
local Gun = lib.require("HordeGun")
-- ------- the code
local KONAMI = { "up", "up", "down", "down",
"left", "right", "left", "right", "b", "a" }
local function feedAll(list)
local fired = false
for _, btn in ipairs(list) do
if Horde.feed({ btn }) then fired = true end
end
return fired
end
T.eq(feedAll(KONAMI), true, "the konami code completes the detector")
T.eq(Horde._progress(), 0, "and the detector resets behind itself")
-- a wrong button in the middle is a wrong code
T.eq(feedAll({ "up", "up", "down", "down", "left", "left" }), false,
"a wrong button abandons the code")
T.eq(Horde._progress(), 0, "and leaves nothing part-entered")
-- the two Ups are two EDGES, which every device gives (the analog stick's
-- hysteresis is what guarantees it there): one held Up is not two
T.eq(feedAll({ "up", "up" }), false, "two Ups alone do not fire")
T.eq(Horde._progress(), 2, "but they do stand at two")
T.eq(feedAll({ "a" }), false, "a stray A after them is not the code")
T.eq(Horde._progress(), 0, "and drops the run entirely")
-- a false start re-enters on the first button rather than dying: pressing
-- Up three times still leaves a code in progress
feedAll({ "up", "up", "up" })
T.eq(Horde._progress(), 2,
"a third Up restarts the run rather than voiding it")
Horde.feed({})
feedAll({ "down", "down", "left", "right", "left", "right", "b" })
T.eq(Horde.feed({ "a" }), true, "and the code still completes from there")
-- every button in one step is still the code: the queue is ordered, and
-- a frame that swallowed several presses must not lose any of them
T.eq(Horde.feed(KONAMI), true, "the whole code inside one fixed step fires")
-- ------- it cannot start from nowhere
--
-- canStart is the gate between the code and the mode. With no overworld
-- in this harness it must refuse, which is also the "in a menu, in a
-- battle, mid-warp" answer.
T.eq(Horde.canStart(keyGame), false,
"the code does nothing outside a live overworld")
T.eq(Horde.active, false, "and the mode stays off")
-- ------- what got registered
T.check(type(Data.screens) == "table" and Data.screens.HordeGameOver ~= nil,
"the GAME OVER card is in the screens registry")
local sfx = Data.audio and Data.audio.sfx or {}
local HordeSfx = lib.require("HordeSfx")
for _, name in ipairs(HordeSfx.SHOTS) do
T.check(type(sfx[name]) == "table" and sfx[name].chip ~= nil,
"the gunshot " .. name .. " assembled into a chip program")
end
for _, name in ipairs({ HordeSfx.MAG_OUT, HordeSfx.MAG_IN, HordeSfx.RACK,
HordeSfx.DRY, HordeSfx.HIT, HordeSfx.HURT }) do
T.check(type(sfx[name]) == "table" and sfx[name].chip ~= nil,
name .. " assembled into a chip program")
end
T.eq(#HordeSfx.SHOTS, 3,
"three shot variants, so a fast trigger overlaps its own echoes")
-- none of them may duck the music: a fanfare pauses the song, and
-- Lavender Town stopping every time the player fires is not the mode
local fanfares = Data.audio and Data.audio.fanfares or {}
for name in pairs(sfx) do
if name:find("^DS_HORDE") then
T.check(not fanfares[name] and not sfx[name].fanfare,
name .. " is an overlay sound, not a fanfare that pauses the song")
end
end
-- ------- the gloom
--
-- The mode pins NIGHT and then drags it down. Both wrappers must be
-- INERT with the mode off -- this mod is a display mod first, and a
-- horde-mode bug that tinted the ordinary game would be the worst kind.
local DayNight = lib.require("DayNight")
local litPal = DayNight.palette(DayNight.T.night)
local litTint = DayNight.tint(true, DayNight.T.night)
T.check(type(litPal) == "table" and #litPal > 0,
"the night palette is readable with the mode off")
Horde.active = true
local darkPal = DayNight.palette(DayNight.T.night)
local darkTint = DayNight.tint(true, DayNight.T.night)
local darkIn = DayNight.tint(false, DayNight.T.night)
Horde.active = false
local litSum, darkSum = 0, 0
for i = 1, #litPal do
for c = 1, 3 do
litSum = litSum + litPal[i][c]
darkSum = darkSum + darkPal[i][c]
end
end
T.check(darkSum < litSum * 0.6,
"the horde's sky is markedly darker than the night it is built on")
T.check(darkTint[1] < litTint[1] and darkTint[3] < litTint[3],
"and the world tint comes down with it")
T.check(darkTint[3] > darkTint[2],
"toward violet rather than flat grey -- blue survives the desaturation")
T.check(darkIn[1] < 1,
"indoors is darkened too: a building is not a refuge")
T.same(DayNight.palette(DayNight.T.night), litPal,
"and with the mode off the palette is byte-for-byte what it was")
T.same(DayNight.tint(true, DayNight.T.night), litTint,
"as is the world tint")
-- ------- the flow field
--
-- A room with a wall down the middle and one gap in it. Every mob's next
-- step comes off this sweep, so what is being pinned here is that the
-- crowd walks THROUGH THE DOORWAY rather than into the wall -- which is
-- the whole of "they path to you" and "they follow you inside".
local Map = require("src.world.Map")
local FLOOR, WALL = 1, 2
local WIDTH, HEIGHT = 6, 5 -- in blocks; cells are twice that
local blocks = {}
for i = 1, WIDTH * HEIGHT do blocks[i] = FLOOR end
local mapDef = {
id = "HORDE_TEST", width = WIDTH, height = HEIGHT,
blocks = blocks, borderBlock = WALL, tileset = "FIX_OUT",
objects = {}, warps = {}, signs = {},
}
-- the tileset says which collision tiles may be stood on; FLOOR may,
-- WALL may not
local tilesetDef = {
walkable = { FLOOR },
blockdefs = nil,
}
-- Map:cellTile reads the block layer through the tileset's blockdefs; the
-- fixture tileset has none, so stand in a cellTile that answers straight
-- off a grid this test owns
local walls = {}
local map = Map.new(mapDef, tilesetDef)
local W, H = map.widthCells, map.heightCells
function map:cellTile(cx, cy)
if cx < 0 or cy < 0 or cx >= W or cy >= H then return WALL end
return walls[cy * W + cx] and WALL or FLOOR
end
-- a wall along cx = 5, with a single gap at cy = 1
for cy = 0, H - 1 do
if cy ~= 1 then walls[cy * W + 5] = true end
end
Mobs._rebuild(map, 2, 2) -- the player, west of the wall
T.eq(Mobs._dist(2, 2), 0, "the sweep starts on the player's own cell")
T.eq(Mobs._dist(3, 2), 1, "and counts outward a cell at a time")
T.eq(Mobs._dist(5, 2), nil, "the wall is not a cell anything stands on")
-- east of the wall the only way in is the gap, so the distance there has
-- to be the way ROUND, not the way through
local through = Mobs._dist(6, 2)
T.check(through ~= nil, "the far side of the wall is still reachable")
local direct = math.abs(6 - 2) + math.abs(2 - 2)
T.check(through > direct,
"and costs more than the straight line, because the wall is in the way")
T.eq(through, Mobs._dist(6, 1) + 1,
"the cheapest way there is through the gap")
-- a mob standing east of the wall must take a step that shortens the
-- walk -- which, from there, means heading for the doorway
local state = { map = map, entities = {}, player = { cellX = 2, cellY = 2 } }
local mob = { cellX = 7, cellY = 3, px = 112, py = 48,
facing = "down", moving = false, progress = 0 }
state.entities[1] = mob
Mobs._stepMob(state, { npc = mob })
T.eq(mob.moving, true, "a mob with a path takes a step")
local before = Mobs._dist(7, 3)
local after = Mobs._dist(mob.targetX, mob.targetY)
T.check(after and before and after < before,
"and the step it takes is one that shortens the walk to the player")
-- an occupied cell is stepped AROUND, not into: this is what makes a
-- pack spread out around the player instead of queueing single file
local blocker = { cellX = mob.targetX, cellY = mob.targetY }
local mob2 = { cellX = 7, cellY = 3, px = 112, py = 48,
facing = "down", moving = false, progress = 0 }
state.entities = { blocker, mob2 }
Mobs._stepMob(state, { npc = mob2 })
if mob2.moving then
T.check(not (mob2.targetX == blocker.cellX
and mob2.targetY == blocker.cellY),
"a mob never steps into a cell another one already holds")
else
T.check(true, "a boxed-in mob waits rather than walking through a body")
end
-- seal the gap and the east side is cut off entirely. A mob over there
-- has nowhere to walk that gets it any closer, and must stand rather
-- than guess -- an island across water is not a path.
walls[1 * W + 5] = true
Mobs._rebuild(map, 2, 2)
T.eq(Mobs._dist(6, 2), nil, "walling the gap cuts the far side off")
local stranded = { cellX = 7, cellY = 3, px = 112, py = 48,
facing = "down", moving = false, progress = 0 }
state.entities = { stranded }
Mobs._stepMob(state, { npc = stranded })
T.eq(stranded.moving, false, "a mob with nowhere to go does not move")
T.eq(stranded.facing, "left",
"it turns toward the player instead, so the pack still reads as a threat")
-- ------- the gun's own bookkeeping
Gun.reset()
local ammo, mag, reloading = Gun.ammo()
T.eq(ammo, mag, "the gun starts with a full magazine")
T.eq(reloading, false, "and is not reloading")
T.eq(Gun.fire(), false, "and cannot be fired with no session running")
T.eq(select(1, Gun.ammo()), mag, "a refused shot spends no round")
T.eq(Gun.reload(), false, "nor reloaded when it is already full")
T.eq(Gun.visible(), false, "and it is not drawn with the mode off")
end)()
Pipelines.reset()
run.release()
+249
View File
@@ -0,0 +1,249 @@
-- Scratch driver: HORDE MODE, from the code to the card.
--
-- Enters the konami code the way a player does -- as Game Boy button
-- edges on the fixed step, which is the same path the pad, the touch
-- overlay and the VR controllers take -- then photographs the intro
-- banner, the darkened sky, the gun at the hip and down the sights, a
-- wave mid-chase, the muzzle flash, the reload, a run through a door with
-- the crowd behind, and the GAME OVER card. Finishes by pressing A and
-- checking that everything the mode changed came back.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/horde_shots.lua \
-- SHOT_DIR=.scratchpad/horde lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/horde")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[horde] DRAMATIC_SHAPE mod not loaded")
return love.event.quit()
end
local V = handle.lib
local Horde = V.require("Horde")
local Mobs = V.require("HordeMobs")
local Gun = V.require("HordeGun")
local FirstPerson = V.require("FirstPerson")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
local shots = 0
local function shot(name)
if U.shot(game, ("%s/%s.png"):format(ROOT, name)) then
shots = shots + 1
end
end
local function settle(frames)
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(frames or 30)
end
-- ------- the code, entered as edges
--
-- One button per fixed step, exactly as a device delivers them: the
-- driver writes the queue Input:step is about to drain, which is the
-- same array a keypress lands in.
--
-- Each one is RELEASED after. A synthetic inject has no source behind
-- it, so Input:step latches it held (see the branch there) and nothing
-- ever lets go -- which leaves all four directions down for the rest of
-- the run and the player walking into a wall forever. U.tap does the
-- same clear for the same reason.
local function pressCode()
local KONAMI = { "up", "up", "down", "down",
"left", "right", "left", "right", "b", "a" }
for _, btn in ipairs(KONAMI) do
table.insert(game.input.pressQueue, btn)
U.wait(2)
game.input.state[btn] = false
end
end
-- ------- before
U.teleport(game, "PALLET_TOWN", 13, 14, "down")
Pipelines.setLevel("voxel", 3) -- the 35-degree diorama
Pipelines.setLevel("tiltshift", 0)
settle(40)
local ow = game.stack:top()
local p = ow.player
local was = {
map = ow.map.id, cellX = p.cellX, cellY = p.cellY, facing = p.facing,
level = Pipelines.level("voxel"),
npcs = #ow.npcs,
}
shot("00_before")
print(("[horde] before: %s (%d,%d) rung %d, %d npcs")
:format(was.map, was.cellX, was.cellY, was.level, was.npcs))
-- ------- the code lands
pressCode()
U.wait(4)
print("[horde] active: " .. tostring(Horde.active)
.. " state: " .. tostring(Horde.state))
if not Horde.active then
print("[horde] the code did not take -- nothing else here can run")
return love.event.quit()
end
U.wait(20)
shot("01_darkness_approaches") -- the banner, over the darkening
print(("[horde] rung is now %d (%s)"):format(Pipelines.level("voxel"),
Pipelines.levelLabel("voxel")))
-- the rung must be locked: the key, SELECT and the VR click all call the
-- one function this refuses through
local Game = require("src.core.Game")
Game.keypressed(game, "3")
print(("[horde] after pressing 3 the rung is %d -- locked: %s")
:format(Pipelines.level("voxel"),
tostring(Pipelines.level("voxel") == Voxel.FP_LEVEL)))
-- ------- the first wave
for _ = 1, 900 do
if Horde.state == "active" and #Horde.session.mobs >= 5 then break end
U.wait(1)
end
FirstPerson.yaw = math.pi -- look north up the street
U.wait(30)
shot("02_wave_hip")
print(("[horde] wave %d, %d mobs standing")
:format(Horde.session.wave, #Horde.session.mobs))
-- ------- the sights
Gun.setAds(true)
U.wait(20)
shot("03_ads")
Gun.setAds(false)
U.wait(14)
-- ------- the shot
--
-- Aimed deliberately at the nearest mob rather than fired into the
-- street: what is being checked is that the ray finds a body, that the
-- body dies, and that the kill is worth something.
local function aimAtNearest()
local best, bestD
for _, e in ipairs(Horde.session.mobs) do
local dx = (e.npc.px + 8) - (p.px + 8)
local dz = (e.npc.py + 8) - (p.py + 8)
local d = dx * dx + dz * dz
if not bestD or d < bestD then best, bestD = e, d end
end
if not best then return nil end
local dx = (best.npc.px + 8) - (p.px + 8)
local dz = (best.npc.py + 8) - (p.py + 8)
FirstPerson.yaw = math.atan2(dx, dz)
FirstPerson.pitch = 0
return best, math.sqrt(bestD)
end
local scoreWas, killsWas = Horde.session.score, Horde.session.kills
local target, range = aimAtNearest()
print(("[horde] aiming at a mob %s px away, yaw %.2f")
:format(range and ("%.0f"):format(range) or "?", FirstPerson.yaw))
U.wait(6)
Gun.fire()
U.wait(1)
shot("04_muzzle_flash")
-- a mob takes more than one round as the waves stack; keep firing at
-- whatever is nearest until something dies or the magazine is out
for _ = 1, 7 do
if Horde.session.kills > killsWas then break end
U.wait(16)
aimAtNearest()
Gun.fire()
end
U.wait(20)
print(("[horde] fired: ammo %d, score %d -> %d, kills %d -> %d")
:format(select(1, Gun.ammo()), scoreWas, Horde.session.score,
killsWas, Horde.session.kills))
-- ------- the reload, caught mid-dip
for _ = 1, 8 do
Gun.fire()
U.wait(12)
end
Gun.reload()
U.wait(45)
shot("05_reloading")
print("[horde] reloading: " .. tostring(select(3, Gun.ammo())))
for _ = 1, 200 do
if not select(3, Gun.ammo()) then break end
U.wait(1)
end
print(("[horde] reloaded to %d"):format(select(1, Gun.ammo())))
-- ------- through a door, with the crowd behind
local before = #Horde.session.mobs
U.teleport(game, "REDS_HOUSE_1F", 3, 6, "down")
settle(60)
for _ = 1, 400 do
if #Horde.session.mobs > 0 then break end
U.wait(1)
end
U.wait(60)
shot("06_indoors_followed")
print(("[horde] indoors: %d mobs followed (was %d outside)")
:format(#Horde.session.mobs, before))
-- ------- the end
--
-- Drained rather than played out, so the driver finishes in seconds and
-- the card is photographed from the same path a real death takes.
Horde.session.hp = 1
Horde.session.hurtCooldown = 0
Horde.damage(50)
for _ = 1, 400 do
if Horde.state == "gameover" then break end
U.wait(1)
end
U.wait(40)
shot("07_game_over")
print(("[horde] game over at score %d, wave %d, %d kills")
:format(Horde.session and Horde.session.score or -1,
Horde.session and Horde.session.wave or -1,
Horde.session and Horde.session.kills or -1))
-- ------- and back
U.tap(game, "a")
for _ = 1, 600 do
if not Horde.active and not game.stack:top().transitioning then break end
U.wait(1)
end
settle(60)
local now = game.stack:top()
local np = now.player
print(("[horde] restored: %s (%d,%d) facing %s, rung %d, %d npcs")
:format(now.map.id, np.cellX, np.cellY, np.facing,
Pipelines.level("voxel"), #now.npcs))
print(("[horde] matches start: map %s cell %s facing %s rung %s npcs %s")
:format(tostring(now.map.id == was.map),
tostring(np.cellX == was.cellX and np.cellY == was.cellY),
tostring(np.facing == was.facing),
tostring(Pipelines.level("voxel") == was.level),
tostring(#now.npcs == was.npcs)))
-- nothing of the mode may be left on any map
local left = 0
for _, def in pairs(game.data.maps) do
for _, obj in ipairs(def.objects or {}) do
if obj.hordeMob then left = left + 1 end
end
end
print(("[horde] horde objects left behind: %d"):format(left))
shot("08_after")
print(("[horde] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end