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