diff --git a/docs/modding.md b/docs/modding.md index cfb70eb0..ee6094d8 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -495,6 +495,21 @@ Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call` already falls straight through to the vanilla function when no mod has wrapped the name, at negligible cost. +## Detached Pokémon icon presentation + +`mod.ui.PokemonIcon.draw(game, summary, x, y, opts)` draws the same party icon +the native Party menu would resolve without exposing a live Pokémon record or +the private Party menu. `summary` is the detached data-only shape +`{ species = string, hp = integer, maxHp = integer }`; `opts.selected` and +`opts.counter` optionally request the native selected-icon animation phase. + +The engine retains icon ownership. Content registered through +`mod.content.icons`, species `icon` definitions, asset overrides, and the +public `pokemon.icon` hook therefore continue to compose. Invalid summaries +return `false, code, message` and draw nothing. The helper is presentation +only: it does not expose moves, status, checkpoint payloads, or mutable party +state. + ## Shared date and time presentation The global Options menu owns `DATE FORMAT` (`DEVICE`, `DD-MM-YYYY`, diff --git a/src/ui/ModUI.lua b/src/ui/ModUI.lua index f04c1488..aa1a75f1 100644 --- a/src/ui/ModUI.lua +++ b/src/ui/ModUI.lua @@ -12,6 +12,7 @@ local MODULES = { QuantityBox = "src.ui.QuantityBox", NamingScreen = "src.ui.NamingScreen", PicBox = "src.ui.PicBox", + PokemonIcon = "src.ui.PokemonIcon", TextBox = "src.render.TextBox", Font = "src.render.Font", Theme = "src.ui.Theme", diff --git a/src/ui/PokemonIcon.lua b/src/ui/PokemonIcon.lua new file mode 100644 index 00000000..e2cd3c28 --- /dev/null +++ b/src/ui/PokemonIcon.lua @@ -0,0 +1,41 @@ +-- Public read-only Pokemon icon presentation for detached summaries. +-- Resolution and rendering deliberately stay engine-owned: PartyMenu already +-- composes content icon registrations, per-species definitions, asset +-- overrides, and the pokemon.icon hook in one canonical path. + +local PartyMenu = require("src.ui.PartyMenu") + +local PokemonIcon = {} + +local function finite(value) + return type(value) == "number" and value == value + and value ~= math.huge and value ~= -math.huge +end + +local function integer(value, minimum) + return finite(value) and value % 1 == 0 and value >= minimum +end + +function PokemonIcon.draw(game, summary, x, y, opts) + opts = type(opts) == "table" and opts or {} + if type(game) ~= "table" or type(summary) ~= "table" + or type(summary.species) ~= "string" or summary.species == "" + or not integer(summary.hp, 0) or not integer(summary.maxHp, 1) + or summary.hp > summary.maxHp or not finite(x) or not finite(y) + or (opts.selected ~= nil and type(opts.selected) ~= "boolean") + or (opts.counter ~= nil and not finite(opts.counter)) then + return false, "invalid_pokemon_preview", + "Pokemon icon presentation needs species and valid captured HP values." + end + local ok, message = pcall(PartyMenu.drawIcon, game, { + species = summary.species, + hp = summary.hp, + stats = { hp = summary.maxHp }, + }, x, y, opts.selected == true, opts.counter or 0) + if not ok then + return false, "pokemon_icon_failed", tostring(message) + end + return true +end + +return PokemonIcon diff --git a/tests/modkit/cases/pokemon_icon.lua b/tests/modkit/cases/pokemon_icon.lua new file mode 100644 index 00000000..55687296 --- /dev/null +++ b/tests/modkit/cases/pokemon_icon.lua @@ -0,0 +1,60 @@ +-- Public read-only Pokemon icon presentation delegates to the same resolver +-- PartyMenu uses, so content registrations and pokemon.icon hooks compose. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local PartyMenu = require("src.ui.PartyMenu") + +local FIXTURE = { + ["mods/icon_probe/manifest.json"] = [[{ + "id": "icon_probe", + "name": "Icon Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/icon_probe/main.lua"] = [[ + local mod = ... + mod.exports.icon = mod.ui.PokemonIcon + ]], +} + +local run = T.sdk.loadMods({ "mods/icon_probe" }, { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, "fixture mod loads cleanly") +local icon = run.loader.exports.icon_probe.icon +T.eq(type(icon), "table", "mod.ui exposes the PokemonIcon helper") +T.eq(type(icon.draw), "function", "PokemonIcon exposes a draw operation") + +local original = PartyMenu.drawIcon +local call +PartyMenu.drawIcon = function(game, mon, x, y, selected, counter) + call = { game = game, mon = mon, x = x, y = y, + selected = selected, counter = counter } +end + +local game = { data = {} } +local drawn, code = icon.draw(game, { + species = "PIKACHU", hp = 4, maxHp = 10, +}, 8, 16, { selected = true, counter = 7 }) +T.eq(drawn, true, "valid detached Pokemon summary is drawable") +T.eq(code, nil, "valid summary has no rejection code") +T.check(call and call.game == game, "helper delegates with the live game") +T.eq(call.mon.species, "PIKACHU", "species reaches the shared party resolver") +T.eq(call.mon.hp, 4, "captured current HP reaches icon animation semantics") +T.eq(call.mon.stats.hp, 10, "captured maximum HP reaches icon animation semantics") +T.eq(call.selected, true, "selection state is presentation-only") +T.eq(call.counter, 7, "animation counter is presentation-only") + +call = nil +local bad, badCode = icon.draw(game, { + species = "PIKACHU", hp = 11, maxHp = 10, +}, 0, 0) +T.eq(bad, false, "invalid detached summary fails closed") +T.eq(badCode, "invalid_pokemon_preview", "invalid summary has a stable error") +T.eq(call, nil, "invalid summary never reaches renderer internals") + +PartyMenu.drawIcon = original +run.release() + +T.finish("pokemon_icon")