Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/battle/BattleState.lua
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ local Weather = require("src.battle.Weather")
local WideBattle = require("src.battle.WideBattle")
local Gen3Battle = require("src.battle.Gen3Battle")
local Gen4Battle = require("src.battle.Gen4Battle")
local Gen2BattleView = require("src.battle.Gen2BattleView")
local GameVersion = require("src.core.GameVersion")

-- FORWARD-DECLARED, because they are defined next to the other doubles
Expand Down Expand Up @@ -1155,12 +1156,9 @@ local function newBattle(game)
local self = setmetatable({}, BattleState)
self.game = game
self.data = game.data
-- On this engine the battle screen IS the battle logic object: there is no
-- separate model that a screen points at. Publishing the self-reference makes
-- that explicit so anything holding "a thing with a .battle" (the party menu
-- during a switch, mods walking the state stack) can treat the battle screen
-- and the party menu uniformly instead of special-casing this one screen.
self.battle = self
-- Native combat keeps battler wrappers; the Gen2 screen contract exposes
-- the actual Pokemon through a live model view. No copied party/HP state.
if GameVersion.isGen2() then Gen2BattleView.bind(self) else self.battle = self end
-- BLACK AND WHITE: their move effects named for this engine, their species'
-- abilities and their ruleset, before the ruleset below is chosen
-- (src/battle/Gen5MoveEffects.lua; sets self.bw on a BW cache only)
Expand Down Expand Up @@ -12795,4 +12793,11 @@ function BattleState:drawClassic()
end
end

Gen2BattleView.install(BattleState, {
load = getImage,
palette = monPalette,
path = imagePathOf,
trueColor = function(image) local meta = imageMeta[image]; return meta and meta.trueColor or false end,
})

return BattleState
167 changes: 167 additions & 0 deletions src/battle/Gen2BattleView.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
-- Gen2's public screen/model boundary over the shared native battle engine.
-- The screen owns execution, wrappers and presentation clocks; readers of
-- screen.battle get the actual party Pokemon, including after a queued switch.
local View = {}

local function partyOf(screen)
return screen.playerParty or screen.game.save.party or {}
end

local function battler(screen, side)
if side == "player" or side == "enemy" then return screen[side] end
end

local function indexOf(party, mon)
for index, candidate in ipairs(party or {}) do
if candidate == mon then return index end
end
end

function View.bind(screen)
local readers = {
player = function() return screen.player and screen.player.mon end,
enemy = function() return screen.enemy and screen.enemy.mon end,
party = function() return partyOf(screen) end,
enemyParty = function() return screen.enemyParty end,
save = function() return screen.game.save end,
data = function() return screen.data end,
wild = function() return screen.kind == "wild" end,
playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end,
enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end,
over = function() return screen.result ~= nil or screen.dead == true end,
outcome = function() return screen.result end,
battleType = function() return screen.battleType end,
trainer = function() return screen.trainer end,
tutorial = function() return screen.demo or screen.dudeDemo or screen.pokedudeDemo end,
contest = function() return screen.contest end,
safari = function() return screen.safari and true or false end,
link = function() return screen.kind == "link" end,
}
screen.battle = setmetatable({}, {
__index = function(_, key) local read = readers[key]; if read then return read() end end,
__newindex = function() error("native Gen2 battle view is read-only", 2) end,
})
screen.shownHp = setmetatable({}, {
__index = function(_, side)
local b = battler(screen, side); return b and b.shownHP
end,
__newindex = function() error("native Gen2 shown HP is owned by stepHPDrain", 2) end,
})
end

function View.activeMon(screen, side)
local b = battler(screen, side)
return b and b.mon
end

local function owner(screen, mon)
for _, side in ipairs({ "player", "enemy" }) do
local b = battler(screen, side)
if b and b.mon == mon then return b, side end
end
end

-- These are drawing operations, not another battle controller. In particular
-- front-art captures of the player keep the REAL battler as the key for blink,
-- substitute, faint and grow-in effects, and never replace its native rear.
function View.install(Screen, images)
Screen.activeMon = View.activeMon

function Screen:pic(mon, back)
if not mon then return nil end
local b = owner(self, mon)
if b and b.isPlayer == back then
return self:picImage(b.sprite), images.trueColor(b.sprite), images.path(b.sprite)
end
local species = b and b.species or mon.species
if b and species ~= mon.species then
local image = self:speciesSprite(species, back)
return self:picImage(image), images.trueColor(image), images.path(image)
end
local path, trueColor = require("src.pokemon.Sprites").path(self.data, species,
back and "back" or "front", { mon = mon, kind = "battle" })
local image = images.load(path, images.palette(self.data, species,
require("src.pokemon.Pokemon").isShiny(mon)), trueColor)
return self:picImage(image), trueColor, path
end

function Screen:picScale(path, mon, back)
return Screen.resolveBattleScale(self.data, back and "back" or "front",
path, mon and mon.species)
end

function Screen:frontAnimFrame(mon)
local b = owner(self, mon)
-- The shared engine owns the entrance runner. Reading a capture does not
-- advance its clock, restart it, or animate a player's unrelated rear.
if not (b and not b.isPlayer and b.picAnim and b.picAnim:frame() > 0) then return nil end
local image = self:battlerPic(b)
if not image then return nil end
return image, nil, image:getWidth()
end

function Screen:animPicState(side)
local b = battler(self, side)
if not b then return nil end
local pf = self.picFx and self.picFx[b]
return {
hidden = (side == "enemy" and (self.enemyHidden or self.enemySendingOut))
or (side == "player" and self.sendingOut) or self:fxHidden(b)
or (b.fainted and not self:fxFaintActive(b)) or (pf and pf.hidden) or false,
pic = b.substituteHP and "substitute" or nil,
slide = pf and pf.ox or 0,
}
end

function Screen:drawPic(mon, back)
local b, side = owner(self, mon)
if not b then return end
local state = self:animPicState(side)
if state and state.hidden then return end
if (back and self.slidingBackpic) or (self.picHidden and self.picHidden[side]) then return end
local trainer = back and self.showPlayerBack and self.playerBackPic
or (not back and self.showEnemyTrainer and self.trainerPic)
local image, _, path = self:pic(mon, back)
if trainer then image, path = self:picImage(trainer), images.path(trainer) end
if not image then return end
local frame = not trainer and not back and self:frontAnimFrame(mon)
if frame then image = frame end
local scale = self:picScale(path, not trainer and mon or nil, back)
local grow = not trainer and self:growInScale(b)
if grow then scale = scale * grow end
if scale <= 0 then return end
local w, h = image:getDimensions()
local x = (back and 16 or 96) + math.max(0, math.floor(((back and 48 or 56) - w) / 2))
local y = (back and 96 or 56) - h
x, y = x + math.floor(w * (1 - scale) / 2), y + math.floor(h * (1 - scale))
if trainer then love.graphics.draw(image, x, y, 0, scale, scale); return end
local screen = self
local capture = setmetatable({
-- This isolated window owns the clip below. The native effect drawer's
-- rear-side window would erase a front capture of the SAME player.
wideRegion = true,
battlerPic = function() return image end,
-- Sprite extraction must stay a sprite even with native Stadium enabled.
drawStadiumBattlerPic = function() return false end,
drawSubstituteDoll = function(_, actual)
local g = love.graphics
local dx = (back and 32 or 112) - (actual.isPlayer and 32 or 112)
local dy = (back and 72 or 32) - (actual.isPlayer and 72 or 32)
g.push(); g.translate(dx, dy)
local ok, err = pcall(screen.drawSubstituteDoll, screen, actual)
g.pop(); if not ok then error(err, 0) end
end,
}, { __index = self })
local g = love.graphics
g.push("all")
if g.intersectScissor then
g.intersectScissor(back and 16 or 96, back and 48 or 0,
back and 48 or 56, back and 48 or 56)
end
local ok, err = pcall(capture.drawBattlerPic, capture, b, x, y, scale)
g.pop()
if not ok then error(err, 0) end
end
end

return View
109 changes: 109 additions & 0 deletions tests/engine/gen2_battle_live_view.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package.path = './?.lua;./?/init.lua;' .. package.path
local T = require('tests.modkit')
local V = require('src.core.GameVersion')
local Battle = require('src.battle.BattleState')
local Pokemon = require('src.pokemon.Pokemon')
local Save = require('src.core.SaveData')
local before = V.get()
local function fixture(version)
V.set(version)
local data = T.fixtures.fresh(); data.constants.gen = V.generation(version)
require('src.render.Font').load(data)
local save = Save.newGame()
save.party = {Pokemon.new(data, 'FIXMON_A', 30), Pokemon.new(data, 'FIXMON_B', 18)}
local game = {data=data, save=save, stack={top=function()end}}
return Battle.newWild(game, 'FIXMON_C', 23), game
end
for _, version in ipairs({'gold','silver','crystal','prism','polishedcrystal'}) do
local b, game = fixture(version)
local model, hp = rawget(b,'battle'), b.shownHp
T.check(model ~= b, version .. ' publishes a distinct native model view')
T.eq(model.player, game.save.party[1], 'model player is the actual saved Pokemon')
T.eq(model.enemy, b.enemy.mon, 'model enemy is actual generated wild Pokemon')
T.eq(model.enemy.level, 23, 'enemy level is live, not a wrapper default')
T.eq(model.player.level, 30, 'player level is live')
T.eq(model.party, game.save.party, 'no copied party')
T.eq(model.save, game.save, 'actual save owner')
T.eq(model.data, game.data, 'actual data owner')
T.eq(model.wild, true, 'native wild classification')
T.eq(model.playerIndex, 1, 'actual active party index')
T.eq(b:activeMon('enemy'), model.enemy, 'presentation follows current native slot')
T.eq(b:activeMon('unknown'), nil, 'unknown side is not another active mon')
T.eq(hp.enemy, b.enemy.shownHP, 'shown HP reads native drain')
local initial = b.enemy.mon.hp
b:performMove(b.player, b.enemy, {id='FIX_TACKLE',pp=10})
T.check(b.enemy.mon.hp < initial, 'actual native move reduces HP')
T.eq(model.enemy.hp, b.enemy.mon.hp, 'model exposes resolved HP')
T.eq(hp.enemy, initial, 'presentation retains pre-drain HP')
local frames = 0
while b:stepHPDrain() and frames < 2000 do
frames=frames+1
T.eq(hp.enemy, b.enemy.shownHP, 'every displayed HP step is native owned')
end
T.check(frames > 0 and frames < 2000, 'actual drain completes')
T.eq(hp.enemy, model.enemy.hp, 'shown HP reaches actual HP')
local old = b.player
b:switchPlayerInto(Battle.POS.PLAYER_LEFT, game.save.party[2])
T.eq(b.battle, model, 'model identity survives actual native switch')
T.eq(b.shownHp, hp, 'HP view identity survives switch')
T.eq(model.player, game.save.party[2], 'model follows incoming actual Pokemon')
T.eq(model.playerIndex, 2, 'live party index follows switch')
T.eq(b:activeMon('player'), game.save.party[2], 'drawn mon follows native deployment')
T.eq(hp.player, b.player.shownHP, 'new slot uses its own HP drain')
T.neq(b.player, old, 'wrapper actually replaced')
T.eq(game.save.party[1].shownHP, nil, 'no presentation fields added to saved Pokemon')
T.raises(function() model.player = old.mon end, 'read-only', 'model cannot write a detached Pokemon')
T.raises(function() hp.player = 0 end, 'stepHPDrain', 'view cannot race native HP clock')
b.result='run'
T.eq(model.over, true, 'actual result closes model')
T.eq(model.outcome, 'run', 'actual outcome exposed')
end

-- The new render boundary must use front art for isolated player captures
-- without changing the native rear, slot identity, or animation ownership.
do
local b = fixture('crystal'); b.introSlide=0; b.sendingOut=false
local original, player = b.player.sprite, b.player
local draws, draw = {}, love.graphics.draw
love.graphics.draw=function(...) draws[#draws+1]={...} end -- output only
b:drawPic(player.mon, false)
T.eq(#draws, 1, 'player front capture draws real native sprite')
local front, _, path = b:pic(player.mon, false)
T.eq(draws[1][1], front, 'front capture uses actual front image')
T.check(path:find('front',1,true) ~= nil, 'front request resolves front asset')
T.eq(b.player, player, 'rendering preserves native battler identity')
T.eq(player.sprite, original, 'native rear image is retained')
T.eq(draws[1][3] + front:getHeight(), 56, 'front feet end at native seven-tile baseline')
draws={}; b:drawPic(player.mon, true)
T.eq(draws[1][1], b:picImage(original), 'native rear remains available')
local pf=b:picFxFor(player);pf.hidden=true
draws={};b:drawPic(player.mon,false)
T.eq(#draws,0,'native vanish effect hides isolated front too')
pf.hidden=nil;b.fx={blink={target=player,frames=10}};b.frame=0
draws={};b:drawPic(player.mon,false)
T.eq(#draws,0,'native damage blink stays keyed to real player')
b.fx={faint={battler=player,frames=7}};player.fainted=true
draws={};b:drawPic(player.mon,false)
T.eq(#draws,1,'real native faint sink can still draw its remaining rows')
b.fx.faint.frames=0;draws={};b:drawPic(player.mon,false)
T.eq(#draws,0,'completed native faint stays hidden')
player.fainted=nil;b.fx=nil;pf.kind='shakeBF'
local intersect,get=love.graphics.intersectScissor,love.graphics.getScissor
local clips={}
love.graphics.intersectScissor=function(...)clips[#clips+1]={...}end
love.graphics.getScissor=function()end
b:drawPic(player.mon,false)
T.check(#clips>0,'effect capture has an actual bounded picture window')
for _,clip in ipairs(clips)do
T.check(clip[1]>=96,'front player effect is never clipped by native rear-side window')
end
love.graphics.intersectScissor,love.graphics.getScissor=intersect,get
love.graphics.draw=draw
end
for _, version in ipairs({'red','firered','emerald'}) do
local b = fixture(version)
T.eq(b.battle,b,version .. ' retains its existing model identity')
T.eq(b.shownHp,nil,'other generations retain existing HP presentation')
end
V.set(before)
T.finish('native Gen2 live battle model and sprite capture')