add ball throw mechanic
DramaticShape · 2026-08-08 ·
parent f0d5ca570a
add ball throw mechanic
Changed files
lib/ExpPanel.lua | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
lib/LetsGo.lua | 26 +++++++++++++++--
tests/letsgo_trainer_exp.lua | 54 +++++++++++++++++++++++++++++++++--
3 files changed, 173 insertions(+), 5 deletions(-)
Diff
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)