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