initial commit

This commit is contained in:
bryanthaboi
2026-07-17 20:30:02 -04:00
commit a5d2e77e7d
298 changed files with 100561 additions and 0 deletions
+749
View File
@@ -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
+66
View File
@@ -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
+201
View File
@@ -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
+76
View File
@@ -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
+427
View File
@@ -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
+100
View File
@@ -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
+231
View File
@@ -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
+48
View File
@@ -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
+55
View File
@@ -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
+16
View File
@@ -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,
}
+12
View File
@@ -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,
}