-- The battle HUD, drawn from the cart's own tiles. -- -- The HUD is not lines and boxes an engine invents: it is tiles the cart loads -- into fixed VRAM slots (engine/gfx/load_font.asm LoadBattleFontsHPBar / -- LoadHPBar), placed at fixed tile coordinates. Drawing it from those tiles is -- what makes it align on the 8px grid by construction rather than by eye, and -- it is why "HP:" looks like the cart's bold glyph instead of three font -- letters. -- -- FontBattleExtra -> $60 "HP:" is $60/$61; the bar's cells are $62 -- (empty) through $6a (8 pixels of fill), and -- $6b is the bar's right end cap -- EnemyHPBarBorderGFX -> $6c 4 tiles: $6d left side, $6f bottom left -- HPExpBarBorderGFX -> $73 6 tiles: $73 right side, $74 bottom left, -- $76 bottom side, $77 / $78 bottom right -- ExpBarGFX -> $55 9 exp-bar fill cells -- -- The HP-bar cells are 2bpp: the bar's rule is shade 3 and its fill shades 1-2, -- so they are coloured through palettes.hpBar (gfx/battle/hp_bar.pal) the same -- way the cart colours PAL_BATTLE_BG_PLAYER_HP. The two border sheets are -- 1bpp and draw black. local Assets = require("src.render.Assets") local GbcPalette = require("src.render.GbcPalette") local HpBar = require("src.battle.gen2.HpBar") local BattleHud = {} BattleHud.__index = BattleHud -- Tile ids, so the arithmetic below reads as the ASM does. local TILE_HP_LABEL = 0x60 -- and $61 local TILE_BAR_EMPTY = 0x62 -- $62..$6a is 0..8 pixels of fill local TILE_BAR_END = 0x6b local FIRST_BATTLE_EXTRA = 0x60 -- Enemy border sheet ($6c..$6f) and player border sheet ($73..$78). local ENEMY_BORDER_FIRST = 0x6c local PLAYER_BORDER_FIRST = 0x73 local TILE_ENEMY_LEFT = 0x6d local TILE_ENEMY_BOTTOM_LEFT = 0x74 -- from the player sheet, per the ASM local TILE_ENEMY_BOTTOM_RIGHT = 0x78 local TILE_BOTTOM_SIDE = 0x76 local TILE_PLAYER_RIGHT = 0x73 local TILE_PLAYER_BOTTOM_RIGHT = 0x77 local TILE_PLAYER_BOTTOM_LEFT = 0x6f -- DrawEnemyHUDBorder's tail: ExpBarGFX's 9th tile, the caught mark -- (engine/battle/trainer_huds.asm:143-152). local TILE_CAUGHT = 0x5d function BattleHud.new(menuGfx, palettes) local self = setmetatable({}, BattleHud) self.gfx = menuGfx and menuGfx.battleHud or nil self.palettes = palettes self.images = {} self.quads = {} return self end function BattleHud:image(key) local path = self.gfx and self.gfx[key] if not path then return nil end local cached = self.images[path] if cached == nil then local ok, image = pcall(Assets.image, path) cached = ok and image or false self.images[path] = cached end return cached or nil end -- One 8x8 tile out of a horizontal strip, cached per (sheet, index). function BattleHud:quad(image, index) local key = tostring(image) .. ":" .. index local quad = self.quads[key] if not quad then local w, h = image:getDimensions() quad = love.graphics.newQuad(index * 8, 0, 8, 8, w, h) self.quads[key] = quad end return quad end function BattleHud:available() return self:image("hpBar") ~= nil end -- The 4-colour palette the HP-bar cells draw with: white, the bar's own light -- colour, the state's fill colour, black -- which is HPBarPals' two colours -- bracketed the way every Gen 2 palette is. function BattleHud:barColors(key) local pal = self.palettes and self.palettes.hpBar and self.palettes.hpBar[key] if not pal then return nil end return { { 255, 255, 255 }, { pal[1][1], pal[1][2], pal[1][3] }, { pal[2][1], pal[2][2], pal[2][3] }, { 0, 0, 0 }, } end -- Draw a run of tiles from a sheet whose first tile is `firstTile`, colouring -- with `colors` when one is given. function BattleHud:drawTile(key, firstTile, tile, tx, ty, colors, mirror) local image = self:image(key) if not image then return false end local index = tile - firstTile if index < 0 then return false end local G = love.graphics G.setColor(1, 1, 1, 1) local function body() if mirror then -- Flip in place: the origin moves a tile right and x scales by -1. G.draw(image, self:quad(image, index), tx * 8 + 8, ty * 8, 0, -1, 1) else G.draw(image, self:quad(image, index), tx * 8, ty * 8) end end if colors and GbcPalette.available() then GbcPalette.with(colors, body) else G.setColor(0, 0, 0, 1) body() G.setColor(1, 1, 1, 1) end return true end -- Six bar cells at (tx, ty), no label and no end cap. -- -- This is DrawBattleHPBar itself: the party menu calls it with `ld d, $6` and -- `ld b, $0` (PlacePartyHPBar), so a party row's bar is literally the battle -- HUD's bar minus the "HP:" prefix -- same tiles, same HPBarPals colour, same -- one-pixel-at-a-time fill. Sharing this method is what keeps the two screens -- from ever disagreeing about how full a bar looks. function BattleHud:drawBar(hp, maxHp, tx, ty) local pixels = HpBar.pixels(hp, maxHp) local colors = self:barColors(HpBar.palette(pixels)) for cell = 0, HpBar.LENGTH_TILES - 1 do local remaining = pixels - cell * 8 local filled = math.max(0, math.min(8, remaining)) self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_BAR_EMPTY + filled, tx + cell, ty, colors) end return tx + HpBar.LENGTH_TILES end -- "HP:" plus the six bar cells plus the end cap, starting at tile (tx, ty). -- Returns the column just past the assembly (tx + 9), so the caller can put the -- frame's vertical stub there. function BattleHud:drawHpBar(hp, maxHp, tx, ty) local pixels = HpBar.pixels(hp, maxHp) local colors = self:barColors(HpBar.palette(pixels)) -- The "HP:" badge sits inside the bar's own attrmap region, so it wears the -- HP palette too: its background is HPBarPals' light colour (the cream the -- cart shows) and its letters are black. Drawing it white-on-black was the -- one place this HUD invented a colour instead of reading one. self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_HP_LABEL, tx, ty, colors) self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_HP_LABEL + 1, tx + 1, ty, colors) self:drawBar(hp, maxHp, tx + 2, ty) self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_BAR_END, tx + 2 + HpBar.LENGTH_TILES, ty, colors) return tx + 3 + HpBar.LENGTH_TILES end -- The exp bar, transcribed from FillInExpBar / PlaceExpBar rather than guessed -- from the tile art: -- -- FillInExpBar starts at (10,11), adds 7 to reach the RIGHTMOST tile, then -- PlaceExpBar writes 8 tiles walking LEFT (ld [hld]): -- * while at least 8 pixels remain: tile $6a, the HP bar's full cell -- * the leftover 1..7 pixels: tile $54 + remainder, i.e. $55..$5b from -- ExpBarGFX -- which is why those cells anchor their fill to the right, -- against the full cells beside them -- * every remaining cell: tile $62, the HP bar's empty cell -- -- So the bar is eight tiles wide (64 pixels), it grows from the RIGHT, and two -- of its three tiles come from FontBattleExtra rather than ExpBarGFX. Getting -- any of those three facts wrong is what makes it look like dashes. BattleHud.EXP_CELLS = 8 BattleHud.EXP_LENGTH_PX = BattleHud.EXP_CELLS * 8 local TILE_EXP_FULL = 0x6a -- FontBattleExtra local TILE_EXP_EMPTY = 0x62 -- FontBattleExtra local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX function BattleHud:drawExpBar(fraction, tx, ty) if not self:image("hpBar") then return false end fraction = math.max(0, math.min(1, fraction or 0)) local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX) -- The whole row wears the exp bar's palette, full and empty cells included. local pal = self.palettes and self.palettes.expBar local colors = pal and { { 255, 255, 255 }, { pal[1][1], pal[1][2], pal[1][3] }, { pal[2][1], pal[2][2], pal[2][3] }, { 0, 0, 0 }, } or nil local remaining = pixels for cell = BattleHud.EXP_CELLS - 1, 0, -1 do local column = tx + cell if remaining >= 8 then remaining = remaining - 8 self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_EXP_FULL, column, ty, colors) elseif remaining > 0 then self:drawTile("expBar", self.gfx.expBarFirstTile, EXP_PARTIAL_BASE + remaining, column, ty, colors) remaining = 0 else self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_EXP_EMPTY, column, ty, colors) end end return true end -- The mark sits inside the enemy HP block, which the battle attrmap fills with -- PAL_BATTLE_BG_ENEMY_HP (engine/gfx/cgb_layouts.asm:123-128). function BattleHud:drawCaughtIcon(tx, ty, hp, maxHp) local first = self.gfx and self.gfx.expBarFirstTile if not first or (self.gfx.expBarCells or 0) < 9 then return false end local colors = self:barColors(HpBar.palette(HpBar.pixels(hp, maxHp))) if not colors then return false end return self:drawTile("expBar", first, TILE_CAUGHT, tx, ty, colors) end -- Both frames come out of PlaceHUDBorderTiles, which lays four tiles in a -- fixed pattern from one starting coordinate: -- -- tiles[0] (side) at the start -- tiles[1] (near corner) one row BELOW it, same column -- tiles[3] (bottom side) x8, stepping by de (+1 right, -1 left) -- tiles[2] (far corner) one step past that run -- -- Getting the corner's row wrong is what leaves the vertical stub floating -- clear of the bottom rule instead of joined to it. function BattleHud:placeBorder(tiles, tx, ty, step) -- side self:drawTile(tiles.sideSheet, tiles.sideFirst, tiles.side, tx, ty) -- near corner, one row down self:drawTile(tiles.cornerSheet, tiles.cornerFirst, tiles.nearCorner, tx, ty + 1) -- eight bottom-side tiles, then the far corner local x = tx for _ = 1, 8 do x = x + step self:drawTile(tiles.cornerSheet, tiles.cornerFirst, tiles.bottom, x, ty + 1) end x = x + step self:drawTile(tiles.farSheet or tiles.cornerSheet, tiles.farFirst or tiles.cornerFirst, tiles.farCorner, x, ty + 1) end -- DrawEnemyHUDBorder: hlcoord 1, 2 stepping right, tiles $6d / $74 / $78 / $76. function BattleHud:drawEnemyFrame() self:placeBorder({ sideSheet = "enemyBorder", sideFirst = ENEMY_BORDER_FIRST, cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST, side = TILE_ENEMY_LEFT, nearCorner = TILE_ENEMY_BOTTOM_LEFT, farCorner = TILE_ENEMY_BOTTOM_RIGHT, bottom = TILE_BOTTOM_SIDE, }, 1, 2, 1) end -- DrawPlayerHUDBorder: hlcoord 18, 10 stepping LEFT, tiles $73 / $77 / $6f / -- $76, plus the extra vertical bar DrawPlayerHUD writes at (18,9) so the stub -- is two rows tall. function BattleHud:drawPlayerFrame() self:drawTile("playerBorder", PLAYER_BORDER_FIRST, TILE_PLAYER_RIGHT, 18, 9) self:placeBorder({ sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST, cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST, -- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet -- (engine/gfx/load_font.asm:57-65). farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST, side = TILE_PLAYER_RIGHT, nearCorner = TILE_PLAYER_BOTTOM_RIGHT, farCorner = TILE_PLAYER_BOTTOM_LEFT, bottom = TILE_BOTTOM_SIDE, }, 18, 10, -1) end BattleHud.TILE_HP_LABEL = TILE_HP_LABEL BattleHud.TILE_BAR_EMPTY = TILE_BAR_EMPTY BattleHud.TILE_BAR_END = TILE_BAR_END return BattleHud