added battle transition into overworld

This commit is contained in:
DramaticShape
2026-07-29 19:09:48 -04:00
parent 41a86b5fb2
commit c3ba10e17a
6 changed files with 360 additions and 1 deletions
+22
View File
@@ -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
+8
View File
@@ -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,
+201
View File
@@ -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
+9 -1
View File
@@ -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
+18
View File
@@ -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
+102
View File
@@ -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()