added battle transition into overworld
DramaticShape · 2026-07-29 ·
parent 41a86b5fb2
added battle transition into overworld
Changed files
CHANGELOG.md | 22 +++++++
README.md | 8 +++
lib/BattleExit.lua | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
lib/Structures.lua | 10 +++-
main.lua | 18 ++++++
tests/dramatic_shape_test.lua | 102 +++++++++++++++++++++++++++++++
6 files changed, 360 insertions(+), 1 deletion(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e72dee0..f1fe661 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,28 @@
### Added
+- **A fade out of a battle, where there used to be a hard cut.** The engine
+ wipes INTO a fight with one of the original's eight transitions and cuts
+ straight out of it: `BattleState:finish` pops itself and the map is simply
+ there on the next frame. Between a white field and a tile map the original got
+ away with that; between a placed camera looking across an arena and a diorama
+ looking down on a walking player it reads as a glitch. The battle now fades to
+ black, closes behind it, and the map fades up out of it -- twelve frames each
+ way, registered as a `voxel_battle_exit` transitions record so the timing is
+ retunable in data like the wipes it answers.
+
+ Only while voxel mode is on, and then for EVERY battle, including one that
+ found no arena and drew on the flat battle screen: what is being smoothed over
+ is the return to the map, and the map is a diorama either way. With the mode
+ off, the vanilla cut is untouched.
+
+ One black rectangle over the FINISHED composite does the fading, so the world,
+ the letterbox bars and the battle's own text box all darken by the same amount
+ -- the renderer's existing warp-fade overlay is painted between the world and
+ the UI, which would have left the text box bright over the black. A blackout's
+ own warp fade or an evolution prompt still owns the way out when it takes the
+ screen: the fade stops at the cut rather than fading in over the top of it.
+
- **A `FULL` rung on the VOXEL row**, directly after `OFF`. One choice that
puts the whole mode in its intended state -- the 35-degree camera, the
miniature blur at maximum, the horizon flat, the view fitted, and battles
diff --git a/README.md b/README.md
index d379068..f5c8173 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,14 @@ of the two pics' feet, and `WIDE` re-lays that screen out on a 304x144
surface, which moves every anchor it is solved against. Switching **3D-BTL**
off hands the row straight back with `WIDE` selectable again.
+Leaving a battle fades rather than cuts while **VOXEL** is on: the battle
+screen fades to black, closes behind it, and the map fades up out of it. The
+engine wipes *into* a fight and cut straight out of one, which is a big jump
+between the arena's placed camera and the walking diorama. It applies to every
+battle while the mode is on — including one that found no arena and drew on
+the flat battle screen — and not at all while the mode is off. The timing is a
+`voxel_battle_exit` transitions record, so a data pack can retune it.
+
The two HP boxes snap to the window's own edges while a battle is staged —
the foe's to the left, yours to the right — instead of huddling in the middle
of the frame with map showing either side of them. Same tiles, same size,
diff --git a/lib/BattleExit.lua b/lib/BattleExit.lua
new file mode 100644
index 0000000..c60004f
--- /dev/null
+++ b/lib/BattleExit.lua
@@ -0,0 +1,201 @@
+-- Leaving a battle: the fade the way back to the map never had.
+--
+-- Going IN is a whole production -- one of the original's eight wipes, picked by
+-- three bits, over a flash (src/render/BattleTransition.lua). Coming OUT was a
+-- hard cut: BattleState:finish pops itself and the map is simply THERE on the
+-- next frame. On the flat battle screen that is a cut between a white field and
+-- a tile map, which the original got away with. In this mode it is a cut between
+-- a placed camera looking across an arena and a diorama looking down on a
+-- walking player, and a jump that big reads as a glitch rather than as an edit.
+--
+-- So the battle fades out, closes behind the black, and the map fades up out of
+-- it. The timing is a transitions record this mod registers rather than a
+-- constant in here, so it is retunable in data like the engine's own eight.
+--
+-- WHEN. Only while voxel mode is on: this is the diorama's own exit, and a
+-- vanilla battle keeps the cut it always had. While the mode IS on, every battle
+-- gets it -- including one that found no arena and drew on the flat battle
+-- screen -- because what is being smoothed over is the return to the MAP, and
+-- the map is a diorama either way.
+--
+-- HOW IT IS DRAWN, which is the part worth reading. Not by this state: it draws
+-- nothing at all. It owns a NUMBER, and one black rectangle over the FINISHED
+-- composite in a wrap around Renderer:endFrame paints it -- after the world
+-- blit, after the letterbox, after the UI blit, which is the only point where a
+-- single rect covers everything on screen at once.
+--
+-- The renderer's own fade (worldFadeAlpha, which the warp fade uses) is painted
+-- BETWEEN the world and the UI, because a warp has no UI over it. A fade that
+-- borrowed it would darken the arena and leave the battle's text box sitting
+-- bright on top of the black -- and the letterbox bars of a flat battle screen,
+-- painted by the renderer's clear before any state draws, would not darken at
+-- all.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local BattleExit = {}
+BattleExit.__index = BattleExit
+
+-- The battle underneath keeps drawing while this is up -- what fades is its own
+-- last live frame, camera drift, HUD and all. Only the top state UPDATES, so
+-- nothing the battle does can outrun the fade either.
+BattleExit.isOpaque = false
+
+-- The registered record's id, and the fallback timing if it is missing (a
+-- headless caller, or a total conversion that dropped the namespace). Per HALF,
+-- matching the engine's warp fade, so the whole edit is 24 frames.
+BattleExit.ID = "voxel_battle_exit"
+BattleExit.FRAMES = 12
+
+-- The fade in progress, or nil. Kept here rather than on the state so the
+-- endFrame wrap has one place to look and nothing can go stale the frame after
+-- the state leaves the stack.
+local live = nil
+
+-- How black the composite is this frame: 0 on the battle's last live frame, 1 at
+-- the cut, 0 again once the map is up. nil when no fade is running, which is
+-- every other frame the game ever draws.
+function BattleExit.veil()
+ if not live then return nil end
+ -- A fade can be taken off the stack by something that is not the fade: a
+ -- script or a shot driver popping down to the overworld, a state teardown.
+ -- Then it is not running, whatever its counter says -- and a veil left behind
+ -- would black the game out for good, because nothing is going to fade it back
+ -- in. Checked here rather than trusted, because this is the one place the
+ -- answer is used. The walk only happens while a fade is live.
+ local stack = live.game and live.game.stack
+ local states = stack and stack.states
+ local onStack = false
+ for i = #(states or {}), 1, -1 do
+ if states[i] == live then onStack = true break end
+ end
+ if not onStack then live = nil; return nil end
+ local a = live.t / live.frames
+ if live.phase == "in" then a = 1 - a end
+ return math.max(0, math.min(1, a))
+end
+
+local function framesFor(game)
+ local records = game and game.data and game.data.transitions
+ local record = records and records[BattleExit.ID]
+ local frames = record and record.frames
+ if type(frames) == "number" and frames > 0 then return frames end
+ return BattleExit.FRAMES
+end
+
+function BattleExit.new(game, battle, onMidpoint)
+ return setmetatable({ game = game, battle = battle, onMidpoint = onMidpoint,
+ frames = framesFor(game), t = 0, phase = "out" },
+ BattleExit)
+end
+
+-- Push the fade over the battle it is closing.
+function BattleExit.start(battle, onMidpoint)
+ local game = battle.game
+ local self = BattleExit.new(game, battle, onMidpoint)
+ live = self
+ game.stack:push(self)
+ return self
+end
+
+function BattleExit:update()
+ self.t = self.t + 1
+ if self.t < self.frames then return end
+ self.t = 0
+
+ if self.phase == "in" then
+ live = nil
+ self.game.stack:pop()
+ return
+ end
+
+ -- ------- the cut, at full black
+ --
+ -- Off the stack FIRST. BattleState:finish pops whatever is on TOP, and while
+ -- this fade is up that is the fade -- so a fade that stayed would eat the
+ -- battle's own pop and leave the battle running underneath, finished but
+ -- still on the stack. Popping ourselves hands the top back to the battle so
+ -- its pop lands on itself.
+ self.phase = "in"
+ local stack = self.game.stack
+ stack:pop()
+ if self.onMidpoint then self.onMidpoint() end
+ if stack:top() == self.game.overworld then
+ stack:push(self) -- and the map comes up out of it
+ return
+ end
+
+ -- We are not going back to the map after all. Either the battle did not
+ -- actually leave -- finish() can be a false start, and wanted() mirrors the
+ -- one the engine has today -- or something else took the screen on the way
+ -- out: a blackout's own warp fade, an evolution prompt. Whatever it is owns
+ -- the transition from here, so this one ends at the cut instead of fading in
+ -- over the top of it. The flag goes back too, so a second finish() that does
+ -- leave gets its own fade.
+ live = nil
+ if self.battle then self.battle.dramaticShapeLeaving = nil end
+end
+
+-- "Voxel mode is on", as the ENGINE answers it: switched on, not retired by a
+-- fault, and runnable on this machine. A function on the table rather than an
+-- inline call so a driver or a headless test can pin it -- the test harness has
+-- no depth buffer, where the honest answer is no on every rung.
+function BattleExit.modeOn()
+ return require("src.render.Pipelines").eligible("voxel") and true or false
+end
+
+-- Whether this ending gets the fade.
+function BattleExit.wanted(battle)
+ local game = battle and battle.game
+ if not (game and game.stack) then return false end
+ -- finish() is not always the end: an unpaid PAY DAY prints its takings and
+ -- comes back through here a moment later (BattleState:finish's first branch).
+ -- Mirrored read-only, so the fade starts on the call that really leaves rather
+ -- than fading to black and snapping back for one more message.
+ if battle.payDay and battle.result == "win" then return false end
+ return BattleExit.modeOn()
+end
+
+-- ------- engine seams
+--
+-- Two wraps, each idempotent so a hot reload cannot stack them.
+function BattleExit.install()
+ local BattleState = require("src.battle.BattleState")
+ if not BattleState.dramaticShapeExitHook then
+ local inner = BattleState.finish
+ -- The one place a battle ends. Wrapped rather than listened for: the
+ -- battle.ended event is emitted AFTER the pop, and by then the battle
+ -- screen is gone and there is nothing left to fade out.
+ function BattleState:finish()
+ if self.dramaticShapeLeaving or not BattleExit.wanted(self) then
+ return inner(self)
+ end
+ self.dramaticShapeLeaving = true
+ BattleExit.start(self, function() inner(self) end)
+ end
+ BattleState.dramaticShapeExitHook = true
+ end
+
+ local Renderer = require("src.render.Renderer")
+ if not Renderer.dramaticShapeExitHook then
+ local inner = Renderer.endFrame
+ function Renderer:endFrame(zones, worldZones)
+ inner(self, zones, worldZones)
+ local a = BattleExit.veil()
+ if not a or a <= 0 then return end
+ -- The composite is on the screen by now, in LOVE units, so one rect over
+ -- the window darkens the world, the letterbox bars, the text box and
+ -- anything a present pass put on top, all by the same amount. Left to
+ -- last on purpose: this is a shutter closing on the finished frame, not a
+ -- layer inside it.
+ local w, h = love.graphics.getDimensions()
+ love.graphics.setColor(0, 0, 0, a)
+ love.graphics.rectangle("fill", 0, 0, w, h)
+ love.graphics.setColor(1, 1, 1, 1)
+ end
+ Renderer.dramaticShapeExitHook = true
+ end
+end
+
+return BattleExit
diff --git a/lib/Structures.lua b/lib/Structures.lua
index d27b364..bcf93e4 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -2134,7 +2134,8 @@ function Structures.buildObject(S, map, region, cluster,
end
for _, c in ipairs(cluster.tiles) do
local k = keyOf(c[1], c[2])
- if support and support.class == "wall" then
+ if support and (support.class == "wall" or support.class == "cliff"
+ or support.art == "bookcase") then
-- a figure drawn above a FULL-HEIGHT block (the gym statue on its
-- plinth) is a statue on a pillar with ONE cell of footprint: the
-- block below already carries the whole base, so the drawn cell
@@ -2142,6 +2143,13 @@ function Structures.buildObject(S, map, region, cluster,
-- the base backwards. Furniture supports (a monitor on its desk)
-- keep the box-extension below -- their drawn cell is the
-- furniture's own upper rows, and floor there would amputate it.
+ --
+ -- STRUCTURE, not height, decides which: `cliff` and `bookcase` are
+ -- full-height blocks like `wall` and belong here, while `desk` is
+ -- 24px and still furniture. The Plateau's statues on stacked
+ -- pilasters found this -- taking the furniture branch turned each
+ -- statue's own two rows into a 32px box wearing the pilaster's art,
+ -- so every one of them stood inside a slab of its own plinth.
S.skip[k] = true
S.ground[k] = best
elseif support then
diff --git a/main.lua b/main.lua
index e5e35d9..274aec2 100644
--- a/main.lua
+++ b/main.lua
@@ -78,6 +78,7 @@ local ChunkMesher = V.require("ChunkMesher")
local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local OverworldBattle = V.require("OverworldBattle")
+local BattleExit = V.require("BattleExit")
-- Forward declaration: the voxel pipeline's update hook (registered below)
-- calls this, and it is defined further down with the settings it drives.
@@ -672,6 +673,23 @@ mod.events:on("battle.ended", function()
OverworldBattle.finish()
end)
+-- ------- and the way back out
+--
+-- The engine wipes INTO a battle with one of the original's eight transitions
+-- and cuts straight OUT of it. That cut is between two very different cameras
+-- in this mode, so while voxel mode is on the battle fades out, closes behind
+-- the black, and the map fades up. The two seams it needs -- BattleState:finish
+-- and Renderer:endFrame -- and the reasoning for each live in lib/BattleExit.lua.
+--
+-- Declared as a transitions record rather than a constant in that file, so the
+-- fade is retunable in data exactly like the eight wipes it answers, and a total
+-- conversion can make it as long or as short as its own pacing wants.
+mod.content.transitions:register(BattleExit.ID, {
+ frames = BattleExit.FRAMES,
+})
+
+BattleExit.install()
+
mod.exports.version = "1.1.1"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 97fbe18..b2f24d6 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -1567,6 +1567,108 @@ T.check(e[2] + e[4] <= hudRect.player[2],
T.eq(e[1], 0, "the bands are full width")
T.eq(e[3], 160, "so a shaken HUD or a long name is carried out with its block")
+-- ------- the way out of a battle is a fade, not a cut
+--
+-- The engine wipes INTO a battle and cuts straight out of it. While voxel mode
+-- is on that cut is between a placed camera looking across an arena and a
+-- diorama looking down on a walking player, so the battle fades out, closes
+-- behind the black, and the map fades up.
+--
+-- The pop ORDER is the part that has to be right: BattleState:finish pops
+-- whatever is on top, which is the fade while it is up, so the fade has to be
+-- off the stack before the battle finishes and back on it afterwards.
+local Exit = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleExit")
+
+T.eq(Data.transitions and Data.transitions[Exit.ID] and
+ Data.transitions[Exit.ID].frames, Exit.FRAMES,
+ "the fade's timing is a registered transitions record, retunable in data")
+
+local function fakeStack(...)
+ local s = { states = { ... } }
+ function s:top() return self.states[#self.states] end
+ function s:push(state) self.states[#self.states + 1] = state end
+ function s:pop() return table.remove(self.states) end
+ return s
+end
+
+-- headless has no depth buffer, so the real gate answers no on every rung;
+-- pin it, which is what the seam is there for
+local realModeOn = Exit.modeOn
+Exit.modeOn = function() return true end
+
+T.eq(Exit.wanted(nil), false, "no battle, no fade")
+T.eq(Exit.wanted({ game = { stack = {} } }), true, "a battle in voxel mode fades")
+T.eq(Exit.wanted({ game = { stack = {} }, payDay = 100, result = "win" }), false,
+ "but not on an unpaid PAY DAY -- that finish() prints a message and comes "
+ .. "back, so the fade belongs to the call that really leaves")
+Exit.modeOn = function() return false end
+T.eq(Exit.wanted({ game = { stack = {} } }), false,
+ "and with voxel mode off the battle keeps the cut it always had")
+Exit.modeOn = function() return true end
+
+local exitOw = { isOverworld = true }
+local exitGame = { data = Data, overworld = exitOw }
+local exitBattle = { game = exitGame }
+exitGame.stack = fakeStack(exitOw, exitBattle)
+
+local finished = 0
+local fade = Exit.start(exitBattle, function()
+ finished = finished + 1
+ exitGame.stack:pop() -- what BattleState:finish does: pops itself
+end)
+T.eq(exitGame.stack:top(), fade, "the fade goes on top of the battle it closes")
+T.eq(Exit.veil(), 0, "and starts on the battle's own last live frame")
+
+for _ = 1, fade.frames - 1 do fade:update() end
+T.check(Exit.veil() > 0.5, "the veil climbs while the battle is still up")
+T.eq(exitGame.stack:top(), fade, "which is a frozen battle: the fade is on top")
+T.eq(finished, 0, "and nothing has finished yet")
+
+fade:update() -- the frame the cut lands on
+T.eq(finished, 1, "at full black the battle finishes for real")
+T.eq(Exit.veil(), 1, "with the screen fully black over the swap")
+T.eq(#exitGame.stack.states, 2, "the battle left the stack")
+T.eq(exitGame.stack.states[1], exitOw, "the map is under it")
+T.eq(exitGame.stack:top(), fade,
+ "and the fade went back on top of the map to bring it up")
+
+for _ = 1, fade.frames - 1 do fade:update() end
+T.check(Exit.veil() < 0.5, "the veil falls away over the map")
+fade:update()
+T.eq(Exit.veil(), nil, "and the fade is done -- no veil left on the screen")
+T.eq(exitGame.stack:top(), exitOw, "with the map back on top, playable")
+T.eq(finished, 1, "the battle finished exactly once")
+
+-- ------- a blackout (or an evolution prompt) owns the way out itself
+--
+-- Those push their own transition on the way through onFinish, so this fade
+-- stops at the cut rather than fading in over the top of somebody else's.
+local other = { isSomeoneElse = true }
+local blackout = { game = exitGame }
+exitGame.stack = fakeStack(exitOw, blackout)
+local warpFade = Exit.start(blackout, function()
+ exitGame.stack:pop() -- the battle leaves
+ exitGame.stack:push(other) -- and a warp fade takes the screen
+end)
+for _ = 1, warpFade.frames do warpFade:update() end
+T.eq(exitGame.stack:top(), other, "the state that took over is on top")
+T.eq(Exit.veil(), nil, "and this fade let go of the screen at the cut")
+T.eq(blackout.dramaticShapeLeaving, nil,
+ "with the flag cleared, so a finish() that really leaves fades again")
+
+-- ------- a stack cleared from under a fade cannot black the game out
+--
+-- A script (or the shot driver) pops down to the overworld without asking. The
+-- fade is gone, so the veil has to go with it -- nothing is left to fade it in.
+exitGame.stack = fakeStack(exitOw, exitBattle)
+local orphan = Exit.start(exitBattle, function() end)
+orphan:update()
+T.check(Exit.veil() > 0, "a live fade veils the frame")
+while exitGame.stack:top() ~= exitOw do exitGame.stack:pop() end
+T.eq(Exit.veil(), nil, "and a fade popped from under itself veils nothing")
+
+Exit.modeOn = realModeOn
+
Pipelines.reset()
run.release()