force 3d battle ui, initial victory road updates
DramaticShape · 2026-07-29 ·
parent 097bdcd14b
force 3d battle ui, initial victory road updates
Changed files
CHANGELOG.md | 26 ++++++
README.md | 15 ++++
data/voxel_heights.lua | 303 +++++++++++++++++++++++++++++++++-----------------------------
lib/BattleHud.lua | 69 ++++++++++++--
lib/OverworldBattle.lua | 193 ++++++++++++++++++++++++++++++++++++++-
lib/TileShape.lua | 7 ++
main.lua | 68 ++++++++++++--
tests/dramatic_shape_test.lua | 139 ++++++++++++++++++++++++++++
8 files changed, 664 insertions(+), 156 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5be5443..e72dee0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index b85e313..d379068 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index 106406c..1c372d4 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -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.
- },
},
}
diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua
index f88f264..34432e7 100644
--- a/lib/BattleHud.lua
+++ b/lib/BattleHud.lua
@@ -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
diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua
index f3998f0..83e669d 100644
--- a/lib/OverworldBattle.lua
+++ b/lib/OverworldBattle.lua
@@ -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
diff --git a/lib/TileShape.lua b/lib/TileShape.lua
index 199bed3..31ac92b 100644
--- a/lib/TileShape.lua
+++ b/lib/TileShape.lua
@@ -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",
diff --git a/main.lua b/main.lua
index faf2595..e5e35d9 100644
--- a/main.lua
+++ b/main.lua
@@ -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
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 582a4d8..97fbe18 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -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()