Merge branch 'dev' into 3rd-person-camera

This commit is contained in:
DramaticShape
2026-08-03 17:51:08 -04:00
committed by GitHub
18 changed files with 3685 additions and 21 deletions
+340
View File
@@ -4589,6 +4589,346 @@ 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")
T.check(type(Data.screens) == "table" and Data.screens.HordeExitPrompt ~= nil,
"and so is the exit prompt START opens")
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 way out
--
-- START is asked, not taken: the mode does not pause, but it does have
-- to be leavable, and every device's START (the pad's, the keyboard's
-- ESCAPE, the touch overlay's) lands on the same GB button.
T.eq(Horde.askExit(keyGame), false,
"there is nothing to exit with the mode off")
-- and asking while the mode IS on must not throw. Worth its own check:
-- askExit reaches for this module's own overworld helper, and a Lua
-- local declared BELOW the function that uses it is a nil global --
-- which says nothing at load, nothing in a headless suite that never
-- turns the mode on, and blows up the first time somebody presses
-- ESCAPE. That is exactly how it shipped the first time.
Horde.active, Horde.state = true, "active"
local askedOk, askErr = pcall(Horde.askExit, keyGame)
Horde.active, Horde.state = false, "idle"
T.eq(askedOk, true, "asking the way out never throws: " .. tostring(askErr))
-- ------- the banner wraps rather than running off the screen
--
-- The HUD's scale follows the window's zoom, so a player zoomed well in
-- gets glyphs twice a large scale -- and the announcement was wider
-- than the frame it was announcing over.
local Hud = lib.require("HordeHud")
local wrap = Hud._layout
T.check(type(wrap) == "function", "the banner exposes its layout")
-- a stand-in font of fixed-width glyphs: the real sheets are not in the
-- fixture set, and pinning the line breaks against exact advances is a
-- claim about the WRAPPING rather than about whatever art is loaded
local stub = {
encode = function(s)
local out = {}
for i = 1, #s do out[i] = s:byte(i) end
return out
end,
advanceOf = function() return 8 end,
}
local SHOUT = "A DARKNESS APPROACHES"
local wide = wrap(stub, SHOUT, 2, 4000)
T.eq(#wide, 1, "given room, the announcement is one line")
local narrow, smallBs = wrap(stub, SHOUT, 2, 300)
T.check(#narrow > 1, "and wraps onto more lines when the frame is narrow")
for _, line in ipairs(narrow) do
T.check(line.width <= 300,
("every wrapped line fits the frame (%q is %d wide)")
:format(line.text, line.width))
end
-- A scale far past what the frame can carry shrinks the glyphs rather
-- than letting a word hang off the edge -- there is no wrap point
-- inside a word, so wrapping alone cannot save this case. This is the
-- one the player hit: the HUD's scale follows the window's zoom.
local tiny, tinyBs = wrap(stub, SHOUT, 40, 200)
for _, line in ipairs(tiny) do
T.check(line.width <= 200,
("a scale the frame cannot carry shrinks to fit (%q is %d wide)")
:format(line.text, line.width))
end
T.check(tinyBs < smallBs,
"and that shrink really is a smaller glyph than the narrow case's")
-- the degenerate end: one unbreakable word and almost no room. The
-- glyph cannot go below one pixel, so this is allowed to overflow --
-- what it may NOT do is fail to lay out
local one = wrap(stub, "APPROACHES", 8, 4)
T.check(one and #one == 1, "a single word always lays out on one line")
T.eq(wrap(stub, " ", 2, 100), nil, "and nothing but spaces lays out to nothing")
-- ------- SMOOTH TURN belongs to the headset
--
-- A comfort setting for a device that is not plugged in decides
-- nothing, so the row exists only while VR is ON -- and it is OFF by
-- default, because a software turn moves the world past a head that
-- did not move and that is how you make somebody ill in a headset.
local VRMod = lib.require("VR")
T.eq(VRMod.smoothTurn:get(), false, "SMOOTH TURN is off out of the box")
local function optionRows()
local out = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "tilt" } })
local ids = {}
for _, row in ipairs(out) do ids[row.id] = row end
return ids
end
Pipelines.setLevel("voxel", 3) -- off FULL, which owns other rows
VRMod.setting:sync(false)
T.check(not optionRows()["DRAMATIC_SHAPE:smoothturn"],
"with VR off the row is not on the OPTIONS menu")
VRMod.setting:sync(true)
local smoothRow = optionRows()["DRAMATIC_SHAPE:smoothturn"]
T.check(smoothRow ~= nil, "and with VR on it is")
if smoothRow then
T.eq(smoothRow.label, "SMOOTH TURN", "under its own name")
T.eq(smoothRow.value(), "OFF", "reading OFF until the player says otherwise")
end
VRMod.setting:sync(false)
-- ------- 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()
+329
View File
@@ -0,0 +1,329 @@
-- 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
-- and the same announcement at the zoom that used to run it off both
-- edges of the screen: it has to wrap (or shrink) rather than overflow
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 3
Zoom.applyOptions(game.save.options)
end)
Horde.banner("A DARKNESS APPROACHES", 2.6)
U.wait(30)
shot("01b_banner_zoomed")
pcall(function()
game.save.options.zoom = 0
Zoom.applyOptions(game.save.options)
end)
U.wait(10)
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))
-- ------- START asks the way out
--
-- The same GB button the pad's START, the keyboard's ESCAPE and the
-- touch overlay all press, so this one tap covers every device except
-- the VR stick click (which calls the same Horde.askExit).
U.tap(game, "start")
U.wait(10)
local prompt = game.stack:top()
print(("[horde] START opened a prompt: %s (still active: %s)")
:format(tostring(prompt ~= game.overworld), tostring(Horde.active)))
shot("02b_exit_prompt")
-- NO, and back to the fight
U.tap(game, "b")
U.wait(10)
print(("[horde] answered NO: back on the overworld %s, active %s")
:format(tostring(game.stack:top() == game.overworld),
tostring(Horde.active)))
-- ------- 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")
-- ------- and out the other door
--
-- The same restore, reached the other way: enter the code again and
-- leave through START -> YES rather than by dying. This is the path a
-- player who just wants their game back actually takes.
pressCode()
U.wait(6)
if not Horde.active then
print("[horde] second activation refused -- the exit path is untested")
print(("[horde] %d shots into %s"):format(shots, ROOT))
return love.event.quit()
end
for _ = 1, 400 do
if Horde.state == "active" then break end
U.wait(1)
end
U.tap(game, "start")
U.wait(10)
U.tap(game, "up") -- NO -> YES
U.wait(6)
shot("09_exit_yes")
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 out = game.stack:top()
local op = out.player
print(("[horde] exited via START/YES: active %s, %s (%d,%d) facing %s, rung %d")
:format(tostring(Horde.active), out.map.id, op.cellX, op.cellY,
op.facing, Pipelines.level("voxel")))
print(("[horde] matches start: map %s cell %s facing %s rung %s npcs %s")
:format(tostring(out.map.id == was.map),
tostring(op.cellX == was.cellX and op.cellY == was.cellY),
tostring(op.facing == was.facing),
tostring(Pipelines.level("voxel") == was.level),
tostring(#out.npcs == was.npcs)))
local left2 = 0
for _, def in pairs(game.data.maps) do
for _, obj in ipairs(def.objects or {}) do
if obj.hordeMob then left2 = left2 + 1 end
end
end
print(("[horde] horde objects left behind: %d"):format(left2))
print(("[horde] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end