From ebd1cad23a4dad0140759ef6a5c8aea0d21bad00 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 07:09:53 +0100 Subject: [PATCH 1/2] Back native Gen2 battle views and isolated sprite captures --- src/battle/BattleState.lua | 17 ++- src/battle/Gen2BattleView.lua | 156 +++++++++++++++++++++++++ tests/engine/gen2_battle_live_view.lua | 98 ++++++++++++++++ 3 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 src/battle/Gen2BattleView.lua create mode 100644 tests/engine/gen2_battle_live_view.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9688aa08f..2f20b6cfb 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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 @@ -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) @@ -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 diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua new file mode 100644 index 000000000..f4acf41e9 --- /dev/null +++ b/src/battle/Gen2BattleView.lua @@ -0,0 +1,156 @@ +-- 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({ + 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 }) + capture:drawBattlerPic(b, x, y, scale) + end +end + +return View diff --git a/tests/engine/gen2_battle_live_view.lua b/tests/engine/gen2_battle_live_view.lua new file mode 100644 index 000000000..3c0905040 --- /dev/null +++ b/tests/engine/gen2_battle_live_view.lua @@ -0,0 +1,98 @@ +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') + 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') From 8b4e90728967b4efc9d1620afe9fd64259f8ca23 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 07:30:25 +0100 Subject: [PATCH 2/2] Keep native player front effects inside their isolated capture window --- src/battle/Gen2BattleView.lua | 13 ++++++++++++- tests/engine/gen2_battle_live_view.lua | 11 +++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua index f4acf41e9..63e07923e 100644 --- a/src/battle/Gen2BattleView.lua +++ b/src/battle/Gen2BattleView.lua @@ -137,6 +137,9 @@ function View.install(Screen, images) 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, @@ -149,7 +152,15 @@ function View.install(Screen, images) g.pop(); if not ok then error(err, 0) end end, }, { __index = self }) - capture:drawBattlerPic(b, x, y, scale) + 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 diff --git a/tests/engine/gen2_battle_live_view.lua b/tests/engine/gen2_battle_live_view.lua index 3c0905040..063b1ebf7 100644 --- a/tests/engine/gen2_battle_live_view.lua +++ b/tests/engine/gen2_battle_live_view.lua @@ -87,6 +87,17 @@ do 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