force 3d battle ui, initial victory road updates

This commit is contained in:
DramaticShape
2026-07-29 18:45:00 -04:00
parent 097bdcd14b
commit 9a34ba9c77
8 changed files with 664 additions and 156 deletions
+64 -5
View File
@@ -201,6 +201,23 @@ local function frostRect(rect, box)
return fx, fy, math.max(1, fw), math.max(1, fh)
end
-- The same map for a rect that is ALREADY in world-canvas pixels. A HUD
-- snapped out to the window's edge has left the GB frame, so it has no GB
-- coordinates to be placed from -- see OverworldBattle.snapRects.
local function frostRectWorld(rect, box)
local kx = frostW / box.pw
local ky = frostH / box.ph
return rect[1] * kx, rect[2] * ky,
math.max(1, rect[3] * kx), math.max(1, rect[4] * ky)
end
-- Which of the two the caller's rects are in. One frost buffer, one panel
-- draw, two coordinate spaces: the GB frame (rects land in the 160x144 UI
-- canvas) or world pixels (rects land in the window-resolution world image).
local function mapper(world)
return world and frostRectWorld or frostRect
end
-- ------- the verdict
--
-- ONE answer for the whole frame, not one per panel. Both HUDs draw in a
@@ -211,11 +228,12 @@ end
-- the tint below then commits both panels to that reading.
local wasDark = false
function BattleHud.verdict(rects, box)
function BattleHud.verdict(rects, box, world)
if not (frost and box and box.scale and box.scale > 0) then return false end
local toFrost = mapper(world)
local darkest = nil
for key, rect in pairs(rects) do
local fx, fy, fw, fh = frostRect(rect, box)
local fx, fy, fw, fh = toFrost(rect, box)
local v = sampleLuma(key, fx, fy, fw, fh)
if v and (not darkest or v < darkest) then darkest = v end
end
@@ -230,14 +248,16 @@ function BattleHud.verdict(rects, box)
return wasDark
end
-- Draw one HUD panel into the current target, in GB coordinates.
-- Draw one HUD panel into the current target, in that target's own
-- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true)
-- for a panel laid straight onto the world image.
--
-- The tint always pushes AWAY from the glyph colour that is about to be
-- used, so the contrast is guaranteed rather than hoped for: a dark panel
-- gets darker under white text, a bright one brighter under black text.
function BattleHud.panel(rect, box, dark)
function BattleHud.panel(rect, box, dark, world)
if not (frost and box and box.scale and box.scale > 0) then return false end
local fx, fy, fw, fh = frostRect(rect, box)
local fx, fy, fw, fh = mapper(world)(rect, box)
local ok = pcall(function()
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
love.graphics.setColor(1, 1, 1, BattleHud.FROST)
@@ -330,6 +350,44 @@ function BattleHud.flipGlyphs(w, h, fn)
love.graphics.setShader()
end
-- ------- the whole HUD layer as a texture
--
-- The two blocks do not sit in the same place any more: each is snapped to its
-- own side of the WINDOW, which is outside the 160x144 canvas the engine draws
-- them in (see OverworldBattle.snapRects). A draw cannot be aimed at two
-- places at once, so the layer is rendered ONCE into a GB-sized canvas and
-- each block is then blitted out of it as a quad.
--
-- `dark` runs the ink through the same flip the in-frame HUD uses, here baked
-- into the texture rather than composited into the caller's target -- the world
-- image the quads land on is a colour canvas, and a flip pass over it would
-- whiten the terrain behind the glyphs along with them.
local hudLayer = nil
function BattleHud.layerTexture(w, h, dark, fn)
if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then
hudLayer = canvasOf(w, h, "nearest")
if not hudLayer then return nil end
end
local g = love.graphics
local prevCanvas = g.getCanvas()
local prevBlend, prevAlpha = g.getBlendMode()
local ok, err = pcall(function()
g.setCanvas(hudLayer)
g.clear(0, 0, 0, 0)
g.setBlendMode("alpha")
g.setColor(1, 1, 1, 1)
-- flipGlyphs renders fn into its own scratch layer and composites the
-- whitened result into whatever is bound, which is this canvas
if dark then BattleHud.flipGlyphs(w, h, fn) else fn() end
end)
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
g.setBlendMode(prevBlend or "alpha", prevAlpha)
g.setColor(1, 1, 1, 1)
if not ok then error(err, 0) end
return hudLayer
end
-- The last luminance measured, for the shot driver's log.
function BattleHud.lastLuma()
local best = nil
@@ -344,6 +402,7 @@ function BattleHud.invalidate()
frostW, frostH = 0, 0
luma = {}
wasDark = false
layer, hudLayer = nil, nil
end
return BattleHud
+191 -2
View File
@@ -140,6 +140,54 @@ OverworldBattle.HUD_RECT = {
player = { 72, 56, 88, 40 },
}
-- ------- the HUDs, out at the window's own edges
--
-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
-- whole of it. That left both HUD blocks huddled together in the middle of the
-- frame with map showing on either side of them, which reads as a Game Boy
-- screenshot pasted over a diorama rather than as the diorama's own furniture.
--
-- So each block is snapped to its own side: the foe's to the left edge of the
-- window, the player's to the right. Nothing about either block changes -- same
-- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar
-- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape
-- of the GB screen there is nowhere to go and the snap is a no-op.
--
-- They cannot simply be MOVED there: the engine draws them into the 160x144 UI
-- canvas and everything outside it is clipped away. So the layer is rendered to
-- a texture and composited into the WORLD image instead, which is the one
-- surface in this mode that covers the whole window.
-- The rows each block is cut out of, full width. Generous on purpose:
-- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches
-- further than the panel does, and the pokeball rows and the safari ball count
-- belong to the block whose rows they sit in. Nothing drawHUDs draws lies
-- outside rows 0-96, and the two bands split that between them.
OverworldBattle.HUD_BAND = {
enemy = { 0, 0, 160, 48 },
player = { 0, 48, 160, 48 },
}
-- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted
-- glass is cut to, plus the x its band is blitted at.
--
-- The foe's panel starts at the window's left edge and the player's ends at the
-- right one. The vertical is untouched, so both stay on the rows the GB put
-- them on. A band's own origin sits outside the window by the panel's inset --
-- the couple of pixels a HUD shake can push past the edge are clipped there,
-- which is the whole cost of the snap and is invisible.
function OverworldBattle.snapRects(shot)
local s = shot.scale
local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player
local ex = -e[1] * s -- foe: panel's left edge to 0
local px = shot.pw - (p[1] + p[3]) * s -- player: right edge to the far side
local rects = {
enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s },
player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s },
}
return rects, { enemy = ex, player = px }
end
-- ------- the live battle
--
-- nil when no overworld battle is running. Never more than one: battles do
@@ -150,6 +198,13 @@ local function game()
return require("src.core.Game")
end
-- Whether this frame's HUDs went out to the window's edges instead of being
-- drawn in the GB frame. False whenever the composite could not be made, which
-- is what leaves the in-frame HUD as the fallback rather than no HUD at all.
local function snapped()
return (session and session.snapped) and true or false
end
-- Put the map's cast back. Both lists are handed back by identity, so
-- anything that captured one before the battle still sees the same table.
local function restoreCast()
@@ -174,6 +229,36 @@ local function cullCast(state)
state.ghosts = {}
end
-- ------- one right battle layout
--
-- Everything this file composes is measured in the GB's own 160x144 frame: the
-- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs
-- the frosted panels are cut to, and the full-frame white intercepted to let
-- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a
-- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those
-- -- the mons would stand where no camera was solved for them, and the panels
-- would land beside the HUDs they are supposed to be under.
--
-- So while a fight can be staged on the map there is one right answer, and it
-- is SET rather than worked around. The engine reads the option live
-- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the
-- top state for its surface the same way), so writing it here lands on the
-- battle being pushed as well as every one after it.
--
-- This is the last line rather than the first: the OPTIONS menu takes the row
-- off the list and pins the value while 3D-BTL is on (see main.lua), so a
-- player is never offered a switch that gets reverted under them. What reaches
-- here is a value that arrived some other way -- a save written before the mod
-- was installed, the mod manager's own page, another mod.
function OverworldBattle.forceOG(g)
g = g or game()
local opts = g and g.save and g.save.options
if not opts or opts.battleLayout ~= "wide" then return false end
opts.battleLayout = "og"
if g.writeOptions then pcall(g.writeOptions, g) end
return true
end
-- Stage a battle triggered from `state`, if this mode can. Returns true when
-- a session started -- which is also the only case where anything visible
-- changes, so a map with no room for an arena plays exactly the vanilla
@@ -189,6 +274,10 @@ function OverworldBattle.begin(state, battle)
state.player.surfing)
if not (ok and arena) then return false end
-- the fight is staged from here on, so the layout it is composed for is not
-- optional any more (see forceOG)
OverworldBattle.forceOG()
session = { state = state, arena = arena, battle = battle, shot = nil,
armed = false, token = 0 }
cullCast(state)
@@ -276,11 +365,13 @@ function OverworldBattle.update(dt)
-- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
-- guard, which retires a pipeline for the session.
session.shot = nil
session.snapped = false
session.broken = true
V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
.. "on the plain battle background", tostring(shot))
return
end
session.snapped = false
if shot and shot.canvas then
-- the depth of field is measured off the two marks: the slab in focus is
-- the one the mons are standing in, at whatever the drift has done to
@@ -295,6 +386,20 @@ function OverworldBattle.update(dt)
-- so a panel over a blurred far field is frosted from what is actually
-- behind it
pcall(BattleHud.build, shot.canvas)
-- and then the HUDs go ON that backdrop, snapped out to the window's own
-- edges (snapHUDs). Here rather than in the battle's draw for the same
-- reason the scene is: it binds a canvas of its own. After the frost, so
-- the glass is frosted from the world alone and never from the glyphs
-- about to sit on it.
local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
session.snapped = (okHud and up) and true or false
-- once per battle, not once per frame: a driver that cannot do this cannot
-- do it sixty times a second either, and the fallback is silent and fine
if not okHud and not session.hudWarned then
session.hudWarned = true
V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
.. "in the battle frame this battle", tostring(up))
end
end
session.shot = shot
end
@@ -394,6 +499,7 @@ local texturing = nil
local texCanvas = {}
local innerPics = nil -- captured by install()
local innerHUDs = nil -- likewise, for the snapped HUD layer
local function texCanvasFor(side)
local c = texCanvas[side]
@@ -695,8 +801,12 @@ function OverworldBattle.install()
--
-- The HP bar is untouched: it is drawn in its own greens and reds, and
-- only an exactly-black set is remapped.
local innerHUDs = BattleState.drawHUDs
innerHUDs = BattleState.drawHUDs
function BattleState:drawHUDs(slide)
-- Normally the HUDs have already been drawn this frame, snapped out to the
-- window's edges and composited into the world image (snapHUDs). Drawing
-- them here as well would show each block twice, once in each place.
if self.dramaticShapeShot and snapped() then return end
if not (self.dramaticShapeShot and self.dramaticShapeDark) then
return innerHUDs(self, slide)
end
@@ -726,12 +836,91 @@ function OverworldBattle.hudLive(battle, slide)
return enemy and true or false, player and true or false
end
-- ------- the snapped composite
--
-- The engine's own HUD layer, rendered into a texture.
--
-- One thing is falsified for the render, and it is falsified because this layer
-- never reaches the battle's zone pass -- it is composited into the world image,
-- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves
-- the HP bar's fill as DMG gray for the zone pass to colour by region (#229);
-- answered false, it tints its own greens and reds instead, exactly as it does
-- on the flat path. The glyphs are pure black either way, which is what the
-- flip in BattleHud.layerTexture is measured against.
--
-- Shadowed on the instance for this call only, the way drawZonePass shadows
-- activeBgp: putting the field back to whatever it was (normally nil) lets the
-- class method be found again.
function OverworldBattle.hudTexture(battle, slide, dark)
if not (innerHUDs and battle) then return nil end
local had = rawget(battle, "colorMode")
battle.colorMode = function() return false end
local ok, layer = pcall(BattleHud.layerTexture,
BattleScene.GB_W, BattleScene.GB_H, dark,
function() innerHUDs(battle, slide) end)
battle.colorMode = had
return ok and layer or nil
end
-- Draw both HUD blocks into the world image at the window's edges, each on its
-- own frosted panel. Returns true when the frame's HUDs are up there and the
-- in-frame draw must be skipped; false leaves the battle screen's own HUD
-- exactly as it was before any of this existed.
--
-- Both bands are blitted whether or not that side's HUD is LIVE, because a band
-- carries more than the HUD: the pokeball rows of the intro and of an enemy
-- faint, and the safari ball count, all draw in these rows and belong at the
-- same edge as the block they share it with. The panels are the ones that
-- follow hudLive -- frosted glass under nothing is a slab floating in the arena.
function OverworldBattle.snapHUDs(battle, shot)
if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then
return false
end
local slide = (battle.introSlide or 0) * 4
local rects, bandX = OverworldBattle.snapRects(shot)
local enemy, player = OverworldBattle.hudLive(battle, slide)
local live = {}
if enemy then live.enemy = rects.enemy end
if player then live.player = rects.player end
-- measured under the SNAPPED rects: the panels are over whatever the world
-- shows at the window's edges now, which is not what was behind them in the
-- middle of the frame
local dark = BattleHud.verdict(live, shot, true)
local layer = OverworldBattle.hudTexture(battle, slide, dark)
if not layer then return false end
local g = love.graphics
local prevCanvas = g.getCanvas()
local prevBlend, prevAlpha = g.getBlendMode()
local ok, err = pcall(function()
g.setCanvas(shot.canvas)
g.setBlendMode("alpha")
for _, rect in pairs(live) do BattleHud.panel(rect, shot, dark, true) end
g.setColor(1, 1, 1, 1)
for side, band in pairs(OverworldBattle.HUD_BAND) do
local quad = g.newQuad(band[1], band[2], band[3], band[4],
BattleScene.GB_W, BattleScene.GB_H)
g.draw(layer, quad, bandX[side] + band[1] * shot.scale,
shot.ly + band[2] * shot.scale, 0, shot.scale, shot.scale)
end
end)
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
g.setBlendMode(prevBlend or "alpha", prevAlpha)
g.setColor(1, 1, 1, 1)
if not ok then error(err, 0) end
return true
end
-- Lay the frosted glass down under whichever HUD is about to draw, and
-- record which way the glyphs have to flip.
--
-- The fallback path only: with the HUDs snapped out to the window's edges their
-- panels went with them, and there is nothing left inside the GB frame to lay
-- glass under.
function OverworldBattle.drawHudPanels(battle)
local shot = battle.dramaticShapeShot
battle.dramaticShapeDark = nil
if not shot then return end
if not shot or snapped() then return end
local slide = (battle.introSlide or 0) * 4
local enemy, player = OverworldBattle.hudLive(battle, slide)
if not (enemy or player) then return end
+7
View File
@@ -54,6 +54,12 @@ local FALLBACK_HEIGHTS = {
sign = 12,
wall = 16,
tree = 16,
-- masonry drawn TWO courses tall: the Indigo Plateau's rim and the
-- badge-check gates down Route 23 are drawn 32px, the same height as a
-- statue on its plinth, and read as a step in the terrain rather than a
-- room's wall. Same fold as `wall`, twice the height -- and its own
-- class because `wall` is 16px for every interior in the game.
cliff = 32,
roof = 28,
cylinder = 16,
-- big round scenery: a 2x2-CELL drawing carved as ONE 32px voxel hull
@@ -110,6 +116,7 @@ local ART = {
ledge = "top",
roof = "top",
wall = "upright",
cliff = "upright",
tree = "upright",
fence = "upright",
sign = "upright",