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
+26
View File
@@ -43,6 +43,32 @@
### Changed
- **The two HP boxes snap to the window's edges during a staged battle.** The
battle screen is 160x144 in the middle of the window and the world is the
whole of it, which left both HUD blocks huddled together in the middle of the
frame with map showing on either side of them -- a Game Boy screenshot pasted
over a diorama rather than the diorama's own furniture. The foe's block now
sits against the left edge and the player's against the right, on the same
frosted glass, with the same tiles at the same size on the same rows. The
pokeball rows and the safari ball count travel with the block whose rows they
share. On a window shaped like the GB screen there is nowhere to go and
nothing moves.
The engine draws them into the 160x144 canvas, which clips at its own edges,
so the layer is rendered to a texture and composited into the world image --
the one surface here that covers the whole window. A driver that cannot do
that falls back to the HUD in the frame rather than to no HUD.
- **`BATTLE LAYOUT` is pinned to `OG` while battles are staged on the map**, and
the row comes off the OPTIONS menu with the rows `FULL` owns. The staged shot
is composed in the GB's own frame -- the arena camera is solved to put a cell
under each pic's feet, and the HUD rects and the intercepted background fill
are measured there too -- and `WIDE` re-lays that screen out on a 304x144
surface, moving every one of them. Set rather than worked around, on every
route in: the options row, hotkey `8`, the mod manager's page, `FULL`'s
preset, and a save that arrived with `WIDE` already on. Switching `3D-BTL`
off hands the row back with `WIDE` selectable again.
- **Hotkey `3` walks the angle rungs only and steps over `FULL`.** The key is
a display-mode cycler -- it should change the camera and nothing else --
and `FULL` reaches in and rewrites four other settings. Landing on it
+15
View File
@@ -38,6 +38,21 @@ menu.
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
on the world whether or not the free-roam camera is pitched over.
While a battle can be staged on the map — **3D-BTL** on, or **VOXEL** on
`FULL`, which owns that row — the engine's **BATTLE LAYOUT** row is set to
`OG` and taken off the OPTIONS menu. The staged shot is composed in the Game
Boy's own 160x144 frame: the arena camera is solved to put a cell under each
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.
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,
same rows, on the same frosted glass; only the pair's position changes. On a
window shaped like the GB screen there is nowhere to snap to and nothing
moves.
## Where a battle is staged
The mod looks for the nearest clearing shaped like this, where every `x` is
+162 -141
View File
@@ -102,6 +102,9 @@ return {
fence = 10,
sign = 12,
wall = 16,
-- masonry drawn two courses tall (the Indigo Plateau's rim, the
-- badge-check gates): as tall as a statue on its plinth
cliff = 32,
tree = 16,
roof = 28,
bed = 7,
@@ -2258,6 +2261,165 @@ return {
-- south-east). Neither map places them; they belong with the
-- wall band above if one ever does.
},
-- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the
-- forecourt the League itself stands on) and ROUTE_23 (the long
-- climb up to it, with its badge-check gates). Everything this
-- blockset draws is one piece of architecture -- striated rock
-- walls, white pillars, and the bird STATUES that line both the
-- avenue and the plaza. 29 of its 73 blocks are never placed, so
-- every tile named below really is one of these two maps'.
--
-- A statue is built exactly like the badge gyms' (see GYM above):
-- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of
-- PLINTH ($15/$16 the cap over $30/$31 the plaque). 47 of them --
-- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more
-- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that
-- stand the same statue on grass are never placed).
--
-- What the detector made of them is the bug this entry exists for.
-- On the avenue the statues stack with NO gap: the plinth's plaque
-- row is drawn directly above the next figure's head, so the
-- flood-fill joined all six of a column into ONE region 24 tile rows
-- tall, and the volume builder's repeat scan read 32px down one half
-- of the drawing and 24px down the other. Each row came out as a
-- continuous stepped RIDGE of boxes wearing the statue art folded
-- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35)
-- at 32/24 and (22,12)-(23,35) mirrored. ROUTE_23's plaza statues
-- stand alone and fared no better: 24px boxes with the figure's top
-- row skipped outright.
--
-- Pinned the gyms' way the ridge becomes statues. The plinth is a
-- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels
-- deep (the thin `prop` pool) that rides the plinth's top face
-- through the authored-box support rule and collapses to the
-- plinth's SINGLE cell of footprint -- Structures' wall-support case,
-- so the base never marches backwards.
--
-- The one thing the gyms did not have to deal with: $28/$29 is
-- SHARED. The same bird is drawn again at the foot of every white
-- pillar, framed there by the pillar's black edge ($25/$26 over that
-- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and
-- four on INDIGO_PLATEAU (its two outer corners and the pair
-- flanking the League's recess). So $25/$26 joins the same pool:
-- pinning half a cell would have stood a half-height bird under a
-- wall. That in turn is why the pillar itself is pinned -- see the
-- last paragraph of `wall`.
PLATEAU = {
-- ONE 16px course for every piece of masonry here.
--
-- $03 is the striated rock face, 2436 placements and the bulk of
-- both maps. It already read 16 nearly everywhere, but in the
-- columns of INDIGO_PLATEAU's rim that stand over a corner pillar
-- the repeat scan came out 24 -- a stagger in the plateau's
-- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and
-- their two mirrors). Authored, the rim is one course.
--
-- $0D/$0F/$0E are the League's outer wall -- top band, face and
-- base. The same three tiles draw the Pokemon League's own
-- facade, the long walls flanking the avenue, and every
-- badge-check gate down ROUTE_23.
--
-- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and
-- the plaque base. $15/$16 over $30/$31 IS the statue's plinth --
-- the artist drew the same stone twice -- which is why one pin
-- serves the gate corners and the statues alike.
--
-- $2E/$2F the white pillar shaft and $20/$21 its cap change no
-- HEIGHT: they derive 16 already. What the pin buys is
-- `authored`, which is exactly what the prop support rule tests.
-- Without it a pillar-foot bird finds no support, drops to ground
-- level, and leaves a hole punched clean through the pillar
-- (probed, shot, then fixed).
-- TWO courses (32px) for the masonry that ENCLOSES both maps -- the
-- plateau's rim and every wall around the terraces. It is drawn two
-- cells tall, which is exactly the height of a statue on its plinth
-- (a 16px plinth under a 16px standee), and that is the read: you are
-- walking in a walled compound whose wall matches the statues lining
-- it, not a room with a 16px skirting. At one course the rim was a
-- kerb you appeared to look over.
--
-- $03 the striated rock face, $0D/$0F/$0E the League's outer wall
-- (top band, face, base -- the same three tiles draw the League's
-- facade, the walls flanking the avenue and every badge-check gate
-- down ROUTE_23), and $2E/$2F the white pillar shaft with $20/$21 its
-- cap. These four groups are the enclosure.
cliff = { 3,
13, 14, 15,
32, 33, 46, 47 },
-- ONE course, and it must stay one: $15/$16 + $05/$06 + $30/$31 is
-- the pilaster -- cap, shaft, plaque base -- and $15/$16 over $30/$31
-- IS the statue's plinth, the artist having drawn the same stone
-- twice. Raising it would carry every statue standee up to 48px and
-- break the very match the cliff height was chosen for.
wall = { 21, 22, 48, 49, 5, 6 },
-- The statues, and the same bird at the pillar feet. Black-outline
-- segmented: the outline and everything it encloses stay, the sky
-- and the paving around them flood away.
prop = { 16, 18, 40, 41, 37, 38 },
-- Round drawings, one voxel ball per 16x16 cell -- the treatment
-- the overworld's canopies and Celadon's hedge take.
--
-- $07/$08 over $17/$18: the seven canopies planted on pillar tops
-- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of
-- them together is never placed). Boxed, the canopy art smeared
-- down the whole pillar column beneath it.
--
-- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's
-- middle terrace (blocks $02/$13/$16), one per cell and NOT
-- walkable. As boxes they sat flat enough to look painted onto
-- the path; as balls they read as the obstacles they are.
cylinder = { 7, 8, 23, 24,
29, 34, 42, 43 },
-- The Route 23 sign, one cell, block $48's only placement.
-- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub
-- with its board skipped. Same thin plate on a stick every other
-- outdoor sign gets.
signpost = { 9, 10, 25, 26 },
-- The Route 22 gate's roof, drawn from ABOVE and filling
-- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its
-- edges, $40/$41 the corners). Art on the TOP face -- the way
-- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb.
-- It stands past the map's last walkable row (you warp to
-- ROUTE_22_GATE before you reach it), so this is a tidy-up rather
-- than a fix.
roof = { 61, 62, 64, 65, 68 },
-- The ground painted where a pinned figure's cell used to be:
-- $23, the pale paving both maps are floored with. It is also the
-- white the pillars are drawn in, so the cell a pillar-foot bird
-- vacates reads as more pillar -- where the neighbour vote left a
-- BLACK hole, having nothing to elect (all four neighbours of that
-- cell are wall). On the avenue and the plaza the statues' own
-- cells simply keep the paving they stand on.
prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35,
[37] = 35, [38] = 35 },
-- Deliberately NOT pinned:
--
-- $14, the water -- 1446 placements, the pond on ROUTE_23's middle
-- terrace -- and its bank shading $32/$33/$1F. $14 and $32 are
-- two of the three stale-cache water ids the trap is named for,
-- but here they are honestly water: TILEANIM_WATER animates $14,
-- the pond is real, and $32/$33/$1F are drawn in the TOP half of
-- water cells as the waterline itself, so the cell rule dropping
-- them to -2 is what the art means. Left to fall through to the
-- engine's water set. ($48, the third trap id, is not in this
-- atlas at all -- it stops at $45.)
--
-- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two
-- and Victory Road's two. They are the tileset's own doorTiles,
-- so the door fold already stands them upright in the facade;
-- probed 16px identically before and after this entry.
--
-- $23/$2C/$2D are in the walkable list outright, and $45 is the
-- tileset's grassTile, which derives its own standing-tuft pin.
--
-- The Victory Road entrance is a `buildings` template
-- (victory_road_gate, at the bottom of this file): 36x6 tiles at
-- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29
-- and $15/$16/$05/$06. Buildings claim their tiles before any of
-- the above can reach them -- probed `b` class over all 216 of
-- them, unchanged by this entry.
},
},
-- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
@@ -3003,146 +3165,5 @@ return {
slab = 4, frontEave = 4, ledge = nil,
},
},
-- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the
-- forecourt the League itself stands on) and ROUTE_23 (the long
-- climb up to it, with its badge-check gates). Everything this
-- blockset draws is one piece of architecture -- striated rock
-- walls, white pillars, and the bird STATUES that line both the
-- avenue and the plaza. 29 of its 73 blocks are never placed, so
-- every tile named below really is one of these two maps'.
--
-- A statue is built exactly like the badge gyms' (see GYM above):
-- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of
-- PLINTH ($15/$16 the cap over $30/$31 the plaque). 47 of them --
-- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more
-- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that
-- stand the same statue on grass are never placed).
--
-- What the detector made of them is the bug this entry exists for.
-- On the avenue the statues stack with NO gap: the plinth's plaque
-- row is drawn directly above the next figure's head, so the
-- flood-fill joined all six of a column into ONE region 24 tile rows
-- tall, and the volume builder's repeat scan read 32px down one half
-- of the drawing and 24px down the other. Each row came out as a
-- continuous stepped RIDGE of boxes wearing the statue art folded
-- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35)
-- at 32/24 and (22,12)-(23,35) mirrored. ROUTE_23's plaza statues
-- stand alone and fared no better: 24px boxes with the figure's top
-- row skipped outright.
--
-- Pinned the gyms' way the ridge becomes statues. The plinth is a
-- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels
-- deep (the thin `prop` pool) that rides the plinth's top face
-- through the authored-box support rule and collapses to the
-- plinth's SINGLE cell of footprint -- Structures' wall-support case,
-- so the base never marches backwards.
--
-- The one thing the gyms did not have to deal with: $28/$29 is
-- SHARED. The same bird is drawn again at the foot of every white
-- pillar, framed there by the pillar's black edge ($25/$26 over that
-- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and
-- four on INDIGO_PLATEAU (its two outer corners and the pair
-- flanking the League's recess). So $25/$26 joins the same pool:
-- pinning half a cell would have stood a half-height bird under a
-- wall. That in turn is why the pillar itself is pinned -- see the
-- last paragraph of `wall`.
PLATEAU = {
-- ONE 16px course for every piece of masonry here.
--
-- $03 is the striated rock face, 2436 placements and the bulk of
-- both maps. It already read 16 nearly everywhere, but in the
-- columns of INDIGO_PLATEAU's rim that stand over a corner pillar
-- the repeat scan came out 24 -- a stagger in the plateau's
-- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and
-- their two mirrors). Authored, the rim is one course.
--
-- $0D/$0F/$0E are the League's outer wall -- top band, face and
-- base. The same three tiles draw the Pokemon League's own
-- facade, the long walls flanking the avenue, and every
-- badge-check gate down ROUTE_23.
--
-- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and
-- the plaque base. $15/$16 over $30/$31 IS the statue's plinth --
-- the artist drew the same stone twice -- which is why one pin
-- serves the gate corners and the statues alike.
--
-- $2E/$2F the white pillar shaft and $20/$21 its cap change no
-- HEIGHT: they derive 16 already. What the pin buys is
-- `authored`, which is exactly what the prop support rule tests.
-- Without it a pillar-foot bird finds no support, drops to ground
-- level, and leaves a hole punched clean through the pillar
-- (probed, shot, then fixed).
wall = { 3,
13, 14, 15,
21, 22, 48, 49, 5, 6,
32, 33, 46, 47 },
-- The statues, and the same bird at the pillar feet. Black-outline
-- segmented: the outline and everything it encloses stay, the sky
-- and the paving around them flood away.
prop = { 16, 18, 40, 41, 37, 38 },
-- Round drawings, one voxel ball per 16x16 cell -- the treatment
-- the overworld's canopies and Celadon's hedge take.
--
-- $07/$08 over $17/$18: the seven canopies planted on pillar tops
-- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of
-- them together is never placed). Boxed, the canopy art smeared
-- down the whole pillar column beneath it.
--
-- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's
-- middle terrace (blocks $02/$13/$16), one per cell and NOT
-- walkable. As boxes they sat flat enough to look painted onto
-- the path; as balls they read as the obstacles they are.
cylinder = { 7, 8, 23, 24,
29, 34, 42, 43 },
-- The Route 23 sign, one cell, block $48's only placement.
-- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub
-- with its board skipped. Same thin plate on a stick every other
-- outdoor sign gets.
signpost = { 9, 10, 25, 26 },
-- The Route 22 gate's roof, drawn from ABOVE and filling
-- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its
-- edges, $40/$41 the corners). Art on the TOP face -- the way
-- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb.
-- It stands past the map's last walkable row (you warp to
-- ROUTE_22_GATE before you reach it), so this is a tidy-up rather
-- than a fix.
roof = { 61, 62, 64, 65, 68 },
-- The ground painted where a pinned figure's cell used to be:
-- $23, the pale paving both maps are floored with. It is also the
-- white the pillars are drawn in, so the cell a pillar-foot bird
-- vacates reads as more pillar -- where the neighbour vote left a
-- BLACK hole, having nothing to elect (all four neighbours of that
-- cell are wall). On the avenue and the plaza the statues' own
-- cells simply keep the paving they stand on.
prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35,
[37] = 35, [38] = 35 },
-- Deliberately NOT pinned:
--
-- $14, the water -- 1446 placements, the pond on ROUTE_23's middle
-- terrace -- and its bank shading $32/$33/$1F. $14 and $32 are
-- two of the three stale-cache water ids the trap is named for,
-- but here they are honestly water: TILEANIM_WATER animates $14,
-- the pond is real, and $32/$33/$1F are drawn in the TOP half of
-- water cells as the waterline itself, so the cell rule dropping
-- them to -2 is what the art means. Left to fall through to the
-- engine's water set. ($48, the third trap id, is not in this
-- atlas at all -- it stops at $45.)
--
-- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two
-- and Victory Road's two. They are the tileset's own doorTiles,
-- so the door fold already stands them upright in the facade;
-- probed 16px identically before and after this entry.
--
-- $23/$2C/$2D are in the walkable list outright, and $45 is the
-- tileset's grassTile, which derives its own standing-tuft pin.
--
-- The Victory Road entrance is a `buildings` template
-- (victory_road_gate, at the bottom of this file): 36x6 tiles at
-- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29
-- and $15/$16/$05/$06. Buildings claim their tiles before any of
-- the above can reach them -- probed `b` class over all 216 of
-- them, unchanged by this entry.
},
},
}
+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",
+60 -8
View File
@@ -281,9 +281,26 @@ applyFull = function(level)
-- half of it is spent. Set rather than forced -- the row is gone from the
-- menu while FULL is on, but a save that already had it off gets it on.
OverworldBattle.setting:setIndex(1, Game)
-- and the battle screen the staged fight is composed for. WIDE re-lays that
-- screen out on a 304x144 surface, which moves every anchor the arena camera
-- is solved against (OverworldBattle.forceOG); FULL has just switched staged
-- fights on, so the layout follows them.
OverworldBattle.forceOG(Game)
if Game.writeOptions then pcall(Game.writeOptions, Game) end
end
-- Whether a fight can be staged on the map, as far as the OPTIONS menu is
-- concerned: 3D-BTL is on, or FULL is selected -- which owns that row and
-- switches it on. Deliberately NOT gated on Voxel3D.available(): the engine
-- offers a pipeline's row whether or not the hardware can run it
-- (Pipelines.rows), so this mode's rows say ON on a machine without a depth
-- buffer too, and a menu that claims 3D battles are on must not also offer the
-- layout they cannot be drawn in.
local function stagedBattles()
local Pipelines = require("src.render.Pipelines")
return OverworldBattle.enabled() or Voxel.isFull(Pipelines.level("voxel"))
end
local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
{ WorldCurve.setting,
@@ -393,6 +410,11 @@ do
-- when the fight starts, so flipping it from inside one would be a
-- switch that appeared to do nothing.
claim:cycle(self)
-- 8 is one of the two ways staged battles get switched on, and they
-- pin BATTLE LAYOUT to OG (see the rows hook). The other two keys
-- parameterise the pass and leave the layout alone; the guard answers
-- for all three, so nothing here has to know which key it was.
if stagedBattles() then OverworldBattle.forceOG(self) end
return
end
end
@@ -442,6 +464,17 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
local out = next(game, rows)
if type(out) ~= "table" then return out end
local Pipelines = require("src.render.Pipelines")
-- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
-- one away. While a fight can be staged on the map, OG is the only layout it
-- can be composed in (OverworldBattle.forceOG), so the value is pinned there
-- and the row comes off the list on the same reasoning as the rows FULL owns:
-- a row that no longer decides anything is worse than no row. Nothing is
-- lost by switching 3D-BTL off -- the row is back, WIDE and all, on the same
-- keypress.
if stagedBattles() then
OverworldBattle.forceOG(game)
dropRow(out, "battleLayout")
end
if Voxel.isFull(Pipelines.level("voxel")) then
return dropRow(out, "pipeline:tiltshift")
end
@@ -457,6 +490,10 @@ mod.events:on("mod.options_changed", function(payload)
for _, entry in ipairs(SETTINGS) do
if payload.key == entry[1].key then entry[1]:sync(payload.value) end
end
-- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as
-- the OPTIONS row does. The manager persists its own value; this is the one
-- that has to follow it.
if stagedBattles() then OverworldBattle.forceOG() end
end)
-- ------- keeping the geometry in step with the world
@@ -539,7 +576,7 @@ mod.events:on("map.reloaded", function(payload)
if mapId then ChunkMesher.invalidate(mapId) end
end)
-- ------- FULL takes rows off the menu, so the menu has to notice
-- ------- rows come and go, so the menu has to notice
--
-- OptionsMenu builds its row list ONCE, when it is opened, and then reads
-- that list every frame. So stepping the VOXEL row onto or off FULL changed
@@ -547,25 +584,40 @@ end)
-- settings FULL owns stayed visible until the menu was closed and reopened,
-- and a player who stepped off FULL could not see the rows come back.
--
-- Rebuilt in place, and only on a step that crosses FULL: every other rung
-- returns the same list, and rebuilding on all of them would rerun every
-- mod's ui.options.rows hook once per keypress. The cursor is clamped rather
-- than reset, so it stays on the VOXEL row it was just used on instead of
-- jumping to the top when the list below it shortens.
-- Rebuilt in place, and only on a step that changes the LIST: crossing FULL,
-- or toggling 3D-BTL, which is the other row that owns one (BATTLE LAYOUT).
-- Every other rung returns the same list, and rebuilding on all of them would
-- rerun every mod's ui.options.rows hook once per keypress. The cursor is
-- clamped rather than reset, so it stays on the row it was just used on
-- instead of jumping to the top when the list below it shortens.
do
local OptionsMenu = require("src.ui.OptionsMenu")
if not OptionsMenu.dramaticShapeFullHook then
local Pipelines = require("src.render.Pipelines")
local inner = OptionsMenu.update
local function idAt(menu, index)
local row = menu.rows and menu.rows[index or 1]
return type(row) == "table" and row.id or nil
end
function OptionsMenu:update(dt)
local before = Pipelines.level("voxel")
local hadBattles = OverworldBattle.enabled()
local wasOn = idAt(self, self.index)
inner(self, dt)
local after = Pipelines.level("voxel")
if after ~= before
and (Voxel.isFull(before) or Voxel.isFull(after)) then
local crossedFull = after ~= before
and (Voxel.isFull(before) or Voxel.isFull(after))
if crossedFull or OverworldBattle.enabled() ~= hadBattles then
local rebuilt = OptionsMenu.new(self.game)
self.rows = rebuilt.rows
-- Follow the row the cursor was ON rather than the slot it was in:
-- 3D-BTL takes BATTLE LAYOUT off the list ABOVE itself, which would
-- otherwise slide the cursor onto the row under the one just used.
for i = 1, #self.rows do
if wasOn and idAt(self, i) == wasOn then self.index = i; break end
end
local cancel = #self.rows + 1
if (self.index or 1) > cancel then self.index = cancel end
end
+139
View File
@@ -135,6 +135,62 @@ T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID")
T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE")
T.check(not fullIds["DRAMATIC_SHAPE:battles"], "and 3D-BTL")
-- ------- BATTLE LAYOUT is pinned to OG while a fight can be staged on the map
--
-- The staged battle is composed in the GB's own 160x144 frame: the anchors the
-- arena camera is solved to put a cell under, the HUD rects the frosted panels
-- are cut to, the full-frame white intercepted to let the world through. WIDE
-- lays the same battle out on a 304x144 surface and moves every one of them. So
-- the value is set and the ENGINE's row comes off the menu -- the one row this
-- mod takes away that is not its own.
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
T.eq(Battles.enabled(), true, "3D-BTL is on by default, which is what pins it")
-- off FULL first: the row on its own has to be enough, and FULL is checked
-- separately below
Pipelines.setLevel("voxel", 2)
local layoutGame = {
data = Data,
save = { options = { battleLayout = "wide", pipelines = {}, modOptions = {} } },
mods = { modOptions = {} },
writeOptions = function() end,
}
local pinned = Runtime.call("ui.options.rows", function(_, r) return r end,
layoutGame,
{ { id = "battleLayout" }, { id = "tilt" },
{ id = "pipeline:voxel" } })
local pinnedIds = {}
for _, row in ipairs(pinned) do pinnedIds[row.id] = true end
T.check(not pinnedIds["battleLayout"],
"with staged battles on, BATTLE LAYOUT is off the menu")
T.eq(layoutGame.save.options.battleLayout, "og",
"and a save that had WIDE is set to OG -- the only layout the shot composes in")
-- switching 3D-BTL off hands the row straight back, WIDE and all
Battles.setting:setIndex(2, layoutGame)
T.eq(Battles.enabled(), false, "3D-BTL off")
local handedBack = Runtime.call("ui.options.rows", function(_, r) return r end,
layoutGame,
{ { id = "battleLayout" }, { id = "tilt" },
{ id = "pipeline:voxel" } })
local backIds = {}
for _, row in ipairs(handedBack) do backIds[row.id] = true end
T.check(backIds["battleLayout"], "the engine's row is back on the menu")
layoutGame.save.options.battleLayout = "wide"
Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
{ { id = "battleLayout" } })
T.eq(layoutGame.save.options.battleLayout, "wide",
"and WIDE is left alone once no battle can be staged on the map")
-- FULL owns the 3D-BTL row, so it pins the layout even with that row switched
-- off underneath it
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
{ { id = "battleLayout" } })
T.eq(layoutGame.save.options.battleLayout, "og",
"FULL pins the layout on its own, because it owns the row that would")
Battles.setting:setIndex(1, layoutGame)
-- ------- and off FULL, the rows come back, grouped with the mode
--
-- The engine splices a pipeline row in beside TILT and lands a mod's own
@@ -200,6 +256,29 @@ T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"),
"and the rows came straight back without reopening the menu")
T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too")
-- ------- 3D-BTL owns BATTLE LAYOUT, and takes it off the OPEN menu too
--
-- The row this one takes away sits ABOVE it in the list, so the cursor has to
-- follow the row it was ON rather than the slot it was in -- otherwise the very
-- press that switched staged battles on would leave the cursor a row further
-- down than the player left it.
Battles.setting:setIndex(2, menuGame) -- staged battles off
menuGame.save.options.battleLayout = "wide"
Pipelines.setLevel("voxel", 2)
local layoutMenu = OptionsMenu.new(menuGame)
T.check(rowIndex(layoutMenu, "battleLayout"),
"with staged battles off, the engine's BATTLE LAYOUT row is on the menu")
layoutMenu.index = rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles")
pressed = { right = true }
layoutMenu:update(0)
pressed = {}
T.eq(Battles.setting:get(), true, "the step switched staged battles on")
T.check(not rowIndex(layoutMenu, "battleLayout"),
"and BATTLE LAYOUT left the open menu with the same keypress")
T.eq(menuGame.save.options.battleLayout, "og", "pinned to OG on the way out")
T.eq(layoutMenu.index, rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles"),
"with the cursor still on the row the player just used")
-- level 2 is the "15" rung: any rung that is not FULL, so the settings the
-- preset owns are back on the menu
Pipelines.setLevel("voxel", 2)
@@ -1428,6 +1507,66 @@ T.check(range > 0, "with a ramp out of it, so the band edge has no seam")
local wide = select(2, BattleDOF.bandFor(120, 30, 144))
T.check(wide > band, "marks further apart hold a deeper slab in focus")
-- ------- the HUDs are snapped to the window's own edges
--
-- The battle screen is 160x144 in the middle of the window and the world is the
-- whole of it, which left both HUD blocks huddled in the middle of the frame
-- with map on either side of them. Each is snapped to its own side instead: the
-- foe's to the left edge, the player's to the right. Measured in world-canvas
-- pixels, because that is the surface they are composited into -- the GB canvas
-- they are drawn in cannot reach past its own 160 columns.
local hudShot = { lx = 100, ly = 12, scale = 3, pw = 1000, ph = 500 }
local hudRects, bandX = Battles.snapRects(hudShot)
local hudRect = Battles.HUD_RECT
T.eq(hudRects.enemy[1], 0, "the foe's panel starts at the window's left edge")
T.eq(hudRects.player[1] + hudRects.player[3], hudShot.pw,
"and the player's ends at the right one")
T.check(hudRects.enemy[1] < hudShot.lx,
"so the foe's block has left the letterbox it used to sit in")
T.check(hudRects.player[1] > hudShot.lx + hudRect.player[1] * hudShot.scale,
"and the player's has gone the other way")
-- the vertical is untouched: both blocks stay on the rows the GB put them on
T.eq(hudRects.enemy[2], hudShot.ly + hudRect.enemy[2] * hudShot.scale,
"the foe's block keeps its own rows")
T.eq(hudRects.player[2], hudShot.ly + hudRect.player[2] * hudShot.scale,
"and so does the player's")
-- and their size: a block is the same GB tiles at the same scale as the rest of
-- the art, moved and not stretched
T.eq(hudRects.enemy[3], hudRect.enemy[3] * hudShot.scale,
"a block is its own width at the frame's scale")
T.eq(hudRects.player[4], hudRect.player[4] * hudShot.scale,
"and its own height")
-- the band each block is cut out of is placed so the block lands on the rect
-- above; that is what the panel and the glyphs agreeing depends on
T.eq(bandX.enemy + hudRect.enemy[1] * hudShot.scale, hudRects.enemy[1],
"the foe's band is offset so its block lands on its panel")
T.eq(bandX.player + hudRect.player[1] * hudShot.scale, hudRects.player[1],
"and the player's likewise")
-- a window the shape of the GB screen has nowhere to snap TO, and the player's
-- block already ends at column 160, so it must not move at all
local snug = { lx = 0, ly = 0, scale = 4, pw = 160 * 4, ph = 144 * 4 }
local snugRects = Battles.snapRects(snug)
T.eq(snugRects.player[1], hudRect.player[1] * snug.scale,
"on a GB-shaped window the player's block stays exactly where it was")
T.eq(snugRects.enemy[1], 0, "and the foe's is flush with a left edge it already met")
-- the bands together cover every row drawHUDs draws into (0-96: the two HUDs,
-- the pokeball rows and the safari ball count) and never overlap, so nothing it
-- draws is dropped or shown twice
local e, p = Battles.HUD_BAND.enemy, Battles.HUD_BAND.player
T.eq(e[2], 0, "the foe's band starts at the top of the frame")
T.eq(e[2] + e[4], p[2], "the player's picks up exactly where it ends")
T.check(p[2] + p[4] >= 96, "and together they reach the bottom of the HUD rows")
T.check(e[2] + e[4] <= hudRect.player[2],
"the split falls between the two blocks, so neither is cut in half")
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")
Pipelines.reset()
run.release()