mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
initial commit
This commit is contained in:
@@ -0,0 +1,749 @@
|
||||
-- Plays back the original battle move animations (beams, blobs, rings...)
|
||||
-- from data/generated/battle_anims.lua, reimplementing the subanimation
|
||||
-- player in pokered's engine/battle/animations.asm:
|
||||
--
|
||||
-- PlayAnimation (:164) walks a move's battle_anim rows: subanimation
|
||||
-- rows (with a tileset + per-frame-block delay) and
|
||||
-- 2-byte special-effect rows (SE_*).
|
||||
-- LoadSubanimation (:270) resolves the subanimation type into a transform:
|
||||
-- on the PLAYER's turn every type but ENEMY plays
|
||||
-- untransformed and ENEMY plays HFLIP'd; on the
|
||||
-- ENEMY's turn the type applies as-is and ENEMY
|
||||
-- plays untransformed (GetSubanimationTransform1/2,
|
||||
-- :333/:346). REVERSE plays the frame-block list
|
||||
-- back to front, untransformed.
|
||||
-- PlaySubanimation (:580) draws each frame block at its base coordinate,
|
||||
-- writing OAM entries from a cursor that resets to
|
||||
-- slot 0 at the start of every subanimation row.
|
||||
-- DrawFrameBlock (:3) per-tile transforms (8-bit math, OAM space):
|
||||
-- HVFLIP: y' = 136-(base+dy), x' = 168-(base+dx),
|
||||
-- toggles both flip bits (flags with
|
||||
-- PRIO/PAL bits become no-flip)
|
||||
-- HFLIP: y' = base+dy+40, x' = 168-(base+dx),
|
||||
-- toggles the x-flip bit
|
||||
-- COORDFLIP: y' = (136-base)+dy, x' = (168-base)+dx
|
||||
-- then applies the frame-block mode:
|
||||
-- 0/1: show for `delay` frames, then clear all
|
||||
-- sprites (one extra frame; GROWL skips the
|
||||
-- clear, :145) and restart at OAM slot 0
|
||||
-- 2: accumulate; no delay, keep the cursor moving
|
||||
-- 3: show for `delay` frames, keep sprites and
|
||||
-- keep the cursor moving (persistent trails)
|
||||
-- 4: show for `delay` frames, keep sprites but
|
||||
-- rewind the cursor (next block overwrites)
|
||||
--
|
||||
-- Special-effect rows are timed here (each SE_* gets the frame count its
|
||||
-- routine really blocks for -- see SE_FRAMES) and exposed through
|
||||
-- :pollEffects() so the caller can route them into the screen fx layer
|
||||
-- (palette fades, mon-pic slides, screen shakes; BattleState implements
|
||||
-- the visuals). The sprite-emitter effects (AnimationSpiralBallsInward,
|
||||
-- AnimationShootBallsUpward/ShootManyBallsUpward,
|
||||
-- AnimationWaterDropletsEverywhere, AnimationLeavesFalling/PetalsFalling)
|
||||
-- ARE executed here: their OAM trajectories are compiled into sprite
|
||||
-- steps at start() from the original routines' math.
|
||||
--
|
||||
-- Per-animation-id frame-block effects (DoSpecialEffectByAnimationId,
|
||||
-- data/battle_anims/special_effects.asm) are also compiled in: the
|
||||
-- screen-flash pulses of Mega Punch/Blizzard/Thunderbolt/Explosion...,
|
||||
-- Rock Slide's 1px rumble, and Explosion's user-pic hide, each timed at
|
||||
-- the wSubAnimCounter values the asm checks.
|
||||
--
|
||||
-- Events carry the row sounds too ({ sound = <move id> }): PlayAnimation
|
||||
-- plays each row's MoveSoundTable entry (with its pitch/tempo modifiers)
|
||||
-- as the row starts, via GetMoveSound.
|
||||
--
|
||||
-- Usage (one :update() per 60fps frame):
|
||||
-- local player = AnimPlayer.new(require("data.generated.battle_anims"))
|
||||
-- player:start("THUNDERBOLT", true)
|
||||
-- ... player:update(); player:draw(); player:pollEffects() ...
|
||||
-- until player:isDone()
|
||||
--
|
||||
-- start() takes an optional opts table: { shakes = n } replays each
|
||||
-- subanimation row n times, opening every pass with an SFX_TINK event
|
||||
-- and a 40-frame pause -- DoBallShakeSpecialEffects (:739), which rewinds
|
||||
-- the ball-shake subanimation wNumShakes times. { ball = "<item id>" }
|
||||
-- marks a ball-toss row with the thrown ball (wCurItem): a MASTER_BALL
|
||||
-- or ULTRA_BALL toss flickers the OBJ palette every frame block --
|
||||
-- DoBallTossSpecialEffects (:685) XORs rOBP0 with %00111100.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local AnimPlayer = {}
|
||||
AnimPlayer.__index = AnimPlayer
|
||||
|
||||
local SE_PAUSE_FRAMES = 8 -- fallback pacing for unknown SE rows
|
||||
|
||||
-- Frames each special-effect routine blocks the animation for
|
||||
-- (engine/battle/animations.asm; delays counted from the routines'
|
||||
-- DelayFrames calls). 0 = a bare register write (palette sets).
|
||||
local SE_FRAMES = {
|
||||
SE_DARK_SCREEN_FLASH = 4, -- AnimationFlashScreen: 2f inverted + 2f white
|
||||
SE_FLASH_SCREEN_LONG = 48, -- 12 palettes x (2f + 1f + 1f) over 3 cycles
|
||||
SE_DARK_SCREEN_PALETTE = 0, -- SetAnimationBGPalette writes
|
||||
SE_LIGHT_SCREEN_PALETTE = 0,
|
||||
SE_DARKEN_MON_PALETTE = 0,
|
||||
SE_RESET_SCREEN_PALETTE = 0,
|
||||
SE_SHAKE_SCREEN = 72, -- PredefShakeScreenHorizontally b=8: sum 9f
|
||||
SE_SHAKE_ENEMY_HUD = 44, -- 8 x (2f + 2f) SCX shake + setup Delay3s
|
||||
SE_DELAY_ANIMATION_10 = 10,
|
||||
SE_SLIDE_MON_OFF = 24, -- 8 tile steps x 3f (wSlideMonDelay)
|
||||
SE_SLIDE_ENEMY_MON_OFF = 24, -- same routine, turn flipped
|
||||
SE_SLIDE_MON_HALF_OFF = 19, -- 4 tile steps x 4f + Delay3
|
||||
SE_SLIDE_MON_UP = 14, -- 7 row shifts x 2f (cyclic wrap)
|
||||
SE_SLIDE_MON_DOWN = 21, -- 7 rows x Delay3
|
||||
SE_SLIDE_MON_DOWN_AND_HIDE = 19, -- 2 x 8f + Delay3
|
||||
SE_MOVE_MON_HORIZONTALLY = 3,
|
||||
SE_RESET_MON_POSITION = 3,
|
||||
SE_SHAKE_BACK_AND_FORTH = 96, -- 16 loops x 2 redraws x Delay3
|
||||
SE_BOUNCE_UP_AND_DOWN = 108, -- 5 x AnimationSlideMonDown + Delay3
|
||||
SE_SQUISH_MON_PIC = 26, -- 4 loops x 2 x Delay3 + 2f
|
||||
SE_MINIMIZE_MON = 6,
|
||||
SE_SHOW_MON_PIC = 3, SE_SHOW_ENEMY_MON_PIC = 3,
|
||||
SE_HIDE_MON_PIC = 3, SE_HIDE_ENEMY_MON_PIC = 3,
|
||||
SE_BLINK_MON = 60, SE_BLINK_ENEMY_MON = 60, -- 6 x (5f off + 5f on)
|
||||
SE_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4,
|
||||
SE_TRANSFORM_MON = 4,
|
||||
SE_SUBSTITUTE_MON = 3,
|
||||
SE_WAVY_SCREEN = 255, -- AnimationWavyScreen: ld c, $ff frames
|
||||
}
|
||||
|
||||
-- data/battle_anims/special_effects.asm AnimationIdSpecialEffects:
|
||||
-- per-frame-block effects keyed on the animation id. "flash" =
|
||||
-- AnimationFlashScreen after every frame block.
|
||||
local ANIM_ID_FX = {
|
||||
MEGA_PUNCH = "flash", GUILLOTINE = "flash", MEGA_KICK = "flash",
|
||||
HEADBUTT = "flash", DISABLE = "flash", BUBBLEBEAM = "flash",
|
||||
REFLECT = "flash", SPORE = "flash",
|
||||
BLIZZARD = "blizzard", -- flash at counters 13/9/5/1
|
||||
HYPER_BEAM = "every4", -- flash when counter % 4 == 0
|
||||
THUNDERBOLT = "every8", -- flash when counter % 8 == 0
|
||||
SELFDESTRUCT = "explode", EXPLOSION = "explode",
|
||||
ROCK_SLIDE = "rockslide", -- 1px shakes at 8-11, flash at 1
|
||||
}
|
||||
|
||||
-- Anim tiles load at vSprites tile $31 (LoadMoveAnimationTiles), so the
|
||||
-- raw VRAM tile ids the emitter routines poke into OAM are sheet tile
|
||||
-- (id - $31).
|
||||
local BALL_TILE = 0x7a - 0x31 -- AnimationSpiralBallsInward/ShootBalls
|
||||
local DROPLET_TILE = 0x71 - 0x31 -- AnimationWaterDropletsEverywhere (ts 0)
|
||||
local LEAF_TILE = 0x37 - 0x31 -- AnimationLeavesFalling (ts 1)
|
||||
local PETAL_TILE = 0x71 - 0x31 -- AnimationPetalsFalling (ts 1)
|
||||
|
||||
function AnimPlayer.new(data)
|
||||
return setmetatable({
|
||||
data = data,
|
||||
images = {}, -- tileset id -> Image | false (load failed)
|
||||
quads = {}, -- tileset id -> { [tile] = Quad }
|
||||
warned = {},
|
||||
steps = {}, -- { dur = frames, sprites = { {x,y,tile,ts,xf,yf}... } }
|
||||
events = {}, -- { effect = "SE_*", frame = n } in firing order
|
||||
stepIndex = 1,
|
||||
stepLeft = 0,
|
||||
elapsed = 0,
|
||||
eventCursor = 1,
|
||||
}, AnimPlayer)
|
||||
end
|
||||
|
||||
function AnimPlayer:warnOnce(key, fmt, ...)
|
||||
if not self.warned[key] then
|
||||
self.warned[key] = true
|
||||
Logger.warn(fmt, ...)
|
||||
end
|
||||
end
|
||||
|
||||
-- engine/battle/animations.asm GetSubanimationTransform1/2
|
||||
local function resolveTransform(subType, attackerIsPlayer)
|
||||
if subType == "ENEMY" then
|
||||
return attackerIsPlayer and "HFLIP" or "NORMAL"
|
||||
end
|
||||
return attackerIsPlayer and "NORMAL" or subType
|
||||
end
|
||||
|
||||
local function wrap(v) return v % 256 end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Sprite-emitter special effects, compiled to per-frame sprite steps.
|
||||
-- Coordinates are OAM space (screen x+8 / y+16) like the frame blocks;
|
||||
-- obp marks which hardware OBJ palette the routine ran under ("e4" =
|
||||
-- ambient rOBP0, "f0" = wAnimPalette on SGB, "obp1" = rOBP1 $6c).
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- AnimationSpiralBallsInward (:1480): 3 ball sprites walk the 21-entry
|
||||
-- coordinate spiral, one entry per 5 frames, player-anchored at (0,0)
|
||||
-- and enemy-anchored at (y-40, x+80); ends with AnimationFlashScreen.
|
||||
local SPIRAL_COORDS = { -- y, x pairs (SpiralBallAnimationCoordinates)
|
||||
{0x38,0x28},{0x40,0x18},{0x50,0x10},{0x60,0x18},{0x68,0x28},{0x60,0x38},
|
||||
{0x50,0x40},{0x40,0x38},{0x40,0x28},{0x46,0x1E},{0x50,0x18},{0x5B,0x1E},
|
||||
{0x60,0x28},{0x5B,0x32},{0x50,0x38},{0x46,0x32},{0x48,0x28},{0x50,0x20},
|
||||
{0x58,0x28},{0x50,0x30},{0x50,0x28},
|
||||
}
|
||||
local function spiralBallSteps(attackerIsPlayer)
|
||||
local by, bx = 0, 0
|
||||
if not attackerIsPlayer then by, bx = -40, 80 end
|
||||
local steps = {}
|
||||
for k = 1, #SPIRAL_COORDS - 2 do -- the step aborts when a ball reads the -1
|
||||
local sprites = {}
|
||||
for i = 0, 2 do
|
||||
local c = SPIRAL_COORDS[k + i]
|
||||
sprites[#sprites + 1] = { x = wrap(bx + c[2]), y = wrap(by + c[1]),
|
||||
tile = BALL_TILE, ts = 0, obp = "e4" }
|
||||
end
|
||||
steps[#steps + 1] = { dur = 5, sprites = sprites }
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
-- _AnimationShootBallsUpward (:1638): a pillar of `n` balls at x, from
|
||||
-- baseY+8*i, each moving up 4px per frame and vanishing at baseY+8.
|
||||
local function shootPillarSteps(steps, n, x, baseY)
|
||||
local ys = {}
|
||||
for i = 1, n do ys[i] = baseY + 8 * i end
|
||||
local function snapshot()
|
||||
local sprites = {}
|
||||
for i = 1, n do
|
||||
if ys[i] then
|
||||
sprites[#sprites + 1] = { x = x, y = wrap(ys[i]), tile = BALL_TILE,
|
||||
ts = 0, obp = "e4" }
|
||||
end
|
||||
end
|
||||
return sprites
|
||||
end
|
||||
steps[#steps + 1] = { dur = 1, sprites = snapshot() } -- init DelayFrame
|
||||
local alive = n
|
||||
while alive > 0 do
|
||||
for i = 1, n do
|
||||
if ys[i] then
|
||||
if ys[i] == baseY + 8 then
|
||||
ys[i] = nil
|
||||
alive = alive - 1
|
||||
else
|
||||
ys[i] = ys[i] - 4
|
||||
end
|
||||
end
|
||||
end
|
||||
steps[#steps + 1] = { dur = 1, sprites = snapshot() }
|
||||
end
|
||||
end
|
||||
|
||||
-- AnimationShootBallsUpward (:1617): one 5-ball pillar; player at
|
||||
-- (x=5*8, baseY=6*8), enemy at (x=16*8, baseY=0).
|
||||
local function shootBallsSteps(attackerIsPlayer)
|
||||
local steps = {}
|
||||
if attackerIsPlayer then
|
||||
shootPillarSteps(steps, 5, 5 * 8, 6 * 8)
|
||||
else
|
||||
shootPillarSteps(steps, 5, 16 * 8, 0)
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
-- AnimationShootManyBallsUpward (:1686): six sequential 4-ball pillars.
|
||||
local function shootManyBallsSteps(attackerIsPlayer)
|
||||
local xs = attackerIsPlayer
|
||||
and { 0x10, 0x40, 0x28, 0x18, 0x38, 0x30 }
|
||||
or { 0x60, 0x90, 0x78, 0x68, 0x88, 0x80 }
|
||||
local baseY = attackerIsPlayer and 0x50 or 0x28
|
||||
local steps = {}
|
||||
for _, x in ipairs(xs) do
|
||||
shootPillarSteps(steps, 4, x, baseY)
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
-- AnimationWaterDropletsEverywhere (:1114): 64 one-frame passes of
|
||||
-- droplet rows; the 8-bit x cursor persists across passes, which is
|
||||
-- what makes the field scroll.
|
||||
local function waterDropletSteps()
|
||||
local steps = {}
|
||||
local baseX = 0xF0 -- ld a, -16
|
||||
for _ = 1, 32 do
|
||||
for _, startY in ipairs({ 16, 24 }) do
|
||||
local sprites = {}
|
||||
local y = startY
|
||||
while true do
|
||||
baseX = wrap(baseX + 27)
|
||||
sprites[#sprites + 1] = { x = baseX, y = y, tile = DROPLET_TILE,
|
||||
ts = 0, obp = "e4" }
|
||||
if baseX >= 144 then
|
||||
baseX = wrap(baseX - 168)
|
||||
y = y + 16
|
||||
if y >= 112 then break end
|
||||
end
|
||||
end
|
||||
steps[#steps + 1] = { dur = 1, sprites = sprites }
|
||||
end
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
-- AnimationFallingObjects (:2335): n objects fall 2px per 3-frame tick,
|
||||
-- swaying via the delta-X table (index advances each tick, direction
|
||||
-- flips past index 8), until object 1 reaches y=104.
|
||||
local FALLING_X = { 0x38,0x40,0x50,0x60,0x70,0x88,0x90,0x56,0x67,0x4A,
|
||||
0x77,0x84,0x98,0x32,0x22,0x5C,0x6C,0x7D,0x8E,0x99 }
|
||||
local FALLING_M = { 0x00,0x84,0x06,0x81,0x02,0x88,0x01,0x83,0x05,0x89,
|
||||
0x09,0x80,0x07,0x87,0x03,0x82,0x04,0x85,0x08,0x86 }
|
||||
local FALLING_DX = { [0]=0, 1, 3, 5, 7, 9, 11, 13, 15 }
|
||||
local function fallingObjectSteps(n, tile, obp)
|
||||
local objs = {}
|
||||
for i = 1, n do
|
||||
objs[i] = { y = (i == 1) and 0 or 8 * i, x = FALLING_X[i],
|
||||
m = FALLING_M[i], xf = false }
|
||||
end
|
||||
local steps = {}
|
||||
while objs[1].y ~= 104 do
|
||||
local sprites = {}
|
||||
for i = 1, n do
|
||||
local o = objs[i]
|
||||
-- FallingObjects_UpdateMovementByte runs before the OAM update
|
||||
local left = o.m >= 0x80
|
||||
local idx = (o.m % 0x80) + 1
|
||||
if idx == 9 then
|
||||
left = not left
|
||||
idx = 0
|
||||
end
|
||||
o.m = (left and 0x80 or 0) + idx
|
||||
o.y = o.y + 2
|
||||
if o.y >= 112 then o.y = 160 end -- parked off-screen
|
||||
local dx = FALLING_DX[idx]
|
||||
o.x = left and wrap(o.x - dx) or wrap(o.x + dx)
|
||||
o.xf = left
|
||||
sprites[#sprites + 1] = { x = o.x, y = o.y, tile = tile, ts = 1,
|
||||
xf = o.xf, obp = obp }
|
||||
end
|
||||
steps[#steps + 1] = { dur = 3, sprites = sprites }
|
||||
if #steps > 120 then break end -- safety; the asm exits at 52 ticks
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
-- effect id -> compiled sprite steps
|
||||
local EMITTERS = {
|
||||
SE_SPIRAL_BALLS_INWARD = function(isPlayer) return spiralBallSteps(isPlayer), "flash" end,
|
||||
SE_SHOOT_BALLS_UPWARD = function(isPlayer) return shootBallsSteps(isPlayer) end,
|
||||
SE_SHOOT_MANY_BALLS_UPWARD = function(isPlayer) return shootManyBallsSteps(isPlayer) end,
|
||||
SE_WATER_DROPLETS_EVERYWHERE = function() return waterDropletSteps() end,
|
||||
-- AnimationLeavesFalling runs under wAnimPalette ($f0 on SGB);
|
||||
-- petals keep the ambient $e4
|
||||
SE_LEAVES_FALLING = function() return fallingObjectSteps(3, LEAF_TILE, "f0") end,
|
||||
SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end,
|
||||
}
|
||||
|
||||
-- One OAM entry for tile `t` of a frame block anchored at base coord `bc`,
|
||||
-- with the subanimation transform applied (DrawFrameBlock).
|
||||
local function placeTile(transform, bc, t, tileset)
|
||||
local x, y, xf, yf
|
||||
if transform == "HVFLIP" then
|
||||
y = wrap(136 - wrap(bc.y + t.y))
|
||||
x = wrap(168 - wrap(bc.x + t.x))
|
||||
-- the engine compares the whole flags byte: plain/xflip/yflip toggle
|
||||
-- both bits, any other combination (both, PRIO, PAL1) becomes no-flip
|
||||
local plain = not (t.prio or t.pal1)
|
||||
if plain and not t.xflip and not t.yflip then
|
||||
xf, yf = true, true
|
||||
elseif plain and t.xflip and not t.yflip then
|
||||
xf, yf = false, true
|
||||
elseif plain and t.yflip and not t.xflip then
|
||||
xf, yf = true, false
|
||||
else
|
||||
xf, yf = false, false
|
||||
end
|
||||
elseif transform == "HFLIP" then
|
||||
y = wrap(wrap(bc.y + t.y) + 40)
|
||||
x = wrap(168 - wrap(bc.x + t.x))
|
||||
xf, yf = not t.xflip, t.yflip
|
||||
elseif transform == "COORDFLIP" then
|
||||
y = wrap(wrap(136 - bc.y) + t.y)
|
||||
x = wrap(wrap(168 - bc.x) + t.x)
|
||||
xf, yf = t.xflip, t.yflip
|
||||
else -- NORMAL (and REVERSE, which only reorders the block list)
|
||||
y = wrap(bc.y + t.y)
|
||||
x = wrap(bc.x + t.x)
|
||||
xf, yf = t.xflip, t.yflip
|
||||
end
|
||||
-- OAM_PAL1 tiles render through rOBP1 ($6c); the rest use rOBP0
|
||||
-- (= wAnimPalette during subanimations: $f0 on SGB, $e4 on DMG)
|
||||
return { x = x, y = y, tile = t.tile, ts = tileset, xf = xf, yf = yf,
|
||||
obp = t.pal1 and "obp1" or "f0" }
|
||||
end
|
||||
|
||||
-- Compile the move's battle_anim rows into a list of timed steps by
|
||||
-- simulating the OAM buffer, so :update()/:draw() are trivial.
|
||||
function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
self.steps, self.events = {}, {}
|
||||
self.stepIndex, self.stepLeft = 1, 0
|
||||
self.elapsed, self.eventCursor = 0, 1
|
||||
|
||||
local anim = self.data and self.data.moveAnims and self.data.moveAnims[moveId]
|
||||
if not anim then
|
||||
self:warnOnce("move:" .. tostring(moveId),
|
||||
"AnimPlayer: no animation data for move %s", tostring(moveId))
|
||||
return
|
||||
end
|
||||
|
||||
local steps, events = self.steps, self.events
|
||||
local oam, oamMax = {}, 0
|
||||
local frame = 0
|
||||
-- DoGrowlSpecialEffects (:928): after every frame block it copies the
|
||||
-- note sprite's 4 OAM entries to a second, untouched slot; since GROWL
|
||||
-- also skips AnimationCleanOAM between blocks (the mode 0/1 branch
|
||||
-- below), that copy from the PREVIOUS block is still on screen -- at
|
||||
-- its old base coordinate -- while the current block draws, so two
|
||||
-- notes are visible each frame, one trailing a step behind the other
|
||||
local growlNoteTrail
|
||||
|
||||
local function emit(dur, spritesOverride)
|
||||
if dur < 1 then dur = 1 end
|
||||
local sprites = spritesOverride
|
||||
if not sprites then
|
||||
sprites = {}
|
||||
for i = 1, oamMax do
|
||||
local s = oam[i]
|
||||
if s then sprites[#sprites + 1] = s end
|
||||
end
|
||||
end
|
||||
steps[#steps + 1] = { dur = dur, sprites = sprites }
|
||||
frame = frame + dur
|
||||
end
|
||||
|
||||
-- AnimationFlashScreen (4 blocking frames), reused by the per-block
|
||||
-- animation-id effects; same visual as an SE_DARK_SCREEN_FLASH row
|
||||
local function flashScreen()
|
||||
events[#events + 1] = { effect = "SE_DARK_SCREEN_FLASH", frame = frame }
|
||||
emit(4)
|
||||
end
|
||||
|
||||
local idFx = ANIM_ID_FX[moveId]
|
||||
|
||||
-- DoBallTossSpecialEffects (:685): while a Master or Ultra ball is
|
||||
-- tossed (wCurItem <= ULTRA_BALL), the per-block special effect XORs
|
||||
-- rOBP0 with %00111100, complementing colors 1 and 2 -- the ball
|
||||
-- flickers between the $f0 and $cc shade maps block to block. The
|
||||
-- effect runs AFTER each block displays, so block 1 shows normal.
|
||||
-- PlayAnimation pushes rOBP0 around every subanimation row (:246-251
|
||||
-- / :259-262), so the ambient palette returns when the toss ends.
|
||||
local ballFlicker = opts
|
||||
and (opts.ball == "MASTER_BALL" or opts.ball == "ULTRA_BALL")
|
||||
and (moveId == "TOSS_ANIM" or moveId == "GREATTOSS_ANIM"
|
||||
or moveId == "ULTRATOSS_ANIM")
|
||||
local obp0Flip = false
|
||||
|
||||
for _, row in ipairs(anim.seq) do
|
||||
-- PlayAnimation/PlaySubanimation: each row's sound byte is a move id
|
||||
-- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as
|
||||
-- the row starts (GetMoveSound)
|
||||
if row.sound then
|
||||
events[#events + 1] = { sound = row.sound, frame = frame }
|
||||
end
|
||||
if row.effect then
|
||||
local emitter = EMITTERS[row.effect]
|
||||
if emitter then
|
||||
-- the emitter routines write OAM from slot 0 and clean up after
|
||||
oam, oamMax = {}, 0
|
||||
local emSteps, tailFx = emitter(attackerIsPlayer)
|
||||
events[#events + 1] = { effect = row.effect, frame = frame }
|
||||
for _, st in ipairs(emSteps) do
|
||||
emit(st.dur, st.sprites)
|
||||
end
|
||||
emit(1, {}) -- AnimationCleanOAM / ClearSprites
|
||||
if tailFx == "flash" then flashScreen() end
|
||||
else
|
||||
local dur = SE_FRAMES[row.effect]
|
||||
events[#events + 1] = { effect = row.effect, frame = frame,
|
||||
dur = dur or SE_PAUSE_FRAMES }
|
||||
if dur == nil then dur = SE_PAUSE_FRAMES end
|
||||
if dur > 0 then emit(dur) end
|
||||
end
|
||||
else
|
||||
local sub = self.data.subanims and self.data.subanims[row.subanim]
|
||||
if not (sub and sub.blocks) then
|
||||
self:warnOnce("subanim:" .. tostring(row.subanim),
|
||||
"AnimPlayer: %s references unknown subanimation %s",
|
||||
tostring(moveId), tostring(row.subanim))
|
||||
else
|
||||
local transform = resolveTransform(sub.type, attackerIsPlayer)
|
||||
local first, last, dir = 1, #sub.blocks, 1
|
||||
if transform == "REVERSE" then first, last, dir = last, first, -1 end
|
||||
-- DoBallShakeSpecialEffects: each ball shake opens with SFX_TINK
|
||||
-- and a 40-frame pause, then rewinds the same subanimation; the
|
||||
-- mode-4 frame blocks persist, so the resting ball stays visible
|
||||
-- through the pauses between wobbles
|
||||
for _ = 1, (opts and opts.shakes) or 1 do
|
||||
if opts and opts.shakes then
|
||||
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
|
||||
emit(40)
|
||||
end
|
||||
local dest = 1 -- PlaySubanimation resets the OAM cursor per row
|
||||
local nblocks = math.abs(last - first) + 1
|
||||
local played = 0
|
||||
for bi = first, last, dir do
|
||||
local entry = sub.blocks[bi]
|
||||
local fb = self.data.frameBlocks and self.data.frameBlocks[entry.block]
|
||||
local bc = self.data.baseCoords and self.data.baseCoords[entry.coord]
|
||||
if not (fb and bc) then
|
||||
self:warnOnce("block:" .. tostring(entry.block) .. ":" .. tostring(entry.coord),
|
||||
"AnimPlayer: %s references missing frame block/coord",
|
||||
tostring(moveId))
|
||||
else
|
||||
for j = 1, #fb do
|
||||
oam[dest + j - 1] = placeTile(transform, bc, fb[j], row.tileset)
|
||||
end
|
||||
if obp0Flip then
|
||||
-- rOBP0 is complemented right now: this block's rOBP0
|
||||
-- tiles show with colors 1/2 swapped ($f0 -> $cc)
|
||||
for j = 1, #fb do
|
||||
local t = oam[dest + j - 1]
|
||||
if t.obp == "f0" then t.obp = "f0x" end
|
||||
end
|
||||
end
|
||||
if dest + #fb - 1 > oamMax then oamMax = dest + #fb - 1 end
|
||||
local mode = entry.mode
|
||||
if mode == 2 then -- accumulate, no frame shown yet
|
||||
dest = dest + #fb
|
||||
elseif mode == 3 then -- show and persist
|
||||
emit(row.delay)
|
||||
dest = dest + #fb
|
||||
elseif mode == 4 then -- show; next block overwrites
|
||||
emit(row.delay)
|
||||
else -- 0/1: show, then clean the OAM buffer
|
||||
if moveId == "GROWL" then
|
||||
-- GROWL quirk: sprites persist (no clean), plus the
|
||||
-- previous block's note copy draws alongside this one
|
||||
local current = {}
|
||||
for i = 1, oamMax do
|
||||
if oam[i] then current[#current + 1] = oam[i] end
|
||||
end
|
||||
local shown = current
|
||||
if growlNoteTrail then
|
||||
shown = {}
|
||||
for _, s in ipairs(current) do shown[#shown + 1] = s end
|
||||
for _, s in ipairs(growlNoteTrail) do shown[#shown + 1] = s end
|
||||
end
|
||||
emit(row.delay, shown)
|
||||
growlNoteTrail = current
|
||||
else
|
||||
emit(row.delay + 1) -- AnimationCleanOAM's extra frame
|
||||
oam, oamMax = {}, 0
|
||||
end
|
||||
dest = 1
|
||||
end
|
||||
-- DoSpecialEffectByAnimationId runs after every frame
|
||||
-- block with wSubAnimCounter = blocks remaining
|
||||
played = played + 1
|
||||
if ballFlicker then obp0Flip = not obp0Flip end
|
||||
if idFx then
|
||||
local counter = nblocks - played + 1
|
||||
if idFx == "flash"
|
||||
or (idFx == "every4" and counter % 4 == 0)
|
||||
or (idFx == "every8" and counter % 8 == 0)
|
||||
or (idFx == "blizzard" and (counter == 13 or counter == 9
|
||||
or counter == 5 or counter == 1)) then
|
||||
flashScreen()
|
||||
elseif idFx == "explode" then
|
||||
if counter % 4 == 0 then flashScreen() end
|
||||
if counter == 1 then
|
||||
-- DoExplodeSpecialEffects: the user's pic vanishes
|
||||
events[#events + 1] = { effect = "SE_HIDE_ATTACKER_PIC",
|
||||
frame = frame }
|
||||
end
|
||||
elseif idFx == "rockslide" then
|
||||
if counter >= 8 and counter <= 11 then
|
||||
-- 1px horizontal + vertical rumble (15 blocking frames)
|
||||
events[#events + 1] = { effect = "SE_ROCK_SLIDE_SHAKE",
|
||||
frame = frame, dur = 15 }
|
||||
emit(15)
|
||||
elseif counter == 1 then
|
||||
flashScreen()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local firstStep = steps[1]
|
||||
self.stepLeft = firstStep and firstStep.dur or 0
|
||||
end
|
||||
|
||||
-- Advance one frame (call once per 60fps tick).
|
||||
function AnimPlayer:update()
|
||||
if self:isDone() then return end
|
||||
self.elapsed = self.elapsed + 1
|
||||
self.stepLeft = self.stepLeft - 1
|
||||
while self.stepLeft <= 0 do
|
||||
self.stepIndex = self.stepIndex + 1
|
||||
local st = self.steps[self.stepIndex]
|
||||
if not st then break end
|
||||
self.stepLeft = st.dur
|
||||
end
|
||||
end
|
||||
|
||||
function AnimPlayer:isDone()
|
||||
return self.steps[self.stepIndex] == nil
|
||||
end
|
||||
|
||||
-- SE_* rows whose time has come since the last poll:
|
||||
-- returns { { effect = "SE_...", frame = n }, ... } (possibly empty).
|
||||
function AnimPlayer:pollEffects()
|
||||
local fired = {}
|
||||
local events = self.events
|
||||
while self.eventCursor <= #events
|
||||
and events[self.eventCursor].frame <= self.elapsed do
|
||||
fired[#fired + 1] = events[self.eventCursor]
|
||||
self.eventCursor = self.eventCursor + 1
|
||||
end
|
||||
return fired
|
||||
end
|
||||
|
||||
function AnimPlayer:sheetImage(ts)
|
||||
local cached = self.images[ts]
|
||||
if cached ~= nil then
|
||||
return cached or nil
|
||||
end
|
||||
local sheet = self.data.tilesheets and self.data.tilesheets[ts]
|
||||
local ok, img = false, nil
|
||||
if sheet and love and love.graphics and love.graphics.newImage then
|
||||
ok, img = pcall(love.graphics.newImage, sheet.path)
|
||||
end
|
||||
if not (ok and img) then
|
||||
self:warnOnce("sheet:" .. tostring(ts),
|
||||
"AnimPlayer: battle anim tilesheet %s unavailable",
|
||||
tostring(sheet and sheet.path or ts))
|
||||
img = false
|
||||
end
|
||||
self.images[ts] = img or false
|
||||
return self.images[ts] or nil
|
||||
end
|
||||
|
||||
function AnimPlayer:tileQuad(ts, tile)
|
||||
local sheet = self.data.tilesheets[ts]
|
||||
if not sheet or tile >= sheet.tiles then return nil end
|
||||
local perSheet = self.quads[ts]
|
||||
if not perSheet then
|
||||
perSheet = {}
|
||||
self.quads[ts] = perSheet
|
||||
end
|
||||
local q = perSheet[tile]
|
||||
if q == nil and love and love.graphics and love.graphics.newQuad then
|
||||
local cols = math.floor(sheet.width / 8)
|
||||
q = love.graphics.newQuad((tile % cols) * 8,
|
||||
math.floor(tile / cols) * 8,
|
||||
8, 8, sheet.width, sheet.height)
|
||||
perSheet[tile] = q
|
||||
end
|
||||
return q
|
||||
end
|
||||
|
||||
-- Draw the current frame's tiles onto the 160x144 battle canvas.
|
||||
-- colorFn (optional): function(sprite, px, py) -> {c1,c2,c3} SGB colors
|
||||
-- (0-1 RGB triples) for the sprite's three opaque shades at screen
|
||||
-- pixel (px, py), or nil to draw the raw DMG grays. BattleState
|
||||
-- supplies the SGB zone palette + OBJ palette mapping.
|
||||
function AnimPlayer:draw(colorFn)
|
||||
local st = self.steps[self.stepIndex]
|
||||
if not st then return end
|
||||
self:drawSprites(st.sprites, colorFn)
|
||||
end
|
||||
|
||||
-- The last compiled step's sprites, or nil. After a capture the
|
||||
-- SHAKE_ANIM chain ends on the resting closed ball (its mode-4 frame
|
||||
-- blocks persist), which the GB leaves in OAM through the caught text;
|
||||
-- BattleState keeps drawing it via drawSprites.
|
||||
function AnimPlayer:finalSprites()
|
||||
local last = self.steps[#self.steps]
|
||||
return last and last.sprites or nil
|
||||
end
|
||||
|
||||
-- two colorFn results (three 0-1 RGB triples) resolve the same palette?
|
||||
local function sameColors(a, b)
|
||||
if a == b then return true end
|
||||
for i = 1, 3 do
|
||||
local p, q = a[i], b[i]
|
||||
if p[1] ~= q[1] or p[2] ~= q[2] or p[3] ~= q[3] then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Draw one compiled step's OAM sprites. With colorFn, each sprite is
|
||||
-- drawn through the PaletteFX shade-remap shader. The SGB colorized
|
||||
-- the finished DMG picture per 8x8 screen cell (the ATTR_BLK regions
|
||||
-- know nothing of OAM), so a tile overlapping a palette boundary shows
|
||||
-- each region's colors on the pixels inside it: colorFn is sampled
|
||||
-- once per attribute cell the 8x8 tile touches (up to 4), and cells
|
||||
-- that resolve to a different palette than the first are repainted
|
||||
-- through a scissor clipped to the cell.
|
||||
function AnimPlayer:drawSprites(sprites, colorFn)
|
||||
local g = love and love.graphics
|
||||
local shader
|
||||
if colorFn and g and g.setShader then
|
||||
shader = require("src.render.PaletteFX").shader()
|
||||
end
|
||||
local slices = shader and g.getScissor and g.intersectScissor
|
||||
and g.setScissor
|
||||
for i = 1, #sprites do
|
||||
local s = sprites[i]
|
||||
-- hardware hides sprites at the OAM extremes (y=0/y>=160, x=0/x>=168);
|
||||
-- wrapped offsets rely on this to park tiles offscreen
|
||||
if s.x > 0 and s.x < 168 and s.y > 0 and s.y < 160 then
|
||||
local img = self:sheetImage(s.ts)
|
||||
local quad = img and self:tileQuad(s.ts, s.tile)
|
||||
if quad then
|
||||
local rx, ry = s.x - 8, s.y - 16 -- screen-space rect of the tile
|
||||
local function blit()
|
||||
g.draw(img, quad,
|
||||
rx + (s.xf and 8 or 0),
|
||||
ry + (s.yf and 8 or 0),
|
||||
0,
|
||||
s.xf and -1 or 1,
|
||||
s.yf and -1 or 1)
|
||||
end
|
||||
-- the attribute cell holding the tile's top-left pixel
|
||||
local cx = math.floor(rx / 8) * 8
|
||||
local cy = math.floor(ry / 8) * 8
|
||||
local colors = shader and colorFn(s, cx, cy)
|
||||
if colors then
|
||||
g.setShader(shader)
|
||||
-- c0 is the transparent color-0 slot; send anything
|
||||
shader:send("c0", colors[1])
|
||||
shader:send("c1", colors[1])
|
||||
shader:send("c2", colors[2])
|
||||
shader:send("c3", colors[3])
|
||||
end
|
||||
blit()
|
||||
if colors and slices and (cx ~= rx or cy ~= ry) then
|
||||
-- unaligned: the tile spills into up to 3 more cells; repaint
|
||||
-- the ones whose zone palette differs (opaque overdraw -- GB
|
||||
-- tiles have binary alpha)
|
||||
local function slice(px, py)
|
||||
if px < 0 or py < 0 or px >= 160 or py >= 144 then
|
||||
return -- fully off-canvas
|
||||
end
|
||||
local cc = colorFn(s, px, py)
|
||||
if not cc or sameColors(cc, colors) then return end
|
||||
local s1, s2, s3, s4 = g.getScissor()
|
||||
g.intersectScissor(px, py, 8, 8)
|
||||
shader:send("c0", cc[1])
|
||||
shader:send("c1", cc[1])
|
||||
shader:send("c2", cc[2])
|
||||
shader:send("c3", cc[3])
|
||||
blit()
|
||||
if s1 then g.setScissor(s1, s2, s3, s4) else g.setScissor() end
|
||||
end
|
||||
local cx2 = math.floor((rx + 7) / 8) * 8
|
||||
local cy2 = math.floor((ry + 7) / 8) * 8
|
||||
if cx2 ~= cx then slice(cx2, cy) end
|
||||
if cy2 ~= cy then slice(cx, cy2) end
|
||||
if cx2 ~= cx and cy2 ~= cy then slice(cx2, cy2) end
|
||||
end
|
||||
if colors then
|
||||
g.setShader()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return AnimPlayer
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
-- Gen 1 catch algorithm (engine/items/item_effects.asm, ItemUseBall).
|
||||
|
||||
local Catching = {}
|
||||
|
||||
local BALL_RAND_MAX = { MASTER_BALL = 0, POKE_BALL = 255, GREAT_BALL = 200,
|
||||
ULTRA_BALL = 150, SAFARI_BALL = 150 }
|
||||
local BALL_HP_FACTOR = { POKE_BALL = 12, GREAT_BALL = 8, ULTRA_BALL = 12,
|
||||
SAFARI_BALL = 12 }
|
||||
|
||||
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
|
||||
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
|
||||
--
|
||||
-- On failure the ball wobbles per the original's shake calculation:
|
||||
-- Y = rate*100/ballFactor2 (255/200/150), Z = X*Y/255 + status2 (5/10)
|
||||
-- where X is the HP factor; Z<10: 0 shakes, <30: 1, <70: 2, else 3.
|
||||
-- (We use the HP factor for X on both failure paths; the original reads
|
||||
-- a stale quotient when the first roll fails.)
|
||||
function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride)
|
||||
rng = rng or love.math.random
|
||||
if ball == "MASTER_BALL" then return true, 3 end
|
||||
local randMax = BALL_RAND_MAX[ball] or 255
|
||||
local rate = rateOverride or targetDef.catchRate
|
||||
|
||||
local statusBonus = 0
|
||||
local s = targetMon.status
|
||||
if s == "SLP" or s == "FRZ" then
|
||||
statusBonus = 25
|
||||
elseif s == "PSN" or s == "BRN" or s == "PAR" then
|
||||
statusBonus = 12
|
||||
end
|
||||
|
||||
-- HP factor (X)
|
||||
local maxhp = targetMon.stats.hp
|
||||
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
|
||||
local factor = BALL_HP_FACTOR[ball] or 12
|
||||
-- the 255 cap applies only after BOTH divisions (ItemUseBall keeps
|
||||
-- the intermediate in 16 bits); capping early collapses the value
|
||||
local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter))
|
||||
|
||||
local function shakes()
|
||||
local ballFactor2 = ball == "POKE_BALL" and 255
|
||||
or ball == "GREAT_BALL" and 200 or 150
|
||||
local y = math.floor(rate * 100 / ballFactor2)
|
||||
local z
|
||||
if y > 255 then
|
||||
z = 255
|
||||
else
|
||||
z = math.floor(f * y / 255)
|
||||
end
|
||||
if s == "SLP" or s == "FRZ" then
|
||||
z = z + 10
|
||||
elseif s then
|
||||
z = z + 5
|
||||
end
|
||||
if z < 10 then return 0 elseif z < 30 then return 1
|
||||
elseif z < 70 then return 2 else return 3 end
|
||||
end
|
||||
|
||||
local r = rng(0, randMax) - statusBonus
|
||||
if r < 0 then return true, 3 end
|
||||
if r > rate then return false, shakes() end
|
||||
if rng(0, 255) <= f then return true, 3 end
|
||||
return false, shakes()
|
||||
end
|
||||
|
||||
return Catching
|
||||
@@ -0,0 +1,201 @@
|
||||
-- Gen 1 damage calculation, ported from engine/battle/core.asm
|
||||
-- (GetDamage / CriticalHitTest / AdjustDamageForMoveType / RandomizeDamage).
|
||||
--
|
||||
-- Battlers carry curStats/curTypes (Transform/Conversion can override the
|
||||
-- species values) plus reflect/lightScreen/focusEnergy volatile flags.
|
||||
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local Damage = {}
|
||||
|
||||
-- Moves with a boosted critical-hit rate (engine/battle/core.asm
|
||||
-- CriticalHitTest checks these move ids explicitly).
|
||||
local HIGH_CRIT = {
|
||||
KARATE_CHOP = true, RAZOR_LEAF = true, CRABHAMMER = true, SLASH = true,
|
||||
}
|
||||
|
||||
-- Critical chance test, following CriticalHitTest's shift chain exactly
|
||||
-- (each left shift caps at 255): b = baseSpeed/2, then x2 (or /2 with
|
||||
-- Focus Energy's famous right-shift bug), then x4 for high-crit moves
|
||||
-- or /2 for normal ones. Net rates: normal = speed/512, high-crit =
|
||||
-- speed*4/256 (capped), Focus Energy bug = 1/4 the usual.
|
||||
function Damage.critRoll(ruleset, attacker, moveId, rng)
|
||||
rng = rng or love.math.random
|
||||
local function shl(x) return math.min(255, x * 2) end
|
||||
local b = math.floor(attacker.def.baseStats.speed / 2)
|
||||
if attacker.focusEnergy then
|
||||
if ruleset.focusEnergyBug then
|
||||
b = math.floor(b / 2) -- srl instead of sla
|
||||
else
|
||||
b = shl(shl(shl(b))) -- intended: x4 the usual rate
|
||||
end
|
||||
else
|
||||
b = shl(b)
|
||||
end
|
||||
if HIGH_CRIT[moveId] then
|
||||
b = shl(shl(b))
|
||||
else
|
||||
b = math.floor(b / 2)
|
||||
end
|
||||
return rng(0, 255) < b
|
||||
end
|
||||
|
||||
-- Accuracy test: rand(0..255) < floor(accuracy * 255 / 100) adjusted by
|
||||
-- accuracy/evasion stages. With oneIn256Miss a max-accuracy move still
|
||||
-- misses on 255.
|
||||
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
|
||||
rng = rng or love.math.random
|
||||
-- X ACCURACY sets USING_X_ACCURACY: the move simply never misses
|
||||
-- (MoveHitTest returns before any accuracy math, 1/256 included)
|
||||
if attacker.xAccuracy then return true end
|
||||
local acc = math.floor(move.accuracy * 255 / 100)
|
||||
-- CalcHitChance scales by the accuracy stage and the evasion stage as
|
||||
-- two separate ratio multiplications, clamping each result
|
||||
acc = math.min(255, Stats.applyStage(acc,
|
||||
attacker.stages and attacker.stages.accuracy or 0))
|
||||
acc = math.min(255, Stats.applyStage(acc,
|
||||
-(defender.stages and defender.stages.evasion or 0)))
|
||||
if not ruleset.oneIn256Miss and move.accuracy >= 100
|
||||
and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then
|
||||
return true
|
||||
end
|
||||
return rng(0, 255) < acc
|
||||
end
|
||||
|
||||
local function isSpecial(moveType)
|
||||
-- Gen 1: WATER/GRASS/FIRE/ICE/ELECTRIC/PSYCHIC/DRAGON are special
|
||||
return moveType == "WATER" or moveType == "GRASS" or moveType == "FIRE"
|
||||
or moveType == "ICE" or moveType == "ELECTRIC" or moveType == "PSYCHIC_TYPE"
|
||||
or moveType == "DRAGON"
|
||||
end
|
||||
Damage.isSpecial = isSpecial
|
||||
|
||||
-- Compute damage. attacker/defender are battler tables.
|
||||
-- opts: rng, forceCrit, explode (halves defense), typeless (confusion
|
||||
-- self-hit: no STAB/type/random factor), screens (battler whose
|
||||
-- Reflect/Light Screen apply when it isn't the defender -- the
|
||||
-- self-hit reads the opponent's screens).
|
||||
-- Returns damage, {crit=bool, typeMult=x10}.
|
||||
function Damage.compute(ruleset, attacker, defender, move, opts)
|
||||
opts = opts or {}
|
||||
local rng = opts.rng or love.math.random
|
||||
if move.power == 0 then
|
||||
return 0, { crit = false, typeMult = 10 }
|
||||
end
|
||||
|
||||
local crit = opts.forceCrit
|
||||
if crit == nil then
|
||||
crit = Damage.critRoll(ruleset, attacker, move.id, rng)
|
||||
end
|
||||
|
||||
local special = isSpecial(move.type)
|
||||
local atkStat = special and "special" or "attack"
|
||||
local defStat = special and "special" or "defense"
|
||||
|
||||
local atk, dfn
|
||||
if crit and ruleset.critIgnoresStages then
|
||||
atk = attacker.curStats[atkStat]
|
||||
dfn = defender.curStats[defStat]
|
||||
else
|
||||
atk = Stats.applyStage(attacker.curStats[atkStat],
|
||||
attacker.stages and attacker.stages[atkStat] or 0)
|
||||
dfn = Stats.applyStage(defender.curStats[defStat],
|
||||
defender.stages and defender.stages[defStat] or 0)
|
||||
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
|
||||
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
|
||||
-- Volcano -> special
|
||||
local badges = attacker.badges
|
||||
if badges then
|
||||
if not special and badges.BOULDERBADGE then
|
||||
atk = math.floor(atk * 9 / 8)
|
||||
elseif special and badges.VOLCANOBADGE then
|
||||
atk = math.floor(atk * 9 / 8)
|
||||
end
|
||||
end
|
||||
local dbadges = defender.badges
|
||||
if dbadges then
|
||||
if not special and dbadges.THUNDERBADGE then
|
||||
dfn = math.floor(dfn * 9 / 8)
|
||||
elseif special and dbadges.VOLCANOBADGE then
|
||||
dfn = math.floor(dfn * 9 / 8)
|
||||
end
|
||||
end
|
||||
-- burn halves physical attack (applied as part of the stat in Gen 1).
|
||||
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
|
||||
-- unmodified attack over the burn-halved battle stat, lifting the
|
||||
-- penalty until the next stat recompute.
|
||||
if not special and attacker.mon.status == "BRN" and not attacker.hazeStatReset then
|
||||
atk = math.max(1, math.floor(atk / 2))
|
||||
end
|
||||
-- screens double the effective defense (crits bypass them). The
|
||||
-- confusion self-hit is the quirk case: HandleSelfConfusionDamage
|
||||
-- swaps the user's own defense in but leaves the screen check
|
||||
-- reading the OPPONENT's battle status, so the typeless path takes
|
||||
-- the screen flags from opts.screens (the opponent) and never from
|
||||
-- the user itself.
|
||||
if not crit then
|
||||
local screens = opts.screens
|
||||
if screens == nil and not opts.typeless then screens = defender end
|
||||
if screens then
|
||||
if special and screens.lightScreen then dfn = dfn * 2 end
|
||||
if not special and screens.reflect then dfn = dfn * 2 end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- GetDamageVars .scaleStats: when either stat no longer fits a byte,
|
||||
-- BOTH are quartered (losing low bits), each bumped to at least 1
|
||||
if atk > 255 or dfn > 255 then
|
||||
atk = math.max(1, math.floor(atk / 4))
|
||||
dfn = math.max(1, math.floor(dfn / 4))
|
||||
end
|
||||
if opts.explode then
|
||||
dfn = math.max(1, math.floor(dfn / 2))
|
||||
end
|
||||
|
||||
local level = attacker.mon.level
|
||||
if crit then level = level * 2 end
|
||||
|
||||
local d = math.floor(math.floor(2 * level / 5) + 2)
|
||||
d = math.floor(math.floor(d * move.power * atk / math.max(1, dfn)) / 50)
|
||||
d = math.min(d, 997) + 2
|
||||
|
||||
local mult = 10
|
||||
if not opts.typeless then
|
||||
-- STAB
|
||||
local stab = false
|
||||
for _, t in ipairs(attacker.curTypes) do
|
||||
if t == move.type then stab = true break end
|
||||
end
|
||||
if stab then
|
||||
d = math.floor(d * 3 / 2)
|
||||
end
|
||||
|
||||
-- type effectiveness: each TypeEffects row is applied to the
|
||||
-- running damage separately with its own floor (0.5*0.5 lands on
|
||||
-- floor(floor(d/2)/2), not d*0.25)
|
||||
mult = TypeChart.effectiveness(move.type, defender.curTypes)
|
||||
if mult == 0 then
|
||||
return 0, { crit = false, typeMult = 0 }
|
||||
end
|
||||
for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do
|
||||
d = math.floor(d * m / 10)
|
||||
end
|
||||
if d == 0 then
|
||||
-- a 2-3 damage hit at 0.25x floors to zero: the original flags
|
||||
-- the move as missed rather than dealing a minimum 1
|
||||
return 0, { crit = false, typeMult = mult, missed = true }
|
||||
end
|
||||
end
|
||||
|
||||
-- random factor; the typeless confusion self-hit skips RandomizeDamage
|
||||
-- along with AdjustDamageForMoveType (HandleSelfConfusionDamage calls
|
||||
-- CalculateDamage directly), so it is fully deterministic
|
||||
if d > 1 and not opts.typeless then
|
||||
local r = rng(ruleset.randMin, ruleset.randMax)
|
||||
d = math.floor(d * r / 255)
|
||||
end
|
||||
return math.max(d, 1), { crit = crit, typeMult = mult }
|
||||
end
|
||||
|
||||
return Damage
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Experience gain (engine/battle/experience.asm):
|
||||
-- exp = floor(baseExp * enemyLevel / 7) for a single participant
|
||||
-- (trainer battles multiply by 1.5 in Gen 1)
|
||||
-- Stat experience: the defeated species' base stats are added to each
|
||||
-- participant's stat exp.
|
||||
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
|
||||
local Experience = {}
|
||||
|
||||
-- engine/battle/experience.asm order: baseExp is divided by the
|
||||
-- participant count FIRST, then *level/7, then the traded x1.5
|
||||
-- (BoostExp) and finally the trainer x1.5.
|
||||
--
|
||||
-- EXP.ALL (core.asm .halveExpDataLoop): the base values are halved,
|
||||
-- GainExperience runs for the participants, then reruns for the whole
|
||||
-- party -- and because DivideExpDataByNumMonsGainingExp divides the
|
||||
-- base values IN PLACE, the second pass inherits the participant
|
||||
-- division: each party member gets (base/2)/participants/partyCount.
|
||||
-- Sequential floor divisions equal one floor division by the product,
|
||||
-- so callers pass numParticipants = 2*participants for the first pass
|
||||
-- and 2*participants*partyCount for the whole-party pass.
|
||||
function Experience.gainFor(defeatedDef, level, isTrainer, numParticipants, traded)
|
||||
local base = math.floor(defeatedDef.baseExp / math.max(1, numParticipants or 1))
|
||||
local exp = math.floor(base * level / 7)
|
||||
if traded then
|
||||
exp = math.floor(exp * 3 / 2)
|
||||
end
|
||||
if isTrainer then
|
||||
exp = math.floor(exp * 3 / 2)
|
||||
end
|
||||
return math.max(1, exp)
|
||||
end
|
||||
|
||||
-- Applies exp/stat exp; returns the list of levels gained plus the raw
|
||||
-- exp delta (wExpAmountGained, printed by _ExpPointsText -- captured
|
||||
-- before the max-level cap, experience.asm:92-100).
|
||||
function Experience.apply(data, mon, defeatedDef, level, isTrainer,
|
||||
numParticipants, traded)
|
||||
local speciesDef = data.pokemon[mon.species]
|
||||
-- stat exp is divided among participants too
|
||||
-- (DivideExpDataByNumMonsGainingExp divides wEnemyMonBaseStats)
|
||||
local statShare = math.max(1, numParticipants or 1)
|
||||
for _, key in ipairs(Stats.ORDER) do
|
||||
local gain = math.floor(defeatedDef.baseStats[key] / statShare)
|
||||
mon.statExp[key] = math.min(65535, (mon.statExp[key] or 0) + gain)
|
||||
end
|
||||
local gained = Experience.gainFor(defeatedDef, level, isTrainer,
|
||||
numParticipants, traded)
|
||||
mon.exp = mon.exp + gained
|
||||
|
||||
local levels = {}
|
||||
local newLevel = Growth.levelForExp(speciesDef.growthRate, mon.exp)
|
||||
while mon.level < math.min(newLevel, 100) do
|
||||
mon.level = mon.level + 1
|
||||
local old = mon.stats
|
||||
mon.stats = Stats.calc(speciesDef, mon.level, mon.dvs, mon.statExp)
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + (mon.stats.hp - old.hp))
|
||||
table.insert(levels, mon.level)
|
||||
end
|
||||
return levels, gained
|
||||
end
|
||||
|
||||
-- Moves learned when reaching exactly `level`.
|
||||
function Experience.movesLearnedAt(speciesDef, level)
|
||||
local out = {}
|
||||
for _, entry in ipairs(speciesDef.learnset) do
|
||||
if entry.level == level then
|
||||
table.insert(out, entry.move)
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
return Experience
|
||||
@@ -0,0 +1,427 @@
|
||||
-- Move effect handlers for every effect constant used in
|
||||
-- data/moves/moves.asm, ported from engine/battle/core.asm and
|
||||
-- engine/battle/move_effects/*. Handlers receive the battle plus user and
|
||||
-- target battler tables and push messages through battle:sayNext.
|
||||
--
|
||||
-- Substitutes block status/stat effects and side effects aimed at their
|
||||
-- owner, like Gen 1.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local MoveEffects = {}
|
||||
|
||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the
|
||||
-- enemy mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||
local function displayName(b)
|
||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
||||
end
|
||||
|
||||
local STAT_LABEL = {
|
||||
attack = "ATTACK", defense = "DEFENSE", speed = "SPEED",
|
||||
special = "SPECIAL", accuracy = "ACCURACY", evasion = "EVADE",
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- stat stages
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local function changeStage(battle, who, stat, delta, fromEnemy)
|
||||
if fromEnemy and (who.substituteHP or who.mist) then
|
||||
if who.mist then
|
||||
return { displayName(who) .. " is\nprotected by MIST!" }
|
||||
end
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
local cur = who.stages[stat] or 0
|
||||
local new = math.max(-6, math.min(6, cur + delta))
|
||||
if new == cur then
|
||||
return { ("Nothing happened!") }
|
||||
end
|
||||
who.stages[stat] = new
|
||||
-- effects.asm:505-506: after any stat-stage change, modified stats are
|
||||
-- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run,
|
||||
-- re-baking the burn/para penalty and ending Haze's temporary lift.
|
||||
who.hazeStatReset = nil
|
||||
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
||||
-- two-stage variants scroll "greatly" onto a third line
|
||||
if delta >= 2 then
|
||||
return { ("%s's\n%s\ngreatly rose!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
elseif delta == 1 then
|
||||
return { ("%s's\n%s rose!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
elseif delta == -1 then
|
||||
return { ("%s's\n%s fell!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
end
|
||||
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
end
|
||||
|
||||
local function statUp(stat, delta)
|
||||
return function(battle, user, target)
|
||||
return changeStage(battle, user, stat, delta, false)
|
||||
end
|
||||
end
|
||||
|
||||
local function statDown(stat, delta)
|
||||
return function(battle, user, target)
|
||||
return changeStage(battle, target, stat, -delta, true)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- status
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local STATUS_LABEL = {
|
||||
SLP = "fell asleep", PSN = "was poisoned", BRN = "was burned",
|
||||
FRZ = "was frozen solid",
|
||||
}
|
||||
|
||||
-- opts: toxic (start the Toxic counter), moveType (for the type
|
||||
-- gates), secondary (side-effect of a damaging move).
|
||||
local function inflictStatus(battle, target, status, opts)
|
||||
opts = opts or {}
|
||||
if target.mon.status then return {} end
|
||||
-- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute)
|
||||
-- and every secondary status, but NOT primary Sleep or Thunder Wave,
|
||||
-- their handlers never check the substitute in Gen 1.
|
||||
if target.substituteHP and (opts.secondary or status == "PSN") then
|
||||
return {}
|
||||
end
|
||||
for _, t in ipairs(target.curTypes) do
|
||||
-- can't poison Poison-types (primary or secondary)
|
||||
if status == "PSN" and t == "POISON" then return {} end
|
||||
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
|
||||
if status == "PAR" and opts.moveType == "ELECTRIC" and t == "GROUND" then
|
||||
return {}
|
||||
end
|
||||
-- FreezeBurnParalyzeEffect: a secondary status never lands when
|
||||
-- the move's type matches either of the target's types (Body Slam
|
||||
-- can't paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice)
|
||||
if opts.secondary and status ~= "PSN" and opts.moveType == t then
|
||||
return {}
|
||||
end
|
||||
-- keep the canonical immunities for any non-secondary path
|
||||
if (status == "BRN" and t == "FIRE") or (status == "FRZ" and t == "ICE") then
|
||||
return {}
|
||||
end
|
||||
end
|
||||
target.mon.status = status
|
||||
if status == "SLP" then
|
||||
target.sleepTurns = battle.rng(1, 7)
|
||||
end
|
||||
if opts.toxic then
|
||||
target.toxicCounter = 1
|
||||
-- _BadlyPoisonedText
|
||||
return { ("%s's\nbadly poisoned!"):format(displayName(target)) }
|
||||
end
|
||||
if status == "PAR" then
|
||||
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
||||
return { ("%s's\nparalyzed! It may\nnot attack!"):format(displayName(target)) }
|
||||
end
|
||||
return { ("%s\n%s!"):format(displayName(target), STATUS_LABEL[status]) }
|
||||
end
|
||||
|
||||
local function statusMove(status)
|
||||
return function(battle, user, target, move)
|
||||
if target.mon.status then
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
if status == "PSN" and target.substituteHP then
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
local msgs = inflictStatus(battle, target, status, {
|
||||
toxic = move and move.id == "TOXIC",
|
||||
moveType = move and move.type,
|
||||
})
|
||||
if #msgs == 0 then
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
end
|
||||
|
||||
local function statusSide(status, chance)
|
||||
return function(battle, user, target, move)
|
||||
-- CheckDefrost: a burn-chance Fire move that lands thaws a frozen
|
||||
-- target (regardless of the burn roll)
|
||||
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
|
||||
target.mon.status = nil
|
||||
return { ("Fire defrosted\n%s!"):format(displayName(target)) }
|
||||
end
|
||||
if battle.rng(0, 255) >= chance then return {} end
|
||||
return inflictStatus(battle, target, status, {
|
||||
moveType = move and move.type,
|
||||
secondary = true,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function statDownSide(stat)
|
||||
return function(battle, user, target)
|
||||
if target.substituteHP then return {} end
|
||||
if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256)
|
||||
-- StatModifierDownEffect's side-effect branch never runs MoveHitTest,
|
||||
-- so the drop pierces MIST (only primary stat-lowering moves check it)
|
||||
return changeStage(battle, target, stat, -1, false)
|
||||
end
|
||||
end
|
||||
|
||||
local function flinchSide(chance)
|
||||
return function(battle, user, target)
|
||||
if target.substituteHP then return {} end
|
||||
if battle.rng(0, 255) < chance then
|
||||
target.flinched = true
|
||||
end
|
||||
return {}
|
||||
end
|
||||
end
|
||||
|
||||
local function confuse(battle, target, pierceSub)
|
||||
if target.confusedTurns or (target.substituteHP and not pierceSub) then
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
target.confusedTurns = battle.rng(2, 5)
|
||||
return { ("%s\nbecame confused!"):format(displayName(target)) }
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- primary (status-only move) handlers
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
MoveEffects.primary = {
|
||||
ATTACK_UP1_EFFECT = statUp("attack", 1),
|
||||
ATTACK_UP2_EFFECT = statUp("attack", 2),
|
||||
DEFENSE_UP1_EFFECT = statUp("defense", 1),
|
||||
DEFENSE_UP2_EFFECT = statUp("defense", 2),
|
||||
SPEED_UP2_EFFECT = statUp("speed", 2),
|
||||
SPECIAL_UP1_EFFECT = statUp("special", 1),
|
||||
SPECIAL_UP2_EFFECT = statUp("special", 2),
|
||||
EVASION_UP1_EFFECT = statUp("evasion", 1),
|
||||
|
||||
ATTACK_DOWN1_EFFECT = statDown("attack", 1),
|
||||
DEFENSE_DOWN1_EFFECT = statDown("defense", 1),
|
||||
DEFENSE_DOWN2_EFFECT = statDown("defense", 2),
|
||||
SPEED_DOWN1_EFFECT = statDown("speed", 1),
|
||||
ACCURACY_DOWN1_EFFECT = statDown("accuracy", 1),
|
||||
|
||||
SLEEP_EFFECT = statusMove("SLP"),
|
||||
POISON_EFFECT = statusMove("PSN"),
|
||||
PARALYZE_EFFECT = statusMove("PAR"),
|
||||
|
||||
CONFUSION_EFFECT = function(battle, user, target)
|
||||
return confuse(battle, target)
|
||||
end,
|
||||
|
||||
LEECH_SEED_EFFECT = function(battle, user, target)
|
||||
-- leech_seed.asm has no substitute check: seeding lands through one
|
||||
if target.leechSeeded then
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
for _, t in ipairs(target.curTypes) do
|
||||
if t == "GRASS" then return { "But, it failed!" } end
|
||||
end
|
||||
target.leechSeeded = true
|
||||
return { ("%s\nwas seeded!"):format(displayName(target)) }
|
||||
end,
|
||||
|
||||
HEAL_EFFECT = function(battle, user, target, move)
|
||||
local mon = user.mon
|
||||
if move.id == "REST" then
|
||||
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
|
||||
mon.hp = mon.stats.hp
|
||||
mon.status = "SLP"
|
||||
user.sleepTurns = 2
|
||||
user.toxicCounter = nil
|
||||
return { ("%s\nstarted sleeping!"):format(displayName(user)) }
|
||||
end
|
||||
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2))
|
||||
return { ("%s\nregained health!"):format(displayName(user)) }
|
||||
end,
|
||||
|
||||
LIGHT_SCREEN_EFFECT = function(battle, user)
|
||||
if user.lightScreen then return { "But, it failed!" } end
|
||||
user.lightScreen = true
|
||||
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) }
|
||||
end,
|
||||
|
||||
REFLECT_EFFECT = function(battle, user)
|
||||
if user.reflect then return { "But, it failed!" } end
|
||||
user.reflect = true
|
||||
return { ("%s\ngained armor!"):format(displayName(user)) }
|
||||
end,
|
||||
|
||||
MIST_EFFECT = function(battle, user)
|
||||
if user.mist then return { "But, it failed!" } end
|
||||
user.mist = true
|
||||
-- _ShroudedInMistText (lowercase "mist")
|
||||
return { ("%s's\nshrouded in mist!"):format(displayName(user)) }
|
||||
end,
|
||||
|
||||
FOCUS_ENERGY_EFFECT = function(battle, user)
|
||||
if user.focusEnergy then return { "But, it failed!" } end
|
||||
user.focusEnergy = true
|
||||
return { ("%s's\ngetting pumped!"):format(displayName(user)) }
|
||||
end,
|
||||
|
||||
HAZE_EFFECT = function(battle, user, target)
|
||||
for _, b in ipairs({ user, target }) do
|
||||
b.stages = {}
|
||||
b.confusedTurns = nil
|
||||
b.leechSeeded = nil
|
||||
b.toxicCounter = nil
|
||||
b.reflect, b.lightScreen, b.mist, b.focusEnergy = nil, nil, nil, nil
|
||||
-- haze.asm also zeroes both disabled-move slots and clears
|
||||
-- USING_X_ACCURACY on both sides
|
||||
b.disabledSlot, b.disabledTurns = nil, nil
|
||||
b.xAccuracy = nil
|
||||
-- haze.asm ResetStats copies each side's UNMODIFIED stats (8 bytes,
|
||||
-- not HP) over its battle stats, which temporarily lifts the burn
|
||||
-- Attack-halving and paralysis Speed-quartering on BOTH battlers
|
||||
-- until the next stat recompute (a stage change or switch-in).
|
||||
b.hazeStatReset = true
|
||||
end
|
||||
-- Gen 1 also removes the enemy's major status; if that cured sleep
|
||||
-- or freeze, the target forfeits its move this turn (haze.asm
|
||||
-- writes $ff/CANNOT_MOVE to its selected move)
|
||||
if target.mon.status == "SLP" or target.mon.status == "FRZ" then
|
||||
target.skipMove = true
|
||||
end
|
||||
target.mon.status = nil
|
||||
return { "All STATUS changes\nare eliminated!" }
|
||||
end,
|
||||
|
||||
SUBSTITUTE_EFFECT = function(battle, user)
|
||||
if user.substituteHP then return { ("%s\nhas a SUBSTITUTE!"):format(displayName(user)) } end
|
||||
local cost = math.floor(user.mon.stats.hp / 4)
|
||||
-- substitute.asm only fails on subtraction underflow (current HP
|
||||
-- strictly below maxHP/4); at equality the substitute is built and
|
||||
-- the user is left standing on exactly 0 HP (it faints only when
|
||||
-- the engine next checks HP, not here)
|
||||
if user.mon.hp < cost then
|
||||
return { "Too weak to make\na SUBSTITUTE!" }
|
||||
end
|
||||
user.mon.hp = user.mon.hp - cost
|
||||
user.substituteHP = cost + 1
|
||||
-- _SubstituteText
|
||||
return { "It created a\nSUBSTITUTE!" }
|
||||
end,
|
||||
|
||||
CONVERSION_EFFECT = function(battle, user, target)
|
||||
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
|
||||
if target.invulnerable then
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
user.curTypes = { target.curTypes[1], target.curTypes[2] }
|
||||
-- _ConvertedTypeText
|
||||
return { ("Converted type to\n%s's!"):format(displayName(target)) }
|
||||
end,
|
||||
|
||||
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
|
||||
-- (effects.asm:1203-1273) runs mid-move -- hit test first, then the
|
||||
-- player's copy menu pauses the message queue, which a table of
|
||||
-- returned strings can't express.
|
||||
|
||||
TRANSFORM_EFFECT = function(battle, user, target)
|
||||
-- transform.asm:31-53 (AnimationTransformMon) morphs the user's
|
||||
-- on-screen pic into the target species; the port swaps user.sprite
|
||||
-- via the same getImage/monPalette path makeBattler uses so the
|
||||
-- change is visible (the renderer draws battler.sprite directly).
|
||||
user.sprite = battle:speciesSprite(target.mon.species, user.isPlayer)
|
||||
or user.sprite
|
||||
user.curStats = {
|
||||
hp = user.mon.stats.hp, -- HP is kept
|
||||
attack = target.curStats.attack, defense = target.curStats.defense,
|
||||
speed = target.curStats.speed, special = target.curStats.special,
|
||||
}
|
||||
user.curTypes = { target.curTypes[1], target.curTypes[2] }
|
||||
-- transform.asm:130-132 copies the target's stat MODS into the user
|
||||
-- (wEnemyMonStatMods -> wPlayerMonStatMods), it does NOT clear them;
|
||||
-- deep copy so later stage changes on either mon stay independent
|
||||
user.stages = {}
|
||||
for stat, stage in pairs(target.stages) do user.stages[stat] = stage end
|
||||
user.curMoves = {}
|
||||
for _, mv in ipairs(target.curMoves) do
|
||||
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
|
||||
end
|
||||
-- _TransformedText: the copied name prints bare (wNameBuffer)
|
||||
return { ("%s\ntransformed into\n%s!"):format(displayName(user), target.name) }
|
||||
end,
|
||||
|
||||
DISABLE_EFFECT = function(battle, user, target)
|
||||
if target.disabledSlot then return { "But, it failed!" } end
|
||||
local usable = {}
|
||||
for i, mv in ipairs(target.curMoves) do
|
||||
if mv.pp > 0 then table.insert(usable, i) end
|
||||
end
|
||||
if #usable == 0 then return { "But, it failed!" } end
|
||||
local slot = usable[battle.rng(1, #usable)]
|
||||
target.disabledSlot = slot
|
||||
target.disabledTurns = battle.rng(1, 8)
|
||||
local id = target.curMoves[slot].id
|
||||
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
||||
return { ("%s's\n%s was\ndisabled!"):format(displayName(target),
|
||||
battle.data.moves[id].name) }
|
||||
end,
|
||||
|
||||
SPLASH_EFFECT = function()
|
||||
return { "No effect!" }
|
||||
end,
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- secondary (after-damage) side effects
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
MoveEffects.secondary = {
|
||||
BURN_SIDE_EFFECT1 = statusSide("BRN", 26),
|
||||
BURN_SIDE_EFFECT2 = statusSide("BRN", 77),
|
||||
FREEZE_SIDE_EFFECT1 = statusSide("FRZ", 26),
|
||||
PARALYZE_SIDE_EFFECT1 = statusSide("PAR", 26),
|
||||
PARALYZE_SIDE_EFFECT2 = statusSide("PAR", 77),
|
||||
POISON_SIDE_EFFECT1 = statusSide("PSN", 52),
|
||||
POISON_SIDE_EFFECT2 = statusSide("PSN", 103),
|
||||
FLINCH_SIDE_EFFECT1 = flinchSide(26),
|
||||
FLINCH_SIDE_EFFECT2 = flinchSide(77),
|
||||
ATTACK_DOWN_SIDE_EFFECT = statDownSide("attack"),
|
||||
DEFENSE_DOWN_SIDE_EFFECT = statDownSide("defense"),
|
||||
SPEED_DOWN_SIDE_EFFECT = statDownSide("speed"),
|
||||
SPECIAL_DOWN_SIDE_EFFECT = statDownSide("special"),
|
||||
CONFUSION_SIDE_EFFECT = function(battle, user, target)
|
||||
if target.confusedTurns then return {} end
|
||||
-- cp 10 percent (no +1): 25/256; ConfusionSideEffect never calls
|
||||
-- CheckTargetSubstitute, so secondary confusion pierces a substitute
|
||||
if battle.rng(0, 255) >= 25 then return {} end
|
||||
return confuse(battle, target, true)
|
||||
end,
|
||||
TWINEEDLE_EFFECT = function(battle, user, target)
|
||||
-- the second hit reroutes to PoisonEffect with POISON_SIDE_EFFECT1:
|
||||
-- 20 percent + 1 (52/256)
|
||||
if battle.rng(0, 255) >= 52 then return {} end
|
||||
return inflictStatus(battle, target, "PSN", { secondary = true })
|
||||
end,
|
||||
}
|
||||
|
||||
-- effects fully handled inside BattleState's damage pipeline
|
||||
MoveEffects.special = {
|
||||
NO_ADDITIONAL_EFFECT = true, TWO_TO_FIVE_ATTACKS_EFFECT = true,
|
||||
ATTACK_TWICE_EFFECT = true, SPECIAL_DAMAGE_EFFECT = true,
|
||||
SUPER_FANG_EFFECT = true, OHKO_EFFECT = true, RECOIL_EFFECT = true,
|
||||
DRAIN_HP_EFFECT = true, DREAM_EATER_EFFECT = true, CHARGE_EFFECT = true,
|
||||
FLY_EFFECT = true, TRAPPING_EFFECT = true, THRASH_PETAL_DANCE_EFFECT = true,
|
||||
JUMP_KICK_EFFECT = true, EXPLODE_EFFECT = true, HYPER_BEAM_EFFECT = true,
|
||||
PAY_DAY_EFFECT = true, SWIFT_EFFECT = true, RAGE_EFFECT = true,
|
||||
BIDE_EFFECT = true, SWITCH_AND_TELEPORT_EFFECT = true,
|
||||
METRONOME_EFFECT = true, MIRROR_MOVE_EFFECT = true,
|
||||
TWINEEDLE_EFFECT = true, MIMIC_EFFECT = true,
|
||||
}
|
||||
|
||||
local warned = {}
|
||||
|
||||
function MoveEffects.warnUnknown(effect)
|
||||
if not warned[effect] then
|
||||
warned[effect] = true
|
||||
Logger.warn("move effect %s not implemented; treated as plain damage", effect)
|
||||
end
|
||||
end
|
||||
|
||||
return MoveEffects
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Per-turn status/volatile condition handling (Gen 1 semantics).
|
||||
|
||||
local Status = {}
|
||||
|
||||
-- Returns canMove, messages, selfHit (true -> hurt itself in confusion)
|
||||
function Status.beforeMove(battler, rng)
|
||||
local mon = battler.mon
|
||||
-- Haze curing this mon's sleep/freeze forfeits its pending move for
|
||||
-- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected
|
||||
-- move; ExecuteMove returns immediately without a message)
|
||||
if battler.skipMove then
|
||||
battler.skipMove = nil
|
||||
return false, {}
|
||||
end
|
||||
if battler.flinched then
|
||||
battler.flinched = false
|
||||
return false, { battler.name .. "\nflinched!" }
|
||||
end
|
||||
if mon.status == "SLP" then
|
||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||
if battler.sleepTurns <= 0 then
|
||||
mon.status = nil
|
||||
return false, { battler.name .. "\nwoke up!" } -- wakes but loses the turn
|
||||
end
|
||||
return false, { battler.name .. "\nis fast asleep!" }
|
||||
end
|
||||
if mon.status == "FRZ" then
|
||||
return false, { battler.name .. "\nis frozen solid!" }
|
||||
end
|
||||
if battler.boundTurns and battler.boundTurns > 0 then
|
||||
battler.boundTurns = battler.boundTurns - 1
|
||||
return false, { battler.name .. "\ncan't move!" }
|
||||
end
|
||||
local msgs = {}
|
||||
if battler.disabledTurns then
|
||||
battler.disabledTurns = battler.disabledTurns - 1
|
||||
if battler.disabledTurns <= 0 then
|
||||
battler.disabledTurns, battler.disabledSlot = nil, nil
|
||||
table.insert(msgs, battler.name .. "'s\ndisabled no more!")
|
||||
end
|
||||
end
|
||||
if battler.confusedTurns then
|
||||
battler.confusedTurns = battler.confusedTurns - 1
|
||||
if battler.confusedTurns <= 0 then
|
||||
battler.confusedTurns = nil
|
||||
table.insert(msgs, battler.name .. "\nsnapped out of\nconfusion!")
|
||||
else
|
||||
table.insert(msgs, battler.name .. "\nis confused!")
|
||||
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
||||
if rng(0, 255) < 128 then
|
||||
return false, msgs, true -- hurt itself
|
||||
end
|
||||
end
|
||||
end
|
||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||
if mon.status == "PAR" and rng(0, 255) < 63 then
|
||||
table.insert(msgs, battler.name .. "'s\nfully paralyzed!")
|
||||
return false, msgs
|
||||
end
|
||||
return true, msgs
|
||||
end
|
||||
|
||||
-- End-of-turn residual damage; opponent is needed for Leech Seed.
|
||||
-- Returns messages.
|
||||
function Status.residual(battler, opponent)
|
||||
local msgs = {}
|
||||
local mon = battler.mon
|
||||
-- the Haze move-forfeit only covers the turn Haze was used; if this
|
||||
-- mon had already moved, drop the flag before it leaks into next turn
|
||||
battler.skipMove = nil
|
||||
if mon.hp <= 0 then return msgs end
|
||||
if mon.status == "PSN" or mon.status == "BRN" then
|
||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||
local dmg = base
|
||||
if battler.toxicCounter then
|
||||
dmg = base * battler.toxicCounter
|
||||
battler.toxicCounter = battler.toxicCounter + 1
|
||||
end
|
||||
mon.hp = math.max(0, mon.hp - dmg)
|
||||
local what = mon.status == "PSN" and "poison" or "the burn"
|
||||
table.insert(msgs, ("%s's\nhurt by %s!"):format(battler.name, what))
|
||||
end
|
||||
if battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then
|
||||
-- the shared Toxic counter multiplies (and advances on) the seed
|
||||
-- drain too -- the Gen 1 Leech Seed glitch
|
||||
-- (HandlePoisonBurnLeechSeed_DecreaseOwnHP)
|
||||
local dmg = math.max(1, math.floor(mon.stats.hp / 16))
|
||||
if battler.toxicCounter then
|
||||
dmg = dmg * battler.toxicCounter
|
||||
battler.toxicCounter = battler.toxicCounter + 1
|
||||
end
|
||||
dmg = math.min(dmg, mon.hp)
|
||||
mon.hp = mon.hp - dmg
|
||||
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
||||
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(battler.name))
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
|
||||
return Status
|
||||
@@ -0,0 +1,231 @@
|
||||
-- Trainer/wild move selection with the per-class "move choice
|
||||
-- modification" layers from data/trainers/move_choices.asm
|
||||
-- (engine/battle/trainer_ai.asm):
|
||||
-- mod 1: heavily discourage zero-power status-ailment moves when the
|
||||
-- player already has a status condition (they would fail)
|
||||
-- mod 2: encourage stat-modifying (and neighbouring) move effects,
|
||||
-- but only on the second move selection per enemy mon
|
||||
-- (wAILayer2Encouragement == 1)
|
||||
-- mod 3: encourage moves whose type is super effective against the
|
||||
-- player (even non-damaging ones), discourage not-very-
|
||||
-- effective/no-effect types when a "better move" is known
|
||||
-- Faithful port of AIEnemyTrainerChooseMoves
|
||||
-- (engine/battle/trainer_ai.asm:3-257): every candidate move starts at a
|
||||
-- base score of 10; mod 1 adds 5, mod 2 subtracts 1, mod 3 subtracts 1
|
||||
-- (super-effective) or adds 1 (not-effective when a better move exists);
|
||||
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
||||
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
||||
-- selectable. Respects PP, Disable and Transform/Mimic move overrides.
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local TrainerAI = {}
|
||||
|
||||
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
|
||||
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
|
||||
|
||||
-- Item use / switching per trainer class (engine/battle/trainer_ai.asm
|
||||
-- via data/scripts/ai_classes.lua). Runs before move choice each enemy
|
||||
-- turn; returns an action { special = "aiItem"/"aiSwitch", ... } or nil.
|
||||
-- battle.aiUses is initialized per enemy Pokémon (wAICount).
|
||||
function TrainerAI.classAction(battle)
|
||||
if battle.kind ~= "trainer" or not battle.trainer then return nil end
|
||||
local class = require("data.scripts.ai_classes")[battle.trainer.id]
|
||||
if not class then return nil end
|
||||
if (battle.aiUses or 0) <= 0 then return nil end
|
||||
local rng = battle.rng
|
||||
local enemy = battle.enemy
|
||||
local roll = rng(0, 255)
|
||||
|
||||
-- Agatha's dedicated switch roll comes before her item roll
|
||||
if class.switchChance and roll < class.switchChance then
|
||||
return TrainerAI.switchAction(battle)
|
||||
end
|
||||
|
||||
if class.onStatus then
|
||||
if enemy.mon.status then
|
||||
return { special = "aiItem", item = class.item }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
if class.chance and roll >= class.chance then return nil end
|
||||
|
||||
if class.switch then
|
||||
return TrainerAI.switchAction(battle)
|
||||
end
|
||||
if class.hpBelow
|
||||
and enemy.mon.hp >= math.floor(enemy.mon.stats.hp / class.hpBelow) then
|
||||
if class.switchBelow
|
||||
and enemy.mon.hp < math.floor(enemy.mon.stats.hp / class.switchBelow) then
|
||||
return TrainerAI.switchAction(battle)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
return { special = "aiItem", item = class.item }
|
||||
end
|
||||
|
||||
-- AISwitchIfEnoughMons (engine/battle/trainer_ai.asm:554-582): counts ALL
|
||||
-- unfainted party mons including the active one and switches when that
|
||||
-- total is >= 2 (cp 2 / jp nc) -- i.e. whenever at least ONE non-active
|
||||
-- mon can still fight. Switch to the first (lowest-index) such backup,
|
||||
-- matching EnemySendOutFirstMon (core.asm:1292-1341).
|
||||
function TrainerAI.switchAction(battle)
|
||||
local alive = {}
|
||||
for i, mon in ipairs(battle.enemyParty or {}) do
|
||||
if mon.hp > 0 and i ~= battle.enemyIndex then
|
||||
table.insert(alive, i)
|
||||
end
|
||||
end
|
||||
if #alive < 1 then return nil end
|
||||
return { special = "aiSwitch", index = alive[1] }
|
||||
end
|
||||
|
||||
-- Apply an aiItem action to the enemy battler; returns messages.
|
||||
function TrainerAI.useItem(battle, item)
|
||||
local enemy = battle.enemy
|
||||
local trainerName = battle.trainer.name
|
||||
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
||||
local msgs = { ("%s\nused %s!"):format(trainerName, itemName) }
|
||||
if item == "FULL_HEAL" then
|
||||
enemy.mon.status = nil
|
||||
enemy.toxicCounter = nil
|
||||
elseif item == "FULL_RESTORE" then
|
||||
enemy.mon.hp = enemy.mon.stats.hp
|
||||
enemy.mon.status = nil
|
||||
enemy.toxicCounter = nil
|
||||
elseif HEAL_AMOUNT[item] then
|
||||
enemy.mon.hp = math.min(enemy.mon.stats.hp, enemy.mon.hp + HEAL_AMOUNT[item])
|
||||
elseif X_STAT[item] then
|
||||
local stat = X_STAT[item]
|
||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
||||
table.insert(msgs, ("%s's\n%s rose!"):format(enemy.name, stat:upper()))
|
||||
elseif item == "GUARD_SPEC" then
|
||||
enemy.mist = true
|
||||
table.insert(msgs, ("%s's\nprotected against\nstat changes!"):format(enemy.name))
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
|
||||
-- AIMoveChoiceModification1's StatusAilmentMoveEffects table: the two
|
||||
-- sleep effects (EFFECT_01 is the unused one), poison and paralysis.
|
||||
local STATUS_EFFECTS = {
|
||||
EFFECT_01 = true, SLEEP_EFFECT = true, POISON_EFFECT = true,
|
||||
PARALYZE_EFFECT = true,
|
||||
}
|
||||
|
||||
-- AIMoveChoiceModification2 encourages the two effect ranges
|
||||
-- ATTACK_UP1_EFFECT..BIDE_EFFECT and ATTACK_UP2_EFFECT..POISON_EFFECT
|
||||
-- (both exclusive of the upper bound): every stat modifier plus the
|
||||
-- effects laid out between them in the constant list.
|
||||
local ENCOURAGE_EFFECTS = {
|
||||
-- $0A ATTACK_UP1_EFFECT .. $19 HAZE_EFFECT
|
||||
ATTACK_UP1_EFFECT = true, DEFENSE_UP1_EFFECT = true, SPEED_UP1_EFFECT = true,
|
||||
SPECIAL_UP1_EFFECT = true, ACCURACY_UP1_EFFECT = true, EVASION_UP1_EFFECT = true,
|
||||
PAY_DAY_EFFECT = true, SWIFT_EFFECT = true,
|
||||
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true, SPEED_DOWN1_EFFECT = true,
|
||||
SPECIAL_DOWN1_EFFECT = true, ACCURACY_DOWN1_EFFECT = true, EVASION_DOWN1_EFFECT = true,
|
||||
CONVERSION_EFFECT = true, HAZE_EFFECT = true,
|
||||
-- $32 ATTACK_UP2_EFFECT .. $41 REFLECT_EFFECT
|
||||
ATTACK_UP2_EFFECT = true, DEFENSE_UP2_EFFECT = true, SPEED_UP2_EFFECT = true,
|
||||
SPECIAL_UP2_EFFECT = true, ACCURACY_UP2_EFFECT = true, EVASION_UP2_EFFECT = true,
|
||||
HEAL_EFFECT = true, TRANSFORM_EFFECT = true,
|
||||
ATTACK_DOWN2_EFFECT = true, DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN2_EFFECT = true,
|
||||
SPECIAL_DOWN2_EFFECT = true, ACCURACY_DOWN2_EFFECT = true, EVASION_DOWN2_EFFECT = true,
|
||||
LIGHT_SCREEN_EFFECT = true, REFLECT_EFFECT = true,
|
||||
}
|
||||
|
||||
-- AIMoveChoiceModification3 .betterMoveFound: a "better move" is any
|
||||
-- known move (PP and Disable ignored) with the Super Fang, fixed-damage
|
||||
-- or Fly effect, or any damaging move of a different type than the move
|
||||
-- being judged.
|
||||
local BETTER_EFFECTS = {
|
||||
SUPER_FANG_EFFECT = true, SPECIAL_DAMAGE_EFFECT = true, FLY_EFFECT = true,
|
||||
}
|
||||
|
||||
local function hasBetterMove(battler, judged, battle)
|
||||
for _, mv in ipairs(battler.curMoves) do
|
||||
local d = battle.data.moves[mv.id]
|
||||
if d then
|
||||
if BETTER_EFFECTS[d.effect] then return true end
|
||||
if d.type ~= judged.type and d.power > 0 then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function TrainerAI.chooseMove(battler, rng, battle)
|
||||
rng = rng or love.math.random
|
||||
local usable = {}
|
||||
for i, mv in ipairs(battler.curMoves) do
|
||||
if mv.pp > 0 and battler.disabledSlot ~= i then
|
||||
table.insert(usable, mv)
|
||||
end
|
||||
end
|
||||
if #usable == 0 then
|
||||
return { id = "STRUGGLE", pp = 1, struggle = true }
|
||||
end
|
||||
|
||||
-- wAILayer2Encouragement starts at 0 on each enemy send-out and gains
|
||||
-- 1 per executed enemy move, so layer 2 (which needs it == 1) only
|
||||
-- fires on the second move selection of each enemy mon. The port
|
||||
-- counts selections instead of executions; they only diverge across
|
||||
-- turns locked into a multi-turn move, which skip selection entirely.
|
||||
local encourageTurn = (battler.aiLayer2 or 0) == 1
|
||||
battler.aiLayer2 = (battler.aiLayer2 or 0) + 1
|
||||
|
||||
local mods = battle and battle.enemyAIMods or nil
|
||||
if not mods or #mods == 0 or not battle then
|
||||
return usable[rng(1, #usable)]
|
||||
end
|
||||
|
||||
-- AIEnemyTrainerChooseMoves (engine/battle/trainer_ai.asm:3-257): every
|
||||
-- usable move starts at a base score of 10; the class's modification
|
||||
-- functions adjust it additively, then the MINIMUM-scored move is chosen
|
||||
-- with ties broken uniformly among the minima (core.asm:2971-3002 rolls a
|
||||
-- fresh byte among the value-1 slots). A non-minimal move is never
|
||||
-- selectable.
|
||||
local target = battle.player
|
||||
local scores = {}
|
||||
for i, mv in ipairs(usable) do
|
||||
local def = battle.data.moves[mv.id]
|
||||
local s = 10
|
||||
for _, mod in ipairs(mods) do
|
||||
if mod == 1 and def and target.mon.status
|
||||
and def.power == 0 and STATUS_EFFECTS[def.effect] then
|
||||
-- AIMoveChoiceModification1: `add $5` -- heavily discourage a
|
||||
-- zero-power status move that would fail (player already statused)
|
||||
s = s + 5
|
||||
elseif mod == 2 and def and encourageTurn
|
||||
and ENCOURAGE_EFFECTS[def.effect] then
|
||||
-- AIMoveChoiceModification2: `dec [hl]` -- slightly encourage
|
||||
s = s - 1
|
||||
elseif mod == 3 and def then
|
||||
-- AIMoveChoiceModification3 via AIGetTypeEffectiveness only reads
|
||||
-- the FIRST matching TypeEffects row for (move type vs either
|
||||
-- defender type) -- no dual-type product -- and runs for
|
||||
-- non-damaging moves too. The table holds no value-10 rows, so
|
||||
-- >10 / <10 reproduces the oracle's compare against $10.
|
||||
local row = TypeChart.rows(def.type, target.curTypes)[1]
|
||||
if row and row > 10 then
|
||||
s = s - 1 -- `dec [hl]`: encourage a super-effective move
|
||||
elseif row and row < 10 and hasBetterMove(battler, def, battle) then
|
||||
s = s + 1 -- `inc [hl]`: discourage when a better move is known
|
||||
end
|
||||
end
|
||||
end
|
||||
scores[i] = s
|
||||
end
|
||||
local best = math.huge
|
||||
for _, s in ipairs(scores) do
|
||||
if s < best then best = s end
|
||||
end
|
||||
local minima = {}
|
||||
for i, s in ipairs(scores) do
|
||||
if s == best then minima[#minima + 1] = usable[i] end
|
||||
end
|
||||
if #minima == 1 then return minima[1] end
|
||||
return minima[rng(1, #minima)]
|
||||
end
|
||||
|
||||
return TrainerAI
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Turn order, from engine/battle/core.asm MainInBattleLoop: compare
|
||||
-- effective speed; ties are a coin flip. QUICK_ATTACK moves first and
|
||||
-- COUNTER last (Gen 1 has only these two priority moves, checked by id).
|
||||
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
|
||||
local TurnOrder = {}
|
||||
|
||||
local function effectiveSpeed(battler)
|
||||
local spd = Stats.applyStage(battler.curStats.speed,
|
||||
battler.stages and battler.stages.speed or 0)
|
||||
-- ApplyBadgeStatBoosts: the SOULBADGE (bit 4) boosts speed
|
||||
if battler.badges and battler.badges.SOULBADGE then
|
||||
spd = math.floor(spd * 9 / 8)
|
||||
end
|
||||
-- paralysis quarters speed; hazeStatReset suppresses it because Haze
|
||||
-- (haze.asm ResetStats) copied the unmodified speed over the quartered
|
||||
-- battle stat, lifting the penalty until the next stat recompute.
|
||||
if battler.mon.status == "PAR" and not battler.hazeStatReset then
|
||||
spd = math.max(1, math.floor(spd / 4))
|
||||
end
|
||||
return spd
|
||||
end
|
||||
|
||||
local function priority(moveId)
|
||||
if moveId == "QUICK_ATTACK" then return 1 end
|
||||
if moveId == "COUNTER" then return -1 end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Returns true when battler a moves before battler b. invertTie flips
|
||||
-- the coin-flip result only: lockstep link battles share one RNG
|
||||
-- stream, so the guest inverts the tie roll to agree with the host on
|
||||
-- who moves first.
|
||||
function TurnOrder.firstMover(a, aMove, b, bMove, rng, invertTie)
|
||||
rng = rng or love.math.random
|
||||
local pa, pb = priority(aMove and aMove.id), priority(bMove and bMove.id)
|
||||
if pa ~= pb then return pa > pb end
|
||||
local sa, sb = effectiveSpeed(a), effectiveSpeed(b)
|
||||
if sa ~= sb then return sa > sb end
|
||||
local aFirst = rng(0, 1) == 0
|
||||
if invertTie then aFirst = not aFirst end
|
||||
return aFirst
|
||||
end
|
||||
|
||||
TurnOrder.effectiveSpeed = effectiveSpeed
|
||||
|
||||
return TurnOrder
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Gen 1 type effectiveness from generated data (multipliers x10).
|
||||
-- Like the original, each matchup row applies independently, so dual types
|
||||
-- multiply (e.g. 20 * 5 -> neutral).
|
||||
|
||||
local TypeChart = {}
|
||||
|
||||
local index -- [atk][def] -> x10 multiplier
|
||||
local matchups -- ROM-ordered TypeEffects rows
|
||||
|
||||
function TypeChart.load(data)
|
||||
index = {}
|
||||
matchups = data.type_chart.matchups
|
||||
for _, m in ipairs(matchups) do
|
||||
index[m.attacker] = index[m.attacker] or {}
|
||||
index[m.attacker][m.defender] = m.multiplier
|
||||
end
|
||||
end
|
||||
|
||||
-- The x10 multipliers of every TypeEffects row that applies, in ROM
|
||||
-- order. AdjustDamageForMoveType applies each row to the running
|
||||
-- damage separately (one application per row even when both defender
|
||||
-- types match it), so callers must floor after every row.
|
||||
function TypeChart.rows(moveType, defenderTypes)
|
||||
assert(matchups, "TypeChart.load not called")
|
||||
local out = {}
|
||||
for _, m in ipairs(matchups) do
|
||||
if m.attacker == moveType then
|
||||
for _, dt in ipairs(defenderTypes) do
|
||||
if m.defender == dt then
|
||||
out[#out + 1] = m.multiplier
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Returns the combined x10 multiplier of moveType against a types list
|
||||
-- (x100 for dual matchups is normalized back: each application is /10).
|
||||
function TypeChart.effectiveness(moveType, defenderTypes)
|
||||
assert(index, "TypeChart.load not called")
|
||||
local mult = 10
|
||||
local row = index[moveType]
|
||||
if not row then return mult end
|
||||
for _, dt in ipairs(defenderTypes) do
|
||||
local m = row[dt]
|
||||
if m ~= nil then
|
||||
mult = math.floor(mult * m / 10)
|
||||
end
|
||||
end
|
||||
return mult
|
||||
end
|
||||
|
||||
return TypeChart
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Default ruleset: preserve Gen 1 behavior, including the famous quirks.
|
||||
|
||||
return {
|
||||
name = "gen1_faithful",
|
||||
-- accuracy roll is rand(0..255) < floor(acc*255/100): a 100%-accurate
|
||||
-- move still misses on a roll of 255 (the 1/256 miss)
|
||||
oneIn256Miss = true,
|
||||
-- critical hits use base speed (not current speed) and ignore stat stages
|
||||
critUsesBaseSpeed = true,
|
||||
critIgnoresStages = true,
|
||||
-- damage random factor r in [217,255], damage = damage * r / 255
|
||||
randMin = 217,
|
||||
randMax = 255,
|
||||
-- Focus Energy famously QUARTERS the crit rate instead of x4
|
||||
focusEnergyBug = true,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Optional ruleset that removes the most notorious Gen 1 quirks while
|
||||
-- keeping the same formulas. Not the default.
|
||||
|
||||
return {
|
||||
name = "modern_clean",
|
||||
oneIn256Miss = false,
|
||||
critUsesBaseSpeed = true,
|
||||
critIgnoresStages = false,
|
||||
randMin = 217,
|
||||
randMax = 255,
|
||||
focusEnergyBug = false,
|
||||
}
|
||||
Reference in New Issue
Block a user