DramaticShapeVoxelMod archival mirror · read-only
87a6c03017306072abd19a5c53502046c92b152e

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)