DramaticShapeVoxelMod archival mirror · read-only
c3ba10e17a2248496ee3f023417ca0b546d3986e

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()