From 87a6c03017306072abd19a5c53502046c92b152e Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 8 Aug 2026 09:44:47 -0400 Subject: [PATCH] add ball throw mechanic --- lib/ExpPanel.lua | 98 ++++++++++++++++++++++++++++++++++++ lib/LetsGo.lua | 26 +++++++++- tests/letsgo_trainer_exp.lua | 54 ++++++++++++++++++-- 3 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 lib/ExpPanel.lua diff --git a/lib/ExpPanel.lua b/lib/ExpPanel.lua new file mode 100644 index 0000000..c967027 --- /dev/null +++ b/lib/ExpPanel.lua @@ -0,0 +1,98 @@ +-- LET'S GO: the whole party's experience, on one card. +-- +-- Sharing experience to everybody has a cost the original game never had +-- to pay: six Pokemon means six "X gained N EXP. Points!" boxes for every +-- knockout, each needing its own press. That is the same information the +-- player wanted, delivered in the most tiring possible way -- and it is +-- worse than a wall of text, because the numbers arrive one at a time so +-- the one thing a shared payout is FOR (comparing them: the little one +-- gained five times what the big one did) can never be seen at once. +-- +-- So the per-Pokemon lines are suppressed and this card is shown in their +-- place: one box, one press, every gain side by side, with a level-up +-- called out on the row it happened to. What the card cannot cover still +-- plays as it always did -- "X grew to level 6!", the stats window, and +-- any move learned -- because those are events, not a tally. +-- +-- Drawn with the engine's own font and box, in the GB's own frame, so it +-- sits in a staged 3D battle exactly like every other battle panel. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ExpPanel = {} +ExpPanel.__index = ExpPanel + +local Font = nil +local function font() + if Font ~= nil then return Font or nil end + local ok, F = pcall(require, "src.render.Font") + Font = ok and F or false + return Font or nil +end + +-- the GB frame, in 8-pixel tiles +local TILE = 8 +local COLS, ROWS = 20, 18 +local ROW_H = 12 -- pixels between listed Pokemon + +-- `rows` is filled by the award loop and read HERE, at draw time: the +-- loop runs to completion long before the battle queue reaches this +-- panel, so the table is always complete by the time it is shown. +function ExpPanel.new(game, rows) + return setmetatable({ game = game, rows = rows or {}, t = 0 }, ExpPanel) +end + +function ExpPanel:update(dt) + self.t = (self.t or 0) + (dt or 0) + local input = self.game and self.game.input + if not input then return end + -- a beat of deafness: the press that dismissed whatever came before + -- must not dismiss this card in the same breath + if self.t < 0.12 then return end + if input:wasPressed("a") or input:wasPressed("b") then + self.game.stack:pop() + if self.onDone then self.onDone() end + end +end + +function ExpPanel:draw() + local F = font() + if not F then return end + local rows = self.rows or {} + local n = #rows + if n == 0 then return end + + -- bottom-anchored and only as tall as it needs to be, so a two-Pokemon + -- party does not black out the fight behind it + local th = 3 + math.ceil(n * ROW_H / TILE) + th = math.min(th, ROWS - 1) + local ty = ROWS - th + F.drawBox(0, ty, COLS, th) + + love.graphics.setColor(0, 0, 0, 1) + local x0 = TILE + local y0 = (ty + 1) * TILE + 2 + F.draw("EXP GAINED", x0, y0) + + for i, r in ipairs(rows) do + local y = y0 + ROW_H + (i - 1) * ROW_H + if y > (ROWS - 1) * TILE then break end + local mon = r.mon + local name = mon.nickname + or (self.game.data.pokemon[mon.species] or {}).name + or tostring(mon.species) + F.draw(name, x0, y) + -- a level-up is called out where it happened rather than left to the + -- message that follows, so the card reads as the whole story + if (r.to or 0) > (r.from or 0) then + local up = ("L%d"):format(r.to) + F.draw(up, 108 - F.width(up), y) + end + local amt = ("+%d"):format(r.gained or 0) + F.draw(amt, 152 - F.width(amt), y) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return ExpPanel diff --git a/lib/LetsGo.lua b/lib/LetsGo.lua index e5965a6..b62ad2f 100644 --- a/lib/LetsGo.lua +++ b/lib/LetsGo.lua @@ -324,11 +324,33 @@ function LetsGo.install() -- -- Everything else -- CATCH ONLY, the row switched off, another mod's -- battle -- falls through to the engine's own split untouched. + -- ------- and it is announced ONCE, not once per Pokemon + -- + -- Six party members would otherwise mean six "X gained N EXP. Points!" + -- boxes per knockout. The per-Pokemon lines are suppressed (the `false` + -- to applyShare) and one card is shown instead -- see lib/ExpPanel.lua + -- for why that is better than a faster wall of the same text. + -- + -- The card is queued BEFORE the loop that fills it. That is not a race: + -- applyShare applies its experience immediately and only QUEUES its + -- messages, so the loop runs to completion synchronously here, while + -- the queue does not reach the card's factory until later -- by which + -- time `rows` is complete. Queueing it first is what puts the tally + -- ahead of the "grew to level" chatter it is a summary of. + local ExpPanel = V.require("ExpPanel") local function payParty(ctx, mult) + local battle = ctx.battle + local rows = {} + battle:uiNext(function() return ExpPanel.new(battle.game, rows) end) granting = { mult = mult } local okAward, err = pcall(function() - for _, mon in ipairs(ctx.battle.game.save.party) do - if mon.hp > 0 then ctx.applyShare(mon, 1, true) end + for _, mon in ipairs(battle.game.save.party) do + if mon.hp > 0 then + local exp0, lv0 = mon.exp, mon.level + ctx.applyShare(mon, 1, false) + rows[#rows + 1] = { mon = mon, gained = mon.exp - exp0, + from = lv0, to = mon.level } + end end end) granting = nil diff --git a/tests/letsgo_trainer_exp.lua b/tests/letsgo_trainer_exp.lua index e344d91..f67c222 100644 --- a/tests/letsgo_trainer_exp.lua +++ b/tests/letsgo_trainer_exp.lua @@ -27,6 +27,27 @@ return function(game) LetsGo.setting:setValue(MODE == "off" and false or MODE, game) U.log("LET'S GO mode: " .. tostring(LetsGo.mode())) + -- count the summary cards at their SOURCE rather than by catching them + -- on the stack: the driver taps fast enough to dismiss one between two + -- polls, and an absence there would look like a card that never came + local ExpPanel = lib.require("ExpPanel") + local innerNew, cards = ExpPanel.new, 0 + ExpPanel.new = function(g, rows) + local panel = innerNew(g, rows) + cards = cards + 1 + -- read at DRAW time in the real thing; here the loop that fills it + -- has already run, so peeking now is honest + local parts = {} + for _, r in ipairs(rows or {}) do + parts[#parts + 1] = ("%s+%d%s"):format( + (g.data.pokemon[r.mon.species] or {}).name or "?", r.gained or 0, + (r.to or 0) > (r.from or 0) and ("->L" .. r.to) or "") + end + U.log(("EXP CARD #%d: %s"):format(cards, table.concat(parts, " "))) + CARD_HOLD = 20 -- frames to stop tapping, so it can be shot + return panel + end + -- one strong fighter and two bystanders: only the fighter gains on the -- engine's own rules, so the bystanders ARE the test game.save.party = { @@ -82,10 +103,28 @@ return function(game) -- through the intro to the menu, then FIGHT + first move, over and -- over until the fight resolves. The enemy's HP is logged as it goes, -- so a fight that stalls is visibly a stall rather than a silent zero. - local lastHP = nil + local lastHP, shotPanel = nil, false for i = 1, 900 do - if battle.result then break end - if battle.phase == "menu" then + -- the fight ending does NOT end the loop until the card has been + -- photographed: the last knockout resolves the battle in the same + -- breath that queues the card, so breaking on `result` alone leaves + -- every run with the card built and never seen + if battle.result and shotPanel then break end + -- hold the taps while the card is up, so it can be shot rather than + -- dismissed on the next frame + if (CARD_HOLD or 0) > 0 then + CARD_HOLD = CARD_HOLD - 1 + if not shotPanel then + local top = game.stack and game.stack:top() + if top and rawget(top, "rows") then + shotPanel = true + U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad") + .. "/exp_panel.png") + U.log("shot the card") + end + end + U.wait(1) + elseif battle.phase == "menu" then battle.menuIndex = 1 -- FIGHT U.tap(game, "a") elseif battle.phase == "moveSelect" then @@ -100,6 +139,15 @@ return function(game) U.log((" foe HP -> %s (phase %s, step %d)") :format(tostring(hp), tostring(battle.phase), i)) end + -- photograph the summary card the moment it is on top, then let the + -- taps carry on and dismiss it + local top = game.stack and game.stack:top() + if top and not shotPanel and top ~= battle and rawget(top, "rows") then + shotPanel = true + U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad") + .. "/exp_panel.png") + U.log("shot the card") + end U.wait(5) end U.log("battle result: " .. tostring(battle.result)