DramaticShapeVoxelMod archival mirror · read-only
lib/LetsGo.lua

LetsGo.lua

lib/LetsGo.lua19.1 KB100644e2075ef4a7
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460
-- LET'S GO: the row, the modes, and every engine seam the capture game
-- stands on.
--
-- Three rungs:
--
--   OFF         nothing changes. The default, and what an unrecognised
--               stored value falls back to.
--   FULL        the whole Let's Go treatment. A wild encounter opens
--               STRAIGHT into capture mode (B backs out to the classic
--               menu for anyone who came to fight), Poke/Great/Ultra
--               Balls are half price at every mart, and EXPERIENCE works
--               the way that game's does: every healthy party member
--               gains from every catch AND every trainer knockout, each
--               measured against its own level. A catch adds the throw
--               stack on top -- grade, first throw, new species, combo.
--   CATCH ONLY  the fights are untouched and the shops are untouched;
--               the one change is that throwing a ball -- from the bag,
--               or a SAFARI BALL from the safari menu -- runs the throw
--               minigame instead of the automatic toss. The minigame's
--               grade still folds into the Gen 1 catch roll (a good
--               throw should matter or the ring is a lie), but nothing
--               outside the throw changes.
--
-- The capture game itself lives in lib/CatchThrow.lua and the ball it
-- throws in lib/Pokeball.lua; this file is the wiring: the ModSetting,
-- the two BattleState wraps that intercept a ball being thrown, the
-- auto-entry tick for FULL, the price patch, and the experience hooks.
--
-- ------- where the minigame declines to run
--
-- The throw is a 3D scene: it needs the staged battle standing (the
-- over-the-shoulder shot the option's own 3D-BTL row provides, ON by
-- default), a driver with a depth buffer, and a flat screen (the VR seat
-- draws through a different pass entirely). Anywhere that fails -- 3D-BTL
-- switched off, a headless driver, a headset -- the ball quietly takes
-- the engine's own toss, which is exactly what the mod's "declines
-- cleanly" rule demands. Trainers, the ghost, the RESTLESS SOUL and the
-- old man's demo keep the vanilla path on purpose: those branches ARE
-- their behaviour.

-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...

local ModSetting = V.require("ModSetting")
local Voxel3D = V.require("Voxel3D")
local CatchThrow = V.require("CatchThrow")

local LetsGo = {}

LetsGo.KEY = "letsgo"
LetsGo.LABEL = "LET'S GO"

-- `false` first: the default, and the fallback for a stored value from
-- some other version of this ladder
LetsGo.setting = ModSetting.new(LetsGo.KEY, LetsGo.LABEL,
                                { false, "full", "catching" },
                                { "OFF", "FULL", "CATCH ONLY" })

-- false | "full" | "catching"
function LetsGo.mode()
  return LetsGo.setting:get()
end

local function game() return require("src.core.Game") end

-- ------- half-price balls (FULL)
--
-- Prices are live data (game.data.items[id].price) and every reader --
-- the buy list, the affordability check, the quantity box -- reads them
-- per use, so patching the table IS the feature. Applied and reverted on
-- the option's edge, polled from the tick because the row, the manager's
-- page and a loaded save can all move it and none of them announces to
-- us. The sell price follows automatically (the mart pays half of list),
-- which is coherent: cheaper balls are worth less back too.
local PRICED = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" }
local fullPrices = nil     -- originals while halved, or nil

local function applyPrices()
  local g = game()
  local items = g and g.data and g.data.items
  if not items then return end
  local wantHalf = LetsGo.mode() == "full"
  if wantHalf and not fullPrices then
    fullPrices = {}
    for _, id in ipairs(PRICED) do
      local def = items[id]
      if def and def.price then
        fullPrices[id] = def.price
        def.price = math.floor(def.price / 2)
      end
    end
  elseif not wantHalf and fullPrices then
    for id, price in pairs(fullPrices) do
      if items[id] then items[id].price = price end
    end
    fullPrices = nil
  end
end

-- ------- whether a throw can be the minigame

local function vrOn()
  local ok, vr = pcall(V.require, "VR")
  return ok and vr and vr.enabled and vr.enabled() or false
end

-- ------- the battles that are cutscenes wearing a battle's clothes
--
-- The catch tutorials -- the VIRIDIAN CITY old man, and Yellow's PROF.OAK
-- catching the PIKACHU (both BattleState:makeOldManDemo, which is why one
-- flag covers both) -- are scripted from the first frame: the cursor moves
-- itself, the bag opens itself, the ball is thrown by someone who is not
-- the player, and the throw always catches a Pokemon nobody keeps. There
-- is no decision in them to hand a minigame, and the story beat is the
-- point, so LET'S GO stays out of them entirely at whatever rung: no
-- capture screen, no FULL treatment, no experience.
function LetsGo.scripted(battle)
  return battle and (battle.demo or battle.oakDemo) and true or false
end

function LetsGo.wantsMinigame(battle)
  if not LetsGo.mode() then return false end
  if not battle or battle.kind ~= "wild" then return false end
  if LetsGo.scripted(battle) then return false end
  if battle.ghost or battle.noCatch then return false end
  if not Voxel3D.available() or vrOn() then return false end
  -- the staged shot must actually be standing: this is "there is a 3D
  -- battle on screen right now", which the throw is aimed into
  local ok, shot = pcall(function()
    return V.require("OverworldBattle").shot()
  end)
  return (ok and shot) and true or false
end

-- A Let's Go wild: the encounters FULL owns outright. In these the foe
-- never takes a turn, the player's Pokemon is never sent out or shown,
-- B runs (and always escapes), and the encounter lives in throw mode
-- from the wipe to the last message.
function LetsGo.fullWild(battle)
  return LetsGo.mode() == "full" and battle and battle.kind == "wild"
         and not LetsGo.scripted(battle)
         and not (battle.safari or battle.ghost or battle.noCatch)
         and true or false
end

-- ------- the experience stack (FULL)
--
-- Let's Go pays a catch like a knockout, through the Gen VII scaled
-- formula -- every party member paid against its OWN level -- times the
-- catch bonuses. Three engine hooks carry it:
--
--   battle.catch_exp    "does a catch pay at all" -- yes, under FULL
--   battle.exp_award    the distribution: every healthy party member its
--                       own full share, no participant split
--   exp.gain            the amount: the scaled formula times the bonus
--                       stack, in place of floor(b*L/7)
--
-- The stack: throw grade (NICE 1.1 / GREAT 1.5 / EXCELLENT 2.0), first
-- ball of the encounter 1.5, species new to the dex 1.1, and the catch
-- combo tier. Traded 1.5 still rides through the engine's own flag.
local expCtx = nil        -- {battle, mult} while a Let's Go catch pays out
local granting = nil      -- set across the applyShare loop for exp.gain

local function comboMult(n)
  if n <= 10 then return 1.1 end
  if n <= 20 then return 1.5 end
  if n <= 30 then return 2.0 end
  if n <= 40 then return 2.5 end
  return 3.0
end

-- the catch combo, persisted with the save (mod.save rides save.modData):
-- catching the same species again extends it, anything else restarts it
local function bumpCombo(species)
  local ms = V.mod and V.mod.save
  local combo = { species = species, count = 1 }
  if ms then
    local ok, held = pcall(ms.get, ms, "letsgoCombo")
    if ok and type(held) == "table" and held.species == species then
      combo.count = (tonumber(held.count) or 0) + 1
    end
    pcall(ms.set, ms, "letsgoCombo", combo)
  end
  return combo.count
end

function LetsGo.combo()
  local ms = V.mod and V.mod.save
  if not ms then return nil end
  local ok, held = pcall(ms.get, ms, "letsgoCombo")
  return ok and type(held) == "table" and held or nil
end

-- Called by CatchThrow the moment a capture resolves as caught, BEFORE
-- storeCaughtMon runs -- the dex is not yet marked, so "new species" is
-- still answerable, and the exp hooks fire inside storeCaughtMon.
function LetsGo.noteCatch(battle, info)
  local species = battle.enemy and battle.enemy.mon
                  and battle.enemy.mon.species
  local chain = species and bumpCombo(species) or 1
  if LetsGo.mode() ~= "full" then return end
  local mult = info.mult or 1
  if info.firstThrow then mult = mult * 1.5 end
  local dex = game().save and game().save.pokedex
  if dex and species and not dex.owned[species] then mult = mult * 1.1 end
  mult = mult * comboMult(chain)
  expCtx = { battle = battle, mult = mult }
end

-- The Gen VII scaled gain: a * b * L / 5, scaled by the RECEIVER's own
-- level, +1, then the traded boost and (for a catch) the bonus stack.
-- `s`, the split divisor, is 1 -- the award loop below hands every mon a
-- full share rather than a share of one.
--
-- `a` is the wild/trainer multiplier, 1.5 for a trainer's Pokemon. It is
-- absent from the catch-side write-ups of this formula for the simple
-- reason that a caught Pokemon is always wild, so it is always 1 there --
-- which is also why adding it leaves every catch payout exactly where it
-- was, verified against the published table in the suite.
local function scaledGain(c, mult)
  local b = (c.defeatedDef and c.defeatedDef.baseExp) or 50
  local L = c.level or 1
  local Lp = (c.mon and c.mon.level) or L
  local a = c.isTrainer and 1.5 or 1
  local scale = ((2 * L + 10) / (L + Lp + 10)) ^ 2.5
  local exp = math.floor(math.floor(a * b * L / 5) * scale + 1)
  if c.traded then exp = math.floor(exp * 1.5) end
  return math.max(1, math.floor(exp * (mult or 1)))
end

LetsGo._scaledGain = scaledGain   -- named for the suite
LetsGo._comboMult = comboMult

-- ------- FULL's auto-entry
--
-- The moment a wild battle's menu opens under FULL, capture mode opens
-- over it, with the last ball the player threw (or the first ball in the
-- bag). B backs out to the classic menu and stays out for that battle --
-- the bag's own ball route still re-enters the throw.
local function autoEnter()
  if LetsGo.mode() ~= "full" then return end
  if CatchThrow.active() then return end
  local ok, battle = pcall(function()
    return V.require("OverworldBattle").battle()
  end)
  if not (ok and battle) then return end
  local g = game()
  if not (g.stack and g.stack:top() == battle) then return end
  if battle.phase ~= "menu" then return end
  if battle.safari then return end      -- the safari menu is already a
                                        -- catch menu; its BALL row enters
  if battle.dramaticShapeDeclined then return end
  if not LetsGo.wantsMinigame(battle) then return end
  local ball = CatchThrow.pickBall()
  local full = LetsGo.fullWild(battle)
  -- An empty bag does NOT fall back to the classic menu under FULL. A
  -- Let's Go wild has no player Pokemon in it and a foe that never takes a
  -- turn, so the menu it would fall back to offers a FIGHT that cannot
  -- happen -- the encounter has to keep its own screen and its own exit.
  -- The capture screen opens empty-handed instead: the foe stands there,
  -- the readout says there is nothing to throw, and RUN is the way out.
  -- At CATCH ONLY there is no auto-entry to speak of and the bag is the
  -- only route in, so no balls simply means no throw, as it always did.
  if not (ball or full) then return end
  CatchThrow.begin(battle, ball, { consumed = false, canSwitch = true,
                                   fullWild = full })
end

-- ------- per frame, from the voxel pipeline's update hook
--
-- BEFORE OverworldBattle.update on the same tick, so the ball pose this
-- frame computes is the ball the scene render a moment later draws.
function LetsGo.update(dt)
  applyPrices()
  CatchThrow.update(dt)
  autoEnter()
end

-- ------- install: the two throw seams, the hooks, the input
--
-- Installed from main.lua AFTER every other input seam, so the capture's
-- pointer wraps sit outside them all while it aims.
local installed = false

function LetsGo.install()
  if installed then return end
  installed = true

  local mod = V.mod
  local BattleState = require("src.battle.BattleState")

  if not BattleState.dramaticShapeLetsGoHook then
    -- The bag's ball route: BagMenu has already consumed the ball and
    -- closed itself when this is called, so a session here owns a paid
    -- ball (cancel refunds it). Vanilla path untouched whenever the
    -- minigame cannot or should not run.
    local innerThrow = BattleState.throwBall
    function BattleState:throwBall(ball)
      if LetsGo.wantsMinigame(self) then
        if CatchThrow.begin(self, ball, {
             consumed = true, fullWild = LetsGo.fullWild(self),
           }) then return end
      end
      return innerThrow(self, ball)
    end

    -- The safari menu's BALL row: same interception, safari flavour --
    -- the ball count and the flee check belong to the safari turn, and
    -- CatchThrow hands back to safariEnemyTurn on a failure.
    local innerSafari = BattleState.safariAction
    function BattleState:safariAction(choice)
      if choice == "ball" and LetsGo.wantsMinigame(self)
         and self.safari and self.safari.balls > 0 then
        if CatchThrow.begin(self, "SAFARI_BALL",
                            { consumed = false, safari = true }) then
          return
        end
      end
      return innerSafari(self, choice)
    end

    BattleState.dramaticShapeLetsGoHook = true
  end

  -- a catch pays experience under FULL, exactly as a knockout would
  -- Never for a scripted demo: the old man's catch is a cutscene, nobody
  -- keeps the Pokemon, and the party it would pay may not exist yet
  -- (Yellow's Pallet intro runs before the lab gift). The engine's own
  -- flow does not reach either hook for a demo today -- oldManThrow ends
  -- the battle without storeCaughtMon or awardExp -- so this guards the
  -- INVARIANT rather than a live bug: a demo pays nothing, whatever route
  -- some later engine takes to get there.
  mod.hooks:wrap("battle.catch_exp", function(next_, ctx)
    if LetsGo.mode() == "full" and ctx and ctx.battle
       and ctx.battle.kind == "wild"
       and not LetsGo.scripted(ctx.battle) then
      return true
    end
    return next_(ctx)
  end)

  -- ------- the Let's Go distribution: every healthy party member, in full
  --
  -- The engine's own rule is that only the Pokemon that FOUGHT are paid,
  -- and they split one award between them; EXP.ALL exists to soften that.
  -- Let's Go deletes the whole arrangement -- everybody gains from
  -- everything, which is why that game ships no EXP.ALL at all -- and
  -- each one is measured against its OWN level, so the low member of a
  -- party pulls several times what the high one does from the same
  -- knockout.
  --
  -- Two ways in. A CATCH arrives with a bonus stack attached (throw
  -- grade, first ball, new species, combo) which `expCtx` carries. A
  -- KNOCKOUT under FULL takes the same distribution with no stack --
  -- those bonuses are rewards for the throw, and there was no throw.
  --
  -- 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(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
    if not okAward then error(err, 0) end
  end

  mod.hooks:wrap("battle.exp_award", function(next_, ctx)
    if ctx and LetsGo.scripted(ctx.battle) then return next_(ctx) end
    local cc = expCtx
    if cc and ctx and ctx.battle == cc.battle then
      expCtx = nil
      return payParty(ctx, cc.mult)
    end
    if LetsGo.mode() == "full" and ctx and ctx.battle then
      return payParty(ctx, 1)
    end
    return next_(ctx)
  end)

  -- and the amount, per receiving mon, while that loop runs
  mod.hooks:wrap("exp.gain", function(next_, c)
    if not granting then return next_(c) end
    return scaledGain(c, granting.mult)
  end)

  -- ------- FULL owns a wild encounter from its first frame
  --
  -- The engine's intro ends by sending the player's Pokemon out -- the
  -- back pic slides off, "Go! X!", the poof, the grow-in -- and a Let's
  -- Go wild has no player Pokemon in it at all. The send-out is exactly
  -- the LAST SIX rows of the intro queue when this event fires (built in
  -- BattleState's start, gated `not safari and not demo`), so they are
  -- stripped by SHAPE -- act, wait, act, say, POOF, act -- and left alone
  -- if a future engine moves them: the veil still hides the visuals, the
  -- engine just narrates a send-out that is not shown.
  --
  -- Stripping them leaves showPlayerBack TRUE for the whole battle, which
  -- is the flag the engine's own HUD path reads as "no player HUD" -- the
  -- player's side vanishes from the readout for free.
  --
  -- The veil goes up in the same breath: the capture table, installed
  -- before any session exists, so the whole encounter -- wipe, "Wild X
  -- appeared!", every beat between throws -- plays from the held head-on
  -- seat with the player's side out of the shot.
  mod.events:on("battle.started", function(payload)
    local b = payload and payload.battle
    if not (b and LetsGo.fullWild(b)) then return end
    if not (Voxel3D.available() and not vrOn()) then return end
    -- deliberately NOT gated on owning a ball: an empty bag still gets the
    -- Let's Go encounter (see autoEnter), so the send-out still has to go
    local q = b.queue
    local n = q and #q or 0
    if n >= 6 and type(q[n]) == "table" and q[n].fn
       and q[n - 1] and q[n - 1].anim == "POOF_ANIM"
       and q[n - 2] and q[n - 2].text
       and q[n - 3] and q[n - 3].fn
       and q[n - 4] and q[n - 4].wait
       and q[n - 5] and q[n - 5].fn then
      for _ = 1, 6 do table.remove(q) end
    end
    pcall(CatchThrow.veil, b)
  end)

  -- a battle ending sweeps everything: the capture epilogue, the veil, a
  -- session a script tore down, and the exp context if the payout never
  -- fired
  mod.events:on("battle.ended", function()
    expCtx = nil
    pcall(CatchThrow.onBattleEnded)
  end)

  CatchThrow.installInput()
end

return LetsGo