mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:20:53 +02:00
330 lines
11 KiB
Lua
330 lines
11 KiB
Lua
-- 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
|