patch holes on flower/grass models
DramaticShape · 2026-08-03 ·
parent 9542ba94b1
patch holes on flower/grass models
Changed files
CHANGELOG.md | 83 ++++++++++++++++++++++++++++
README.md | 5 ++
lib/Structures.lua | 117 ++++++++++++++++++++++++++--------------
main.lua | 25 +++++++++
tests/cam_shots.lua | 211 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 401 insertions(+), 40 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 22d4f77..ec33298 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,89 @@
well-known way to make people ill. The rung still changes the walk and
the sprites the same way.
+- **Every camera zooms, on whatever the machine has.** The mouse wheel,
+ `Q`/`E`, a two-finger pinch and the pad's two stick clicks all reach
+ whichever camera is actually in front of you -- the third-person boom,
+ the staged battle's lens, or the engine's own survey zoom on an orbit
+ rung. One module (`lib/CamControl.lua`) answers "which camera is this
+ aimed at" so the four cameras never race each other for an event, and
+ forwards everything it does not claim. 1ST claims nothing: the eye is in
+ the player's head, and a pinch there would only wind the survey zoom for
+ whenever they stepped back out.
+
+- **The battle camera is yours to steer.** The right stick, a drag across
+ the screen or the mouse walks the staged shot around the arena and raises
+ the seat; the wheel, `Q`/`E`, a pinch or a stick click work the lens.
+
+ Both axes stop where the composition does. LEFT stops at the shot the rig
+ was solved for, because there is nothing to the left of it. RIGHT ends
+ SIDE-ON -- the eye square to the arena's axis, both Pokemon at the same
+ distance instead of one behind the other -- computed from each rig's own
+ stance rather than written down. DOWN stops at the rig's low stance and
+ UP is 45 degrees above it, raised about the focus at a constant radius so
+ climbing never doubles as zooming. Input accumulates into a goal the eye
+ eases after, so a flick reads as the camera being pushed rather than
+ dragged.
+
+ The lens **opens by exactly the amount the pair spreads**: the solved
+ shot looks along the arena's axis at a shallow angle, which foreshortens
+ the gap between the two mons to less than half its length, and swinging
+ round or climbing un-foreshortens it. Left alone that threw both Pokemon
+ off the edges of the frame at the far end of either range, which made the
+ whole far end unusable.
+
+ Where you leave the camera is where the next battle opens. An angle and a
+ lens you chose are how you want to watch battles, not a fact about one
+ encounter.
+
+- **Move animations track the camera.** They already slid to follow the
+ pair's midpoint; now they follow its SEPARATION too. Both mons are
+ geometry standing on the map, so the camera sizes them -- and an effects
+ layer that kept the authored 106-pixel spacing through a zoom and a
+ 60-degree swing fired its beams into the air beside the Pokemon they were
+ aimed at.
+
+- **BACK SPRITES locks the battle camera.** That setting pins your own mon
+ to the GB's slot on the menu while the foe stands out on the map, and no
+ angle holds a composition that is half frame and half world. The steer,
+ the climb and the lens all stand down -- in the rig as well as at the
+ inputs, so an angle stored from before the row was switched on cannot
+ leave it steered anyway. The slow drift stays: it was always there under
+ BACK SPRITES and two degrees is not a composition problem.
+
+- **BATTLE BG is pinned to WHITE and its row comes off the menu.** The row
+ picks what fills the screen AROUND the battle, and this mode fills the
+ window with the map the fight is standing on -- there are no voids left
+ for it to be about. WORLD was actively wrong under it: it makes the
+ battle non-opaque so the engine draws a second, dimmed copy of the
+ overworld beneath the arena pass's own. Pinned rather than merely hidden,
+ so a save written before the mod was installed cannot carry a value the
+ menu can no longer reach. Uninstall and the row is back.
+
+### Fixed
+
+- **Grass and flowers are closed off at the sides.** Both stand as
+ per-pixel slabs built from runs of lit pixels, and only the front, the
+ back and a lid were ever emitted -- so from any angle off square you
+ looked straight in through the open end of every run and out the far
+ side. At the low cameras this release adds, that is most of the time.
+ Each run now wears end walls in the colour of the pixel they close off.
+
+ Flowers needed more than that, because a flower SWAYS: the mesh spans the
+ union of every animation frame and each frame is cut back out in texture
+ space, so a pixel that drops out of a frame takes the union's wall with
+ it and leaves an interior boundary that never had one. The first cut of
+ this looked solid on the base frame and still had gaps on every other.
+ Every pixel of a flower now carries a cap on all four of its remaining
+ faces: enclosed and invisible while its neighbour is there, and already
+ in place the moment the animation takes that neighbour away.
+
+- **No more machine-gun bonking in 1ST and 3RD.** The grid walk's collision
+ sound marks a discrete event -- a direction pressed, a step refused. A
+ free walk has no such moment: the body slides along every wall it grazes,
+ continuously, so a corridor taken at a slight angle rang the bonk twice a
+ second from end to end. The wall stopping you is the feedback.
+
## 1.5.2
### Added
diff --git a/README.md b/README.md
index 2b79a9f..0636994 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,11 @@ through the engine's own machinery.
| right stick | look |
| a touch drag off the overlay's controls | look |
| left stick / touch d-pad / arrow keys | walk, relative to where the camera looks |
+| wheel, `Q` / `E`, pinch, or a stick click | **3RD only** — let the boom out and pull it in (`Q` and left stick click out, `E` and right stick click in) |
+
+On an **orbit rung** the same wheel, `Q`/`E` and pinch drive the engine's own
+survey zoom. On **1ST** they do nothing at all: the eye is in your head, and
+there is no distance to change.
On **3RD** the boom shortens against whatever is behind you, so backing into
a wall walks the camera in to your shoulders rather than through it — squeeze
diff --git a/lib/Structures.lua b/lib/Structures.lua
index be4aac2..2c99c8d 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -3314,29 +3314,56 @@ end
-- carries the animation: when a frame keys that pixel out, the wall's own
-- fragments discard with the faces either side of it, so a swaying tuft
-- never leaves a wall standing where its blade no longer is.
+-- `everyPixel` is for a standee whose silhouette ANIMATES. The mesh is
+-- built once, over the UNION of every frame's mask, and each frame is cut
+-- out again in texture space -- so a run that is six pixels wide in the
+-- union may be two pixels wide in the frame on screen, and the four pixels
+-- that dropped out took the union's end walls with them. What is left
+-- exposed is an interior boundary, which had no wall because in the union
+-- it was not a boundary at all. That is the gap that survived closing the
+-- run ends: the first frame looked solid and every other frame did not.
+--
+-- So an animated standee gets a wall on BOTH sides of EVERY pixel. A wall
+-- between two lit pixels is enclosed by the front and back faces and never
+-- seen; the moment its neighbour is keyed out it becomes the edge, already
+-- in place and already wearing the right colour. Each is inset a hair into
+-- its own pixel so the two that meet at a boundary are not coplanar -- the
+-- voxel pass draws with culling off, and two quads in the same plane would
+-- z-fight rather than politely take turns.
+local SIDE_INSET = 0.03
+
local function sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
- ax0, ay0, atlasW, atlasH, py, lit)
+ ax0, ay0, atlasW, atlasH, py, lit, everyPixel)
local function texel(px)
return (ax0 + px + 0.5) / atlasW, (ay0 + py + 0.5) / atlasH
end
- if not lit(ix - 1, py) then
- local u, v = texel(ix)
- quads[#quads + 1] = { -- the run's left wall, facing -X
- { ix, yBot, zB }, { ix, yBot, zF },
- { ix, yTop, zF }, { ix, yTop, zB },
+ local function left(px, at)
+ local u, v = texel(px)
+ quads[#quads + 1] = { -- facing -X
+ { at, yBot, zB }, { at, yBot, zF },
+ { at, yTop, zF }, { at, yTop, zB },
uv = { { u, v }, { u, v }, { u, v }, { u, v } },
shade = OBJ_SHADE.side,
}
end
- if not lit(ix2 + 1, py) then
- local u, v = texel(ix2)
- quads[#quads + 1] = { -- and its right wall, facing +X
- { ix2 + 1, yBot, zF }, { ix2 + 1, yBot, zB },
- { ix2 + 1, yTop, zB }, { ix2 + 1, yTop, zF },
+ local function right(px, at)
+ local u, v = texel(px)
+ quads[#quads + 1] = { -- facing +X
+ { at, yBot, zF }, { at, yBot, zB },
+ { at, yTop, zB }, { at, yTop, zF },
uv = { { u, v }, { u, v }, { u, v }, { u, v } },
shade = OBJ_SHADE.side,
}
end
+ if everyPixel then
+ for px = ix, ix2 do
+ left(px, px + SIDE_INSET)
+ right(px, px + 1 - SIDE_INSET)
+ end
+ return
+ end
+ if not lit(ix - 1, py) then left(ix, ix) end
+ if not lit(ix2 + 1, py) then right(ix2, ix2 + 1) end
end
-- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
@@ -3590,41 +3617,51 @@ local function flowerTemplate(map, data, tileId)
uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } },
shade = OBJ_SHADE.back,
}
- -- petal tips: a top strip where the row above is clear. The
- -- strip samples its own row's texel, so a tip that is not in
- -- the current frame discards with the face beneath it
- if not on(ix, py - 1) then
- quads[#quads + 1] = {
- { ix, yTop, zB }, { ix2 + 1, yTop, zB },
- { ix2 + 1, yTop, zF }, { ix, yTop, zF },
- uv = { { u0, v0 }, { u1, v0 }, { u1, v0 }, { u0, v0 } },
+ -- ------- the shell, closed on all four remaining faces
+ --
+ -- A flower SWAYS: the geometry spans the union of every animation
+ -- frame's mask and each frame is cut back out of it in texture
+ -- space (see the header). So "is there a pixel next door" has two
+ -- different answers -- one in the union this mesh was built from,
+ -- and one in the frame actually on screen -- and only the second
+ -- decides what is exposed.
+ --
+ -- Closing the union's own edges is therefore not enough, and was
+ -- the bug the first cut of this shipped: the base frame looked
+ -- solid and every other frame still had gaps, because a pixel that
+ -- drops out of a frame takes the union's wall with it and leaves an
+ -- interior boundary that never had one.
+ --
+ -- So every pixel gets a cap on all four of its remaining faces,
+ -- whatever its neighbours do. A cap between two lit pixels sits
+ -- inside the slab, enclosed by the front and back faces, and is
+ -- never seen; the moment its neighbour is keyed out it IS the edge,
+ -- already there and already wearing the right colour. Each samples
+ -- its own pixel's texel, so it appears and vanishes with the pixel
+ -- it belongs to rather than with the one it is closing off.
+ --
+ -- Inset a hair into its own pixel, because the voxel pass draws
+ -- with culling off: the two caps that meet at a boundary would be
+ -- coplanar and z-fight rather than politely take turns.
+ for px = ix, ix2 do
+ local tu = (ax0 + px + 0.5) / atlasW
+ local tv = (ay0 + py + 0.5) / atlasH
+ local xa, xb = px, px + 1
+ local yT = yTop - SIDE_INSET
+ local yB = yBot + SIDE_INSET
+ quads[#quads + 1] = { -- the pixel's own lid
+ { xa, yT, zB }, { xb, yT, zB }, { xb, yT, zF }, { xa, yT, zF },
+ uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } },
shade = OBJ_SHADE.top,
}
- end
- -- and the same strip on the bottom, where the row below is clear:
- -- a petal that ends mid-air is a solid thing seen from underneath,
- -- and a low camera (1ST, 3RD, the battle's floor-level seat) is
- -- looking straight up at it
- if not on(ix, py + 1) then
- quads[#quads + 1] = {
- { ix, yBot, zF }, { ix2 + 1, yBot, zF },
- { ix2 + 1, yBot, zB }, { ix, yBot, zB },
- uv = { { u0, v1 }, { u1, v1 }, { u1, v1 }, { u0, v1 } },
+ quads[#quads + 1] = { -- and its floor
+ { xa, yB, zF }, { xb, yB, zF }, { xb, yB, zB }, { xa, yB, zB },
+ uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } },
shade = OBJ_SHADE.bottom,
}
end
- -- The ENDS of the run, which close the slab off sideways. Without
- -- them the flower is two faces and a lid: from anywhere but square
- -- on you look straight through its open edge and out the far side,
- -- which is what stopped it reading as a solid thing.
- --
- -- Each wall samples ONE texel -- the run's own end pixel, at its
- -- centre -- so the side wears the colour of the pixel it is closing
- -- off (the nearest coloured pixel there is) rather than a keyed
- -- hole, and discards with that pixel when the animation frame it
- -- belongs to is not the one on screen.
sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
- ax0, ay0, atlasW, atlasH, py, on)
+ ax0, ay0, atlasW, atlasH, py, on, true)
ix = ix2 + 1
else
ix = ix + 1
diff --git a/main.lua b/main.lua
index df02cee..4f5ebf9 100644
--- a/main.lua
+++ b/main.lua
@@ -646,6 +646,25 @@ end
--
-- Everything they did is still reachable: uninstall the mod and both rows are
-- back, at whatever they were last set to.
+-- BATTLE BG rides the same reasoning, and comes off for a reason of its own.
+-- The row picks what fills the screen AROUND the battle's 160x144 field --
+-- WHITE paper, BLACK bars, or the frozen overworld dimmed behind it -- and
+-- all three were answers to the same question: what to do with the voids,
+-- given the battle is a small picture in the middle of a big window.
+--
+-- This mod answers that question differently and permanently. A staged fight
+-- fills the whole window with the map the fight is standing on, and the
+-- flat battle screen it composites over it is drawn on the mode's own
+-- surface; there are no voids left for the row to fill. WORLD is the worst
+-- of the three under it -- it makes the battle non-opaque so the engine
+-- draws the overworld underneath, which is a SECOND copy of the world drawn
+-- under the one the arena pass already put there, dimmed and at a different
+-- camera. BLACK bars over a diorama read as a letterboxed screenshot.
+--
+-- So the value is pinned at WHITE, which is the one the mode was composed
+-- against, and the row comes off the menu on the same reasoning as TILT and
+-- GBC FX: a row that no longer decides anything is worse than no row.
+-- Uninstall the mod and it is back, at whatever it was last set to.
local function pinEngineFx(game)
game = game or require("src.core.Game")
local opts = game and game.save and game.save.options
@@ -654,7 +673,9 @@ local function pinEngineFx(game)
local changed = false
if opts then
changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0
+ or (opts.battleBg or "white") ~= "white"
opts.tilt, opts.gbcfx = 0, 0
+ opts.battleBg = "white"
end
pcall(Tilt.setLevel, 0)
pcall(GBCFX.setLevel, 0)
@@ -672,6 +693,10 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
pinEngineFx(game)
dropRow(out, "tilt")
dropRow(out, "gbcfx")
+ -- and BATTLE BG with them: this mode fills the window with the map, so
+ -- the row's whole question -- what to put in the voids around the battle
+ -- -- no longer has voids to be about (see pinEngineFx)
+ dropRow(out, "battleBg")
-- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
-- one away. While a fight can be staged on the map, OG is the only layout it
-- can be composed in (OverworldBattle.forceOG), so the value is pinned there
diff --git a/tests/cam_shots.lua b/tests/cam_shots.lua
new file mode 100644
index 0000000..3bcbba3
--- /dev/null
+++ b/tests/cam_shots.lua
@@ -0,0 +1,211 @@
+-- Scratch driver: the cameras the player steers -- the third-person boom's
+-- zoom, and the battle shot's orbit, climb and lens, including the far
+-- stops (side-on, 45 degrees up) and what BACK SPRITES takes away.
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/cam_shots.lua \
+-- SHOT_DIR=.scratchpad/camshots 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/cam")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[cam] DRAMATIC_SHAPE mod not loaded")
+ return love.event.quit()
+ end
+ local V = handle.lib
+ local FirstPerson = V.require("FirstPerson")
+ local ThirdPerson = V.require("ThirdPerson")
+ local BattleCam = V.require("BattleCam")
+ local OverworldBattle = V.require("OverworldBattle")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+ local DayNight = V.require("DayNight")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ -- The flower's animation frame, pinned per shot rather than left to the
+ -- clock: the mesh spans the union of every frame and each one is cut back
+ -- out in texture space, so a gap that only opens on frame 2 is invisible
+ -- to a driver that always photographs frame 0 -- which is exactly how the
+ -- first cut of the closed sides shipped looking correct.
+ local ANIM = { frame = 0 }
+ TileRenderer.animFrame = function() return ANIM.frame end
+ DayNight.setting:sync("day")
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ local shots = 0
+ local function shot(name)
+ if U.shot(game, ("%s/%s.png"):format(ROOT, name)) then
+ shots = shots + 1
+ end
+ end
+
+ -- ------- the boom's zoom
+ U.teleport(game, "PALLET_TOWN", 13, 14, "down")
+ Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ for _ = 1, 200 do
+ if FirstPerson.blend >= 1 then break end
+ U.wait(1)
+ end
+ FirstPerson.yaw = math.pi
+ U.wait(20)
+ for _, z in ipairs({ "min", "default", "max" }) do
+ ThirdPerson.zoomGoal = (z == "min" and ThirdPerson.ZOOM_MIN)
+ or (z == "max" and ThirdPerson.ZOOM_MAX) or 1
+ for _ = 1, 90 do U.wait(1) end
+ print(("[cam] boom %-8s zoom %.2f len %.1f"):format(z, ThirdPerson.zoom,
+ ThirdPerson.len))
+ shot("boom_" .. z)
+ end
+ ThirdPerson.zoomGoal = 1
+ U.wait(60)
+
+ -- ------- the standees' closed sides
+ --
+ -- Pallet's flower beds and Route 1's tall grass, from a low camera close
+ -- in -- the angle that showed the slabs were open at the ends of every
+ -- run and let you see straight through them.
+ ThirdPerson.zoomGoal = ThirdPerson.ZOOM_MIN
+ for _, s in ipairs({
+ { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
+ pitch = math.rad(6), label = "flowers_low" },
+ { map = "PALLET_TOWN", x = 13, y = 14, yaw = 3 * math.pi / 4,
+ pitch = math.rad(30), label = "flowers_angled" },
+ { map = "ROUTE_1", x = 10, y = 28, yaw = math.pi,
+ pitch = math.rad(8), label = "grass_low" },
+ }) do
+ U.teleport(game, s.map, s.x, s.y, "down")
+ settle()
+ FirstPerson.yaw, FirstPerson.pitch = s.yaw, s.pitch
+ U.wait(60)
+ -- EVERY animation frame, because a gap the union closed and a frame
+ -- reopens is only visible on that frame
+ for f = 0, 3 do
+ ANIM.frame = f
+ U.wait(6)
+ shot(("%s_f%d"):format(s.label, f))
+ end
+ ANIM.frame = 0
+ end
+ ThirdPerson.zoomGoal = 1
+ U.wait(60)
+
+ -- ------- the battle shot
+ --
+ -- Staged the way the game stages one, then steered to each stop with the
+ -- module's own entry points -- the same ones the wheel, the stick and a
+ -- drag reach.
+ Pipelines.setLevel("voxel", 4)
+ U.wait(60)
+ do
+ -- Somebody to fight WITH: a fresh driver save has an empty party, and
+ -- a trainer battle with nobody to send out ends on the frame it starts
+ -- -- which is what made every steered shot below sample a dead session.
+ local Pokemon = require("src.pokemon.Pokemon")
+ game.save.party = {
+ Pokemon.new(game.data, "CHARIZARD", 45),
+ Pokemon.new(game.data, "PIKACHU", 40),
+ }
+ game.save.player.name = "RED"
+ local BattleState = require("src.battle.BattleState")
+ local class = next(game.data.trainers)
+ local battle = BattleState.newTrainer(game, class, 1)
+ battle.onFinish = function() end
+ game.overworld:pushBattle(battle)
+ end
+ -- The wipe, then just enough of the send-out chatter to get both mons
+ -- standing on the arena -- and NOT one tap more. A is also FIGHT and then
+ -- the first move, so tapping past the intro starts an exchange and the
+ -- fight can be over before the camera has been steered anywhere.
+ U.wait(70)
+ for _ = 1, 40 do
+ if OverworldBattle.shot() then break end
+ U.tap(game, "a")
+ U.wait(10)
+ end
+ U.wait(60)
+ print(("[cam] staged: arena %s shot %s top %s")
+ :format(tostring(OverworldBattle.arena() ~= nil),
+ tostring(OverworldBattle.shot() ~= nil),
+ tostring(game.stack:top() ~= game.overworld)))
+ if not OverworldBattle.shot() then
+ print("[cam] no staged battle -- skipping the battle shots")
+ print(("[cam] %d shots into %s"):format(shots, ROOT))
+ return love.event.quit()
+ end
+
+ local function settleCam(label)
+ for _ = 1, 120 do U.wait(1) end
+ print(("[cam] battle %-20s orbit %.2f/%.2f pitch %.2f/%.2f "
+ .. "zoom %.2f/%.2f steerable %s live %s")
+ :format(label, BattleCam.orbit, BattleCam.orbitGoal,
+ BattleCam.pitch, BattleCam.pitchGoal,
+ BattleCam.zoom, BattleCam.zoomGoal,
+ tostring(BattleCam.steerable),
+ tostring(OverworldBattle.shot() ~= nil)))
+ shot("battle_" .. label)
+ end
+
+ BattleCam.recentre()
+ settleCam("home")
+
+ -- right to the stop: this must land SQUARE to the arena's axis
+ BattleCam.dragOrbit(10)
+ settleCam("side_on")
+
+ -- and there is nothing to the left of home
+ BattleCam.recentre()
+ BattleCam.dragOrbit(-10)
+ settleCam("left_stop")
+
+ -- up to the stop, and refusing to go below home
+ BattleCam.recentre()
+ BattleCam.dragPitch(10)
+ settleCam("high")
+ BattleCam.recentre()
+ BattleCam.dragPitch(-10)
+ settleCam("low_stop")
+
+ -- the lens at both ends
+ BattleCam.recentre()
+ for _ = 1, 40 do BattleCam.stepZoom(-1) end
+ settleCam("zoom_in")
+ BattleCam.recentre()
+ for _ = 1, 40 do BattleCam.stepZoom(1) end
+ settleCam("zoom_out")
+
+ -- everything at once, which is the shot a player would actually build
+ BattleCam.recentre()
+ BattleCam.dragOrbit(0.55)
+ BattleCam.dragPitch(0.5)
+ for _ = 1, 4 do BattleCam.stepZoom(-1) end
+ settleCam("steered")
+
+ -- What BACK SPRITES takes away is NOT shot here: the flag it works
+ -- through is re-derived from the row every frame
+ -- (OverworldBattle.update), so a driver cannot hold it down for the
+ -- hundred-odd frames a settled shot needs, and a picture that claimed to
+ -- show a locked camera while the camera was in fact free would be worse
+ -- than no picture. The suite asserts it instead, on both axes and the
+ -- lens, and on the RIG as well as on the inputs.
+ BattleCam.recentre()
+
+ print(("[cam] %d shots into %s"):format(shots, ROOT))
+ love.event.quit()
+end