mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
G2 support
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,480 @@
|
||||
-- The Gen 2 battle-animation command interpreter.
|
||||
--
|
||||
-- pokegold engine/battle_anims/anim_commands.asm: RunBattleAnimScript's frame
|
||||
-- loop and the 48-entry BattleAnimCommands jumptable it dispatches through.
|
||||
-- The scripts themselves are already disassembled into the cache by
|
||||
-- RomExtractorGen2 (`data/generated/battle_anims.lua`), keyed by their ROM
|
||||
-- address because that is what a branch names.
|
||||
--
|
||||
-- One frame of an animation is exactly three things, in this order:
|
||||
--
|
||||
-- RunBattleAnimCommand run script bytes until one asks to wait
|
||||
-- ExecuteBGEffects one pass over the five BG-effect structs
|
||||
-- BattleAnim_UpdateOAM_All one pass over the ten object structs
|
||||
--
|
||||
-- so `step()` here is one 60 Hz frame and nothing else needs a clock. The
|
||||
-- animation is over when a `ret` runs outside a subroutine, which is what
|
||||
-- BATTLEANIM_STOP_F means.
|
||||
--
|
||||
-- Two things about the script format that are easy to get wrong and are
|
||||
-- already handled by the extractor, repeated here because this is where they
|
||||
-- bite: anything under $d0 is `anim_wait <n>` and carries no arguments, and a
|
||||
-- branch's target is the LAST two bytes of the command, which the extractor
|
||||
-- has already rewritten into a pool key.
|
||||
--
|
||||
-- Love-free: sound and cries go out through the `hooks` table the battle
|
||||
-- screen supplies, so a test can step a whole animation and assert what it
|
||||
-- asked to play.
|
||||
|
||||
local bit = require("bit")
|
||||
local AnimObjects = require("src.battle.gen2.AnimObjects")
|
||||
local BgEffects = require("src.battle.gen2.BgEffects")
|
||||
|
||||
local AnimRunner = {}
|
||||
local Runner = {}
|
||||
Runner.__index = Runner
|
||||
|
||||
-- wBattleAnimTileDict is five {gfx id, tile id} pairs.
|
||||
local NUM_TILEDICT_ENTRIES = 5
|
||||
-- BATTLEANIM_BASE_TILE is 7*7; the sheets share the tiles above it up to
|
||||
-- vTiles1, so the running allocator stops at 128 - 49.
|
||||
local MAX_ANIM_TILES = 128 - 49
|
||||
|
||||
-- BattleAnimCmd_BattlerGFX_*: the battlers' own pic tiles are registered in
|
||||
-- the dict at fixed ids rather than loaded from AnimObjGFX. These are the
|
||||
-- ASM's `($80 - 6 - 7) - BATTLEANIM_BASE_TILE` and friends.
|
||||
local BATTLER_TILES = {
|
||||
oneRow = { player = (0x80 - 6 - 7) - 49, enemy = (0x80 - 6) - 49 },
|
||||
twoRow = { player = (0x80 - 6 * 2 - 7 * 2) - 49, enemy = (0x80 - 6 * 2) - 49 },
|
||||
}
|
||||
|
||||
-- BattleAnimCmd_Cry's .CryData: a pitch and a length added to the mon's own
|
||||
-- cry, indexed by the command's argument masked to NUM_NOISE_CHANS.
|
||||
local CRY_DATA = {
|
||||
[0] = { pitch = 0x0000, length = 0x00c0 },
|
||||
[1] = { pitch = 0x0000, length = 0x0040 },
|
||||
[2] = { pitch = 0x0000, length = 0x0000 },
|
||||
[3] = { pitch = 0x0000, length = 0x0000 },
|
||||
}
|
||||
|
||||
-- BattleAnimCmd_Sound's .GetPanning, indexed by the cry-track pair.
|
||||
local PANNING = { [0] = 0xf0, [1] = 0x0f, [2] = 0xf0, [3] = 0x0f }
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- opts:
|
||||
-- data the cache's battle_anims.lua
|
||||
-- constants the cache's constants.lua
|
||||
-- battleTurn hBattleTurn -- 0 while the player is attacking
|
||||
-- param wBattleAnimParam, which the effect layer sets (hit count,
|
||||
-- stat direction, the Beat Up party slot...)
|
||||
-- animId the move (or ANIM_* id) whose script this is
|
||||
-- hooks { sound(name, panning, duration), cry(side, pitch, length) }
|
||||
-- ballPalette the PAL_BATTLE_OB_* name for the ball being thrown
|
||||
function AnimRunner.new(opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, Runner)
|
||||
self.data = opts.data or {}
|
||||
self.constants = opts.constants or {}
|
||||
self.hooks = opts.hooks or {}
|
||||
-- Shared by both pools; the object functions and the BG effects read the
|
||||
-- same hBattleTurn.
|
||||
self.env = {
|
||||
battleTurn = opts.battleTurn or 0,
|
||||
animId = opts.animId,
|
||||
ballPalette = opts.ballPalette,
|
||||
sgb = opts.sgb,
|
||||
flying = opts.flying or {},
|
||||
}
|
||||
self.objects = AnimObjects.new(self.data, self.constants, self.env)
|
||||
self.bg = BgEffects.new(self.constants, self.env)
|
||||
self.gfxOrder = self.constants.battleAnimGfxOrder or {}
|
||||
self.sfxOrder = opts.sfxOrder or {}
|
||||
|
||||
self.param = opts.param or 0 -- wBattleAnimParam
|
||||
self.var = 0 -- wBattleAnimVar
|
||||
self.delay = 0 -- wBattleAnimDelay
|
||||
self.loops = 0 -- wBattleAnimLoops
|
||||
self.inSubroutine = false
|
||||
self.inLoop = false
|
||||
self.stopped = false
|
||||
self.keepSprites = false
|
||||
self.frames = 0
|
||||
-- wBattleAnimTileDict, and the sheets it points at.
|
||||
self.tileDict = {}
|
||||
self.loaded = {}
|
||||
-- Set by the substitute / minimize / transform commands, for the view.
|
||||
self.picOverride = { player = nil, enemy = nil }
|
||||
self.address = nil
|
||||
self.parent = nil
|
||||
return self
|
||||
end
|
||||
|
||||
-- ClearBattleAnims: the whole animation block, then the entry point.
|
||||
function Runner:start(scriptKey)
|
||||
self.objects:clear()
|
||||
self.bg:reset()
|
||||
self.var, self.delay, self.loops = 0, 0, 0
|
||||
self.inSubroutine, self.inLoop, self.stopped = false, false, false
|
||||
self.keepSprites = false
|
||||
self.frames = 0
|
||||
self.tileDict = {}
|
||||
self.loaded = {}
|
||||
self.picOverride = { player = nil, enemy = nil }
|
||||
self.address = scriptKey and { key = scriptKey, index = 1 } or nil
|
||||
self.parent = nil
|
||||
return self
|
||||
end
|
||||
|
||||
-- The script for a move, or nil when the cache has none (which is what an
|
||||
-- unextracted or modded move looks like).
|
||||
function AnimRunner.scriptForMove(data, moveId)
|
||||
local moves = (data or {}).moves or {}
|
||||
return moves[moveId]
|
||||
end
|
||||
|
||||
function Runner:scriptRows(key)
|
||||
return (self.data.scripts or {})[key]
|
||||
end
|
||||
|
||||
-- GetBattleAnimByte, one decoded row at a time.
|
||||
function Runner:fetch()
|
||||
local at = self.address
|
||||
if not at then return nil end
|
||||
local rows = self:scriptRows(at.key)
|
||||
if not rows then return nil end
|
||||
local row = rows[at.index]
|
||||
if not row then return nil end
|
||||
at.index = at.index + 1
|
||||
return row
|
||||
end
|
||||
|
||||
-- The three "skip the branch target" tails: a conditional that does not take
|
||||
-- its branch steps the address past the two address bytes, which in a decoded
|
||||
-- row list is simply "carry on".
|
||||
function Runner:jumpTo(key)
|
||||
self.address = { key = key, index = 1 }
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- The tile dict
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- GetBattleAnimTileOffset: the dict is scanned for the gfx id and its tile
|
||||
-- returned; a miss is 0, which is why an object whose sheet the script never
|
||||
-- loaded draws whatever happens to sit at the base tile.
|
||||
function Runner:tileOffsetFor(gfxId)
|
||||
for i = 1, NUM_TILEDICT_ENTRIES do
|
||||
local entry = self.tileDict[i]
|
||||
if entry and entry.gfx == gfxId then return entry.tile end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- BattleAnimCmd_1GFX..5GFX. The running tile id restarts at 0 for every
|
||||
-- command and each sheet is laid down after the last, so two animations that
|
||||
-- load different sheet counts do not agree about where anything is -- which
|
||||
-- is exactly why the dict exists. Entries past the count are NOT cleared.
|
||||
function Runner:loadGfx(names)
|
||||
local tile = 0
|
||||
for slot, gfxId in ipairs(names) do
|
||||
if tile >= MAX_ANIM_TILES then break end
|
||||
local name = gfxId
|
||||
if type(gfxId) == "number" then
|
||||
name = self.gfxOrder[gfxId + 1] or gfxId
|
||||
end
|
||||
self.tileDict[slot] = { gfx = name, tile = tile }
|
||||
local sheet = (self.data.gfx or {})[name]
|
||||
self.loaded[#self.loaded + 1] = {
|
||||
gfx = name, tile = tile, tiles = (sheet and sheet.tiles) or 0,
|
||||
}
|
||||
tile = tile + ((sheet and sheet.tiles) or 0)
|
||||
end
|
||||
end
|
||||
|
||||
-- BattleAnimCmd_BattlerGFX_1Row / _2Row. The battlers' pic tiles are
|
||||
-- APPENDED after whatever the script already loaded rather than replacing it,
|
||||
-- and they always land on the same two fixed tile ids.
|
||||
--
|
||||
-- (pokegold's jumptable has these two labels the other way round from the
|
||||
-- macro names -- $d9 dispatches to BattleAnimCmd_BattlerGFX_1Row while
|
||||
-- anim_battlergfx_2row is $d9 -- so the names below follow the MACRO, which
|
||||
-- is what a script actually writes.)
|
||||
function Runner:loadBattlerGfx(rows)
|
||||
local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow
|
||||
local slot = 1
|
||||
while slot <= NUM_TILEDICT_ENTRIES and self.tileDict[slot] do
|
||||
slot = slot + 1
|
||||
end
|
||||
if slot + 1 > NUM_TILEDICT_ENTRIES then return end
|
||||
self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player }
|
||||
self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy }
|
||||
self.loaded[#self.loaded + 1] =
|
||||
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 6,
|
||||
battler = "player", rows = rows }
|
||||
self.loaded[#self.loaded + 1] =
|
||||
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 7,
|
||||
battler = "enemy", rows = rows }
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- BattleAnimCommands
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
local C = {}
|
||||
|
||||
C.obj = function(self, row)
|
||||
self.objects:queue(row[2], row[3], row[4], row[5], function(gfx)
|
||||
return self:tileOffsetFor(gfx)
|
||||
end)
|
||||
end
|
||||
|
||||
for count = 1, 5 do
|
||||
C[count .. "gfx"] = function(self, row)
|
||||
local names = {}
|
||||
for i = 1, count do names[i] = row[i + 1] end
|
||||
self:loadGfx(names)
|
||||
end
|
||||
end
|
||||
|
||||
C.incobj = function(self, row)
|
||||
local st = self.objects:findByIndex(row[2])
|
||||
if st then st.jt = AnimObjects.u8(st.jt + 1) end
|
||||
end
|
||||
|
||||
C.setobj = function(self, row)
|
||||
local st = self.objects:findByIndex(row[2])
|
||||
if st then st.jt = AnimObjects.u8(row[3]) end
|
||||
end
|
||||
|
||||
C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end
|
||||
|
||||
C.battlergfx_1row = function(self) self:loadBattlerGfx(1) end
|
||||
C.battlergfx_2row = function(self) self:loadBattlerGfx(2) end
|
||||
|
||||
-- GetPokeBallWobble's answer, which the ball's own script then branches on.
|
||||
C.checkpokeball = function(self)
|
||||
self.var = self.hooks.pokeballWobble and self.hooks.pokeballWobble() or 0
|
||||
end
|
||||
|
||||
-- The commands that swap a battler's pic out for something else. The port
|
||||
-- records which, and the view draws it.
|
||||
--
|
||||
-- Every one of them branches on hBattleTurn the same way
|
||||
-- (engine/battle_anims/anim_commands.asm): `and a / jr z, .player`, and the
|
||||
-- .player arm is the one that writes vTiles2 tile $31, the 6x6 BACKPIC slot.
|
||||
-- So turn 0, the player attacking, always repaints the PLAYER's own pic, and
|
||||
-- the fall-through arm (tile $00, the 7x7 frontpic) repaints the enemy's.
|
||||
C.transform = function(self)
|
||||
-- BattleAnimCmd_Transform: .player loads wTempEnemyMonSpecies into the
|
||||
-- backpic slot, i.e. the player's sprite becomes what it transformed into.
|
||||
local side = self.env.battleTurn == 0 and "player" or "enemy"
|
||||
self.picOverride[side] = "transform"
|
||||
end
|
||||
|
||||
C.raisesub = function(self)
|
||||
local side = self.env.battleTurn == 0 and "player" or "enemy"
|
||||
self.picOverride[side] = "substitute"
|
||||
end
|
||||
|
||||
C.dropsub = function(self)
|
||||
local side = self.env.battleTurn == 0 and "player" or "enemy"
|
||||
self.picOverride[side] = nil
|
||||
end
|
||||
|
||||
-- BattleAnimCmd_MinimizeOpp / GetMinimizePic: despite the name it shrinks the
|
||||
-- ATTACKER, because .player (turn 0) requests the 6x6 block at tile $31. The
|
||||
-- other minimize opcode, $e9, is one of the dummies below.
|
||||
C.minimizeopp = function(self)
|
||||
local side = self.env.battleTurn == 0 and "player" or "enemy"
|
||||
self.picOverride[side] = "minimize"
|
||||
end
|
||||
|
||||
C.beatup = function(self)
|
||||
-- wBattleAnimParam is the party slot whose pic to show.
|
||||
local side = self.env.battleTurn == 0 and "player" or "enemy"
|
||||
self.picOverride[side] = { kind = "beatup", slot = self.param }
|
||||
end
|
||||
|
||||
C.resetobp0 = function(self)
|
||||
self.bg.obp0 = self.env.sgb and 0xf0 or 0xe0
|
||||
end
|
||||
|
||||
C.sound = function(self, row)
|
||||
local packed = row[2] or 0
|
||||
-- The first byte is BOTH the duration (its top six bits) and the cry-track
|
||||
-- pair (its bottom two), which is why the same value reads twice here.
|
||||
local duration = bit.rshift(packed, 2)
|
||||
local tracks = bit.band(packed, 3)
|
||||
if self.env.battleTurn ~= 0 then tracks = bit.bxor(tracks, 1) end
|
||||
local id = row[3] or 0
|
||||
local name = self.sfxOrder[id + 1]
|
||||
if self.hooks.sound then
|
||||
self.hooks.sound(name, PANNING[tracks] or 0xff, duration, id)
|
||||
end
|
||||
end
|
||||
|
||||
C.cry = function(self, row)
|
||||
local slot = bit.band(row[2] or 0, 3)
|
||||
local entry = CRY_DATA[slot] or CRY_DATA[0]
|
||||
local side = self.env.battleTurn == 0 and "player" or "enemy"
|
||||
if self.hooks.cry then self.hooks.cry(side, entry.pitch, entry.length) end
|
||||
end
|
||||
|
||||
C.clearobjs = function(self) self.objects:clearObjs() end
|
||||
|
||||
C.oamon = function() end
|
||||
C.oamoff = function() end
|
||||
C.updateactorpic = function() end
|
||||
-- $e7 and $e8-$ed are `ret` on the cart; $f5-$f7 too. $e9 is `minimize`, and
|
||||
-- it really is one of them: BattleAnimCmd_E8 through BattleAnimCmd_ED are six
|
||||
-- labels stacked on a single `ret` (engine/battle_anims/anim_commands.asm).
|
||||
-- The minimize animation that is actually drawn is $e2, minimizeopp above.
|
||||
C.minimize = function() end
|
||||
C.unknown_e7 = function() end
|
||||
C.unknown_ea = function() end
|
||||
C.unknown_eb = function() end
|
||||
C.unknown_ec = function() end
|
||||
C.unknown_ed = function() end
|
||||
C.unknown_f5 = function() end
|
||||
C.unknown_f6 = function() end
|
||||
C.unknown_f7 = function() end
|
||||
|
||||
C.keepsprites = function(self) self.keepSprites = true end
|
||||
|
||||
C.bgp = function(self, row) self.bg.bgp = row[2] end
|
||||
C.obp0 = function(self, row) self.bg.obp0 = row[2] end
|
||||
C.obp1 = function(self, row) self.bg.obp1 = row[2] end
|
||||
|
||||
C.bgeffect = function(self, row)
|
||||
self.bg:queue(row[2], row[3], row[4], row[5])
|
||||
end
|
||||
|
||||
C.setvar = function(self, row) self.var = AnimObjects.u8(row[2]) end
|
||||
C.incvar = function(self) self.var = AnimObjects.u8(self.var + 1) end
|
||||
|
||||
C.if_var_equal = function(self, row)
|
||||
if row[2] == self.var then self:jumpTo(row[3]) end
|
||||
end
|
||||
|
||||
C.if_param_equal = function(self, row)
|
||||
if row[2] == self.param then self:jumpTo(row[3]) end
|
||||
end
|
||||
|
||||
C.if_param_and = function(self, row)
|
||||
if bit.band(self.param, row[2] or 0) ~= 0 then self:jumpTo(row[3]) end
|
||||
end
|
||||
|
||||
-- The one conditional that CONSUMES what it tests: each pass decrements
|
||||
-- wBattleAnimParam, so `anim_jumpuntil` runs its block param times.
|
||||
C.jumpuntil = function(self, row)
|
||||
if self.param == 0 then return end
|
||||
self.param = AnimObjects.u8(self.param - 1)
|
||||
self:jumpTo(row[2])
|
||||
end
|
||||
|
||||
C.jump = function(self, row) self:jumpTo(row[2]) end
|
||||
|
||||
C.loop = function(self, row)
|
||||
local count = row[2] or 0
|
||||
if not self.inLoop then
|
||||
-- A count of 0 loops forever and never claims the loop flag.
|
||||
if count ~= 0 then
|
||||
self.inLoop = true
|
||||
self.loops = AnimObjects.u8(count - 1)
|
||||
end
|
||||
self:jumpTo(row[3])
|
||||
return
|
||||
end
|
||||
if self.loops == 0 then
|
||||
self.inLoop = false
|
||||
return -- falls through past the target
|
||||
end
|
||||
self.loops = self.loops - 1
|
||||
self:jumpTo(row[3])
|
||||
end
|
||||
|
||||
C.call = function(self, row)
|
||||
self.parent = { key = self.address.key, index = self.address.index }
|
||||
self.inSubroutine = true
|
||||
self:jumpTo(row[2])
|
||||
end
|
||||
|
||||
C.ret = function(self)
|
||||
self.inSubroutine = false
|
||||
self.address = self.parent
|
||||
and { key = self.parent.key, index = self.parent.index } or nil
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- RunBattleAnimCommand: burn the delay, otherwise run script rows until one
|
||||
-- of them asks to wait or the animation ends.
|
||||
function Runner:runCommands()
|
||||
if self.delay ~= 0 then
|
||||
self.delay = self.delay - 1
|
||||
return
|
||||
end
|
||||
for _ = 1, 512 do
|
||||
local row = self:fetch()
|
||||
if not row then
|
||||
self.stopped = true
|
||||
return
|
||||
end
|
||||
local cmd = row[1]
|
||||
if cmd == "ret" then
|
||||
-- A `ret` outside a subroutine is what ends the whole animation.
|
||||
if not self.inSubroutine then
|
||||
self.stopped = true
|
||||
return
|
||||
end
|
||||
C.ret(self, row)
|
||||
elseif cmd == "wait" then
|
||||
self.delay = row[2] or 0
|
||||
return
|
||||
else
|
||||
local fn = C[cmd]
|
||||
if fn then fn(self, row) end
|
||||
end
|
||||
end
|
||||
-- A script that never waits would hang the battle; stopping is the only
|
||||
-- honest thing to do with one.
|
||||
self.stopped = true
|
||||
end
|
||||
|
||||
-- One frame. Returns false once the animation is over.
|
||||
function Runner:step()
|
||||
if self.stopped then return false end
|
||||
self.frames = self.frames + 1
|
||||
self:runCommands()
|
||||
self.bg:playFrame()
|
||||
-- A BG effect can ask for an object (the battler-pic ones do), and it has
|
||||
-- to land before the object pass or it would be a frame late.
|
||||
for _, spawn in ipairs(self.bg:takeSpawns()) do
|
||||
self.objects:queue(spawn.object, spawn.x, spawn.y, spawn.param,
|
||||
function(gfx) return self:tileOffsetFor(gfx) end)
|
||||
end
|
||||
self.objects:playFrame()
|
||||
-- Rollout hands the shake to the first object's Y offset.
|
||||
if self.bg.rolloutYOffset then
|
||||
local first = self.objects.structs[1]
|
||||
if first and first.index ~= 0 then first.yOffset = self.bg.rolloutYOffset end
|
||||
end
|
||||
if self.stopped then
|
||||
-- BattleAnim_ClearOAM: unless the script asked to keep them, every object
|
||||
-- goes at the end.
|
||||
if not self.keepSprites then self.objects.oam = {} end
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Runner:oam() return self.objects.oam end
|
||||
function Runner:done() return self.stopped end
|
||||
|
||||
AnimRunner.COMMANDS = C
|
||||
AnimRunner.NUM_TILEDICT_ENTRIES = NUM_TILEDICT_ENTRIES
|
||||
AnimRunner.MAX_ANIM_TILES = MAX_ANIM_TILES
|
||||
AnimRunner.BATTLER_TILES = BATTLER_TILES
|
||||
|
||||
return AnimRunner
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
-- Which song a battle plays, and which one its win plays.
|
||||
--
|
||||
-- engine/battle/start_battle.asm PlayBattleMusic and engine/battle/core.asm
|
||||
-- PlayVictoryMusic, as pure lookups: the caller hands over the trainer class,
|
||||
-- the region and the time of day, and gets a song label back. Keeping it out
|
||||
-- of World.lua is what lets a test assert the whole ladder -- Falkner's theme,
|
||||
-- the Kanto split, the RIVAL2 cut-off -- without a window or an audio device.
|
||||
--
|
||||
-- Both routines start with `ld de, MUSIC_NONE / call PlayMusic`, i.e. the map
|
||||
-- theme is stopped first; the port's Music.play replaces whatever is current,
|
||||
-- so that step needs nothing here.
|
||||
|
||||
local BattleMusic = {}
|
||||
|
||||
-- data/trainers/leaders.asm. The two lists are contiguous in the ROM and
|
||||
-- IsGymLeader reads BOTH (GymLeaders falls through into KantoGymLeaders),
|
||||
-- while IsKantoGymLeader starts at the second -- so a Kanto leader is in each
|
||||
-- list and a Johto one only in the first.
|
||||
BattleMusic.KANTO_GYM_LEADERS = {
|
||||
BROCK = true, MISTY = true, LT_SURGE = true, ERIKA = true,
|
||||
JANINE = true, SABRINA = true, BLAINE = true, BLUE = true,
|
||||
}
|
||||
BattleMusic.JOHTO_GYM_LEADERS = {
|
||||
FALKNER = true, WHITNEY = true, BUGSY = true, MORTY = true,
|
||||
PRYCE = true, JASMINE = true, CHUCK = true, CLAIR = true,
|
||||
WILL = true, BRUNO = true, KAREN = true, KOGA = true,
|
||||
-- The list carries these two as well; PlayBattleMusic never reaches them
|
||||
-- because it tests for them first, but PlayVictoryMusic's IsGymLeader call
|
||||
-- does, which is why the Champion's defeat plays the gym jingle.
|
||||
CHAMPION = true, RED = true,
|
||||
}
|
||||
|
||||
-- IsGymLeader searches GymLeaders, which runs on into KantoGymLeaders.
|
||||
function BattleMusic.isGymLeader(class)
|
||||
if not class then return false end
|
||||
return BattleMusic.JOHTO_GYM_LEADERS[class] == true
|
||||
or BattleMusic.KANTO_GYM_LEADERS[class] == true
|
||||
end
|
||||
|
||||
function BattleMusic.isKantoGymLeader(class)
|
||||
return class ~= nil and BattleMusic.KANTO_GYM_LEADERS[class] == true
|
||||
end
|
||||
|
||||
-- RegionCheck (engine/overworld/landmarks.asm) compares the map's landmark
|
||||
-- against KANTO_LANDMARK; the Victory Road block above it counts as Johto
|
||||
-- again, and so does the S.S. Aqua.
|
||||
-- Indices into constants.lua's `landmarkOrder`.
|
||||
BattleMusic.KANTO_LANDMARK = 46
|
||||
BattleMusic.LANDMARK_VICTORY_ROAD = 87
|
||||
BattleMusic.LANDMARK_FAST_SHIP = 94
|
||||
|
||||
function BattleMusic.isKanto(landmark)
|
||||
local index = landmark or 0
|
||||
if index == BattleMusic.LANDMARK_FAST_SHIP then return false end
|
||||
if index < BattleMusic.KANTO_LANDMARK then return false end
|
||||
return index < BattleMusic.LANDMARK_VICTORY_ROAD
|
||||
end
|
||||
|
||||
-- The rival's theme becomes the Champion's from RIVAL2_2 onward (the Indigo
|
||||
-- Plateau rematch): `cp RIVAL2_2_CHIKORITA / jr c, .done`, a comparison
|
||||
-- against the MEMBER id inside the class.
|
||||
BattleMusic.RIVAL2_CHAMPION_MEMBER = "RIVAL2_2_CHIKORITA"
|
||||
|
||||
-- opts:
|
||||
-- class trainer class id ("FALKNER"), or nil for a wild battle
|
||||
-- member trainer member id ("RIVAL2_1_TOTODILE") -- only RIVAL2 reads it
|
||||
-- members the class's member list, to order `member` against
|
||||
-- landmark the map's landmark index, for RegionCheck
|
||||
-- daytime "MORN" | "DAY" | "NITE" | "DARK"
|
||||
function BattleMusic.battleSong(opts)
|
||||
opts = opts or {}
|
||||
local class = opts.class
|
||||
local kanto = BattleMusic.isKanto(opts.landmark)
|
||||
|
||||
if not class then
|
||||
if kanto then return "Music_KantoWildBattle" end
|
||||
-- Only NITE has its own wild theme; DARK (an unlit cave) is a palette
|
||||
-- state, not a time of day, and keeps the day theme.
|
||||
if opts.daytime == "NITE" then return "Music_JohtoWildBattleNight" end
|
||||
return "Music_JohtoWildBattle"
|
||||
end
|
||||
|
||||
if class == "CHAMPION" or class == "RED" then
|
||||
return "Music_ChampionBattle"
|
||||
end
|
||||
-- The cart's own bug, kept: only the two GRUNT classes get the Rocket
|
||||
-- theme, so an EXECUTIVE or SCIENTIST fights to the ordinary trainer song
|
||||
-- (docs/bugs_and_glitches.md).
|
||||
if class == "GRUNTM" or class == "GRUNTF" then
|
||||
return "Music_RocketBattle"
|
||||
end
|
||||
if BattleMusic.isKantoGymLeader(class) then
|
||||
return "Music_KantoGymBattle"
|
||||
end
|
||||
if BattleMusic.isGymLeader(class) then
|
||||
return "Music_JohtoGymBattle"
|
||||
end
|
||||
if class == "RIVAL1" then return "Music_RivalBattle" end
|
||||
if class == "RIVAL2" then
|
||||
local cutoff, index
|
||||
for i, id in ipairs(opts.members or {}) do
|
||||
if id == BattleMusic.RIVAL2_CHAMPION_MEMBER then cutoff = i end
|
||||
if id == opts.member then index = i end
|
||||
end
|
||||
if cutoff and index and index >= cutoff then
|
||||
return "Music_ChampionBattle"
|
||||
end
|
||||
return "Music_RivalBattle"
|
||||
end
|
||||
if kanto then return "Music_KantoTrainerBattle" end
|
||||
return "Music_JohtoTrainerBattle"
|
||||
end
|
||||
|
||||
-- PlayVictoryMusic. A wild win is SILENT unless the player still has a
|
||||
-- participant standing (or an Exp. Share, or Pay Day money) -- `wBattle
|
||||
-- ParticipantsNotFainted` zero falls through to `.lost` with no PlayMusic at
|
||||
-- all, which is why a battle won by a mon that fainted to recoil ends on the
|
||||
-- map theme. Returns nil for that case.
|
||||
function BattleMusic.victorySong(opts)
|
||||
opts = opts or {}
|
||||
if not opts.class then
|
||||
if opts.participantsFainted then return nil end
|
||||
return "Music_WildPokemonVictory"
|
||||
end
|
||||
if BattleMusic.isGymLeader(opts.class) then
|
||||
return "Music_GymLeaderVictory"
|
||||
end
|
||||
return "Music_TrainerVictory"
|
||||
end
|
||||
|
||||
return BattleMusic
|
||||
@@ -0,0 +1,60 @@
|
||||
-- ConvertBerriesToBerryJuice (engine/events/pokerus/pokerus.asm:124), the
|
||||
-- first thing GivePokerusAndConvertBerries does on a battle WIN: gated on
|
||||
-- ENGINE_REACHED_GOLDENROD like the Pokerus roll beside it, one byte under
|
||||
-- `1 out_of 16` (16/256), then a walk down the party for a SHUCKLE holding
|
||||
-- a BERRY. Only the FIRST match converts -- the routine returns the moment
|
||||
-- it rewrites one item byte -- and nothing tells the player; the changed
|
||||
-- held item is the whole event.
|
||||
--
|
||||
-- Shuckie (the Cianwood loaner) arrives holding a BERRY, which is the
|
||||
-- intended payoff. The Pokerus half lives in src/core/gen2/Pokerus.lua;
|
||||
-- both are called from the same battle-exit arm in
|
||||
-- src/ui/gen2/BattleState.lua, conversion first, the way the asm orders
|
||||
-- them.
|
||||
|
||||
local BerryJuice = {}
|
||||
|
||||
-- constants/engine_flags.asm index 21, same gate Pokerus.give reads.
|
||||
BerryJuice.ENGINE_REACHED_GOLDENROD = 21
|
||||
|
||||
-- `cp 1 out_of 16` with out_of = `* $100 /`: a byte under 16 converts.
|
||||
BerryJuice.ROLL_LIMIT = 16
|
||||
|
||||
function BerryJuice.random()
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(0, 255)
|
||||
end
|
||||
return math.random(0, 255)
|
||||
end
|
||||
|
||||
-- The walk itself. `opts.random` is a function of no arguments returning
|
||||
-- 0..255 (the Pokerus convention), `opts.reachedGoldenrod` the engine flag.
|
||||
-- Returns the party slot that converted, or nil.
|
||||
function BerryJuice.convert(party, opts)
|
||||
opts = opts or {}
|
||||
if not opts.reachedGoldenrod then return nil end
|
||||
local roll = (opts.random or BerryJuice.random)()
|
||||
if roll >= BerryJuice.ROLL_LIMIT then return nil end
|
||||
for index, mon in ipairs(party or {}) do
|
||||
if mon.species == "SHUCKLE" and mon.item == "BERRY" then
|
||||
mon.item = "BERRY_JUICE"
|
||||
return index
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The save-facing wrapper, mirroring Pokerus.giveAfterBattle's shape so the
|
||||
-- battle exit calls the two the same way.
|
||||
function BerryJuice.convertAfterBattle(save, party, opts)
|
||||
if type(save) ~= "table" then return nil end
|
||||
opts = opts or {}
|
||||
local flags = save.engineFlags or {}
|
||||
return BerryJuice.convert(party or save.party or {}, {
|
||||
random = opts.random,
|
||||
reachedGoldenrod =
|
||||
flags[BerryJuice.ENGINE_REACHED_GOLDENROD] == true,
|
||||
})
|
||||
end
|
||||
|
||||
return BerryJuice
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
||||
-- Gen 2 catch rate (engine/items/item_effects.asm PokeBallEffect).
|
||||
--
|
||||
-- The rate itself, transcribed from the ASM:
|
||||
--
|
||||
-- rate = ((3 * maxHP - 2 * curHP) * ballAdjustedCatchRate) / (3 * maxHP)
|
||||
-- rate = max(1, rate) + statusBonus
|
||||
-- rate = min(255, rate)
|
||||
--
|
||||
-- Two documented cart bugs are reproduced deliberately, because a port that
|
||||
-- "fixes" them catches mons at rates the real game never would (both are in
|
||||
-- pokegold's docs/bugs_and_glitches.md):
|
||||
--
|
||||
-- * When 3 * maxHP >= 256 the routine shifts both HP terms right by two
|
||||
-- before subtracting, which loses precision and makes the formula
|
||||
-- misbehave for maxHP above 341.
|
||||
-- * The status bonus was meant to be 10 for sleep/freeze and 5 for
|
||||
-- burn/poison/paralysis, but the `and` that tests for sleep/freeze leaves
|
||||
-- the accumulator zero on the fall-through, so burn, poison and paralysis
|
||||
-- give no bonus at all.
|
||||
--
|
||||
-- Pass `fixBugs = true` to get the intended behaviour instead; nothing in the
|
||||
-- game sets it, but it makes the difference testable and documents intent.
|
||||
|
||||
-- The mod event/hook buses. `catch.rate` and `battle.ball_thrown` are the SAME
|
||||
-- names src/battle/BattleState.lua raises on Gen 1, with the same argument
|
||||
-- order and the same payload keys (docs/mod-api-gen2-compat.md).
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Catching = {}
|
||||
|
||||
-- Ball multipliers applied to the species catch rate before the HP term.
|
||||
-- MASTER_BALL never fails, so it short-circuits rather than multiplying.
|
||||
-- The specialty balls (BallMultiplierFunctionTable) are conditional and live
|
||||
-- in Catching.specialtyRate below; FRIEND_BALL has no rate function at all --
|
||||
-- its whole effect is the caught mon's happiness, which the catch site sets.
|
||||
Catching.BALL_MULTIPLIER = {
|
||||
MASTER_BALL = math.huge,
|
||||
ULTRA_BALL = 2,
|
||||
-- SafariBallMultiplier, GreatBallMultiplier and ParkBallMultiplier are one
|
||||
-- shared routine on the cart (x1.5); Safari is the RBY leftover.
|
||||
GREAT_BALL = 1.5,
|
||||
POKE_BALL = 1,
|
||||
SAFARI_BALL = 1.5,
|
||||
PARK_BALL = 1.5,
|
||||
FRIEND_BALL = 1,
|
||||
}
|
||||
|
||||
-- FastBallMultiplier (engine/items/item_effects.asm): meant to cover all
|
||||
-- three FleeMons tables, but the loop advances `d` on every byte instead of
|
||||
-- every table (`jr nz, .next` where the intended jump is `.loop`), so only
|
||||
-- the first three rows of SometimesFleeMons (data/wild/flee_mons.asm) ever
|
||||
-- get the x4. Reproduced deliberately, like the two catch-formula bugs.
|
||||
Catching.FAST_BALL_SPECIES = {
|
||||
MAGNEMITE = true, GRIMER = true, TANGELA = true,
|
||||
}
|
||||
|
||||
-- FRIEND_BALL_HAPPINESS (constants/pokemon_data_constants.asm): the one thing
|
||||
-- a Friend Ball does. The catch site stamps it on the caught mon.
|
||||
Catching.FRIEND_BALL_HAPPINESS = 200
|
||||
|
||||
-- HeavyBallMultiplier's weight conversion: the dex weight (tenths of a
|
||||
-- pound) is turned into tenths of a kilogram with three shift-subtracts
|
||||
-- (w/2 - w/32 - w/64), and only the HIGH byte of that is compared.
|
||||
function Catching.heavyBallBoost(weight)
|
||||
local half = math.floor((weight or 0) / 2)
|
||||
local sub1 = math.floor(half / 16)
|
||||
local sub2 = math.floor(sub1 / 2)
|
||||
local high = math.floor((half - sub1 - sub2) / 256)
|
||||
if high < 4 then return -20 end -- under 102.4 kg
|
||||
if high < 8 then return 0 end -- under 204.8 kg
|
||||
if high < 12 then return 20 end -- under 307.2 kg
|
||||
if high < 16 then return 30 end -- under 409.6 kg
|
||||
return 40
|
||||
end
|
||||
|
||||
-- The conditional balls (engine/items/item_effects.asm
|
||||
-- BallMultiplierFunctionTable, HeavyBallMultiplier..FastBallMultiplier).
|
||||
-- `rate` is the species catch rate byte; every arm caps at 255 the way each
|
||||
-- `sla b / jr c` pins $ff. Three cart bugs are reproduced deliberately (all
|
||||
-- in pokegold's own comments): Fast Ball only knows three species, Love Ball
|
||||
-- boosts SAME-sex pairs, and Moon Ball compares the evolution stone against
|
||||
-- Gen 1's Moon Stone constant -- Burn Heal in Gen 2 -- so it never boosts.
|
||||
-- `fixBugs` flips all three to the intended behaviour, same contract as the
|
||||
-- catch-formula bugs above.
|
||||
-- One arm per row of BallMultiplierFunctionTable, each fn(rate, opts) -> rate.
|
||||
-- The records below hang these on `specialty` by identity, so a registry read
|
||||
-- and Catching.specialtyRate can never answer differently.
|
||||
local SPECIALTY = {
|
||||
HEAVY_BALL = function(rate, opts)
|
||||
-- Additive, not a multiplier; the light-mon subtraction floors at 1
|
||||
-- (`ld b, $1` on underflow).
|
||||
if not opts.weight then return rate end
|
||||
return math.max(1, rate + Catching.heavyBallBoost(opts.weight))
|
||||
end,
|
||||
LEVEL_BALL = function(rate, opts)
|
||||
-- x2 / x4 / x8 as the wild level falls below the player's level, its
|
||||
-- half and its quarter (strictly below at each rung).
|
||||
local player = opts.playerLevel
|
||||
local enemy = opts.level
|
||||
if not (player and enemy) or enemy >= player then return rate end
|
||||
rate = rate * 2
|
||||
if enemy < math.floor(player / 2) then rate = rate * 2 end
|
||||
if enemy < math.floor(player / 4) then rate = rate * 2 end
|
||||
return math.min(255, rate)
|
||||
end,
|
||||
LURE_BALL = function(rate, opts)
|
||||
-- x3, only in a BATTLETYPE_FISH battle.
|
||||
if not opts.fishing then return rate end
|
||||
return math.min(255, rate * 3)
|
||||
end,
|
||||
FAST_BALL = function(rate, opts)
|
||||
if opts.fixBugs then
|
||||
if not opts.fleeing then return rate end
|
||||
elseif not Catching.FAST_BALL_SPECIES[opts.species] then
|
||||
return rate
|
||||
end
|
||||
return math.min(255, rate * 4)
|
||||
end,
|
||||
MOON_BALL = function(rate, opts)
|
||||
-- MOON_STONE_RED is BURN_HEAL's Gen 2 id and nothing evolves with a
|
||||
-- Burn Heal, so the intended x4 never happens on the cart.
|
||||
local wanted = opts.fixBugs and "MOON_STONE" or "BURN_HEAL"
|
||||
if opts.evolveItem ~= wanted then return rate end
|
||||
return math.min(255, rate * 4)
|
||||
end,
|
||||
LOVE_BALL = function(rate, opts)
|
||||
-- x8 for the same species; the sex test's `ret nz` should be `ret z`,
|
||||
-- so the boost lands on SAME-sex pairs. Genderless mons never boost.
|
||||
if not opts.species or opts.species ~= opts.playerSpecies then
|
||||
return rate
|
||||
end
|
||||
local wild, player = opts.gender, opts.playerGender
|
||||
if not wild or not player or wild == "unknown"
|
||||
or player == "unknown" then
|
||||
return rate
|
||||
end
|
||||
local same = wild == player
|
||||
if opts.fixBugs then same = not same end
|
||||
if not same then return rate end
|
||||
return math.min(255, rate * 8)
|
||||
end,
|
||||
}
|
||||
|
||||
function Catching.specialtyRate(rate, ball, opts)
|
||||
opts = opts or {}
|
||||
local arm = SPECIALTY[ball]
|
||||
if not arm then return rate end
|
||||
return arm(rate, opts)
|
||||
end
|
||||
|
||||
Catching.STATUS_BONUS = { sleep = 10, freeze = 10 }
|
||||
Catching.STATUS_BONUS_FIXED = {
|
||||
sleep = 10, freeze = 10, burn = 5, poison = 5, toxic = 5, paralyze = 5,
|
||||
}
|
||||
|
||||
-- ------------------------------------------------------------------ registry
|
||||
--
|
||||
-- Gold's own ball records, in the shape src/mods/Schemas.lua's `balls` registry
|
||||
-- validates -- the SAME registry name Gen 1 fills from src/battle/Catching.lua,
|
||||
-- because a mod that adds a ball should not have to learn a second noun. The
|
||||
-- Gen 1 fields keep their Gen 1 meaning where Gen 2 has one:
|
||||
--
|
||||
-- randMax the ceiling of the catch roll. PokeBallEffect rolls ONE byte
|
||||
-- against wFinalCatchRate, so every rolling ball is 255 here;
|
||||
-- Gen 1's per-ball 200/150 ceilings have no Gen 2 counterpart.
|
||||
-- autoCatch MASTER_BALL, which returns before the rate is computed.
|
||||
-- flicker DoBallTossSpecialEffects' OBJ-palette strobe, Master and Ultra.
|
||||
--
|
||||
-- hpFactor / wobbleFactor / tossAnim are deliberately absent: Gen 2 decides the
|
||||
-- wobble count inside the animation (GetPokeBallWobble re-rolls per wobble), so
|
||||
-- there is no ballFactor2 to carry, and the toss arc is the animation's.
|
||||
--
|
||||
-- Two fields Gen 2 genuinely carries that Gen 1 does not, added rather than
|
||||
-- renaming anything (the catalog's top-level records are extensible):
|
||||
--
|
||||
-- multiplier the flat factor BallMultiplierFunctionTable applies to the
|
||||
-- species catch rate. math.huge is the Master Ball's "never
|
||||
-- fails" and pairs with autoCatch.
|
||||
-- specialty the conditional arm, fn(rate, opts) -> rate, for the balls
|
||||
-- whose factor depends on the battle rather than the ball.
|
||||
-- Present exactly where BALL_MULTIPLIER has no row.
|
||||
--
|
||||
-- FRIEND_BALL keeps multiplier 1 and carries its one real effect as
|
||||
-- catchHappiness; src/ui/gen2/BattleState.lua stamps it on the caught mon.
|
||||
Catching.BALLS = {
|
||||
MASTER_BALL = { randMax = 0, autoCatch = true, flicker = true,
|
||||
multiplier = Catching.BALL_MULTIPLIER.MASTER_BALL },
|
||||
ULTRA_BALL = { randMax = 255, flicker = true,
|
||||
multiplier = Catching.BALL_MULTIPLIER.ULTRA_BALL },
|
||||
GREAT_BALL = { randMax = 255,
|
||||
multiplier = Catching.BALL_MULTIPLIER.GREAT_BALL },
|
||||
POKE_BALL = { randMax = 255,
|
||||
multiplier = Catching.BALL_MULTIPLIER.POKE_BALL },
|
||||
SAFARI_BALL = { randMax = 255,
|
||||
multiplier = Catching.BALL_MULTIPLIER.SAFARI_BALL },
|
||||
PARK_BALL = { randMax = 255,
|
||||
multiplier = Catching.BALL_MULTIPLIER.PARK_BALL },
|
||||
FRIEND_BALL = { randMax = 255,
|
||||
multiplier = Catching.BALL_MULTIPLIER.FRIEND_BALL,
|
||||
catchHappiness = Catching.FRIEND_BALL_HAPPINESS },
|
||||
HEAVY_BALL = { randMax = 255 },
|
||||
LEVEL_BALL = { randMax = 255 },
|
||||
LURE_BALL = { randMax = 255 },
|
||||
FAST_BALL = { randMax = 255 },
|
||||
MOON_BALL = { randMax = 255 },
|
||||
LOVE_BALL = { randMax = 255 },
|
||||
}
|
||||
|
||||
-- the conditional arms, hung on the records they belong to by identity so a
|
||||
-- registry read and Catching.specialtyRate cannot answer differently
|
||||
for id, arm in pairs(SPECIALTY) do
|
||||
Catching.BALLS[id].specialty = arm
|
||||
end
|
||||
|
||||
-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of
|
||||
-- one of these ids collides the way it does on Red and has to say override
|
||||
function Catching.registerInto(registry, _, owner)
|
||||
for id, record in pairs(Catching.BALLS) do
|
||||
registry:register(id, record, owner)
|
||||
end
|
||||
end
|
||||
|
||||
-- The merged `balls` table for this boot, or nil. Every catch site hands the
|
||||
-- module what it already holds -- a Battle (src/battle/gen2/Battle.lua keeps
|
||||
-- `data`), the data table itself, or the merged subtable -- so no state has to
|
||||
-- live on this module.
|
||||
local function mergedBalls(opts)
|
||||
if not opts then return nil end
|
||||
if opts.balls then return opts.balls end
|
||||
local data = opts.data or (opts.battle and opts.battle.data)
|
||||
return data and data.gen2Balls or nil
|
||||
end
|
||||
|
||||
-- The merged record for a ball id, the module's own when no loader ran. An
|
||||
-- unknown id answers nil on both paths: PokeBallEffect's table has no default
|
||||
-- row, and the rate math below leaves the species rate alone for one.
|
||||
function Catching.recordFor(ball, opts)
|
||||
local merged = mergedBalls(opts)
|
||||
local record = merged and merged[ball]
|
||||
if record then return record end
|
||||
return Catching.BALLS[ball]
|
||||
end
|
||||
|
||||
local function rand(random, n)
|
||||
if random then return random(n) end
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(n) - 1
|
||||
end
|
||||
return math.random(n) - 1
|
||||
end
|
||||
|
||||
-- The 0..255 rate. `opts`: maxHp, hp, catchRate, ball, status, fixBugs;
|
||||
-- the specialty-ball conditions ride the same table (weight, level,
|
||||
-- playerLevel, fishing, species, playerSpecies, gender, playerGender,
|
||||
-- evolveItem), each supplied by the catch site out of what it already knows.
|
||||
--
|
||||
-- One more optional key and it is the registry seam: `data` (or `battle`, or
|
||||
-- the merged `balls` / `statuses` subtables directly). A catch site that
|
||||
-- passes it gets the merged records, so a mod's ball and a mod's status reach
|
||||
-- the roll; one that does not gets the module's own, which is what every
|
||||
-- pure-module test and every loader-free boot has always had.
|
||||
function Catching.rate(opts)
|
||||
opts = opts or {}
|
||||
local ball = opts.ball or "POKE_BALL"
|
||||
-- Through the merged `balls` registry, module records when no loader ran.
|
||||
-- The three arms are exactly the three rows the record can carry: a flat
|
||||
-- multiplier, a conditional arm, or neither -- and "neither" is also what an
|
||||
-- unknown ball id gets, which is BallMultiplierFunctionTable's own answer of
|
||||
-- leaving the species rate alone.
|
||||
local record = Catching.recordFor(ball, opts)
|
||||
local multiplier = record and record.multiplier
|
||||
if record and record.autoCatch then return 255, true end
|
||||
if multiplier == math.huge then return 255, true end
|
||||
|
||||
local maxHp = math.max(1, opts.maxHp or 1)
|
||||
local hp = math.max(0, math.min(opts.hp or maxHp, maxHp))
|
||||
local catchRate
|
||||
if multiplier then
|
||||
catchRate = math.floor((opts.catchRate or 45) * multiplier)
|
||||
elseif record and record.specialty then
|
||||
catchRate = record.specialty(opts.catchRate or 45, opts)
|
||||
else
|
||||
catchRate = opts.catchRate or 45
|
||||
end
|
||||
catchRate = math.max(1, math.min(255, catchRate))
|
||||
|
||||
local tripleMax = maxHp * 3
|
||||
local doubleHp = hp * 2
|
||||
if tripleMax >= 256 then
|
||||
-- The cart's precision loss: both terms shift right two bits.
|
||||
tripleMax = math.floor(tripleMax / 4)
|
||||
doubleHp = math.floor(doubleHp / 4)
|
||||
if not opts.fixBugs then
|
||||
-- And it then compares only the low byte of the shifted max.
|
||||
tripleMax = tripleMax % 256
|
||||
end
|
||||
doubleHp = math.max(1, doubleHp)
|
||||
end
|
||||
tripleMax = math.max(1, tripleMax)
|
||||
|
||||
local rate = math.floor((tripleMax - doubleHp) * catchRate / tripleMax)
|
||||
rate = math.max(1, rate)
|
||||
|
||||
rate = rate + Catching.statusBonus(opts.status, opts)
|
||||
return math.min(255, rate), false
|
||||
end
|
||||
|
||||
-- The status half of the rate, off the merged `statuses` record the same way
|
||||
-- src/battle/Catching.lua reads record.catchBonus on Gen 1. Gold's records
|
||||
-- live on src/battle/gen2/Battle.lua (Battle.STATUSES) and carry BOTH numbers:
|
||||
-- `catchBonus` is what the cart actually adds (the `and` that tests for
|
||||
-- sleep/freeze leaves burn, poison and paralysis at zero) and
|
||||
-- `catchBonusIntended` is the 5 the table meant to give them, which is what
|
||||
-- `fixBugs` asks for. The two module tables answer when no loader ran.
|
||||
function Catching.statusBonus(status, opts)
|
||||
if not status then return 0 end
|
||||
local data = opts and (opts.data or (opts.battle and opts.battle.data))
|
||||
local statuses = (opts and opts.statuses) or (data and data.gen2Statuses)
|
||||
local record = statuses and statuses[status]
|
||||
if record then
|
||||
if opts and opts.fixBugs then
|
||||
return record.catchBonusIntended or record.catchBonus or 0
|
||||
end
|
||||
return record.catchBonus or 0
|
||||
end
|
||||
local bonuses = (opts and opts.fixBugs) and Catching.STATUS_BONUS_FIXED
|
||||
or Catching.STATUS_BONUS
|
||||
return bonuses[status] or 0
|
||||
end
|
||||
|
||||
-- Does the ball catch? Returns caught and the final rate (wFinalCatchRate).
|
||||
-- A rate of 255 or a Master Ball is certain.
|
||||
--
|
||||
-- No wobble count comes out of here: unlike Gen 1, Gen 2 decides how many times
|
||||
-- the ball rocks DURING the animation. GetPokeBallWobble
|
||||
-- (engine/battle_anims/pokeball_wobble.asm) is called once per wobble and
|
||||
-- re-rolls Random against the WobbleProbabilities row that wFinalCatchRate
|
||||
-- picks, so the count is a property of the animation loop and not of how near
|
||||
-- the catch roll was. The caller runs that loop with the rate returned here.
|
||||
function Catching.attempt(opts)
|
||||
opts = opts or {}
|
||||
local caught, rate
|
||||
if Runtime.wantsHook("catch.rate") then
|
||||
-- catch.rate, the same hook BattleState:catchAttempt calls on Gen 1 and
|
||||
-- with the same four arguments: the ball id, the target mon, its species
|
||||
-- record, and the options table vanilla is actually run on -- so a mod
|
||||
-- that edits o.catchRate or o.status changes the roll exactly as it does
|
||||
-- on Red, and one that returns `caught, rate` replaces it outright.
|
||||
--
|
||||
-- `mon` and `def` are whatever the catch site supplied. Gold's battle
|
||||
-- screen (src/ui/gen2/BattleState.lua) hands this module a FLAT opts table
|
||||
-- -- hp, maxHp, catchRate, status, species -- rather than the mon and its
|
||||
-- record, so both are nil there until it passes them; nil, not a stand-in
|
||||
-- a mod would read as the real mon. Passing `battle = self.battle` and
|
||||
-- `mon = enemy` with them is what puts the capture tail below on the real
|
||||
-- catch: the Transform reload and the battle.catch_exp hook both hang off
|
||||
-- the battle, and neither can be reached from a flat table.
|
||||
caught, rate = Runtime.call("catch.rate", function(_, _, _, o)
|
||||
return Catching.vanillaAttempt(o)
|
||||
end, opts.ball or "POKE_BALL", opts.mon, opts.def, opts)
|
||||
else
|
||||
caught, rate = Catching.vanillaAttempt(opts)
|
||||
end
|
||||
-- PokeBallEffect's captured tail runs BEFORE the mon is added to anything
|
||||
-- (item_effects.asm:514-566), so a caught mon is reloaded out of its base
|
||||
-- data -- and the battle.catch_exp hook is asked -- here, while the record
|
||||
-- the catch site is about to keep is still the battle's. Both live on
|
||||
-- Battle:caught; this is the seam that reaches it, and it is only reachable
|
||||
-- when the site hands over the battle it is catching out of (see the note on
|
||||
-- `mon` below). A caller with no battle -- every pure-module test -- gets
|
||||
-- the roll and nothing else, exactly as before.
|
||||
if caught and opts.battle and opts.battle.caught then
|
||||
opts.battle:caught(opts.mon)
|
||||
end
|
||||
-- battle.ball_thrown, the payload BattleState:throwBall emits on Gen 1.
|
||||
-- `shakes` is deliberately nil rather than 0: Gen 2 does not decide the
|
||||
-- wobble count here at all (see the note on Catching.attempt above -- the
|
||||
-- animation loop re-rolls GetPokeBallWobble per wobble), so there is no
|
||||
-- number to report at throw time and a 0 would read as "it did not rock".
|
||||
-- `rate` is the Gen 2 addition, the wFinalCatchRate the animation runs on.
|
||||
if Runtime.wants("battle.ball_thrown") then
|
||||
Runtime.emit("battle.ball_thrown", {
|
||||
battle = opts.battle, ball = opts.ball or "POKE_BALL",
|
||||
caught = caught, shakes = nil, rate = rate,
|
||||
mon = opts.mon, species = opts.species,
|
||||
})
|
||||
end
|
||||
return caught, rate
|
||||
end
|
||||
|
||||
function Catching.vanillaAttempt(opts)
|
||||
local rate, guaranteed = Catching.rate(opts)
|
||||
local random = opts and opts.random
|
||||
if guaranteed or rate >= 255 then return true, rate end
|
||||
-- The cart rolls one byte against the rate; a roll under it catches.
|
||||
local roll = rand(random, 256)
|
||||
if roll < rate then return true, rate end
|
||||
return false, rate
|
||||
end
|
||||
|
||||
return Catching
|
||||
@@ -0,0 +1,328 @@
|
||||
-- Gen 2 damage.
|
||||
--
|
||||
-- Ported from engine/battle/effect_commands.asm, in the order the cart's move
|
||||
-- sequence runs them: damagestats -> damagecalc -> stab -> damagevariation.
|
||||
--
|
||||
-- What differs from Gen 1 (src/battle/Damage.lua), and why this is its own
|
||||
-- module rather than a flag on that one:
|
||||
-- * Special is split into Special Attack and Special Defense, so a special
|
||||
-- move reads the attacker's SpA against the defender's SpD instead of both
|
||||
-- sides' single `special`.
|
||||
-- * Critical hits are a *chance ladder* (data/battle/critical_hit_chances.asm
|
||||
-- 1/15, 1/8, 1/4, 1/3, 1/2) indexed by a "critical level" that Focus
|
||||
-- Energy, a high-crit move, Scope Lens, and the Lucky Punch / Stick raise.
|
||||
-- Gen 1 instead derived the chance from base Speed.
|
||||
-- * A critical hit is a flat x2 and, unlike Gen 1, ignores the attacker's
|
||||
-- *negative* stat stages rather than all stages.
|
||||
-- * Type-boost held items (Charcoal, Mystic Water, ...) multiply before the
|
||||
-- crit, and Steel and Dark exist in the matchup table.
|
||||
--
|
||||
-- Whether a move is physical or special is still decided by its *type*, not
|
||||
-- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's
|
||||
-- records carry that as `category`.
|
||||
|
||||
local Damage = {}
|
||||
|
||||
-- data/battle/critical_hit_chances.asm, as "1 in N".
|
||||
Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 }
|
||||
|
||||
-- Gen 2's damage spread: 85% to 100% inclusive.
|
||||
Damage.MIN_VARIATION = 85
|
||||
Damage.MAX_VARIATION = 100
|
||||
|
||||
-- The cart caps a single hit at 999 (DAMAGE_CAP + MIN_DAMAGE in
|
||||
-- BattleCommand_DamageCalc).
|
||||
Damage.MAX_DAMAGE = 999
|
||||
|
||||
-- BattleCommand_DamageCalc's tail (engine/battle/effect_commands.asm) caps the
|
||||
-- computed damage at DAMAGE_CAP (997) and then adds MIN_DAMAGE (2) back, so
|
||||
-- every damaging hit leaves DamageCalc worth at least 2 -- which is what keeps
|
||||
-- a resisted hit from flooring to zero once the type matchup halves it.
|
||||
Damage.MIN_DAMAGE = 2
|
||||
|
||||
-- Stat stage multipliers (numerator, denominator), -6..+6. Same table as
|
||||
-- Gen 1; a critical hit skips only the negative half for the attacker.
|
||||
local STAGE = {
|
||||
[-6] = { 25, 100 }, [-5] = { 28, 100 }, [-4] = { 33, 100 },
|
||||
[-3] = { 40, 100 }, [-2] = { 50, 100 }, [-1] = { 66, 100 },
|
||||
[0] = { 1, 1 },
|
||||
[1] = { 15, 10 }, [2] = { 2, 1 }, [3] = { 25, 10 },
|
||||
[4] = { 3, 1 }, [5] = { 35, 10 }, [6] = { 4, 1 },
|
||||
}
|
||||
|
||||
function Damage.stageMultiplier(stage)
|
||||
local entry = STAGE[math.max(-6, math.min(6, stage or 0))]
|
||||
return entry[1], entry[2]
|
||||
end
|
||||
|
||||
-- Apply a stat stage, flooring like the cart's Multiply/Divide pair, and never
|
||||
-- letting a stat reach 0 (a 0 defence would divide by zero).
|
||||
function Damage.applyStage(value, stage)
|
||||
local numerator, denominator = Damage.stageMultiplier(stage)
|
||||
local out = math.floor(value * numerator / denominator)
|
||||
return math.max(1, out)
|
||||
end
|
||||
|
||||
-- Is this move physical? `types` is type_chart.lua's `types` table.
|
||||
function Damage.isPhysical(moveType, types)
|
||||
local record = types and types[moveType]
|
||||
if record and record.category then return record.category == "physical" end
|
||||
-- Without the table, fall back to the Gen 1/2 boundary: the physical block
|
||||
-- runs NORMAL..GROUND, and the special block starts at FIRE.
|
||||
local PHYSICAL = {
|
||||
NORMAL = true, FIGHTING = true, FLYING = true, POISON = true,
|
||||
GROUND = true, ROCK = true, BUG = true, GHOST = true, STEEL = true,
|
||||
}
|
||||
return PHYSICAL[moveType] == true
|
||||
end
|
||||
|
||||
-- The 1-in-N chance for a critical level.
|
||||
function Damage.criticalChance(level)
|
||||
local capped = math.max(0, math.min(6, level or 0))
|
||||
return Damage.CRITICAL_CHANCES[capped]
|
||||
end
|
||||
|
||||
-- BattleCommand_Critical, as a level rather than a roll:
|
||||
-- +1 Focus Energy, +2 a high-crit move, +1 Scope Lens,
|
||||
-- +2 Lucky Punch on Chansey / Stick on Farfetch'd.
|
||||
function Damage.criticalLevel(opts)
|
||||
local level = 0
|
||||
if opts.focusEnergy then level = level + 1 end
|
||||
if opts.highCritMove then level = level + 2 end
|
||||
if opts.scopeLens then level = level + 1 end
|
||||
if opts.speciesItemBonus then level = level + 2 end
|
||||
return math.min(6, level)
|
||||
end
|
||||
|
||||
-- Roll a critical hit. `random(n)` must return 0..n-1 (the cart compares a
|
||||
-- BattleRandom byte against the chance), and defaults to love/math random.
|
||||
function Damage.rollCritical(criticalLevel, random)
|
||||
local chance = Damage.criticalChance(criticalLevel)
|
||||
local roll
|
||||
if random then
|
||||
roll = random(chance)
|
||||
elseif love and love.math then
|
||||
roll = love.math.random(chance) - 1
|
||||
else
|
||||
roll = math.random(chance) - 1
|
||||
end
|
||||
return roll == 0
|
||||
end
|
||||
|
||||
-- The x10 type multiplier of a move against a defender, applying each matchup
|
||||
-- row separately and flooring in between -- the same rule Gen 1 follows, which
|
||||
-- is why a dual type can land on 4x or 0.25x.
|
||||
function Damage.typeMultiplier(moveType, defenderTypes, matchups)
|
||||
local multiplier = 10
|
||||
for _, row in ipairs(matchups or {}) do
|
||||
if row.attacker == moveType then
|
||||
for _, defenderType in ipairs(defenderTypes or {}) do
|
||||
if row.defender == defenderType then
|
||||
multiplier = math.floor(multiplier * row.multiplier / 10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return multiplier
|
||||
end
|
||||
|
||||
-- The core formula (BattleCommand_DamageCalc):
|
||||
--
|
||||
-- (((2 * Level / 5 + 2) * Power * Attack / Defense) / 50)
|
||||
--
|
||||
-- every step floored, defence clamped to at least 1.
|
||||
function Damage.base(level, power, attack, defense)
|
||||
if (power or 0) <= 0 then return 0 end
|
||||
defense = math.max(1, defense or 1)
|
||||
local value = math.floor(level * 2 / 5) + 2
|
||||
value = value * power
|
||||
value = value * attack
|
||||
value = math.floor(value / defense)
|
||||
value = math.floor(value / 50)
|
||||
return value
|
||||
end
|
||||
|
||||
-- Every `info` table Damage.calc returns carries BOTH generations' names for
|
||||
-- the same two facts: Gen 2 calls them `critical` and `effectiveness`, Gen 1
|
||||
-- (src/battle/Damage.lua) calls them `crit` and `typeMult`. The battle.damage
|
||||
-- hook hands this table to whatever wrapped it, and a mod written against Red
|
||||
-- must be able to read what it wrapped.
|
||||
local function withGen1Names(info)
|
||||
info.crit = info.critical
|
||||
info.typeMult = info.effectiveness
|
||||
return info
|
||||
end
|
||||
|
||||
-- opts:
|
||||
-- level, power, moveType
|
||||
-- attacker { attack, specialAttack, types, stages = { attack =, ... } }
|
||||
-- defender { defense, specialDefense, types, stages }
|
||||
-- types, matchups -- type_chart.lua's `types` / `matchups`
|
||||
-- critical -- boolean (roll it with rollCritical first)
|
||||
-- itemBoostPercent -- type-boost held item, e.g. 10 for Charcoal
|
||||
-- weatherPercent -- DoWeatherModifiers in tenths (15 / 5 / nil),
|
||||
-- applied ahead of the badge boost and STAB
|
||||
-- badgeTypeBoost -- DoBadgeTypeBoosts: the player owns the badge
|
||||
-- matching the move's type, +1/8 before STAB
|
||||
-- variation -- 85..100; omit to roll
|
||||
-- random(n) -- 0..n-1, for the variation roll
|
||||
-- screen -- Reflect/Light Screen active on the defender
|
||||
-- defenseHalved -- EFFECT_SELFDESTRUCT's srl c
|
||||
--
|
||||
-- Returns damage, info where info carries the pieces a battle message needs:
|
||||
-- effectiveness (x10), critical, physical, variation.
|
||||
function Damage.calc(opts)
|
||||
local physical = Damage.isPhysical(opts.moveType, opts.types)
|
||||
local attacker = opts.attacker or {}
|
||||
local defender = opts.defender or {}
|
||||
local stagesA = attacker.stages or {}
|
||||
local stagesD = defender.stages or {}
|
||||
|
||||
local rawAttack = physical and (attacker.attack or 1)
|
||||
or (attacker.specialAttack or attacker.special or 1)
|
||||
local rawDefense = physical and (defender.defense or 1)
|
||||
or (defender.specialDefense or defender.special or 1)
|
||||
local stageA = physical and (stagesA.attack or 0)
|
||||
or (stagesA.specialAttack or 0)
|
||||
local stageD = physical and (stagesD.defense or 0)
|
||||
or (stagesD.specialDefense or 0)
|
||||
|
||||
-- A critical hit ignores stat changes that would *lower* the damage: the
|
||||
-- attacker's negative stages and the defender's positive ones.
|
||||
if opts.critical then
|
||||
if stageA < 0 then stageA = 0 end
|
||||
if stageD > 0 then stageD = 0 end
|
||||
end
|
||||
|
||||
local attack = Damage.applyStage(rawAttack, stageA)
|
||||
local defense = Damage.applyStage(rawDefense, stageD)
|
||||
|
||||
-- Reflect and Light Screen double the matching defence, and are the one
|
||||
-- multiplier a critical hit also ignores.
|
||||
if opts.screen and not opts.critical then
|
||||
defense = defense * 2
|
||||
end
|
||||
|
||||
-- BattleCommand_DamageCalc (effect_commands.asm:2905-2913): Selfdestruct and
|
||||
-- Explosion halve the defence, never below 1.
|
||||
if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end
|
||||
|
||||
if (opts.power or 0) <= 0 then
|
||||
return 0, withGen1Names({ effectiveness = 10, critical = false,
|
||||
physical = physical })
|
||||
end
|
||||
local damage = Damage.base(opts.level or 1, opts.power or 0, attack, defense)
|
||||
|
||||
-- Type-boost held items multiply before the crit (.NextItem / .DoneItem).
|
||||
if opts.itemBoostPercent and opts.itemBoostPercent > 0 then
|
||||
damage = math.floor(damage * (100 + opts.itemBoostPercent) / 100)
|
||||
end
|
||||
|
||||
if opts.critical then damage = damage * 2 end
|
||||
|
||||
-- BattleCommand_DamageCalc's tail: cap at DAMAGE_CAP, then add MIN_DAMAGE
|
||||
-- back, so even a hit whose stat math floored to nothing leaves with 2.
|
||||
damage = math.min(damage, Damage.MAX_DAMAGE - Damage.MIN_DAMAGE)
|
||||
+ Damage.MIN_DAMAGE
|
||||
|
||||
-- DoWeatherModifiers (engine/battle/misc.asm:102-140), farcalled by
|
||||
-- BattleCommand_Stab as its very FIRST act (effect_commands.asm:1254): it
|
||||
-- sits ahead of the badge boost, the STAB x1.5, the type rows and
|
||||
-- DamageVariation, so every later step floors on top of it. The table's
|
||||
-- values are tenths (weather_modifiers.asm: MORE_EFFECTIVE 15,
|
||||
-- NOT_VERY_EFFECTIVE 05), and .ApplyModifier's zero-quotient arm forces the
|
||||
-- result back to 1, so a weather-halved hit never falls to nothing.
|
||||
if opts.weatherPercent and opts.weatherPercent ~= 10 then
|
||||
damage = math.max(1, math.floor(damage * opts.weatherPercent / 10))
|
||||
end
|
||||
|
||||
-- DoBadgeTypeBoosts (engine/battle/misc.asm:146), farcalled from
|
||||
-- BattleCommand_Stab ahead of the STAB multiply: a matching owned badge
|
||||
-- adds an eighth of the running damage, at least 1, on the player's turn.
|
||||
if opts.badgeTypeBoost then
|
||||
damage = damage + math.max(1, math.floor(damage / 8))
|
||||
end
|
||||
|
||||
-- STAB, then each type row. BattleCommand_Stab does STAB first, so a
|
||||
-- resisted same-type move floors after the x1.5.
|
||||
local stab = false
|
||||
for _, attackerType in ipairs(attacker.types or {}) do
|
||||
if attackerType == opts.moveType then stab = true break end
|
||||
end
|
||||
if stab then damage = math.floor(damage * 15 / 10) end
|
||||
|
||||
-- Each matchup row multiplies the running damage separately, the way
|
||||
-- BattleCommand_Stab's .TypesLoop does -- and its zero-quotient check forces
|
||||
-- the damage back to 1 whenever a non-immune row floors it to nothing, so a
|
||||
-- resisted hit that lands always deals at least 1 HP.
|
||||
local effectiveness = Damage.typeMultiplier(
|
||||
opts.moveType, defender.types, opts.matchups)
|
||||
for _, row in ipairs(opts.matchups or {}) do
|
||||
if row.attacker == opts.moveType then
|
||||
for _, defenderType in ipairs(defender.types or {}) do
|
||||
if row.defender == defenderType then
|
||||
damage = math.floor(damage * row.multiplier / 10)
|
||||
if damage == 0 and row.multiplier > 0 then damage = 1 end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if effectiveness <= 0 or damage <= 0 then
|
||||
return 0, withGen1Names({
|
||||
effectiveness = effectiveness, critical = opts.critical or false,
|
||||
physical = physical, stab = stab,
|
||||
})
|
||||
end
|
||||
|
||||
-- Damage variation last, and only when the running damage is 2 or more
|
||||
-- (BattleCommand_DamageVariation returns early below that).
|
||||
local variation = opts.variation
|
||||
if not variation then
|
||||
if opts.random then
|
||||
variation = Damage.MIN_VARIATION
|
||||
+ opts.random(Damage.MAX_VARIATION - Damage.MIN_VARIATION + 1)
|
||||
elseif love and love.math then
|
||||
variation = love.math.random(Damage.MIN_VARIATION, Damage.MAX_VARIATION)
|
||||
else
|
||||
variation = math.random(Damage.MIN_VARIATION, Damage.MAX_VARIATION)
|
||||
end
|
||||
end
|
||||
if damage >= 2 then
|
||||
damage = math.floor(damage * variation / 100)
|
||||
end
|
||||
|
||||
damage = math.max(1, math.min(Damage.MAX_DAMAGE, damage))
|
||||
return damage, withGen1Names({
|
||||
effectiveness = effectiveness,
|
||||
critical = opts.critical or false,
|
||||
physical = physical,
|
||||
stab = stab,
|
||||
variation = variation,
|
||||
})
|
||||
end
|
||||
|
||||
-- Accuracy check. Gen 2 rolls one byte against accuracy scaled by the
|
||||
-- attacker's accuracy stage and the defender's evasion stage; accuracy of 0 in
|
||||
-- the data means "never misses" (Swift and friends).
|
||||
function Damage.rollHit(accuracy, accuracyStage, evasionStage, random)
|
||||
if not accuracy or accuracy <= 0 then return true end
|
||||
local numerator, denominator = Damage.stageMultiplier(accuracyStage or 0)
|
||||
local value = math.floor(accuracy * numerator / denominator)
|
||||
numerator, denominator = Damage.stageMultiplier(-(evasionStage or 0))
|
||||
value = math.floor(value * numerator / denominator)
|
||||
value = math.max(1, math.min(100, value))
|
||||
local roll
|
||||
if random then
|
||||
roll = random(100)
|
||||
elseif love and love.math then
|
||||
roll = love.math.random(100) - 1
|
||||
else
|
||||
roll = math.random(100) - 1
|
||||
end
|
||||
return roll < value
|
||||
end
|
||||
|
||||
return Damage
|
||||
@@ -0,0 +1,510 @@
|
||||
-- Gen 2 move effects, as data plus the small amount of arithmetic each one
|
||||
-- needs. Ported from engine/battle/effect_commands.asm; the battle engine
|
||||
-- (src/battle/gen2/Battle.lua) owns the turn loop and calls in here for what a
|
||||
-- move does beyond "roll damage, maybe inflict a status".
|
||||
--
|
||||
-- Everything is keyed by the move's *effect*, the same byte data/moves/moves.asm
|
||||
-- stores, so a modded move that copies an effect inherits its behaviour -- and
|
||||
-- an effect this table does not name still lands as an ordinary hit rather than
|
||||
-- silently doing the wrong thing.
|
||||
--
|
||||
-- No love calls and no engine state: every function takes what it needs and
|
||||
-- returns a value or a small table, which is what lets the tests drive them
|
||||
-- directly.
|
||||
|
||||
local Effects = {}
|
||||
|
||||
-- --------------------------------------------------------------- stat stages
|
||||
--
|
||||
-- Stat changes come in four shapes and the effect name says which:
|
||||
-- *_UP / *_UP_2 raise the user, by one stage or two
|
||||
-- *_DOWN / *_DOWN_2 lower the target
|
||||
-- *_UP_HIT raise the user after a damaging hit
|
||||
-- *_DOWN_HIT lower the target after a damaging hit
|
||||
-- StatUpMessage / StatDownMessage are the same either way, so the direction
|
||||
-- and the target are the only things worth tabulating.
|
||||
|
||||
-- { stat, stages, target } where target is "self" or "foe".
|
||||
Effects.STAT_CHANGES = {
|
||||
EFFECT_ATTACK_UP = { "attack", 1, "self" },
|
||||
EFFECT_DEFENSE_UP = { "defense", 1, "self" },
|
||||
EFFECT_SP_ATK_UP = { "specialAttack", 1, "self" },
|
||||
EFFECT_EVASION_UP = { "evasion", 1, "self" },
|
||||
EFFECT_ATTACK_UP_2 = { "attack", 2, "self" },
|
||||
EFFECT_DEFENSE_UP_2 = { "defense", 2, "self" },
|
||||
EFFECT_SPEED_UP_2 = { "speed", 2, "self" },
|
||||
EFFECT_SP_DEF_UP_2 = { "specialDefense", 2, "self" },
|
||||
-- Defense Curl also arms Rollout, which Battle tracks separately.
|
||||
EFFECT_DEFENSE_CURL = { "defense", 1, "self" },
|
||||
|
||||
EFFECT_ATTACK_DOWN = { "attack", -1, "foe" },
|
||||
EFFECT_DEFENSE_DOWN = { "defense", -1, "foe" },
|
||||
EFFECT_SPEED_DOWN = { "speed", -1, "foe" },
|
||||
EFFECT_ACCURACY_DOWN = { "accuracy", -1, "foe" },
|
||||
EFFECT_EVASION_DOWN = { "evasion", -1, "foe" },
|
||||
EFFECT_ATTACK_DOWN_2 = { "attack", -2, "foe" },
|
||||
EFFECT_DEFENSE_DOWN_2 = { "defense", -2, "foe" },
|
||||
EFFECT_SPEED_DOWN_2 = { "speed", -2, "foe" },
|
||||
}
|
||||
|
||||
-- The secondary versions, rolled against the move's effect chance after a hit.
|
||||
Effects.STAT_CHANGES_ON_HIT = {
|
||||
EFFECT_ATTACK_UP_HIT = { "attack", 1, "self" },
|
||||
EFFECT_DEFENSE_UP_HIT = { "defense", 1, "self" },
|
||||
EFFECT_ATTACK_DOWN_HIT = { "attack", -1, "foe" },
|
||||
EFFECT_DEFENSE_DOWN_HIT = { "defense", -1, "foe" },
|
||||
EFFECT_SPEED_DOWN_HIT = { "speed", -1, "foe" },
|
||||
EFFECT_ACCURACY_DOWN_HIT = { "accuracy", -1, "foe" },
|
||||
EFFECT_SP_DEF_DOWN_HIT = { "specialDefense", -1, "foe" },
|
||||
}
|
||||
|
||||
-- Ancient Power raises every one of the user's stats at once.
|
||||
Effects.ALL_UP_STATS = {
|
||||
"attack", "defense", "speed", "specialAttack", "specialDefense",
|
||||
}
|
||||
|
||||
Effects.STAT_NAMES = {
|
||||
attack = "ATTACK", defense = "DEFENSE", speed = "SPEED",
|
||||
specialAttack = "SPCL.ATK", specialDefense = "SPCL.DEF",
|
||||
accuracy = "ACCURACY", evasion = "EVASION",
|
||||
}
|
||||
|
||||
-- Stages clamp at ±6 (BattleCommand_StatUp's .CantRaise / .CantLower).
|
||||
Effects.MAX_STAGE = 6
|
||||
|
||||
-- Applies a change and says what happened, so the caller can emit the cart's
|
||||
-- own message: nil when the stage was already at the cap.
|
||||
function Effects.applyStage(stages, stat, delta)
|
||||
if not (stages and stat) then return nil end
|
||||
local current = stages[stat] or 0
|
||||
local wanted = current + delta
|
||||
if wanted > Effects.MAX_STAGE then wanted = Effects.MAX_STAGE end
|
||||
if wanted < -Effects.MAX_STAGE then wanted = -Effects.MAX_STAGE end
|
||||
if wanted == current then return nil end
|
||||
stages[stat] = wanted
|
||||
return wanted - current
|
||||
end
|
||||
|
||||
-- BattleCommand_StatUpMessage / StatDownMessage: one stage is "rose"/"fell",
|
||||
-- two are "sharply rose" / "sharply fell".
|
||||
function Effects.stageMessage(name, stat, applied)
|
||||
local label = Effects.STAT_NAMES[stat] or stat
|
||||
local sharply = math.abs(applied) >= 2 and "sharply " or ""
|
||||
local verb = applied > 0 and "rose" or "fell"
|
||||
return ("%s's %s %s%s!"):format(name, label, sharply, verb)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ hit count
|
||||
--
|
||||
-- BattleCommand_CheckHit's multi-hit roll: 2 and 3 hits are 3/8 each, 4 and 5
|
||||
-- are 1/8 each, which is what the `and 3` on a 0-3 roll plus the two-step
|
||||
-- fallthrough in .DetermineNumberOfHits produces.
|
||||
function Effects.multiHitCount(random)
|
||||
local roll = random and random(4) or 0
|
||||
if roll < 2 then return roll + 2 end
|
||||
-- Hits 4 and 5 take a second roll, so each ends up half as likely.
|
||||
local second = random and random(2) or 0
|
||||
return second + 4
|
||||
end
|
||||
|
||||
Effects.HIT_COUNTS = {
|
||||
EFFECT_DOUBLE_HIT = 2,
|
||||
-- Triple Kick stops early if a hit misses; Battle rolls that per hit.
|
||||
EFFECT_TRIPLE_KICK = 3,
|
||||
}
|
||||
|
||||
function Effects.hitCount(effect, random)
|
||||
if effect == "EFFECT_MULTI_HIT" or effect == "EFFECT_POISON_MULTI_HIT" then
|
||||
return Effects.multiHitCount(random)
|
||||
end
|
||||
return Effects.HIT_COUNTS[effect] or 1
|
||||
end
|
||||
|
||||
-- Triple Kick's power climbs 10/20/30 across its three kicks
|
||||
-- (BattleCommand_TripleKick).
|
||||
function Effects.tripleKickPower(base, hit)
|
||||
return (base or 10) * hit
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------- recoil and drain
|
||||
|
||||
-- BattleCommand_Recoil: a quarter of the damage dealt, minimum 1.
|
||||
function Effects.recoilDamage(damageDealt)
|
||||
return math.max(1, math.floor((damageDealt or 0) / 4))
|
||||
end
|
||||
|
||||
-- BattleCommand_DrainTarget: half the damage dealt, minimum 1.
|
||||
function Effects.drainAmount(damageDealt)
|
||||
return math.max(1, math.floor((damageDealt or 0) / 2))
|
||||
end
|
||||
|
||||
Effects.DRAIN = {
|
||||
EFFECT_LEECH_HIT = true,
|
||||
EFFECT_DREAM_EATER = true,
|
||||
}
|
||||
|
||||
-- --------------------------------------------------------------- two-turn
|
||||
--
|
||||
-- The charge moves all share BattleCommand_Charge: turn one prints a line and
|
||||
-- stores the move, turn two attacks. Fly and Dig also make the user
|
||||
-- untargetable in between, which is the `semi-invulnerable` flag here.
|
||||
Effects.CHARGE = {
|
||||
EFFECT_RAZOR_WIND = { text = "%s made a whirlwind!" },
|
||||
EFFECT_SOLARBEAM = { text = "%s took in sunlight!" },
|
||||
EFFECT_SKULL_BASH = { text = "%s lowered its head!" },
|
||||
EFFECT_SKY_ATTACK = { text = "%s is glowing!" },
|
||||
EFFECT_FLY = { text = "%s flew up high!", vanish = true },
|
||||
}
|
||||
|
||||
-- CheckHit's .FlyDigMoves (effect_commands.asm:1713-1746): a vanished target
|
||||
-- is not a flat miss, four moves reach it in the air and three underground.
|
||||
Effects.FLY_DIG_EXCEPTIONS = {
|
||||
FLY = { GUST = true, WHIRLWIND = true, THUNDER = true, TWISTER = true },
|
||||
DIG = { EARTHQUAKE = true, FISSURE = true, MAGNITUDE = true },
|
||||
}
|
||||
|
||||
-- Keyed by the charge move the target is partway through, which is what the
|
||||
-- port carries in place of SUBSTATUS_FLYING / SUBSTATUS_UNDERGROUND.
|
||||
function Effects.hitsVanished(chargeMove, moveId)
|
||||
local reaches = Effects.FLY_DIG_EXCEPTIONS[chargeMove]
|
||||
return (reaches and reaches[moveId]) and true or false
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- fixed damage
|
||||
|
||||
-- BattleCommand_LevelDamage / SuperFang / Psywave, all of which skip the
|
||||
-- damage formula entirely.
|
||||
function Effects.fixedDamage(effect, attacker, defender, random)
|
||||
if effect == "EFFECT_LEVEL_DAMAGE" then
|
||||
return math.max(1, attacker.level or 1)
|
||||
end
|
||||
if effect == "EFFECT_SUPER_FANG" then
|
||||
return math.max(1, math.floor((defender.hp or 1) / 2))
|
||||
end
|
||||
if effect == "EFFECT_PSYWAVE" then
|
||||
-- 1..(level * 1.5), rerolled until it is in range; one roll is enough here.
|
||||
local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2))
|
||||
return math.max(1, (random and random(ceiling) or 0) + 1)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- Substitute
|
||||
|
||||
-- BattleCommand_Substitute: a quarter of max HP, which is also what the user
|
||||
-- pays. Refuses when the user has that much HP or less.
|
||||
function Effects.substituteCost(maxHp)
|
||||
return math.max(1, math.floor((maxHp or 1) / 4))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ counter moves
|
||||
|
||||
-- Counter answers physical damage, Mirror Coat special, both at double and
|
||||
-- both only when the foe hit the user this turn (BattleCommand_Counter).
|
||||
Effects.COUNTER = {
|
||||
EFFECT_COUNTER = "physical",
|
||||
EFFECT_MIRROR_COAT = "special",
|
||||
}
|
||||
|
||||
function Effects.counterDamage(taken)
|
||||
return math.max(1, (taken or 0) * 2)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------ rollout / fury cutter
|
||||
|
||||
-- Both double their power per consecutive use, Rollout for five turns and Fury
|
||||
-- Cutter until it misses; the cart caps the doubling at 5 steps either way.
|
||||
Effects.RAMPING = {
|
||||
EFFECT_ROLLOUT = 5,
|
||||
EFFECT_FURY_CUTTER = 5,
|
||||
}
|
||||
|
||||
function Effects.rampedPower(base, count, curled)
|
||||
local steps = math.min(math.max(count or 0, 0), 4)
|
||||
local power = (base or 1) * 2 ^ steps
|
||||
-- Defense Curl doubles Rollout again (BattleCommand_RolloutPower).
|
||||
if curled then power = power * 2 end
|
||||
return math.floor(power)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------- magnitude
|
||||
--
|
||||
-- data/moves/magnitude_power.asm, one row per magnitude: { chance, power,
|
||||
-- magnitude number }. The chance column is assembled through `percent`
|
||||
-- (`* $ff / 100`, macros/data.asm:23), so `5 percent + 1` is 13 and
|
||||
-- `100 percent` is 255 -- the thresholds below are those bytes, not the
|
||||
-- percentages they were written as.
|
||||
Effects.MAGNITUDE_POWER = {
|
||||
{ 13, 10, 4 },
|
||||
{ 38, 30, 5 },
|
||||
{ 89, 50, 6 },
|
||||
{ 166, 70, 7 },
|
||||
{ 217, 90, 8 },
|
||||
{ 242, 110, 9 },
|
||||
{ 255, 150, 10 },
|
||||
}
|
||||
|
||||
-- BattleCommand_GetMagnitude (engine/battle/move_effects/magnitude.asm): ONE
|
||||
-- random byte walks the table and the first row whose threshold is not below
|
||||
-- it wins (`ld a, [hli] / cp b / jr nc`). The row's power goes into d, which
|
||||
-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores
|
||||
-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
|
||||
-- power and the magnitude number the text prints.
|
||||
function Effects.magnitudePower(random)
|
||||
local roll = random and random(256) or 0
|
||||
for _, row in ipairs(Effects.MAGNITUDE_POWER) do
|
||||
if row[1] >= roll then return row[2], row[3] end
|
||||
end
|
||||
local last = Effects.MAGNITUDE_POWER[#Effects.MAGNITUDE_POWER]
|
||||
return last[2], last[3]
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------- weather
|
||||
--
|
||||
-- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to
|
||||
-- 5, which HandleWeather decrements at the end of every turn; the turn it
|
||||
-- reaches zero the weather ends. data/battle/weather_modifiers.asm is the
|
||||
-- whole of what weather does to damage.
|
||||
|
||||
Effects.WEATHER = {
|
||||
EFFECT_RAIN_DANCE = "rain",
|
||||
EFFECT_SUNNY_DAY = "sun",
|
||||
EFFECT_SANDSTORM = "sandstorm",
|
||||
}
|
||||
|
||||
Effects.WEATHER_TURNS = 5
|
||||
|
||||
Effects.WEATHER_START_TEXT = {
|
||||
rain = "It started to rain!",
|
||||
sun = "The sunlight got bright!",
|
||||
sandstorm = "A sandstorm brewed!",
|
||||
}
|
||||
|
||||
Effects.WEATHER_TURN_TEXT = {
|
||||
rain = "Rain continues to fall.",
|
||||
sun = "The sunlight is strong.",
|
||||
sandstorm = "The sandstorm rages.",
|
||||
}
|
||||
|
||||
Effects.WEATHER_END_TEXT = {
|
||||
rain = "The rain stopped.",
|
||||
sun = "The sunlight faded.",
|
||||
sandstorm = "The sandstorm subsided.",
|
||||
}
|
||||
|
||||
-- data/battle/weather_modifiers.asm pairs each weather with MORE_EFFECTIVE or
|
||||
-- NOT_VERY_EFFECTIVE, and those are 15 and 05 in tenths
|
||||
-- (constants/battle_constants.asm:22, :24) -- MORE_EFFECTIVE is x1.5, NOT the
|
||||
-- type chart's x2, which is SUPER_EFFECTIVE (20). Gen 2's weather boost is a
|
||||
-- half again, and only the type chart doubles.
|
||||
Effects.WEATHER_TYPE_MODIFIERS = {
|
||||
rain = { WATER = 1.5, FIRE = 0.5 },
|
||||
sun = { FIRE = 1.5, WATER = 0.5 },
|
||||
}
|
||||
|
||||
-- The one move whose EFFECT rather than type is modified: Solarbeam in rain.
|
||||
Effects.WEATHER_MOVE_MODIFIERS = {
|
||||
rain = { EFFECT_SOLARBEAM = 0.5 },
|
||||
}
|
||||
|
||||
function Effects.weatherModifier(weather, moveType, effect)
|
||||
if not weather then return 1 end
|
||||
local byType = Effects.WEATHER_TYPE_MODIFIERS[weather]
|
||||
if byType and byType[moveType] then return byType[moveType] end
|
||||
local byMove = Effects.WEATHER_MOVE_MODIFIERS[weather]
|
||||
if byMove and byMove[effect] then return byMove[effect] end
|
||||
return 1
|
||||
end
|
||||
|
||||
-- HandleWeather's .SandstormDamage: an eighth of max HP, and Rock, Ground and
|
||||
-- Steel are immune. A mon underground (Dig) is skipped too.
|
||||
Effects.SANDSTORM_IMMUNE = { ROCK = true, GROUND = true, STEEL = true }
|
||||
|
||||
function Effects.sandstormDamage(maxHp)
|
||||
return math.max(1, math.floor((maxHp or 8) / 8))
|
||||
end
|
||||
|
||||
function Effects.sandstormHits(types)
|
||||
for _, type_ in ipairs(types or {}) do
|
||||
if Effects.SANDSTORM_IMMUNE[type_] then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather
|
||||
-- shifts that one step either way: x2 in sun, /2 in rain or sandstorm
|
||||
-- (BattleCommand_Heal's .Weather block walks a multiplier index).
|
||||
Effects.SUN_HEAL = {
|
||||
EFFECT_MORNING_SUN = true,
|
||||
EFFECT_SYNTHESIS = true,
|
||||
EFFECT_MOONLIGHT = true,
|
||||
}
|
||||
|
||||
function Effects.weatherHealFraction(weather)
|
||||
if weather == "sun" then return 2 / 3 end
|
||||
if weather == "rain" or weather == "sandstorm" then return 1 / 4 end
|
||||
return 1 / 2
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- Perish Song
|
||||
--
|
||||
-- BattleCommand_PerishSong sets the counter to 4 on BOTH sides; it ticks down
|
||||
-- at the end of every turn and the mon faints when it reaches 0.
|
||||
Effects.PERISH_TURNS = 4
|
||||
|
||||
-- -------------------------------------------------------------------- Encore
|
||||
--
|
||||
-- 3-6 turns (`and $3` plus three increments), and the target is locked into
|
||||
-- the move it last used. Encore, Mirror Move and Struggle cannot be encored,
|
||||
-- and neither can a move with no PP left.
|
||||
Effects.ENCORE_BLOCKED = {
|
||||
ENCORE = true, MIRROR_MOVE = true, STRUGGLE = true,
|
||||
}
|
||||
|
||||
function Effects.encoreTurns(random)
|
||||
return (random and random(4) or 0) + 3
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------- Disable
|
||||
--
|
||||
-- The count is a packed byte: the low nybble is the number of turns (1-8, the
|
||||
-- `and 7` retried until nonzero, then incremented) and the high nybble is the
|
||||
-- move slot plus one. Only the turn count matters to the port, but the shape
|
||||
-- is what says a slot of 0 means "nothing disabled".
|
||||
function Effects.disableTurns(random)
|
||||
local roll = 0
|
||||
for _ = 1, 8 do
|
||||
roll = (random and random(8) or 1) % 8
|
||||
if roll ~= 0 then break end
|
||||
end
|
||||
if roll == 0 then roll = 1 end
|
||||
return roll + 1
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------- Protect and Endure
|
||||
--
|
||||
-- ProtectChance halves the success chance for every CONSECUTIVE use: the
|
||||
-- threshold starts at $ff and is shifted right once per use, so use n
|
||||
-- succeeds with probability (256 >> n) / 256. Once the shift reaches zero the
|
||||
-- move always fails, which is five uses.
|
||||
function Effects.protectChance(consecutive)
|
||||
local threshold = 0xff
|
||||
for _ = 1, (consecutive or 0) do
|
||||
threshold = math.floor(threshold / 2)
|
||||
if threshold == 0 then return 0 end
|
||||
end
|
||||
return threshold
|
||||
end
|
||||
|
||||
-- The roll is a non-zero byte, decremented, and the move succeeds when it is
|
||||
-- BELOW the threshold.
|
||||
function Effects.protectSucceeds(consecutive, random)
|
||||
local threshold = Effects.protectChance(consecutive)
|
||||
if threshold == 0 then return false end
|
||||
local roll = (random and random(255) or 0) + 1
|
||||
return (roll - 1) < threshold
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------- Bide
|
||||
--
|
||||
-- BattleCommand_UnleashEnergy stores for 2 or 3 turns (`and 1` plus two
|
||||
-- increments) and BattleCommand_StoreEnergy pays back DOUBLE everything the
|
||||
-- user took while storing, capped at the 16-bit maximum.
|
||||
function Effects.bideTurns(random)
|
||||
return (random and random(2) or 0) + 2
|
||||
end
|
||||
|
||||
function Effects.bideDamage(stored)
|
||||
return math.min(0xffff, (stored or 0) * 2)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------- Rage
|
||||
--
|
||||
-- SUBSTATUS_RAGE: while it is set, every hit the user takes raises its Attack
|
||||
-- one stage. It is cleared by using any other move.
|
||||
|
||||
-- ---------------------------------------------------------------- Future Sight
|
||||
--
|
||||
-- Four turns: the damage is rolled NOW, stored, and lands when the counter
|
||||
-- reaches one. The move itself does nothing on the turn it is used.
|
||||
Effects.FUTURE_SIGHT_TURNS = 4
|
||||
|
||||
-- ---------------------------------------------------------------------- OHKO
|
||||
--
|
||||
-- BattleCommand_OHKO fails outright when the target is the higher level; when
|
||||
-- it is not, the move's accuracy becomes acc + 2 * (level difference), capped
|
||||
-- at 255, and a hit sets damage to $ffff.
|
||||
function Effects.ohkoAccuracy(baseAccuracy, userLevel, targetLevel)
|
||||
if (targetLevel or 1) > (userLevel or 1) then return nil end
|
||||
local bonus = ((userLevel or 1) - (targetLevel or 1)) * 2
|
||||
return math.min(255, (baseAccuracy or 0) + bonus)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------- Beat Up
|
||||
--
|
||||
-- One hit per party member that is alive and free of any major status, each
|
||||
-- swinging with that member's own base Attack and level against the target's
|
||||
-- base Defense. The move fails outright when nobody qualifies.
|
||||
function Effects.beatUpParty(party, activeIndex)
|
||||
local hits = {}
|
||||
for index, mon in ipairs(party or {}) do
|
||||
local healthy = (mon.hp or 0) > 0
|
||||
-- The ACTIVE mon is checked against its battle status rather than its
|
||||
-- party record, which is the same thing here.
|
||||
local clean = not mon.status or (index == activeIndex and not mon.status)
|
||||
if healthy and clean then
|
||||
hits[#hits + 1] = { index = index, mon = mon }
|
||||
end
|
||||
end
|
||||
return hits
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- Baton Pass
|
||||
--
|
||||
-- ResetBatonPassStatus: what does NOT survive the switch. Everything else --
|
||||
-- the stat stages, Substitute, Leech Seed, Perish Song, the confusion counter
|
||||
-- -- goes with the incoming mon, which is the whole point of the move.
|
||||
--
|
||||
-- `preTransform` is deliberately NOT on this list even though `transformed` is:
|
||||
-- it is the passer's own identity waiting to be put back, and the drops run
|
||||
-- BEFORE Battle:clearVolatile, which is what puts it back and clears both keys
|
||||
-- (Battle:untransform). Dropping it here would strand a passing DITTO as a
|
||||
-- permanent copy instead.
|
||||
Effects.BATON_PASS_DROPS = {
|
||||
"nightmare", "disable", "disableTurns", "attract", "transformed",
|
||||
"encore", "encoreTurns", "lastMove",
|
||||
}
|
||||
|
||||
-- ------------------------------------------------------------------ Metronome
|
||||
--
|
||||
-- data/moves/metronome_exception_moves.asm: Metronome cannot pick these, and
|
||||
-- it also never picks a move the user already knows.
|
||||
Effects.METRONOME_EXCEPTIONS = {
|
||||
METRONOME = true, STRUGGLE = true, SKETCH = true, MIMIC = true,
|
||||
COUNTER = true, MIRROR_COAT = true, PROTECT = true, DETECT = true,
|
||||
ENDURE = true, DESTINY_BOND = true, SLEEP_TALK = true, THIEF = true,
|
||||
}
|
||||
|
||||
-- Picks a move id uniformly out of `moveOrder`, rerolling on an excepted move
|
||||
-- or one the user already has -- the same reject loop .GetMove runs.
|
||||
function Effects.metronomePick(moveOrder, known, random)
|
||||
local count = #(moveOrder or {})
|
||||
if count == 0 then return nil end
|
||||
local owned = {}
|
||||
for _, move in ipairs(known or {}) do owned[move.id or move] = true end
|
||||
for _ = 1, 64 do
|
||||
local pick = moveOrder[(random and random(count) or 0) + 1]
|
||||
if pick and not Effects.METRONOME_EXCEPTIONS[pick] and not owned[pick] then
|
||||
return pick
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- Mirror Move
|
||||
--
|
||||
-- Copies the OPPONENT's last move, and fails when there is none or when the
|
||||
-- user already knows it -- CheckUserMove returns "found", and Mirror Move
|
||||
-- takes the .failed branch on a hit.
|
||||
|
||||
return Effects
|
||||
@@ -0,0 +1,182 @@
|
||||
-- Gen 2 wild encounters (engine/overworld/wildmons.asm).
|
||||
--
|
||||
-- The Gen 2 mechanic Gen 1 does not have: a grass table holds three separate
|
||||
-- seven-slot lists, one per time of day, plus a per-time encounter *rate*. So
|
||||
-- the same patch of grass on Route 29 gives Pidgey in the morning and Hoothoot
|
||||
-- at night, and the clock that decides which is the same one that decides the
|
||||
-- palette -- see src/world/gen2/Palettes.lua.
|
||||
--
|
||||
-- Slot probabilities are Gen 2's ProbabilityTable (data/wild/probabilities.asm):
|
||||
-- 30, 30, 20, 10, 5, 4, 1 percent across the seven slots, cumulative.
|
||||
|
||||
local Encounter = {}
|
||||
|
||||
-- data/wild/probabilities.asm, cumulative out of 100.
|
||||
Encounter.GRASS_SLOT_CHANCES = { 30, 60, 80, 90, 95, 99, 100 }
|
||||
-- Water has three slots: 60, 30, 10.
|
||||
Encounter.WATER_SLOT_CHANCES = { 60, 90, 100 }
|
||||
|
||||
local function roll(random, n)
|
||||
if random then return random(n) end
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(n) - 1
|
||||
end
|
||||
return math.random(n) - 1
|
||||
end
|
||||
|
||||
-- Which slot a 0..99 roll lands in.
|
||||
local function slotFor(chances, value)
|
||||
for index, cumulative in ipairs(chances) do
|
||||
if value < cumulative then return index end
|
||||
end
|
||||
return #chances
|
||||
end
|
||||
|
||||
-- Does a step in grass start a battle? The map's rate is out of 256
|
||||
-- (`db 2 percent`), and the cart compares one random byte against it.
|
||||
function Encounter.triggers(rate, random)
|
||||
if not rate or rate <= 0 then return false end
|
||||
return roll(random, 256) < rate
|
||||
end
|
||||
|
||||
-- The grass encounter for a map at a time of day, or nil when that map has
|
||||
-- none. `daytime` is "MORN"/"DAY"/"NITE"/"DARK"; DARK reuses the night list,
|
||||
-- since the cart only stores three (wildmons.asm masks the palette daytime down
|
||||
-- to three when indexing).
|
||||
function Encounter.grassSlot(encounters, mapId, daytime, random)
|
||||
local entry = encounters and encounters.grass and encounters.grass[mapId]
|
||||
if not entry then return nil end
|
||||
local key = (daytime == "DARK") and "NITE" or (daytime or "DAY")
|
||||
local slots = entry.slots and (entry.slots[key] or entry.slots.DAY)
|
||||
if not slots then return nil end
|
||||
local index = slotFor(Encounter.GRASS_SLOT_CHANCES, roll(random, 100))
|
||||
local slot = slots[index]
|
||||
if not slot or not slot.species then return nil end
|
||||
return { species = slot.species, level = slot.level, slot = index }
|
||||
end
|
||||
|
||||
function Encounter.grassRate(encounters, mapId, daytime)
|
||||
local entry = encounters and encounters.grass and encounters.grass[mapId]
|
||||
if not entry then return 0 end
|
||||
local key = (daytime == "DARK") and "NITE" or (daytime or "DAY")
|
||||
return (entry.rates and (entry.rates[key] or entry.rates.DAY)) or 0
|
||||
end
|
||||
|
||||
function Encounter.waterSlot(encounters, mapId, random)
|
||||
local entry = encounters and encounters.water and encounters.water[mapId]
|
||||
if not entry or not entry.slots then return nil end
|
||||
local index = slotFor(Encounter.WATER_SLOT_CHANCES, roll(random, 100))
|
||||
local slot = entry.slots[index]
|
||||
if not slot or not slot.species then return nil end
|
||||
return { species = slot.species, level = slot.level, slot = index }
|
||||
end
|
||||
|
||||
function Encounter.waterRate(encounters, mapId)
|
||||
local entry = encounters and encounters.water and encounters.water[mapId]
|
||||
return (entry and entry.rate) or 0
|
||||
end
|
||||
|
||||
-- Fishing: a rod's list is (cumulative chance, species, level) rows out of 256,
|
||||
-- ending at 100%. A roll past the group's own `chance` is a bite of nothing.
|
||||
function Encounter.fish(encounters, fishGroup, rod, random)
|
||||
local group = encounters and encounters.fishGroups
|
||||
and encounters.fishGroups[fishGroup]
|
||||
if not group then return nil end
|
||||
local list = group[rod or "old"]
|
||||
if not list or #list == 0 then return nil end
|
||||
local value = roll(random, 256)
|
||||
for _, row in ipairs(list) do
|
||||
if value < (row.chance or 0) then
|
||||
if not row.species or row.species == "NO_ITEM" then return nil end
|
||||
return { species = row.species, level = row.level }
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- GetFishGroupIndex (engine/events/fish.asm), the fishing half of a swarm:
|
||||
-- Fish calls it before it indexes FishGroups, and it swaps FISHGROUP_QWILFISH
|
||||
-- for FISHGROUP_QWILFISH_SWARM (and FISHGROUP_REMORAID for
|
||||
-- FISHGROUP_REMORAID_SWARM) while wFishingSwarmFlag names that swarm. Nothing
|
||||
-- else is substituted: FISHGROUP_QWILFISH_NO_SWARM is a map header value of its
|
||||
-- own and never becomes a swarm group. `fishSwarm` is the FISHSWARM_* byte
|
||||
-- (constants/script_constants.asm), which the port keeps in
|
||||
-- save.dailyFlags.fishingSwarm and reads back through Roamers.Swarm.fishing.
|
||||
Encounter.FISHSWARM_NONE = 0
|
||||
Encounter.FISHSWARM_QWILFISH = 1
|
||||
Encounter.FISHSWARM_REMORAID = 2
|
||||
|
||||
local FISH_SWARM_GROUPS = {
|
||||
[Encounter.FISHSWARM_QWILFISH] = {
|
||||
FISHGROUP_QWILFISH = "FISHGROUP_QWILFISH_SWARM",
|
||||
},
|
||||
[Encounter.FISHSWARM_REMORAID] = {
|
||||
FISHGROUP_REMORAID = "FISHGROUP_REMORAID_SWARM",
|
||||
},
|
||||
}
|
||||
|
||||
-- A cache built before the extractor carried the two swarm rows has no such
|
||||
-- group at all, and Fish on a missing group is a bite of nothing; falling back
|
||||
-- to the map's own group keeps those rods rolling their ordinary list.
|
||||
function Encounter.fishGroupFor(encounters, group, fishSwarm)
|
||||
local swap = FISH_SWARM_GROUPS[fishSwarm or Encounter.FISHSWARM_NONE]
|
||||
local swarmed = swap and swap[group]
|
||||
if not swarmed then return group end
|
||||
local groups = encounters and encounters.fishGroups
|
||||
if not (groups and groups[swarmed]) then return group end
|
||||
return swarmed
|
||||
end
|
||||
|
||||
-- Which fish group a MAP belongs to lives on the map record, so a caller with
|
||||
-- a map id and a rod does not have to know about groups at all.
|
||||
function Encounter.fishSlot(encounters, mapId, rod, random, maps, fishSwarm)
|
||||
local map = maps and maps[mapId]
|
||||
local group = map and map.fishGroup
|
||||
if not group then
|
||||
-- Callers that already hold the map (the World does) pass it in; without
|
||||
-- it, fall back to the pond, which is what an unlisted map fishes.
|
||||
group = "FISHGROUP_POND"
|
||||
end
|
||||
group = Encounter.fishGroupFor(encounters, group, fishSwarm)
|
||||
local key = rod
|
||||
if rod == "OLD_ROD" then key = "old"
|
||||
elseif rod == "GOOD_ROD" then key = "good"
|
||||
elseif rod == "SUPER_ROD" then key = "super" end
|
||||
return Encounter.fish(encounters, group, key or "old", random)
|
||||
end
|
||||
|
||||
-- Headbutt trees: TreeMonMaps says which set a map uses and TreeMons holds
|
||||
-- that set's two lists. The rows are cumulative percentages ending at -1, the
|
||||
-- same shape as the fishing lists.
|
||||
function Encounter.treeSet(encounters, mapId)
|
||||
return encounters and encounters.trees and encounters.trees[mapId] or nil
|
||||
end
|
||||
|
||||
-- A headbutt on the tree at (cx, cy). Whether the COMMON or the RARE list is
|
||||
-- rolled comes from the tree's own coordinates on the cart -- GetTreeMons
|
||||
-- hashes them so the same tree always behaves the same way -- which is what
|
||||
-- keeps a player from re-rolling one tree for a rare mon.
|
||||
function Encounter.treeIsRare(cx, cy)
|
||||
return ((cx or 0) * 5 + (cy or 0) * 7) % 10 < 1
|
||||
end
|
||||
|
||||
function Encounter.treeSlot(encounters, mapId, cx, cy, random)
|
||||
local setName = Encounter.treeSet(encounters, mapId)
|
||||
if not setName then return nil end
|
||||
local set = encounters and encounters.treeSets and encounters.treeSets[setName]
|
||||
if not set then return nil end
|
||||
local list = Encounter.treeIsRare(cx, cy) and set.rare or set.common
|
||||
if not list or #list == 0 then return nil end
|
||||
local value = roll(random, 100)
|
||||
local total = 0
|
||||
for _, row in ipairs(list) do
|
||||
total = total + (row.chance or 0)
|
||||
if value < total then
|
||||
if not row.species then return nil end
|
||||
return { species = row.species, level = row.level }
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return Encounter
|
||||
@@ -0,0 +1,155 @@
|
||||
-- The HP bar, exactly as the cart computes it.
|
||||
--
|
||||
-- Two routines, and both matter for parity because the bar is what a player
|
||||
-- reads the whole battle off:
|
||||
--
|
||||
-- engine/pokemon/health.asm ComputeHPBarPixels
|
||||
-- pixels = curHP * HP_BAR_LENGTH_PX / maxHP, floored, where
|
||||
-- HP_BAR_LENGTH_PX = HP_BAR_LENGTH (6 tiles) * TILE_WIDTH (8) = 48.
|
||||
-- A live mon never shows an empty bar: a result of 0 is forced to 1.
|
||||
-- A fainted mon shows exactly 0.
|
||||
-- When maxHP >= 256 the routine divides both the product and maxHP by 4
|
||||
-- first, because hDivisor is one byte -- so a high-HP mon's bar moves in
|
||||
-- coarser steps than the exact ratio would. Reproduced, not smoothed over.
|
||||
--
|
||||
-- home/tilemap.asm GetHPPal
|
||||
-- green when pixels >= 24 (HP_BAR_LENGTH_PX * 50 / 100)
|
||||
-- yellow when pixels >= 10 (HP_BAR_LENGTH_PX * 21 / 100, integer 10)
|
||||
-- red otherwise
|
||||
-- Note the boundaries are inclusive on the *pixel* count, not a percentage:
|
||||
-- exactly half HP is green, and the yellow floor is 10/48 rather than 21%.
|
||||
--
|
||||
-- Colours themselves come from palettes.lua's hpBar (gfx/battle/hp_bar.pal).
|
||||
|
||||
local HpBar = {}
|
||||
|
||||
HpBar.LENGTH_TILES = 6
|
||||
HpBar.TILE_WIDTH = 8
|
||||
HpBar.LENGTH_PX = HpBar.LENGTH_TILES * HpBar.TILE_WIDTH -- 48
|
||||
-- How tall the coloured channel is inside the bar's frame. The cart's bar
|
||||
-- tiles are 8px rows with a 1px rule above and below the fill.
|
||||
HpBar.CHANNEL_PX = 3
|
||||
|
||||
-- GetHPPal's thresholds, computed the way RGBDS does (integer division).
|
||||
HpBar.GREEN_PIXELS = math.floor(HpBar.LENGTH_PX * 50 / 100) -- 24
|
||||
HpBar.YELLOW_PIXELS = math.floor(HpBar.LENGTH_PX * 21 / 100) -- 10
|
||||
|
||||
-- Pixels of bar to fill, 0..48.
|
||||
function HpBar.pixels(hp, maxHp)
|
||||
hp = math.max(0, hp or 0)
|
||||
maxHp = math.max(0, maxHp or 0)
|
||||
if hp == 0 then return 0 end
|
||||
if maxHp == 0 then return 0 end
|
||||
local product = hp * HpBar.LENGTH_PX
|
||||
local divisor = maxHp
|
||||
if divisor >= 256 then
|
||||
-- The one-byte-divisor shift, applied to both sides.
|
||||
product = math.floor(product / 4)
|
||||
divisor = math.floor(divisor / 4)
|
||||
if divisor == 0 then divisor = 1 end
|
||||
end
|
||||
local pixels = math.floor(product / divisor)
|
||||
if pixels == 0 then return 1 end
|
||||
return math.min(HpBar.LENGTH_PX, pixels)
|
||||
end
|
||||
|
||||
-- "green" / "yellow" / "red", keyed to match palettes.lua's hpBar table.
|
||||
function HpBar.palette(pixels)
|
||||
if (pixels or 0) >= HpBar.GREEN_PIXELS then return "green" end
|
||||
if (pixels or 0) >= HpBar.YELLOW_PIXELS then return "yellow" end
|
||||
return "red"
|
||||
end
|
||||
|
||||
function HpBar.paletteFor(hp, maxHp)
|
||||
return HpBar.palette(HpBar.pixels(hp, maxHp))
|
||||
end
|
||||
|
||||
-- The bar's two colours out of palettes.lua: the light background the empty
|
||||
-- part of the bar shows, and the fill.
|
||||
function HpBar.colors(palettes, key)
|
||||
local pal = palettes and palettes.hpBar and palettes.hpBar[key]
|
||||
if not pal then return nil, nil end
|
||||
return pal[1], pal[2]
|
||||
end
|
||||
|
||||
-- Draw the bar at a pixel position: 6 tiles wide, black frame, white interior,
|
||||
-- coloured fill from the left.
|
||||
--
|
||||
-- The cart builds it out of tiles -- $62 is an empty bar cell and $63..$6a are
|
||||
-- the eight partial fills, so the fill really does move one pixel at a time
|
||||
-- inside a fixed 48px frame, and the *unfilled* part is white, not tinted.
|
||||
function HpBar.draw(palettes, hp, maxHp, px, py)
|
||||
local G = love and love.graphics
|
||||
if not G then return end
|
||||
local pixels = HpBar.pixels(hp, maxHp)
|
||||
local _, fill = HpBar.colors(palettes, HpBar.palette(pixels))
|
||||
-- Frame: one pixel of black around the 48x2 channel the fill lives in.
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.rectangle("fill", px - 1, py - 1, HpBar.LENGTH_PX + 2, HpBar.CHANNEL_PX + 2)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", px, py, HpBar.LENGTH_PX, HpBar.CHANNEL_PX)
|
||||
if pixels > 0 then
|
||||
if fill then
|
||||
G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1)
|
||||
else
|
||||
G.setColor(0.1, 0.1, 0.1, 1)
|
||||
end
|
||||
G.rectangle("fill", px, py, pixels, HpBar.CHANNEL_PX)
|
||||
end
|
||||
G.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
-- The battle HUD's bar, which is the plain bar with the cart's "HP:" prefix in
|
||||
-- front of it (tiles $60/$61 in home/pokemon.asm, two tiles wide). `tx`/`ty`
|
||||
-- are the tile the prefix starts at; the bar follows two tiles later, so the
|
||||
-- whole assembly is 2 + 6 = 8 tiles wide.
|
||||
--
|
||||
-- Returns the tile column just past the bar, so a caller can put the bar's end
|
||||
-- cap or the frame stub there.
|
||||
function HpBar.drawWithLabel(palettes, hp, maxHp, tx, ty, font)
|
||||
if font then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
font.draw("HP:", tx * 8, ty * 8)
|
||||
end
|
||||
-- The bar's channel sits in the middle of the tile row, matching the tiles.
|
||||
HpBar.draw(palettes, hp, maxHp, (tx + 2) * 8, ty * 8 + 2)
|
||||
return tx + 2 + HpBar.LENGTH_TILES
|
||||
end
|
||||
|
||||
-- The experience bar under the player's HUD (FillInExpBar). Same 6-tile
|
||||
-- width, but it fills toward the *next* level rather than showing a ratio of a
|
||||
-- maximum, and it is a flat blue with no colour states (gfx/battle/exp_bar.pal).
|
||||
function HpBar.drawExp(palettes, fraction, px, py)
|
||||
local G = love and love.graphics
|
||||
if not G then return end
|
||||
fraction = math.max(0, math.min(1, fraction or 0))
|
||||
local pixels = math.floor(fraction * HpBar.LENGTH_PX)
|
||||
local pal = palettes and palettes.expBar
|
||||
local fill = pal and pal[2] or pal and pal[1]
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.rectangle("fill", px - 1, py - 1, HpBar.LENGTH_PX + 2, 3)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", px, py, HpBar.LENGTH_PX, 1)
|
||||
if pixels > 0 then
|
||||
if fill then
|
||||
G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1)
|
||||
else
|
||||
G.setColor(0.3, 0.55, 0.95, 1)
|
||||
end
|
||||
G.rectangle("fill", px, py, pixels, 1)
|
||||
end
|
||||
G.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
-- How far along its current level a mon is, for the exp bar.
|
||||
function HpBar.expFraction(mon, growth, levelFor)
|
||||
if not (mon and growth and levelFor) then return 0 end
|
||||
local level = mon.level or 1
|
||||
local base = levelFor(growth, level)
|
||||
local next_ = levelFor(growth, level + 1)
|
||||
if next_ <= base then return 0 end
|
||||
local into = (mon.experience or base) - base
|
||||
return math.max(0, math.min(1, into / (next_ - base)))
|
||||
end
|
||||
|
||||
return HpBar
|
||||
@@ -0,0 +1,480 @@
|
||||
-- A Gen 2 party member: stats, moves, level-up and experience.
|
||||
--
|
||||
-- Separate from src/pokemon/Pokemon.lua because the struct itself changed:
|
||||
-- Gen 2 splits `special` into Special Attack and Special Defense, adds a held
|
||||
-- item, happiness and pokerus, and its level-up moves come from EvosAttacks
|
||||
-- rather than a Gen 1 learnset table.
|
||||
--
|
||||
-- Stat formula is unchanged from Gen 1 (data/pokemon/base_stats + DVs):
|
||||
-- stat = floor((base * 2 + DV * 2 + floor(sqrt(statExp) / 4)) * level / 100) + 5
|
||||
-- HP is the same but + level + 10
|
||||
-- with the Gen 2 twist that a mon's SpA and SpD share one Special DV, which is
|
||||
-- why a high-Special DV raises both.
|
||||
--
|
||||
-- Experience curves come from data/growth_rates.asm, whose `growth_rate` macro
|
||||
-- documents its own polynomial:
|
||||
-- [1]/[2] * n^3 + [3] * n^2 + [4] * n - [5]
|
||||
-- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per
|
||||
-- GROWTH_* so this needs no hardcoded table.
|
||||
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
-- The mod event bus. pokemon.level_up and pokemon.move_learned are the SAME
|
||||
-- names src/battle/Experience.lua and src/battle/BattleState.lua raise on
|
||||
-- Gen 1, with the same payload keys: a mod that watches a Red party watches a
|
||||
-- Gold one unchanged (docs/mod-api-gen2-compat.md).
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Mon = {}
|
||||
|
||||
Mon.MAX_LEVEL = 100
|
||||
Mon.PARTY_SIZE = 6
|
||||
|
||||
-- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess.
|
||||
Mon.MAX_DV = 15
|
||||
|
||||
local function rand(a, b)
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(a, b)
|
||||
end
|
||||
return math.random(a, b)
|
||||
end
|
||||
|
||||
function Mon.randomDVs()
|
||||
return {
|
||||
hp = nil, -- derived below
|
||||
attack = rand(0, Mon.MAX_DV),
|
||||
defense = rand(0, Mon.MAX_DV),
|
||||
speed = rand(0, Mon.MAX_DV),
|
||||
special = rand(0, Mon.MAX_DV),
|
||||
}
|
||||
end
|
||||
|
||||
-- The HP DV is not stored: it is the low bit of each of the other four
|
||||
-- (Gen 1 and 2 both build it this way), which is why a perfect-HP mon needs
|
||||
-- all four others odd.
|
||||
function Mon.hpDV(dvs)
|
||||
local function bit(value) return (value or 0) % 2 end
|
||||
return bit(dvs.attack) * 8 + bit(dvs.defense) * 4
|
||||
+ bit(dvs.speed) * 2 + bit(dvs.special)
|
||||
end
|
||||
|
||||
local function statValue(base, dv, level, statExp)
|
||||
local exp = math.floor(math.sqrt(statExp or 0) / 4)
|
||||
return math.floor((((base or 1) * 2 + (dv or 0) * 2 + exp) * level) / 100) + 5
|
||||
end
|
||||
|
||||
-- All six stats at a level. `statExp` is optional per-stat effort.
|
||||
function Mon.stats(baseStats, dvs, level, statExp)
|
||||
baseStats = baseStats or {}
|
||||
dvs = dvs or {}
|
||||
statExp = statExp or {}
|
||||
local hpDv = dvs.hp or Mon.hpDV(dvs)
|
||||
local hp = math.floor((((baseStats.hp or 1) * 2 + hpDv * 2
|
||||
+ math.floor(math.sqrt(statExp.hp or 0) / 4)) * level) / 100)
|
||||
+ level + 10
|
||||
return {
|
||||
hp = hp,
|
||||
attack = statValue(baseStats.attack, dvs.attack, level, statExp.attack),
|
||||
defense = statValue(baseStats.defense, dvs.defense, level, statExp.defense),
|
||||
speed = statValue(baseStats.speed, dvs.speed, level, statExp.speed),
|
||||
-- One Special DV feeds both special stats, and so does one Special stat
|
||||
-- exp: the Gen 2 party struct kept Gen 1's five exp words (macros/ram.asm
|
||||
-- box_struct ends them at SpcExp), so SpA and SpD grow together. The
|
||||
-- per-stat keys are still read as a fallback for a record written before
|
||||
-- the shared word existed.
|
||||
specialAttack = statValue(baseStats.specialAttack, dvs.special, level,
|
||||
statExp.special or statExp.specialAttack),
|
||||
specialDefense = statValue(baseStats.specialDefense, dvs.special, level,
|
||||
statExp.special or statExp.specialDefense),
|
||||
}
|
||||
end
|
||||
|
||||
-- The five stat exp words, in struct order. There is no sixth: see Mon.stats.
|
||||
Mon.STAT_EXP_ORDER = { "hp", "attack", "defense", "speed", "special" }
|
||||
|
||||
-- Each word is 16 bit and GiveExperiencePoints stops it at $ffff rather than
|
||||
-- letting it wrap (.stat_exp_maxed_out).
|
||||
Mon.MAX_STAT_EXP = 65535
|
||||
|
||||
function Mon.newStatExp()
|
||||
return { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }
|
||||
end
|
||||
|
||||
-- GiveExperiencePoints' .stat_exp_loop (engine/battle/core.asm): the defeated
|
||||
-- mon's base stats are added to every participant's stat exp, and the loop runs
|
||||
-- NUM_EXP_STATS = 5 times over a six-entry base stat block, so the Special word
|
||||
-- takes the loser's Special ATTACK and the Special Defense base stat is never
|
||||
-- read at all.
|
||||
--
|
||||
-- `.EvenlyDivideExpAmongParticipants` divides the base stats in place before
|
||||
-- any of this, and only when two or more mons took part, which is why the
|
||||
-- divisor is shared with Mon.experienceGain rather than computed here.
|
||||
--
|
||||
-- Pokerus adds the same value a SECOND time (`jr z, .stat_exp_awarded` skips
|
||||
-- the second add when the byte is zero) -- doubled, not multiplied by a rate,
|
||||
-- so it stacks with nothing.
|
||||
-- `halved` is the EXP.SHARE tax: with any holder in the party the whole
|
||||
-- wEnemyMon base stat block is `srl`'d in place before EITHER pass runs
|
||||
-- (engine/battle/core.asm, the IsAnyMonHoldingExpShare block ahead of the
|
||||
-- first GiveExperiencePoints call), so participants and holders both draw
|
||||
-- stat exp from the halved values.
|
||||
function Mon.gainStatExp(mon, loserDef, participants, doubled, halved)
|
||||
if type(mon) ~= "table" then return nil end
|
||||
local base = (loserDef and loserDef.baseStats) or {}
|
||||
local share = math.max(1, math.floor(participants or 1))
|
||||
mon.statExp = mon.statExp or Mon.newStatExp()
|
||||
local gains = {}
|
||||
for _, key in ipairs(Mon.STAT_EXP_ORDER) do
|
||||
local from = (key == "special") and base.specialAttack or base[key]
|
||||
from = from or 0
|
||||
if halved then from = math.floor(from / 2) end
|
||||
local gain = math.floor(from / share)
|
||||
if doubled then gain = gain * 2 end
|
||||
local value = (mon.statExp[key] or 0) + gain
|
||||
if value > Mon.MAX_STAT_EXP then value = Mon.MAX_STAT_EXP end
|
||||
mon.statExp[key] = value
|
||||
gains[key] = gain
|
||||
end
|
||||
return gains
|
||||
end
|
||||
|
||||
-- Total experience needed to *be* `level`, from a GROWTH_* record.
|
||||
-- The single point every experience calculation resolves its curve through.
|
||||
-- The merged growth_rates registry wins, then the extractor's own coefficient
|
||||
-- rows on data.pokemon.growthRates, so a mod-free boot reads exactly the table
|
||||
-- it always did and a boot with a registered curve reads that instead.
|
||||
--
|
||||
-- Both live at the same key space (GROWTH_MEDIUM_FAST and friends), and the
|
||||
-- registry's Data path is the shared `growth_rates` one Gen 1 uses -- the
|
||||
-- point of routing it there rather than to a gen2 table is that a mod writes
|
||||
-- ONE record for both games (src/mods/Builtins.lua's Gen 2 registrant seeds
|
||||
-- this registry from the coefficient rows, so the vanilla curves are Gold's
|
||||
-- own).
|
||||
function Mon.growthFor(data, curve)
|
||||
if not curve then return nil end
|
||||
local registered = data and data.growth_rates and data.growth_rates[curve]
|
||||
if registered then return registered end
|
||||
return data and data.pokemon and data.pokemon.growthRates
|
||||
and data.pokemon.growthRates[curve]
|
||||
end
|
||||
|
||||
-- Seeds the growth_rates registry with Gold's own curves, as records carrying
|
||||
-- expForLevel so the id space and the record shape both match Gen 1's. Called
|
||||
-- by src/mods/Builtins.lua under Gen 2. A dataset with no coefficient rows
|
||||
-- (the ROM-free fixtures) seeds nothing rather than registering broken curves.
|
||||
function Mon.registerInto(registry, data, owner)
|
||||
local rows = data and data.pokemon and data.pokemon.growthRates
|
||||
if type(rows) ~= "table" then return end
|
||||
for curve, row in pairs(rows) do
|
||||
-- the closure holds the coefficient row, so the registered record computes
|
||||
-- exactly what the arm below would have
|
||||
registry:register(curve, {
|
||||
expForLevel = function(level) return Mon.experienceForLevel(row, level) end,
|
||||
}, owner)
|
||||
end
|
||||
end
|
||||
|
||||
-- `growth` is either the extractor's coefficient row (numerator / denominator /
|
||||
-- squared / linear / constant, straight off GrowthRates in the ROM) or a
|
||||
-- growth_rates REGISTRY record, which carries expForLevel(level) instead --
|
||||
-- the same record shape Gen 1's registry uses (src/pokemon/Growth.lua), so a
|
||||
-- mod that registers a custom curve writes one record and it works in both
|
||||
-- games. A registered curve wins outright; the coefficient arm is what a
|
||||
-- mod-free boot and every driver still run.
|
||||
function Mon.experienceForLevel(growth, level)
|
||||
if growth and growth.expForLevel then
|
||||
return math.max(0, math.floor(growth.expForLevel(level) or 0))
|
||||
end
|
||||
if not growth then return level * level * level end
|
||||
local n = level
|
||||
local numerator = growth.numerator or 1
|
||||
local denominator = growth.denominator or 1
|
||||
local value = math.floor(numerator * n * n * n / denominator)
|
||||
value = value + (growth.squared or 0) * n * n
|
||||
value = value + (growth.linear or 0) * n
|
||||
value = value - (growth.constant or 0)
|
||||
return math.max(0, value)
|
||||
end
|
||||
|
||||
-- The level a total experience buys. Walks up rather than inverting the
|
||||
-- polynomial, which the cart also does (it only ever compares against the next
|
||||
-- level's threshold).
|
||||
function Mon.levelForExperience(growth, experience)
|
||||
local level = 1
|
||||
while level < Mon.MAX_LEVEL do
|
||||
if experience < Mon.experienceForLevel(growth, level + 1) then break end
|
||||
level = level + 1
|
||||
end
|
||||
return level
|
||||
end
|
||||
|
||||
-- The moves a species knows on arrival at `level`: its last four level-up
|
||||
-- moves at or below it (EvosAttacks order, later moves pushing earlier ones
|
||||
-- out, which is what makes a caught mon's moveset deterministic).
|
||||
function Mon.movesAtLevel(def, level, moves)
|
||||
local known = {}
|
||||
for _, entry in ipairs((def and def.levelMoves) or {}) do
|
||||
if entry.level <= level then
|
||||
-- A move already known is not learned twice.
|
||||
local duplicate = false
|
||||
for _, existing in ipairs(known) do
|
||||
if existing == entry.move then duplicate = true break end
|
||||
end
|
||||
if not duplicate then
|
||||
known[#known + 1] = entry.move
|
||||
if #known > 4 then table.remove(known, 1) end
|
||||
end
|
||||
end
|
||||
end
|
||||
local out = {}
|
||||
for _, id in ipairs(known) do
|
||||
local moveDef = moves and moves[id]
|
||||
out[#out + 1] = {
|
||||
id = id,
|
||||
pp = moveDef and moveDef.pp or 0,
|
||||
maxPp = moveDef and moveDef.pp or 0,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Build a party member. `data` needs `pokemon` and `moves`; growth records
|
||||
-- live on data.pokemon.growthRates (written by the extractor).
|
||||
function Mon.new(data, species, level, opts)
|
||||
opts = opts or {}
|
||||
local def = data and data.pokemon and data.pokemon[species]
|
||||
if not def then return nil end
|
||||
level = math.max(1, math.min(Mon.MAX_LEVEL, level or 5))
|
||||
local dvs = opts.dvs or Mon.randomDVs()
|
||||
dvs.hp = Mon.hpDV(dvs)
|
||||
local statExp = opts.statExp or Mon.newStatExp()
|
||||
local stats = Mon.stats(def.baseStats, dvs, level, statExp)
|
||||
local growth = Mon.growthFor(data, def.growthRate)
|
||||
return {
|
||||
species = species,
|
||||
name = def.name or species,
|
||||
nickname = opts.nickname,
|
||||
level = level,
|
||||
experience = Mon.experienceForLevel(growth, level),
|
||||
dvs = dvs,
|
||||
-- The five stat exp words. A wild or gift mon starts at zero: nothing in
|
||||
-- the cart seeds them, MON_STAT_EXP is zeroed by _MoveMon.
|
||||
statExp = statExp,
|
||||
-- MON_PKRS. Zero is "never infected"; src/core/gen2/Pokerus.lua owns every
|
||||
-- read and write of it after this.
|
||||
pokerus = opts.pokerus or 0,
|
||||
stats = stats,
|
||||
hp = opts.hp or stats.hp,
|
||||
maxHp = stats.hp,
|
||||
types = def.types,
|
||||
moves = opts.moves or Mon.movesAtLevel(def, level, data.moves),
|
||||
-- Held item; wild mons roll one from BaseData's two item slots on the cart,
|
||||
-- which is not modeled yet, so only scripted gifts carry one.
|
||||
item = opts.item,
|
||||
status = nil,
|
||||
-- 70 for a caught mon, 120 for a gift/hatched one.
|
||||
happiness = opts.happiness or 70,
|
||||
caughtLevel = level,
|
||||
-- shiny.roll / gender.roll get the species and level as context; opts.shiny
|
||||
-- still wins, because a FORCED shiny battle (Red Gyarados) is the cart
|
||||
-- overriding the roll rather than a roll to be hooked.
|
||||
shiny = opts.shiny or Mon.isShiny(dvs,
|
||||
{ species = species, def = def, level = level }),
|
||||
gender = Mon.gender(def, dvs, { species = species, level = level }),
|
||||
-- Unown has no gender and no shininess worth looking at, but it does have
|
||||
-- a FORM, and the form is the same DVs read a different way
|
||||
-- (GetUnownLetter, engine/gfx/load_pics.asm). Stamped at build time so
|
||||
-- every screen that shows an Unown -- the battle pic, the box, the #DEX --
|
||||
-- reads one field instead of each redoing the bit shuffle.
|
||||
unownLetter = (species == Unown.SPECIES)
|
||||
and Unown.letterFromDVs(dvs) or nil,
|
||||
}
|
||||
end
|
||||
|
||||
-- AddPartyMon copies wPlayerName into wPartyMonOTs and wPlayerID into MON_ID
|
||||
-- (move_mon.asm:44-56, :143-149); SendMonIntoBox does the same (:970-994).
|
||||
function Mon.stampOT(save, mon)
|
||||
local player = save and save.player
|
||||
if not (mon and player) then return mon end
|
||||
if player.id == nil then player.id = rand(0, 65535) end
|
||||
mon.ot = mon.ot or player.name
|
||||
-- NpcTrade.lua:150: `ot` is what Breeding reads, `otName` what the summary prints.
|
||||
mon.otName = mon.otName or mon.ot
|
||||
mon.otId = mon.otId or player.id
|
||||
return mon
|
||||
end
|
||||
|
||||
-- shiny.roll and gender.roll, two of the names Gen 2 invents: Gen 1 has
|
||||
-- neither shininess nor gender in the ROM at all (Red's shiny indicator mods
|
||||
-- read src/pokemon/Stats.lua's virtual pattern, which is a mod-side
|
||||
-- convention, not an engine seam), so there is no Gen 1 name to share.
|
||||
--
|
||||
-- Both wrap the DV-derived roll rather than the mon that comes out of it,
|
||||
-- because on the cart these ARE the roll: CheckShininess and GetGender read
|
||||
-- the same two DV bytes LoadEnemyMon just generated, and nothing later can
|
||||
-- change the answer without changing the DVs. Wrapping here means a shiny-odds
|
||||
-- mod and a gender-ratio mod work on every route a mon arrives by -- a wild
|
||||
-- encounter, a gift, a hatch, a trade -- because Mon.new is the one builder.
|
||||
--
|
||||
-- Shared ctx keys:
|
||||
-- dvs the DV set being read, exactly as stored on the mon
|
||||
-- species the species id, nil when the caller had none to give
|
||||
-- def that species' record, nil likewise
|
||||
-- level the level the mon is being built at, nil for a bare query
|
||||
--
|
||||
-- gender.roll's ctx carries `ratio` as well, BaseData's genderRatio byte, so a
|
||||
-- mod can shift the threshold rather than restate the whole rule. A chain that
|
||||
-- returns something that is not one of "male" / "female" / "unknown" is
|
||||
-- ignored, because every screen that prints a gender indexes by those three.
|
||||
|
||||
-- Gen 2 shininess: the classic DV pattern (Speed/Defense/Special all 10, and
|
||||
-- Attack in {2,3,6,7,10,11,14,15}).
|
||||
function Mon.vanillaShiny(dvs)
|
||||
if not dvs then return false end
|
||||
if dvs.speed ~= 10 or dvs.defense ~= 10 or dvs.special ~= 10 then
|
||||
return false
|
||||
end
|
||||
local attack = dvs.attack or 0
|
||||
return attack % 4 == 2 or attack % 4 == 3
|
||||
end
|
||||
|
||||
function Mon.isShiny(dvs, ctx)
|
||||
if not Runtime.wantsHook("shiny.roll") then return Mon.vanillaShiny(dvs) end
|
||||
local shiny = Runtime.call("shiny.roll", function(c)
|
||||
return Mon.vanillaShiny(c.dvs)
|
||||
end, { dvs = dvs, species = ctx and ctx.species, def = ctx and ctx.def,
|
||||
level = ctx and ctx.level })
|
||||
return shiny and true or false
|
||||
end
|
||||
|
||||
-- Gender comes from the Attack DV against the species' ratio threshold: an
|
||||
-- Attack DV *below* the threshold is female (BaseData's `db GENDER_F12_5` is
|
||||
-- already scaled out of 256).
|
||||
function Mon.vanillaGender(def, dvs)
|
||||
local ratio = def and def.genderRatio
|
||||
if not ratio then return "unknown" end
|
||||
if ratio == 0xff then return "unknown" end
|
||||
-- The DV is 0..15; the threshold is out of 256 in steps of 16.
|
||||
local threshold = math.floor(ratio / 16)
|
||||
return ((dvs and dvs.attack or 0) < threshold) and "female" or "male"
|
||||
end
|
||||
|
||||
local GENDERS = { male = true, female = true, unknown = true }
|
||||
|
||||
function Mon.gender(def, dvs, ctx)
|
||||
if not Runtime.wantsHook("gender.roll") then
|
||||
return Mon.vanillaGender(def, dvs)
|
||||
end
|
||||
local gender = Runtime.call("gender.roll", function(c)
|
||||
return Mon.vanillaGender(c.def, c.dvs)
|
||||
end, { def = def, dvs = dvs, ratio = def and def.genderRatio,
|
||||
species = (ctx and ctx.species) or (def and def.id),
|
||||
level = ctx and ctx.level })
|
||||
if not GENDERS[gender] then return Mon.vanillaGender(def, dvs) end
|
||||
return gender
|
||||
end
|
||||
|
||||
-- Experience for defeating `loser`, per recipient. Gen 2:
|
||||
-- exp = baseExp * loserLevel / 7, split among the recipients of the pass,
|
||||
-- then GiveExperiencePoints' three BoostExp arms in the cart's own order,
|
||||
-- each a floored x1.5 on the running amount:
|
||||
-- traded the mon's OT id differs from the player's (BoostedExpPointsText)
|
||||
-- trainer a trainer battle (wBattleMode)
|
||||
-- luckyEgg the mon HOLDS a LUCKY_EGG -- checked by item id, not held
|
||||
-- effect, exactly as the cart's `cp LUCKY_EGG` does
|
||||
-- opts.halved is the EXP.SHARE tax: any holder in the party halves the base
|
||||
-- exp byte before either pass (the same `srl` block that halves stat exp).
|
||||
function Mon.experienceGain(loserDef, loserLevel, participants, trainer, opts)
|
||||
opts = opts or {}
|
||||
local baseExp = (loserDef and loserDef.baseExp) or 0
|
||||
if opts.halved then baseExp = math.floor(baseExp / 2) end
|
||||
local value = math.floor(baseExp * (loserLevel or 1) / 7)
|
||||
value = math.floor(value / math.max(1, participants or 1))
|
||||
if opts.traded then value = math.floor(value * 3 / 2) end
|
||||
if trainer then value = math.floor(value * 3 / 2) end
|
||||
if opts.luckyEgg then value = math.floor(value * 3 / 2) end
|
||||
return math.max(1, value)
|
||||
end
|
||||
|
||||
-- Award experience, level up as far as it reaches, and report what happened so
|
||||
-- the battle can print "grew to level N!" and offer new moves.
|
||||
function Mon.gainExperience(mon, amount, data)
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
local growth = Mon.growthFor(data, def and def.growthRate)
|
||||
mon.experience = (mon.experience or 0) + math.max(0, amount or 0)
|
||||
local before = mon.level
|
||||
local capped = Mon.experienceForLevel(growth, Mon.MAX_LEVEL)
|
||||
if mon.experience > capped then mon.experience = capped end
|
||||
local after = Mon.levelForExperience(growth, mon.experience)
|
||||
if after <= before then
|
||||
return { levels = 0, learned = {} }
|
||||
end
|
||||
mon.level = after
|
||||
-- Recompute stats and carry the HP gain, the way the cart adds the delta
|
||||
-- rather than refilling.
|
||||
local previousMax = mon.maxHp or (mon.stats and mon.stats.hp) or 1
|
||||
mon.stats = Mon.stats(def and def.baseStats, mon.dvs, after, mon.statExp)
|
||||
mon.maxHp = mon.stats.hp
|
||||
mon.hp = math.min(mon.maxHp, (mon.hp or previousMax)
|
||||
+ (mon.maxHp - previousMax))
|
||||
|
||||
-- pokemon.level_up, once per level crossed and after the stats were
|
||||
-- recalculated, exactly as src/battle/Experience.lua raises it on Gen 1 --
|
||||
-- a jump of three levels is three events, not one. `learnable` is the moves
|
||||
-- this species learns at exactly that level, the same list Gen 1 carries.
|
||||
if Runtime.wants("pokemon.level_up") then
|
||||
for level = before + 1, after do
|
||||
local learnable = {}
|
||||
for _, entry in ipairs((def and def.levelMoves) or {}) do
|
||||
if entry.level == level then learnable[#learnable + 1] = entry.move end
|
||||
end
|
||||
Runtime.emit("pokemon.level_up", {
|
||||
mon = mon, level = level, prevLevel = level - 1, learnable = learnable,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Every level-up move between the old and new level is offered.
|
||||
local learned = {}
|
||||
for _, entry in ipairs((def and def.levelMoves) or {}) do
|
||||
if entry.level > before and entry.level <= after then
|
||||
learned[#learned + 1] = entry.move
|
||||
end
|
||||
end
|
||||
return { levels = after - before, learned = learned, from = before, to = after }
|
||||
end
|
||||
|
||||
-- Teach a move, or report that all four slots are full so the caller can ask
|
||||
-- which to forget.
|
||||
function Mon.learnMove(mon, moveId, data)
|
||||
mon.moves = mon.moves or {}
|
||||
for _, move in ipairs(mon.moves) do
|
||||
if move.id == moveId then return false, "known" end
|
||||
end
|
||||
local def = data and data.moves and data.moves[moveId]
|
||||
local entry = {
|
||||
id = moveId,
|
||||
pp = def and def.pp or 0,
|
||||
maxPp = def and def.pp or 0,
|
||||
}
|
||||
if #mon.moves >= 4 then return false, "full", entry end
|
||||
mon.moves[#mon.moves + 1] = entry
|
||||
-- pokemon.move_learned, the payload BattleState:learnMove emits on Gen 1.
|
||||
-- This is Gen 2's single choke point for teaching a move -- the level-up
|
||||
-- award, an evolution's new move and the TM path all arrive here -- so the
|
||||
-- event covers all three rather than only the battle's.
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
return true
|
||||
end
|
||||
|
||||
-- Which evolution (if any) fires at this level.
|
||||
function Mon.evolutionAtLevel(def, level)
|
||||
for _, entry in ipairs((def and def.evolutions) or {}) do
|
||||
if entry.method == "EVOLVE_LEVEL" and (entry.level or 0) <= level then
|
||||
return entry
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return Mon
|
||||
@@ -0,0 +1,223 @@
|
||||
-- Prize money for beating a trainer.
|
||||
--
|
||||
-- Two routines, in two files, and both halves matter:
|
||||
--
|
||||
-- ComputeTrainerReward (engine/battle/read_trainer_party.asm) runs when the
|
||||
-- party is READ, not when it is beaten: wBattleReward = the class's
|
||||
-- TRNATTR_BASE_REWARD times wCurPartyLevel, and wCurPartyLevel at that
|
||||
-- point is whatever the LAST row of data/trainers/parties.asm left there.
|
||||
-- So Falkner pays for his level 9 Pidgeotto and not for the level 7 Pidgey
|
||||
-- that came out first, whichever of them faints last.
|
||||
-- WinTrainerBattle (engine/battle/core.asm) is the trainer-defeated arm, and
|
||||
-- it hands out wBattleReward FOUR TIMES -- `ld c, 4`, one add per pass --
|
||||
-- before doubling the figure twice for the text. That factor of four is
|
||||
-- the difference between Falkner's ¥900 and a ¥225 that would look
|
||||
-- plausible and be wrong.
|
||||
--
|
||||
-- The split is the other half of Bank of Mom: those four quarters are dealt
|
||||
-- between wMoney and wMomsMoney by wMomSavingMoney, so "save some money for
|
||||
-- me" is a standing 25% deduction on every trainer you beat, not a thing that
|
||||
-- only happens when you walk into the house.
|
||||
--
|
||||
-- love-free and save-shaped: takes the Gold save table
|
||||
-- (src/core/gen2/Save.lua) and writes the two accounts on it, so the battle
|
||||
-- engine, the world and the tests all reach the same routine.
|
||||
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Prize = {}
|
||||
|
||||
-- constants/misc_constants.asm. The same cap Save.MAX_MONEY carries; spelled
|
||||
-- out here so this module stays usable against a bare save-shaped table.
|
||||
Prize.MAX_MONEY = 999999
|
||||
|
||||
-- .DoubleReward saturates: `sla [hl] / rl [hl] / rl [hl] / ret nc` and then
|
||||
-- $ff into all three bytes, so the shift tops out at 24 bits rather than
|
||||
-- wrapping. wBattleReward is three bytes, which is where the number comes
|
||||
-- from -- it is NOT the money cap.
|
||||
local REWARD_CAP = 0xffffff
|
||||
|
||||
-- constants/ram_constants.asm, wMomSavingMoney's low bits. MOM_ACTIVE_F (bit
|
||||
-- 7) is deliberately outside MOM_SAVING_MONEY_MASK: it says the bank
|
||||
-- conversation has happened, not that anything is being skimmed.
|
||||
--
|
||||
-- "All" is bits 0 AND 1 together, not MOM_SAVING_ALL_MONEY_F -- that third
|
||||
-- bit is inside the mask and is never written by anything, which is why
|
||||
-- WinTrainerBattle compares against `(1 << SOME) | (1 << HALF)` rather than
|
||||
-- against it.
|
||||
local MOM_SAVING_MONEY_MASK = 7
|
||||
local MOM_SAVING_SOME, MOM_SAVING_HALF = 1, 2
|
||||
local MOM_SAVING_ALL = MOM_SAVING_SOME + MOM_SAVING_HALF
|
||||
|
||||
-- data/items/attributes.asm: HELD_AMULET_COIN is the only held effect that
|
||||
-- reaches this file. CheckAmuletCoin (engine/battle/core.asm) latches
|
||||
-- wAmuletCoin when a mon holding one is SENT OUT, and nothing clears it for
|
||||
-- the rest of the battle, so the coin still pays after its holder has fainted.
|
||||
Prize.AMULET_COIN = "AMULET_COIN"
|
||||
|
||||
-- data/text/battle.asm. Declared here and formatted at the call site so
|
||||
-- Strings.source is what registers them, the same way Decorations declares
|
||||
-- its own five. No line markers: every battle message in this port is one
|
||||
-- flowing string that Chrome.wrap breaks to the box.
|
||||
local GOT_MONEY = Strings.source("%s got %s%d for winning!")
|
||||
local SENT_SOME = Strings.source("%s got %s%d for winning! Sent some to MOM!")
|
||||
-- The half and all texts really are this short on the cart: they replace the
|
||||
-- money line rather than following it, which is a quirk no Gold player can
|
||||
-- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F.
|
||||
local SENT_HALF = Strings.source("Sent half to MOM!")
|
||||
local SENT_ALL = Strings.source("Sent all to MOM!")
|
||||
|
||||
-- charmap.asm: the currency glyph, the same one Chrome.money floats in front
|
||||
-- of a six-digit field.
|
||||
local YEN = "\xc2\xa5"
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- ComputeTrainerReward
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- hProduct is four bytes and wBattleReward takes the low two of them with a
|
||||
-- zero on top, so the product is kept modulo 65536. No vanilla class can
|
||||
-- reach that (255 * 100 = 25500), but a mod that raises a base reward should
|
||||
-- truncate the way the cart does rather than quietly pay more.
|
||||
function Prize.reward(baseMoney, level)
|
||||
local base = math.floor(tonumber(baseMoney) or 0)
|
||||
local lvl = math.floor(tonumber(level) or 0)
|
||||
if base < 0 then base = 0 end
|
||||
if lvl < 0 then lvl = 0 end
|
||||
return (base * lvl) % 0x10000
|
||||
end
|
||||
|
||||
-- The level ComputeTrainerReward would have seen: wCurPartyLevel after
|
||||
-- ReadTrainerParty's loop, which is the last row it built.
|
||||
function Prize.rewardLevel(party)
|
||||
local last = party and party[#party]
|
||||
return (last and last.level) or 0
|
||||
end
|
||||
|
||||
local function doubleReward(value)
|
||||
local doubled = (value or 0) * 2
|
||||
if doubled > REWARD_CAP then return REWARD_CAP end
|
||||
return doubled
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- The accounts
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
local function playerMoney(save)
|
||||
local player = save and save.player
|
||||
return (player and player.money) or 0
|
||||
end
|
||||
|
||||
local function momMoney(save)
|
||||
local mom = save and save.mom
|
||||
return (mom and mom.savedMoney) or 0
|
||||
end
|
||||
|
||||
-- AddBattleMoneyToAccount: a 24-bit add followed by a compare against
|
||||
-- MAX_MONEY, and the overflow arm WRITES the cap rather than refusing the
|
||||
-- add. The cart clamps; it does not wrap and it does not reject.
|
||||
local function addToAccount(have, amount)
|
||||
local total = have + amount
|
||||
if total > Prize.MAX_MONEY then return Prize.MAX_MONEY end
|
||||
return total
|
||||
end
|
||||
|
||||
local function setPlayerMoney(save, value)
|
||||
local player = save and save.player
|
||||
if player then player.money = value end
|
||||
end
|
||||
|
||||
local function setMomMoney(save, value)
|
||||
local mom = save and save.mom
|
||||
if mom then mom.savedMoney = value end
|
||||
end
|
||||
|
||||
-- wMomSavingMoney & MOM_SAVING_MONEY_MASK. BankOfMom (engine/events/mom.asm)
|
||||
-- only ever stores (1 << MOM_ACTIVE_F) or that plus (1 <<
|
||||
-- MOM_SAVING_SOME_MONEY_F), so in Gold the masked byte is 0 or 1 and nothing
|
||||
-- else -- which is why `savingMoney` is a boolean on this save rather than a
|
||||
-- number. MOM_SAVING_HALF / _ALL are kept below anyway because the split
|
||||
-- loop reads them and a Crystal-shaped save would set them.
|
||||
function Prize.savingMode(save)
|
||||
local mom = save and save.mom
|
||||
if not (mom and mom.active and mom.savingMoney) then return 0 end
|
||||
if type(mom.savingMoney) == "number" then
|
||||
return mom.savingMoney % (MOM_SAVING_MONEY_MASK + 1)
|
||||
end
|
||||
return MOM_SAVING_SOME
|
||||
end
|
||||
|
||||
-- `ld b, a` then the two loops: b quarters to Mom, 4 - b to the wallet. The
|
||||
-- `cp (1 << SOME) | (1 << HALF) / inc a` is what turns the setting into a
|
||||
-- count -- 3 means ALL, which is four quarters, not three. A masked byte of
|
||||
-- 4 or more is not a value anything writes, and the cart's own text lookup
|
||||
-- would run off the end of .SentToMomTexts for one, so it is read as nothing
|
||||
-- rather than guessed at.
|
||||
local function quartersToMom(mode)
|
||||
if mode == MOM_SAVING_ALL then return 4 end
|
||||
if mode == MOM_SAVING_HALF then return 2 end
|
||||
if mode == MOM_SAVING_SOME then return 1 end
|
||||
return 0
|
||||
end
|
||||
|
||||
Prize.QUARTERS = 4
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- WinTrainerBattle
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- opts:
|
||||
-- baseMoney the class's TRNATTR_BASE_REWARD (Trainers.lookup's baseMoney)
|
||||
-- level wCurPartyLevel, i.e. Prize.rewardLevel(the trainer's party)
|
||||
-- amuletCoin wAmuletCoin, latched by CheckAmuletCoin
|
||||
--
|
||||
-- Returns a record of what happened, which is what the caller turns into the
|
||||
-- message: `total` is the figure the text prints (the quarter doubled twice),
|
||||
-- `toMom` is how many of the four quarters Mom took, and `mode` is the
|
||||
-- wMomSavingMoney setting the text is chosen by.
|
||||
function Prize.award(save, opts)
|
||||
opts = opts or {}
|
||||
local quarter = Prize.reward(opts.baseMoney, opts.level)
|
||||
-- `ld a, [wAmuletCoin] / and a / call nz, .DoubleReward` -- before the
|
||||
-- split, so Mom's cut doubles with everything else.
|
||||
if opts.amuletCoin then quarter = doubleReward(quarter) end
|
||||
|
||||
-- .CheckMaxedOutMomMoney: carry means wMomsMoney is BELOW the cap. With no
|
||||
-- carry the whole reward goes to the wallet and the text is .KeepItAll,
|
||||
-- however the savings setting is left -- Mom stops skimming once she is
|
||||
-- full rather than throwing the quarter away.
|
||||
local mode = 0
|
||||
if momMoney(save) < Prize.MAX_MONEY then mode = Prize.savingMode(save) end
|
||||
local toMom = quartersToMom(mode)
|
||||
|
||||
local wallet, saved = playerMoney(save), momMoney(save)
|
||||
for _ = 1, toMom do saved = addToAccount(saved, quarter) end
|
||||
for _ = 1, Prize.QUARTERS - toMom do wallet = addToAccount(wallet, quarter) end
|
||||
setPlayerMoney(save, wallet)
|
||||
setMomMoney(save, saved)
|
||||
|
||||
return {
|
||||
quarter = quarter,
|
||||
total = doubleReward(doubleReward(quarter)),
|
||||
toMom = toMom,
|
||||
mode = mode,
|
||||
wallet = wallet,
|
||||
saved = saved,
|
||||
}
|
||||
end
|
||||
|
||||
-- The line StdBattleTextbox prints, chosen by .SentToMomTexts / .KeepItAll.
|
||||
function Prize.message(award, playerName)
|
||||
local name = playerName or "PLAYER"
|
||||
local total = (award and award.total) or 0
|
||||
local mode = (award and award.mode) or 0
|
||||
if mode == MOM_SAVING_ALL then return Strings(SENT_ALL) end
|
||||
if mode == MOM_SAVING_HALF then return Strings(SENT_HALF) end
|
||||
if mode == MOM_SAVING_SOME then
|
||||
return Strings(SENT_SOME, name, YEN, total)
|
||||
end
|
||||
return Strings(GOT_MONEY, name, YEN, total)
|
||||
end
|
||||
|
||||
return Prize
|
||||
Reference in New Issue
Block a user