CLOSES #223, CLOSES #233, CLOSES #236, CLOSES #240, CLOSES #241, CLOSES #249, CLOSES #252, CLOSES #255, CLOSES #257, CLOSES #258, CLOSES #263, CLOSES #265, CLOSES #274, CLOSES #275, CLOSES #276, CLOSES #279, CLOSES #280, CLOSES #282, CLOSES #283, CLOSES #287, CLOSES #291, CLOSES #292, CLOSES #293, CLOSES #301, CLOSES #304, CLOSES #315, CLOSES #316, CLOSES #317, CLOSES #321, CLOSES #322, CLOSES #330

This commit is contained in:
bryanthaboi
2026-07-28 10:29:05 -04:00
parent 3b1032cc63
commit f9f38d161f
103 changed files with 11157 additions and 838 deletions
+340 -51
View File
@@ -53,16 +53,22 @@ local BALL_ANIMS = {
}
local imageCache = {}
-- The three tables below are keyed by the Image OBJECT, not by a path, and a
-- running battle holds the pics it built at enter() (battler.sprite,
-- playerBackPic, trainerPic). Weak keys let a dropped pic's row go with it,
-- so invalidate() can drop the path cache without orphaning what is on
-- screen right now (#316).
local WEAK_KEYS = { __mode = "k" }
-- fully transparent rows below a pic's content (the extracted 32x32 back
-- pics carry baked-in padding); used to sit the pic flush on the text box
local imagePadBottom = {}
local imagePadBottom = setmetatable({}, WEAK_KEYS)
-- fully transparent columns left of a pic's content; at 2x (back pics)
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
-- where those columns were white-on-white rather than shifted content
local imagePadLeft = {}
local imagePadLeft = setmetatable({}, WEAK_KEYS)
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it
local imageMeta = {}
local imageMeta = setmetatable({}, WEAK_KEYS)
-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy.
-- trueColor art (14 §the 4-shade contract) opts out of the quantize
-- entirely, so its palette variant collapses back onto the plain path.
@@ -118,10 +124,19 @@ local function getImage(path, pal, trueColor)
return imageCache[key]
end
-- hot reload: the next getImage re-resolves every pic through the asset
-- search path and re-measures its ground padding
-- Hot reload / COLORS change (PaletteFX.setMode calls this): the next
-- getImage re-resolves every pic through the asset search path and
-- re-measures its ground padding. ONLY the path->image cache is dropped.
-- Wiping the three per-image tables as well orphaned the pics a running
-- battle already holds: imagePadBottom went nil, so backPlacement lost the
-- four transparent rows it grounds the back pic on and the pic jumped
-- pad * 2x = 8px UP the frame COLORS changed (#316), and imageMeta went nil,
-- so picImage's forced-mono grayImage (#207), fadeImage's BGP variants and
-- imagePathOf's battle_sprite_scales lookup all silently stopped resolving.
-- Those rows are weak-keyed, so entries for pics nothing references any more
-- are collected on their own rather than leaking.
function BattleState.invalidate()
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
imageCache = {}
end
Assets.register(BattleState.invalidate)
@@ -174,6 +189,25 @@ local function grayImage(img)
return getImage(meta.path) or img
end
-- The blacked-out battle screen. HandlePlayerBlackOut (core.asm:1151) runs
-- SET_PAL_BATTLE_BLACK, i.e. SetPal_BattleBlack sends PalPacket_Black --
-- PAL_BLACK in all four slots of BlkPacket_Battle (engine/gfx/palettes.asm:
-- 22-25). The mon pics are drawn OVER the zone pass with their palette
-- already baked in, so darkening them means re-baking through PAL_BLACK the
-- way fadeImage re-bakes through a BGP permutation (#292). Reads the palette
-- out of the active pack, exactly like sgbBattlePals, so the zone pass and
-- the pics can never disagree. trueColor art has no DMG shades to remap.
local function blackImage(data, img)
local meta = imageMeta[img]
if not meta or meta.trueColor then return img end
local PaletteFX = require("src.render.PaletteFX")
local pack = PaletteFX.pack(data)
local colors = pack and pack.palettes and pack.palettes.BLACK
if not colors then return img end
local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK"
return getImage(meta.path, { name = name, colors = colors }) or img
end
-- the asset path a loaded battle image came from (nil for the headless
-- stub images), so the battle_sprite_scales registry can be looked up by
-- the same path data references
@@ -200,6 +234,11 @@ function BattleState:picImage(img)
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
or PaletteFX.mode == "classic"
if self.grayPics or mono then return grayImage(img) end
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
-- dark with the HP bars while the blackout text is up (#292). Below the
-- mono check on purpose: the forced-mono modes re-threshold the whole
-- frame downstream, and the DMG had no SGB darkening to begin with.
if self.blackedOut then return blackImage(self.data, img) end
return fadeImage(img, self:activeBgp())
end
@@ -725,6 +764,7 @@ function BattleState:startMessage(item)
-- it against self.total); the current line's revealed count is #shown[last]
self.charIndex = 0
self.msgWaiting = nil
self.msgPrompt = nil
self.scrollPx = nil
self:beginMsgLine()
end
@@ -930,9 +970,18 @@ function BattleState:updateQueue()
end))
return true
end
if not (item and item.choice)
and (input:wasPressed("a") or input:wasPressed("b")) then
self.current = nil
if not (item and item.choice) then
-- The page is typed out and waiting on the player: PromptText
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
-- blinks it until A/B, so the arrow belongs on a finished page and not
-- only on a \v CONT hold (#317). A flag of its own, not msgWaiting:
-- that branch above scrolls the NEXT line in, which this page has not
-- got, so reusing it would call beginMsgLine on a drained message.
self.msgPrompt = true
if input:wasPressed("a") or input:wasPressed("b") then
self.msgPrompt = nil
self.current = nil
end
end
end
return true
@@ -1021,6 +1070,17 @@ function BattleState:enter()
-- sides slide in; the trainer pics stay up until the send-outs
self.introSlide = 40
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
-- the intro text; _InitBattleCommon (core.asm:6755-6762) ClearScreenArea's
-- both HUD blocks and ClearSprites's the balls the moment that text is
-- dismissed. This flag is exactly that window: drawHUDs draws the intro
-- chrome while it is up and holds the real enemy HUD back, since a wild
-- battle's DrawEnemyHUDAndHPBar only runs after the text (#317). The
-- draw is still gated on the slide having landed, so nothing shows while
-- the silhouettes are still coming in.
self.introBalls = true
-- SGB: the player-side battle palette while the back pic is up is
-- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when
-- the intro's SET_PAL_BATTLE runs -- SetPal_Battle,
@@ -1052,12 +1112,27 @@ function BattleState:enter()
queueEnemyCry()
end
self:say(self.introText)
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
-- dismissed both HUD blocks are cleared and ClearSprites drops the
-- pokeball OAM, so the intro chrome never returns for the rest of the
-- battle -- not on a switch, and not when the beaten trainer's pic
-- scrolls back in (#317, #282)
self:act(function() self.introBalls = nil end)
if self.kind == "trainer" then
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
-- not blink out under the text (#317)
self:act(function() self:slidePic("foe", 0, 64, 4) end)
table.insert(self.queue, { wait = 16 })
self:act(function()
self.showEnemyTrainer = false
self:slidePic("foe")
end)
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
self.showEnemyTrainer = false
self:startGrowIn(self.enemy)
end)
queueEnemyCry()
@@ -1075,13 +1150,20 @@ function BattleState:enter()
queueEnemyCry()
end
if not self.safari and not self.demo then
self:say(self:sendOutText(self.player.name))
-- Red's pic clears, the POOF plays, then the mon appears with its
-- cry (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
-- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic
-- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5,
-- a = 9 tiles, one tile every 2 frames) BEFORE SendOutMon prints
-- "Go! X!" -- Red does not simply vanish under the message (#317)
self:act(function() self:slidePic("back", 0, -72, 4) end)
table.insert(self.queue, { wait = 18 })
self:act(function()
self.showPlayerBack = false
self.sendingOut = true
self:slidePic("back")
end)
self:say(self:sendOutText(self.player.name))
-- then the POOF plays and the mon appears with its cry
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
self:act(function()
self.sendingOut = false
@@ -2165,19 +2247,34 @@ end
-- battle (EndLowHealthAlarm sets wLowHealthAlarmDisabled, mirrored by
-- playVictoryMusic) and every other outcome tears it down in
-- end_of_battle.asm -- self.result covers those. The damage drain
-- gates the start (the HUD redraw runs after UpdateHPBar finishes),
-- but healing out of the red stops it at once (item_effects.asm clears
-- the alarm before the bar animates). No alarm before the player HUD
-- first draws (send-out), nor in the safari/old-man battles, which
-- have no player mon HUD.
-- gates the START (the HUD redraw runs after UpdateHPBar finishes) but
-- never the stop, and healing out of the red silences it at once
-- (item_effects.asm:991-994 clears the alarm before the bar animates).
-- No alarm before the player HUD first draws (send-out), nor in the
-- safari/old-man battles, which have no player mon HUD.
function BattleState:lowHealthAlarmActive()
local p = self.player
if not p or self.safari or self.demo or self.result
or self.lowHealthAlarmDisabled then return false end
if self.showPlayerBack or (self.introSlide or 0) > 0 then return false end
if p.fainted then return false end
-- A siren that is ALREADY sounding follows the drawn bar, not the
-- model: wLowHealthAlarm is a latch DrawPlayerHUDAndHPBar only revisits
-- once UpdateHPBar2 has finished animating (core.asm:4727-4729 /
-- core.asm:4845-4847 both drain first, then jp DrawHUDsAndHPBars), and
-- a KO clears it in RemoveFaintedPlayerMon (core.asm:1011-1016), i.e.
-- after the bar has drained empty. applyDamage takes the HP off the
-- model while the turn is still being queued, so keying a running alarm
-- off mon.hp cut it dead for the whole "used X!" line + move animation
-- + drain window (#293). max() keeps a heal out of the red silencing
-- it on the spot, the way item_effects.asm does.
local hp = p.mon.hp
if hp <= 0 or p.fainted then return false end
if p.shownHP and p.shownHP > hp then return false end -- drain running
if self.lowHealthAlarmOn then
hp = math.max(hp, shownHP(p))
elseif p.shownHP and p.shownHP > hp then
return false -- drain running: the HUD redraw has not happened yet
end
if hp <= 0 then return false end
local px = math.max(1, math.floor(hp * 48 / math.max(1, p.mon.stats.hp)))
return px < 10
end
@@ -2193,10 +2290,47 @@ local function stepProgram(prog)
return head
end
-- Trainer-pic slides. SlideTrainerPicOffScreen (core.asm:1235) walks a
-- trainer pic off its own screen edge one tile every 2 frames (9 tiles left
-- for the player back pic, 8 tiles right for the foe), and
-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm)
-- brings the beaten foe back in from the right one column every 4 frames.
-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic,
-- "back" = the player's back pic -- as a screen-pixel x offset stepped
-- toward `to`; updateFx advances them, drawPicsLayer adds them, and the
-- queue rows that start them park a { wait } of the matching length. Call
-- with no target to clear a slot (#317, #282).
function BattleState:slidePic(slot, from, to, step)
self.picOff = self.picOff or {}
if to == nil then
self.picOff[slot] = nil
return
end
self.picOff[slot] = { x = from or 0, to = to, step = step or 4 }
end
-- the live x offset for a pic slot, 0 when nothing is sliding
function BattleState:picOffset(slot)
local p = self.picOff and self.picOff[slot]
return p and p.x or 0
end
function BattleState:updateFx()
if self.introSlide and self.introSlide > 0 then
self.introSlide = self.introSlide - 1
end
-- step each live trainer-pic slide toward its target; a landed program
-- holds its offset (the after-battle scroll-in rests two tiles right of
-- the battle slot) until its owner clears the slot
if self.picOff then
for _, p in pairs(self.picOff) do
if p.x < p.to then
p.x = math.min(p.to, p.x + p.step)
elseif p.x > p.to then
p.x = math.max(p.to, p.x - p.step)
end
end
end
local fx = self.fx
if fx then
if fx.shake and fx.shake > 0 then fx.shake = fx.shake - 1 end
@@ -2285,7 +2419,12 @@ function BattleState:updateFx()
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
-- loops while the player's bar is red; see lowHealthAlarmActive
local Sound = require("src.core.Sound")
if self:lowHealthAlarmActive() then
-- self.lowHealthAlarmOn mirrors wLowHealthAlarm's bit 7: a latch read
-- back inside lowHealthAlarmActive (the RHS sees last frame's value)
-- so a sounding siren rides out the next hit's HP drain instead of
-- dropping out mid-announcement (#293)
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
if self.lowHealthAlarmOn then
Sound.startLoop(self.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
@@ -2922,6 +3061,16 @@ function BattleState:enemyMonFainted()
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
local partyCount = #self.game.save.party
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
-- the block FaintEnemyPokemon just cleared, after the exp text and
-- BEFORE the next send-out. It survives EnemySendOutFirstMon's
-- SlideTrainerPicOffScreen (core.asm:1308-1310, 8 steps x DelayFrames 2)
-- so SET style gets the brief flash, and stays up through the whole
-- SHIFT prompt below (#283).
self:act(function() self.showEnemyBalls = true end)
table.insert(self.queue, { wait = 16 })
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
local shiftSwitchMon = nil
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
@@ -2957,6 +3106,10 @@ function BattleState:enemyMonFainted()
})
self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species)
-- EnemySendOutFirstMon .next4 (core.asm:1413-1417): ClearSprites and
-- the 4x11 ClearScreenArea take the ball row away with the rest of
-- the enemy HUD block, right before TrainerSentOutText (#283)
self.showEnemyBalls = nil
self:markParticipant()
-- EnemySendOutFirstMon (core.asm:1413-1435): the enemy HUD area
-- clears, TrainerSentOutText prints, THEN the pic appears
@@ -2984,6 +3137,20 @@ function BattleState:enemyMonFainted()
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
-- the reset the mon that was out when the enemy fainted -- marked by
-- the send-out act above, which mirrors EnemySendOut's own re-flag
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
-- enemyMonFainted counted two mons and the switch-in earned half the
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
-- replacements (openReplacementMenu) must NOT do this: pokered's
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
-- the deliberate exp-share, and a fainted mon is already dropped by
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
self.participants = {}
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
@@ -2999,16 +3166,39 @@ function BattleState:enemyMonFainted()
end
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
self.game.save.money = self.game.save.money + prize
-- the beaten trainer's pic returns for the defeat text (pokered
-- DisplayBattleMenu's defeat flow)
self:act(function() self.showEnemyTrainer = self.trainerPic ~= nil end)
-- TrainerBattleVictory (core.asm:915-933): EndLowHealthAlarm, then
-- the victory theme starts BEFORE TrainerDefeatedText and the
-- prize money
-- TrainerBattleVictory (core.asm:915-949) in order: EndLowHealthAlarm
-- and the victory theme, TrainerDefeatedText, ScrollTrainerPicAfterBattle
-- (the beaten trainer scrolls back in from the right, one column every 4
-- frames, resting two tiles right of the battle slot), DelayFrames 40,
-- PrintEndBattleText -- the trainer's OWN loss line, on the battle
-- screen -- and only then MoneyForWinningText. Every row rides the
-- *Next inserters so it keeps that order behind the running queue item;
-- the plain act() the pic used to ride appended to the END of the queue,
-- which is why the trainer only flashed up for a frame or two as the
-- battle popped and the loss line had to be printed by the overworld
-- afterwards, stranding any evolution between two cuts (#282).
-- endBattleText is filled in by whoever started the battle
-- (OverworldState:engageTrainer in src/world/OverworldController.lua);
-- scripted battles that print their own follow-up leave it nil.
self:actNext(function() self:playVictoryMusic() end)
-- _TrainerDefeatedText: "<PLAYER> defeated\nTRAINER!"
self:sayNext(Strings("%s defeated\n%s!", self.game.save.player.name,
self.trainer.name))
self:actNext(function()
self.showEnemyTrainer = self.trainerPic ~= nil
if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end
end)
-- the 24-frame scroll-in plus the DelayFrames 40 that follows it
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 64 })
if self.endBattleText then
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
-- starts a fresh page, which is a message row of its own here (five
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
if page ~= "" then self:sayNext(page) end
end
end
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
end
self.result = "win"
@@ -3076,6 +3266,15 @@ function BattleState:playerMonFainted()
-- Oak's Lab starter rival: Rival1WinText only (no blackout lines).
-- Any other wipe, including Route 22 RIVAL1, still blacks out.
if not BattleState.isOaksLabStarterRival(self) then
-- HandlePlayerBlackOut (core.asm:1150-1159): SET_PAL_BATTLE_BLACK runs
-- BEFORE PlayerBlackedOutText2, so the enemy pic and both HP bars are
-- already dark under the blackout lines (#292). The Oak's Lab starter
-- rival returns one line above that call and never darkens. Set here
-- rather than queued: this whole function already runs from a queued
-- act after "<mon> fainted!" was dismissed, which is where the palette
-- command sits. (The Route 22 RIVAL1 wipe darkens one box early, over
-- Rival1WinText, which pokered prints just before the same command.)
self.blackedOut = true
self:sayNext(Strings("%s is out of\nuseable POKéMON!", self.game.save.player.name))
self:sayNext(Strings("%s blacked\nout!", self.game.save.player.name))
end
@@ -3484,13 +3683,41 @@ end
-- called by BagMenu when a ball is thrown
function BattleState:throwBall(ball)
self:say(Strings("%s used\n%s!", self.game.save.player.name,
self.data.items[ball].name))
-- ItemUseBall branches to ThrowBallAtTrainerMon on wIsInBattle != 1
-- (item_effects.asm:109-113) BEFORE it reaches `ld hl, ItemUseText00 /
-- call PrintText` (:146-147), so a trainer battle never shows the
-- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are
-- still wIsInBattle == 1, and this port models both as kind == "wild".
if self.kind == "wild" then
self:say(Strings("%s used\n%s!", self.game.save.player.name,
self.data.items[ball].name))
end
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
if self.kind ~= "wild" then
self:sayNext(Strings("The TRAINER\nblocked the BALL!"))
self:sayNext(Strings("Don't be a thief!"))
-- ThrowBallAtTrainerMon (item_effects.asm:2292-2303) still animates the
-- toss: MoveAnimation routes TOSS_ANIM to TossBallAnimation, which takes
-- its .BlockBall branch in a trainer battle (animations.asm:2582-2585,
-- 2629-2637) -- the plain TOSS arc whatever the ball tier, then
-- SFX_FAINT_THUD and BLOCKBALL_ANIM, and only then the two texts. The
-- ball still counts as used: UseItem_ sets
-- wActionResultOrTookBattleTurn = 1 (item_effects.asm:1-3) and this path
-- never clears it, so UseBagItem does not fall back to the bag
-- (core.asm:2257-2259) and the turn is spent -- the foe moves (#291).
local t = self.data.text
self:animNext("TOSS_ANIM", true, nil, ball)
self:actNext(function()
require("src.core.Sound").play(self.data, "Faint_Thud")
end)
self:animNext("BLOCKBALL_ANIM", true)
self:sayNext(t._ThrowBallAtTrainerMonText1
or Strings("The trainer\nblocked the BALL!"))
self:sayNext(t._ThrowBallAtTrainerMonText2
or Strings("Don't be a thief!"))
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
self:act(function() self:endOfTurn() end)
return
end
if self.ghost then
@@ -3941,6 +4168,16 @@ function BattleState:sgbBattlePals()
local pack = PaletteFX.pack(self.data)
local pals = pack and pack.palettes
if not pals then return nil end
-- HandlePlayerBlackOut (core.asm:1151) runs SET_PAL_BATTLE_BLACK:
-- SetPal_BattleBlack sends PalPacket_Black, PAL_BLACK in all four slots of
-- BlkPacket_Battle (engine/gfx/palettes.asm:22-25), so every zone of the
-- battle screen -- both HP bars and both mon regions -- goes dark behind
-- the blackout text. picImage re-bakes the pics through the same palette,
-- since those draw over the zone pass rather than through it (#292).
if self.blackedOut and pals.BLACK then
local b = pals.BLACK
return { [0] = b, [1] = b, [2] = b, [3] = b }
end
local function bar(b)
if not b then return pals.GREENBAR end
local hp = b.shownHP or b.mon.hp
@@ -4180,7 +4417,8 @@ function BattleState:drawPicsLayer(slide, sx, sy)
local img = self:picImage(self.trainerPic)
love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy)
love.graphics.draw(img, ex, ey)
-- SlideTrainerPicOffScreen / _ScrollTrainerPicAfterBattle offset (#317)
love.graphics.draw(img, ex + self:picOffset("foe"), ey)
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
local img = self:picImage(self.enemy.sprite)
@@ -4223,7 +4461,9 @@ function BattleState:drawPicsLayer(slide, sx, sy)
love.graphics.setColor(1, 1, 1, 1)
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(),
pad, padL, s)
love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s)
-- picOffset: SlideTrainerPicOffScreen walking the back pic off the left
love.graphics.draw(img, dx + slide + sx + self:picOffset("back"),
dy + sy, 0, s, s)
elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then
local img = self:picImage(self.player.sprite)
@@ -4276,9 +4516,13 @@ function BattleState:drawHUDs(slide)
local hudShake = (fx and fx.hudShakeX) or 0
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
-- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out
-- ...and it is not up yet during the intro text either: a wild battle's
-- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763)
-- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the
-- player's ball row with no enemy HUD beside it (#317)
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
and not self:growInScale(self.enemy) and slide == 0
and not self.enemy.fainted then
and not self.introBalls and not self.enemy.fainted then
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
-- AnimationShakeEnemyHUD nudges just this block via SCX
@@ -4306,28 +4550,60 @@ function BattleState:drawHUDs(slide)
end
end
-- ReplaceFaintedEnemyMon -> DrawEnemyPokeballs (core.asm:896,
-- draw_hud_pokeball_gfx.asm:9-11 -> SetupEnemyPartyPokeballs :33-45):
-- between a KO and the next send-out the foe's ball row sits in the enemy
-- HUD block FaintEnemyPokemon cleared, over the chrome PlaceEnemyHUDTiles
-- writes with it -- the same $73 (1,2) / $74 (1,3) / $76 run / $78 tiles
-- the live HUD draws, minus the HP bar (#283). Its own block rather than
-- a third arm of showIntroBalls below: that window is DrawAllPokeballs's
-- (#317) and clears for the rest of the battle, this one reopens on every
-- enemy faint. wBaseCoordX $48 / wBaseCoordY $20 stepping -8 is screen
-- (64,16) leftward, the same row the intro draws.
if self.showEnemyBalls and self.enemyParty and slide == 0 then
hudTile(0x73, 8, 16)
hudTile(0x74, 8, 24)
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
hudTile(0x78, 80, 24)
love.graphics.setColor(1, 1, 1, 1)
self:drawBallRow(self.enemyParty, 64, 16, -8)
end
-- Safari shows only the ball count; the old man demo shows neither mon
if self.safari then
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
end
-- trainer/link party pokeball rows during the intro
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
local showIntroBalls = slide == 0 and (
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
)
-- Party pokeball rows and the HUD chrome under them, for exactly the
-- window DrawAllPokeballs owns (common_text.asm:27, with the intro text).
-- SetupOwnPartyPokeballs runs in EVERY battle, so the player's row belongs
-- on the wild intro too -- keying it off the enemy trainer pic meant a
-- wild battle never drew one (#317) -- and SetupEnemyPartyPokeballs is
-- skipped when wIsInBattle == 1, so only a trainer/link battle gets the
-- foe's row. Gating on introBalls rather than on the pics also stops the
-- rows coming back when the beaten trainer scrolls in (#282):
-- _ScrollTrainerPicAfterBattle redraws tilemap columns and never touches
-- OAM, which ClearSprites emptied when the intro text was dismissed.
local showIntroBalls = self.introBalls and slide == 0
if showIntroBalls then
love.graphics.setColor(1, 1, 1, 1)
if self.enemyParty and (
(self.kind == "trainer" and self.showEnemyTrainer)
or (self.kind == "link" and self.enemySendingOut)
) then
if self.enemyParty and (self.kind == "trainer" or self.kind == "link") then
-- PlaceEnemyHUDTiles (hlcoord 1,2): $73, then $74 + 8x $76 + $78
-- rightward along row 3 (draw_hud_pokeball_gfx.asm:133-165)
hudTile(0x73, 8, 16)
hudTile(0x74, 8, 24)
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
hudTile(0x78, 80, 24)
love.graphics.setColor(1, 1, 1, 1)
self:drawBallRow(self.enemyParty, 64, 16, -8)
end
if self.showPlayerBack then
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
end
-- PlacePlayerHUDTiles (hlcoord 18,10): $73, then $77 + 8x $76 + $6F
-- LEFTWARD along row 11 (draw_hud_pokeball_gfx.asm:119-131)
hudTile(0x73, 144, 80)
hudTile(0x77, 144, 88)
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
hudTile(0x6F, 72, 88)
love.graphics.setColor(1, 1, 1, 1)
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
end
local hidePlayer = self.safari or self.demo
if self.player and not hidePlayer and not self.showPlayerBack
@@ -4377,9 +4653,10 @@ function BattleState:drawTextArea()
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
end
end
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait holds the
-- box, bottom-right of the box like TextBox / home/text.asm
if self.msgWaiting and self.frame % 60 < 30 then
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
-- (_ContText) or a typed-out page (PromptText) holds the box; both write
-- it at (18,16), bottom-right, like TextBox / home/text.asm (#317)
if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
end
elseif self.phase == "menu" and self.demo then
@@ -4419,6 +4696,18 @@ function BattleState:drawTextArea()
-- the move box's top border ('─' at (4,12), '┘' at (10,12)).
Font.drawBox(0, 8, 11, 5)
Font.drawBox(4, 12, 16, 6)
-- Those two cells are REPLACED on hardware: MoveSelectionMenu writes them
-- straight into the tilemap over the border it just laid down
-- (core.asm:2492-2501), and PrintMenuItem's own TextBoxBorder then redraws
-- the whole row on top (core.asm:2838-2844). Font.drawCode blits a
-- black-on-transparent glyph instead, so the tile underneath survives: the
-- move box's '┌' keeps its Poké Ball corner showing through the '─', and
-- the '─' the move box drew at (10,12) pokes two dots out from under the
-- '┘' (#240). Wipe each cell back to box white first, the way a tilemap
-- write does.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 32, 96, 8, 8)
love.graphics.rectangle("fill", 80, 96, 8, 8)
Font.drawCode(Font.BORDER.h, 32, 96)
Font.drawCode(Font.BORDER.br, 80, 96)
love.graphics.setColor(0, 0, 0, 1)
+1 -1
View File
@@ -16,7 +16,7 @@
-- 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 Disable (and PP only when the ruleset depletes
-- enemy PP Gen 1 AI never reads wEnemyMonPP).
-- enemy PP -- Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
+1 -1
View File
@@ -144,7 +144,7 @@ function Input:step()
elseif next(sources) ~= nil then
self.state[btn] = true
end
-- sources == {}: real press fully released before this step keep up
-- sources == {}: real press fully released before this step -- keep up
end
for btn, sources in pairs(self.sources) do
if next(sources) == nil then
+13 -1
View File
@@ -16,6 +16,7 @@ local SaveSerializer = require("src.core.SaveSerializer")
local Runtime = require("src.mods.Runtime")
local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Stats = require("src.pokemon.Stats")
local Bag = require("src.inventory.Bag")
local Badges = require("src.inventory.Badges")
@@ -972,6 +973,17 @@ local function scrubKnownMon(mon, data)
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
end
mon.level = clamp(mon.level, 1, 100, 1)
-- Box mons imported from a real .sav carry NO stat block: box_struct stops
-- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon
-- only fills `stats` for party slots. Every HP-bar draw then nil-indexes
-- mon.stats: the status screen opened in the box (#233) and the party list
-- after withdrawing one (#304). The original derives them on demand
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
-- every later reader (menus, battle, items, SGB bar zones, the link
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
-- so the derived stats use a sane level. A save that already has stats is
-- untouched.
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
local moves = mon.moves
if type(moves) ~= "table" then return end
local hadMoves = #moves > 0
@@ -1216,7 +1228,7 @@ function SaveData.newGame(boot)
inventory = {},
-- Vanilla Gen1 seeds one Potion in the player's item PC
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
-- already have this only applies to New Game.
-- already have -- this only applies to New Game.
pcItems = { POTION = 1 },
party = {},
box = {},
+43
View File
@@ -75,6 +75,32 @@ local function resolveMkdir()
return mkdirFn
end
-- Lazily-resolved windowless rmdir, the mirror of resolveMkdir above:
-- function(absolutePath) or false when FFI is unavailable. Both syscalls
-- refuse a non-empty directory, so a caller has to delete the files first.
local rmdirFn = nil
local function resolveRmdir()
if rmdirFn ~= nil then return rmdirFn end
rmdirFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return rmdirFn end
if ffi.os == "Windows" then
pcall(ffi.cdef, "int RemoveDirectoryA(const char *lpPathName);")
local resolved = pcall(function() return ffi.C.RemoveDirectoryA end)
if resolved then
rmdirFn = function(path) pcall(ffi.C.RemoveDirectoryA, path) end
end
else
pcall(ffi.cdef, "int rmdir(const char *pathname);")
local resolved = pcall(function() return ffi.C.rmdir end)
if resolved then
rmdirFn = function(path) pcall(ffi.C.rmdir, path) end
end
end
return rmdirFn
end
-- Mount an external directory onto the physfs read path (appended, so the
-- game's own source always wins a name clash). Returns true on success.
--
@@ -229,6 +255,23 @@ function CacheFs.remove(rel)
love.filesystem.remove(rel)
end
-- Remove a single cache-relative directory once its files are gone. Needed
-- because os.remove cannot delete a directory on Windows and
-- love.filesystem.remove never reaches outside the save directory, so the
-- portable game folder gets the same FFI-syscall treatment as its mkdir
-- (issue #74: os.execute would flash a console window per call). Used by the
-- mod installer so an uninstall leaves nothing behind (#330).
function CacheFs.removeDir(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
local rmdir = resolveRmdir()
if rmdir then rmdir(realPath(root, rel)) end
return
end
love.filesystem.remove(rel)
end
-- Remove the game-folder copy of a cache subtree before a fresh import, so a
-- cache-format bump does not leave orphaned files behind. No-op when the
-- portable cache is inactive (the save-directory copy is cleared by
+30 -1
View File
@@ -255,7 +255,36 @@ local function fileUrl(path)
return "file://" .. encoded
end
-- The native pickers below block the whole loop inside io.popen, and they are
-- opened straight out of mousepressed -- with the button still physically
-- down. SDL auto-captures the pointer for the length of a press (on X11 an
-- XGrabPointer with owner_events) and only drops that capture when it
-- processes the matching button-up, which it cannot do while we sit in popen
-- and never pump. The grab then outlives the click and every pointer event
-- over the file chooser is still routed to our window: the dialog draws and
-- keyboard-navigates (keyboard focus is a separate grab) but ignores the
-- mouse entirely -- issue #254 on Linux. Whether it bites is a race with how
-- long the click was held, which is why the same build picks one ROM fine and
-- then hangs the mouse on the next. So pump until no button is held, letting
-- SDL see the release and let go first; bounded, so a stuck button costs a
-- moment and never the launcher. pump() only drains OS events into LOVE's
-- queue -- it dispatches nothing -- so there is no reentry into mousepressed
-- and the release is still delivered normally on the next frame.
local function releasePointerGrab()
if not (love.mouse and love.mouse.isDown and love.event and love.event.pump
and love.timer) then
return
end
local deadline = love.timer.getTime() + 1
while love.mouse.isDown(1, 2, 3) do
love.event.pump()
if love.timer.getTime() > deadline then break end
love.timer.sleep(0.005)
end
end
local function commandOutput(command)
releasePointerGrab()
local pipe = io.popen(command, "r")
if not pipe then return nil end
local result = pipe:read("*a")
@@ -2476,7 +2505,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
end
-- right cluster: status chip, toggle, Delete vertically centred
-- right cluster: status chip, toggle, Delete -- vertically centred
local clusterX = x + w - padH - L.clusterW
local clusterY = cy + (cardH - clusterH) / 2
local _, chipColor = modStatusChip(m.status)
+21 -2
View File
@@ -47,6 +47,17 @@ ItemEffects.BALLS = BALLS
function ItemEffects.isBall(id) return BALLS[id] or false end
function ItemEffects.isStone(id) return STONES[id] or false end
-- Does using this item take item_effects.asm's .healHP path, the one that
-- plays SFX_HEAL_HP and lengthens the party HP bar with UpdateHPBar2 before
-- the message (item_effects.asm .doneHealing)? The status-only cures branch
-- to .playStatusAilmentCuringSound instead and never touch the bar. BagMenu
-- keeps the party picker open for these so the fill has something to draw
-- on (#252).
function ItemEffects.healsHP(id)
return HEAL_AMOUNT[id] ~= nil or id == "MAX_POTION" or id == "FULL_RESTORE"
or id == "REVIVE" or id == "MAX_REVIVE"
end
-- Does this item need a party-member target?
function ItemEffects.needsTarget(id, itemDef)
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
@@ -257,6 +268,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
return "failed", { Strings("It won't have\nany effect.") }
end
-- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE
-- the item landed (item_effects.asm latches it with the party menu still
-- up), so latch it here and hand it back as extra.healedFrom for the
-- party-menu fill (#252)
local before = target.hp
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
target.hp = target.stats.hp
else
@@ -268,7 +284,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
cureActiveToxic(battle, target)
end
require("src.core.Sound").play(data, "Heal_HP")
return "consumed", msgs
return "consumed", msgs, { healedFrom = before }
end
local cures = STATUS_HEAL[itemId]
@@ -289,7 +305,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
target.status = nil
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
require("src.core.Sound").play(data, "Heal_HP")
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }
-- a revive takes the same .healHP -> .doneHealing route, animating up
-- from the fainted mon's 0 HP (#252)
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) },
{ healedFrom = 0 }
end
if itemId == "RARE_CANDY" then
+58 -10
View File
@@ -3,8 +3,18 @@
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
-- uninstalls a mod by removing that tree + clearing options.mods[id].
-- installs a dropped/chosen .zip into a "mods/<id>/" tree, and uninstalls a
-- mod by removing that tree + clearing options.mods[id].
--
-- Where that tree lives is CacheFs's call, not love.filesystem's: a portable
-- install (portable.txt beside the executable) keeps its mods in the game
-- folder like everything else it owns, and only the OS save directory
-- otherwise (#330 -- love.filesystem.write always resolves to the save dir,
-- so the installer used to strand every mod in appdata). Reads stay on
-- love.filesystem: the portable folder is on the physfs read path either way
-- (it IS the source for a `love <gamedir>` run, and CacheFs mounts it for a
-- fused build), which is why those mods still loaded while landing in the
-- wrong place.
--
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery,
@@ -15,6 +25,7 @@ local ManagerState = require("src.mods.ManagerState")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData")
local CacheFs = require("src.import.CacheFs")
local LauncherMods = {}
@@ -143,6 +154,13 @@ local function discover()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
-- A fused portable build keeps its mods in the game folder next to the
-- executable; resolving the cache root is what mounts that folder onto the
-- physfs read path, so this is what makes those mods enumerable at all
-- (#330). A source run needs nothing (the game folder IS the source), and
-- the launcher's readiness check has usually resolved it already; the call
-- is cached and idempotent.
CacheFs.root()
if not fs.getInfo("mods") then return out end
local seen = {}
for _, name in ipairs(fs.getDirectoryItems("mods")) do
@@ -233,11 +251,14 @@ local function topLevelPaths(mount)
return paths
end
-- Copy the mounted archive subtree at `src` to the install path `dst`. Reads
-- come from love.filesystem (the .zip is mounted there); every write goes
-- through CacheFs so it lands in the portable game folder when portable.txt is
-- in play and in the OS save directory otherwise (#330). No explicit mkdir:
-- CacheFs.write creates the parent chain on both paths, which also means an
-- empty folder inside the .zip is simply not carried over (it holds nothing).
local function copyTree(src, dst)
local fs = love.filesystem
if not fs.createDirectory(dst) then
return nil, "could not create " .. dst
end
for _, name in ipairs(fs.getDirectoryItems(src)) do
local s = src .. "/" .. name
local d = dst .. "/" .. name
@@ -248,13 +269,18 @@ local function copyTree(src, dst)
else
local data = fs.read(s)
if data == nil then return nil, "could not read " .. name end
local ok, err = fs.write(d, data)
local ok, err = CacheFs.write(d, data)
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
end
end
return true
end
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
-- portable game folder is on its read path), but the deletes go through
-- CacheFs so a portable install's real files actually go away instead of
-- love.filesystem no-opping outside the save directory (#330). Directories
-- are removed after their children, since rmdir refuses a non-empty one.
local function removeTree(path)
local fs = love.filesystem
local info = fs.getInfo(path)
@@ -263,7 +289,15 @@ local function removeTree(path)
for _, child in ipairs(fs.getDirectoryItems(path)) do
removeTree(path .. "/" .. child)
end
CacheFs.removeDir(path)
else
CacheFs.remove(path)
end
-- A portable install can still be carrying a pre-#330 copy in the OS save
-- directory, which is where every install used to land and which physfs
-- searches first. CacheFs only touched the game folder, so clear the
-- save-directory twin too or that copy would keep the mod alive; outside
-- portable mode this repeats the delete CacheFs just did and no-ops.
fs.remove(path)
end
@@ -322,10 +356,19 @@ function LauncherMods.installZip(source)
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
fs.createDirectory("mods")
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
-- the mods tree is shared by Red and Blue, so pin the prefix to the root for
-- the copy and the rollback, then hand back whatever the launcher had set
-- (an import coroutine leaves it pointed at that version -- RomImporter.lua).
-- No fs.createDirectory("mods") here any more: CacheFs.write creates the
-- parent chain in both homes, and doing it through love.filesystem would
-- only ever make the directory in the save dir (#330).
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
local copied, copyErr = copyTree(root, dest)
if not copied then removeTree(dest) end
CacheFs.prefix = savedPrefix
if not copied then
removeTree(dest)
cleanup()
return nil, copyErr or "could not copy the mod files"
end
@@ -334,8 +377,9 @@ function LauncherMods.installZip(source)
end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
-- so the loader and in-game manager no longer see it. Rejects missing ids.
-- Does not touch other mods' enable state.
function LauncherMods.uninstall(id)
if type(id) ~= "string" or id == "" then
@@ -352,7 +396,11 @@ function LauncherMods.uninstall(id)
if not fs.getInfo(dest) then
return nil, "mod '" .. id .. "' is not installed"
end
-- same root pin as installZip: the mods tree is not version-prefixed (#330)
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
removeTree(dest)
CacheFs.prefix = savedPrefix
-- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions()
+1 -1
View File
@@ -187,7 +187,7 @@ end
-- After-battle hook: evolve mons that leveled this battle and still
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
-- only considers mons that gained a level during the fight a B-cancel
-- only considers mons that gained a level during the fight -- a B-cancel
-- means "not this time", and the next offer waits for the next level-up
-- (or Rare Candy / stone, which call Evolution.evolve directly).
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
+1 -1
View File
@@ -31,7 +31,7 @@ end
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
-- the oldest slot out when full. Silent no LearnMove prompts.
-- the oldest slot out when full. Silent -- no LearnMove prompts.
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
if not (speciesDef and speciesDef.learnset and mon) then return end
mon.moves = mon.moves or {}
+22
View File
@@ -42,6 +42,28 @@ function Stats.calc(speciesDef, level, dvs, statExp)
return out
end
-- Give a mon a stat block when it has none. A real Gen 1 box_struct is a
-- byte-for-byte PREFIX of party_struct that stops before MON_LEVEL and
-- MON_STATS (macros/ram.asm box_struct / party_struct), so mons decoded out
-- of an imported .sav arrive without one (src/save_convert/GenSave.lua
-- decodeMon, isParty = false). The original derives them on demand at
-- exactly two moments: when a box or daycare mon's status screen opens
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
-- CalcStats) and when one is moved back into the party
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
-- kept (box_struct does hold it) but clamped to the recalculated maximum so
-- a tampered save cannot overfill the bar. A mon that already has stats is
-- returned untouched, so a vanilla save round-trips. #233, #304
function Stats.ensure(speciesDef, mon)
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
return mon
end
mon.stats = Stats.calc(speciesDef, mon.level or 1, mon.dvs or {}, mon.statExp)
mon.hp = math.max(0, math.min(tonumber(mon.hp) or mon.stats.hp, mon.stats.hp))
return mon
end
-- Battle stat stage multipliers (data/battle/stat_modifiers.asm): stages
-- -6..+6 map to N/D pairs 25/100 .. 400/100.
local STAGE_MULT = {
+15 -1
View File
@@ -23,6 +23,20 @@ local FLASH_STEPS = { 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0,
local FLASH_HOLD = 2 -- frames per palette step
local FLASH_CYCLES = 3
-- The screen stays black after the wipe lands. Shrink and Split ask for it
-- outright (BattleTransition_BlackScreen, then `ld c, 10 / jp DelayFrames`:
-- battle_transitions.asm:390-392 and 422-424); the other six get the same gap
-- for free, because BattleTransition_BlackScreen has already set rBGP/rOBP0/
-- rOBP1 to $ff (:168-174) while DoBattleTransitionAndInitBattleVariables
-- reloads the HUD tile patterns and clears the screen (core.asm:6152-6185),
-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port
-- has no load to hide behind, so the hold has to be explicit (#315). 30 is a
-- frame budget for that work, not a number pokered states; retune this one
-- constant against a reference recording if it reads long or short.
local BLACK_HOLD = 30
local TILE = 8
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
@@ -208,7 +222,7 @@ function BattleTransition:update(dt)
self.t = 0
end
else
if self.t >= self.wipeLen + 6 then
if self.t >= self.wipeLen + BLACK_HOLD then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
+66 -17
View File
@@ -2,6 +2,11 @@
-- screen: pokered overlays the $62-$7F font area with the HP bar /
-- status sheet (font_battle_extra -> $62) and the HUD line tiles
-- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73).
--
-- The two screens do NOT use the same overlay: the status screen scatters
-- hud_2 and hud_3 instead of copying them contiguously, which is what keeps
-- its № and <ID> glyphs alive. HudTiles.tile draws the battle layout,
-- HudTiles.statusTile the status one -- see STATUS_PAGES below. #280
local Assets = require("src.render.Assets")
@@ -23,32 +28,62 @@ local PAGES = {
image = "assets/generated/battle/battle_hud_3.png", base = 0x76 },
}
local tiles
function HudTiles.tile(code, x, y, tint)
if not tiles then
tiles = {}
local registered = require("src.core.Data").font
registered = registered and registered.pages or nil
local function add(path, base)
local ok, img = pcall(Assets.image, path)
if not ok then return end
-- The STATUS SCREEN overlays the SAME sheets differently, and the layout
-- above would break it: engine/pokemon/status_screen.asm:86-97 copies 3
-- tiles of hud_1 to $6D, ONE tile of hud_2 to $78 and 2 tiles of hud_3 to
-- $76, which leaves $70/$73/$74 as font_battle_extra's <to>, <ID> and № --
-- the glyphs the screen prints "№." and "<ID>№/" from
-- (constants/charmap.asm:69-73). The battle overlay instead copies
-- hud_2+hud_3 contiguously over $73-$78 (engine/battle/core.asm:6520/6532),
-- burying № under a line tile, so the status screen needs its own table.
-- The line glyphs land identically either way -- $76 ─, $77 ┘, $6F the
-- halfarrow -- only the vertical bar moves ($73 in battle, $78 here). #280
local STATUS_PAGES = {
{ id = "font_battle_extra",
image = "assets/generated/battle/font_battle_extra.png", base = 0x62 },
{ id = "battle_hud_1",
image = "assets/generated/battle/battle_hud_1.png", base = 0x6D },
{ id = "battle_hud_3",
image = "assets/generated/battle/battle_hud_3.png", base = 0x76, count = 2 },
{ id = "battle_hud_2",
image = "assets/generated/battle/battle_hud_2.png", base = 0x78, count = 1 },
}
local tiles, statusTiles
-- Build one code -> {img, quad} map from a page list. `count` caps a page
-- at the number of tiles the asm actually copies (the extracted sheets all
-- carry 3 tiles; the status overlay uses fewer). A mod's registered page
-- swaps the image in either table, but only the battle table honors its
-- `base`: the status layout is the asm's own placement, and sliding hud_2
-- there would bury № again.
local function build(pages, fixedBase)
local out = {}
local registered = require("src.core.Data").font
registered = registered and registered.pages or nil
for _, page in ipairs(pages) do
local override = registered and registered[page.id]
local path, base = page.image, page.base
if override and override.image then path = override.image end
if not fixedBase and override and override.base then base = override.base end
local ok, img = pcall(Assets.image, path)
if ok then
local iw, ih = img:getDimensions()
local per = iw / 8
for i = 0, per * (ih / 8) - 1 do
tiles[base + i] = {
local count = page.count or per * (ih / 8)
for i = 0, count - 1 do
out[base + i] = {
img = img,
quad = love.graphics.newQuad((i % per) * 8,
math.floor(i / per) * 8, 8, 8, iw, ih),
}
end
end
for _, page in ipairs(PAGES) do
local override = registered and registered[page.id]
add(override and override.image or page.image,
override and override.base or page.base)
end
end
local t = tiles[code]
return out
end
local function put(t, x, y, tint)
if not t then return end
local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(tint or { 1, 1, 1, 1 })
@@ -56,9 +91,23 @@ function HudTiles.tile(code, x, y, tint)
love.graphics.setColor(r, g, b, a)
end
function HudTiles.tile(code, x, y, tint)
if not tiles then tiles = build(PAGES) end
put(tiles[code], x, y, tint)
end
-- The same sheets under the status screen's overlay (STATUS_PAGES). The HP
-- bar codes $62-$6D are identical in both layouts, so drawHPBar below keeps
-- using the battle table. #280
function HudTiles.statusTile(code, x, y, tint)
if not statusTiles then statusTiles = build(STATUS_PAGES, true) end
put(statusTiles[code], x, y, tint)
end
-- lazy: the next tile() rebuilds every page from the search path
function HudTiles.invalidate()
tiles = nil
statusTiles = nil
end
Assets.register(HudTiles.invalidate)
+90 -24
View File
@@ -93,6 +93,25 @@ function PaletteFX.ogObj()
return PaletteFX.GBC_OBJ, "gbcobj"
end
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
-- plus its cache group (same two-value contract as ogObj). Entry 1 is never
-- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's
-- unconditional OBJ transparency -- and entries 2..4 are OBJ colors 1..3 sent
-- through rOBP0 = $D0 (home/fade.asm FadePal4 `dc 3,1,0,0`, the entry
-- LoadGBPal reads while wMapPalOffset is 0): color 1 -> DMG shade 0, color 2
-- -> shade 1, color 3 -> shade 3. Leaving the result in DMG shades is the
-- whole point: the zone shader then colors a character out of the same map
-- palette it colors the ground with, which is all the Super Game Boy can do to
-- an OBJ (#301), and the OBP0 lift is what puts Red's cap on the ROUTE
-- palette's grass green instead of its light-blue (#150).
PaletteFX.OBP0_SHADES = {
{ 255, 255, 255 }, { 255, 255, 255 }, { 170, 170, 170 }, { 0, 0, 0 },
}
function PaletteFX.dmgObj()
return PaletteFX.OBP0_SHADES, "obp0"
end
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader()
@@ -207,32 +226,45 @@ function PaletteFX.usesGbcPack(mode)
end
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
-- background, pink over Blue's blue background), so the player and NPCs carry a
-- fixed object color instead of tinting with whatever region palette their
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
-- = light-blue and the character clashed with the grass it should blend into).
-- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette;
-- only OG RED short-circuits BG to the one global red palette. An EARLIER
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
-- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy
-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
-- green over Red's red background, pink over Blue's blue background), so on
-- that machine the player and NPCs carry a fixed object color instead of
-- tinting with whatever region palette their feet stand over. An EARLIER
-- attempt at per-sprite color baked GBC_BG (the RED background ramp) onto
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
-- palette is GBC_OBJ (green), so baking it here is the fix, not that
-- regression. RED++ colors sprites through the usesGbcPack() path in
-- SpriteRenderer instead.
-- palette is GBC_OBJ (green), so baking that here is the fix, not that
-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
-- BG palette; only OG RED short-circuits BG to the one global red palette.
--
-- SGB does NOT. The Super Game Boy colorizes the composited DMG picture it is
-- handed and cannot tell an OBJ pixel from a BG one; pokered never sends the
-- OBJ_TRN packet that would enable SGB sprite mode (data/sgb/sgb_packets.asm
-- defines ATTR_BLK / PAL_SET / PAL_TRN / MLT_REQ / CHR_TRN / PCT_TRN and
-- nothing else), so a character there wears the very palette its map does.
-- Baking GBC_OBJ over it was issue #301 ("people in SGB mode are green": the
-- boot-ROM greens sat on top of ROUTE's own greens and blues). What issue
-- #150 actually caught was the missing rOBP0 step, not a missing object
-- palette: overworld OBJs run through OBP0 = $D0 (home/fade.asm FadePal4
-- `dc 3,1,0,0`), which lifts OBJ color 1 to DMG shade 0 and color 2 to shade 1,
-- so Red's cap lands on the ROUTE palette's shade-1 GRASS GREEN and blends into
-- the grass exactly as #150's reference shot shows. Drawn with an identity
-- shade map it landed on shade 2 = light-blue instead, which is the clash #150
-- reported. SpriteRenderer bakes that OBP0 ramp (PaletteFX.dmgObj) and lets
-- the zone shader color the result. RED++ colors sprites through the
-- usesGbcPack() path in SpriteRenderer instead.
function PaletteFX.usesSpriteObp(mode)
mode = mode or PaletteFX.mode
return mode == "ogred" or mode == "gbc"
return mode == "ogred"
end
-- ------- post-zone sprite redraw (GBC mode)
-- ------- post-zone sprite redraw (OG RED)
--
-- In GBC mode the world canvas still runs through the per-map zone
-- In OG RED the world canvas still runs through the whole-screen zone
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
-- pixels. So SpriteRenderer draws the baked sprite into the canvas (its
-- pixels. (SGB used to come through here too; it no longer bakes an object
-- palette at all, so its characters are colorized by the zone like the ground
-- they stand on and never queue a replay -- see usesSpriteObp, #301.) So SpriteRenderer draws the baked sprite into the canvas (its
-- pixels come out zone-tinted there) AND records the draw here;
-- Renderer:endFrame replays the list on top of the finished zone pass,
-- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw
@@ -534,6 +566,34 @@ function PaletteFX.permute(colors, map)
colors[map[2] + 1], colors[map[3] + 1] }
end
-- ------- global shade map (rBGP)
--
-- home/fade.asm's LoadGBPal writes ONE rBGP for the whole screen, indexing
-- FadePal4 - wMapPalOffset. A dark cave sets wMapPalOffset = 6
-- (home/overworld.asm's ROCK_TUNNEL_1F check; the value rides in the extracted
-- field.darkMaps.palOffset), which lands on FadePal2 = `dc 3,3,3,2`: DMG white
-- drops to shade 2 and every darker shade goes to shade 3. So the WHOLE
-- screen darkens -- the original never cuts a window of light around the
-- player (#322) -- and FLASH clears wMapPalOffset again
-- (engine/menus/start_sub_menus.asm .flash).
--
-- Renderer:beginFrame clears this every frame and the state that draws a dark
-- map re-arms it while it draws, so it can never outlive the map it belongs
-- to: a battle or a full-screen menu draws with no map beneath it and comes
-- out lit, exactly like init_battle_variables.asm's `ld [wMapPalOffset], a`
-- leaves the original.
PaletteFX.DARK_BGP = { [0] = 2, [1] = 3, [2] = 3, [3] = 3 }
local shadeMap = nil
function PaletteFX.setShadeMap(map)
shadeMap = map
end
function PaletteFX.shadeMap()
return shadeMap
end
function PaletteFX.setMode(mode)
local prev = PaletteFX.mode
local ok = false
@@ -623,16 +683,22 @@ end
function PaletteFX.effectiveColors(c)
if not c then return nil end
local mode = PaletteFX.mode or "gbc"
local out = c
if mode == "og" then
return PaletteFX.GRAYS
out = PaletteFX.GRAYS
elseif mode == "og_inv" then
return PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
elseif mode == "classic" then
return PaletteFX.CLASSIC
out = PaletteFX.CLASSIC
elseif mode == "gbc_inv" then
return PaletteFX.permute(c, INV_MAP)
out = PaletteFX.permute(c, INV_MAP)
end
return c
-- The shade map goes on LAST: rBGP is a hardware register write, so it
-- composes on top of whatever colors the display mode settled on -- a dark
-- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too
-- (#322). permute() is the identity (and returns `out` itself, which the
-- mod-graphics parity check leans on) while nothing has armed one.
return PaletteFX.permute(out, shadeMap)
end
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
+3
View File
@@ -174,6 +174,9 @@ function Renderer:beginFrame(transparent)
-- draws this one
PaletteFX.clearTrueColor()
PaletteFX.clearSpriteRedraws()
-- rBGP is a per-frame register here: the state that draws a dark map
-- re-arms it while it draws (#322), so nothing inherits last frame's
PaletteFX.setShadeMap(nil)
PaletteFX.setPass("ui")
love.graphics.setCanvas(self.canvas)
if transparent then
+26 -5
View File
@@ -18,10 +18,14 @@ local function getImage(path)
return imageCache[path]
end
-- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm
-- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic
-- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG
-- shades to the resolved OBP colors, cached per (image path, group).
-- Overworld sprite OBJ-palette recolor, baked into an ImageData like
-- BattleState's mon-pic palette bake (src/battle/BattleState.lua getImage):
-- CPU-remap the 4 DMG shades to the resolved OBP colors, cached per
-- (image path, group). Every colour mode goes through it now (#301): RED++
-- resolves real per-sprite colours (color/sprites.asm ColorOverworldSprite),
-- OG RED the one boot-ROM object palette, and everything else the plain
-- rOBP0 = $D0 shade lift (PaletteFX.dmgObj) that leaves the sprite in DMG
-- shades for the zone shader to colour.
--
-- Sprite sheets carry no real alpha (every pixel, including the
-- background, is opaque -- confirmed by sampling the extracted PNGs): the
@@ -110,7 +114,13 @@ function SpriteRenderer:resolveImage()
-- collide in obpCache) -- see issue #155
return getObpImage(self.def.image, PaletteFX.ogObj())
end
return self.image
-- Every other mode (SGB and the mono/inverted novelties) leaves the sprite
-- in DMG shades so the zone shader colors it out of the map's own palette,
-- but still bakes rOBP0 = $D0 in and keys OBJ color 0 to alpha -- the two
-- things a raw sheet blit cannot express (#301, #150). The sheets carry no
-- real alpha (see getObpImage), so returning self.image here would put an
-- opaque white box behind every character a pipeline textures.
return getObpImage(self.def.image, PaletteFX.dmgObj())
end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
@@ -152,6 +162,17 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
-- top.
image = getObpImage(self.def.image, PaletteFX.ogObj())
redraw = true
else
-- SGB and the mono/inverted modes (and OG RED's tilt upright pass, which
-- has no post-zone replay to restore a bake): the sprite stays in DMG
-- shades -- rOBP0 = $D0 baked in, OBJ color 0 keyed to alpha -- and the
-- whole-canvas zone shader colors it with the map's palette. That is the
-- only thing the Super Game Boy can do to an OBJ, since pokered never
-- sends the OBJ_TRN packet that would give sprites palettes of their own
-- (data/sgb/sgb_packets.asm defines ATTR_BLK / PAL_SET / PAL_TRN /
-- MLT_REQ / CHR_TRN / PCT_TRN and nothing else). No redraw is queued:
-- being colorized by the zone IS the point (#301).
image = getObpImage(self.def.image, PaletteFX.dmgObj())
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine,
+12
View File
@@ -27,6 +27,11 @@ local MAX_COLS = 18
-- WaitForSoundToFinish; nil headless), then auto.delay frames pass
-- (default 3, Delay3) and the box pops itself + calls onDone. No
-- blinking cursor, no Press_AB beep.
-- opts.auto.tick, when given, runs once per frame for as long as the box
-- is held open. It is the only per-frame hook a script has while a box is
-- up: StateStack updates the top state only, so the overworld and its
-- ScriptRunner are frozen underneath (the Pewter JIGGLYPUFF dance drives
-- its spin off it, data/scripts/story5.lua, #249).
function TextBox.new(game, text, onDone, opts)
local self = setmetatable({}, TextBox)
self.game = game
@@ -179,6 +184,13 @@ function TextBox:update(dt)
self.autoSrc = self.auto.sound and self.auto.sound() or nil
self.autoTimer = 0
end
-- auto.tick: one call per frame for as long as the box is held open,
-- run before the autoSrc gate so a tick-driven gate can clear itself
-- on the same frame. It is the only per-frame hook a script gets
-- while a box is up, since StateStack updates the top state only and
-- the overworld underneath is frozen (the Pewter JIGGLYPUFF spin,
-- #249).
if self.auto.tick then self.auto.tick() end
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
return -- the cry is still sounding (WaitForSoundToFinish)
end
+3 -3
View File
@@ -11,9 +11,9 @@ TileRenderer.__index = TileRenderer
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
-- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
-- trees (default) solid tree wall $0F (Viridian/Cerulean/Celadon)
-- water solid water $43 (Cinnabar/Route 19 border block)
-- black solid black (no tiled metatile)
-- trees (default) -- solid tree wall $0F (Viridian/Cerulean/Celadon)
-- water -- solid water $43 (Cinnabar/Route 19 border block)
-- black -- solid black (no tiled metatile)
-- Other tilesets keep their designated border (interiors stay black/void).
local TREE_WALL_BLOCK = 0x0F
local WATER_BORDER_BLOCK = 0x43
+96 -4
View File
@@ -38,6 +38,8 @@ local NAME_LENGTH = 11
local PARTY_LENGTH = 6
local MONS_PER_BOX = 20
local NUM_BADGES = 8
local NUM_CITY_MAPS = 11 -- PALLET_TOWN..SAFFRON_CITY, the bit width of
-- wTownVisitedFlag (constants/map_constants.asm)
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
@@ -65,6 +67,22 @@ O.numPcItems = O.mainData + 579 -- 1B
O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term)
O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
O.coins = O.mainData + 685 -- 2B BCD
-- wTownVisitedFlag (ram/wram.asm:2057): the FLY destination set, a
-- flag_array NUM_CITY_MAPS whose bit index IS the town's map index (see the
-- decode note). Triangulated from both neighbours, which agree exactly:
-- backwards from the checksum-covered, independently derived O.eventFlags
-- below by summing every wram.asm declaration between the two labels --
-- 2 (wTownVisitedFlag) + 2 (wSafariSteps) + 1 + 1 + 2 + 1 + 1 + 1 + 1 + 1
-- + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 1 + 1 + 1 (wBeatGymFlags) + 1 + 1 + 1
-- (wStatusFlags3, aliased wCableClubDestinationMap) + 1 + 1 + 1 + 1 + 1 + 1
-- + 1 + 1 + 1 (wMovementFlags) + 2 + 2 + 1 + 1 + 2 + 1 + 1 + 2 + 1 + 1 + 2
-- = 60, so 1104 - 60 = 1044; and forwards from O.coins (mainData + 685)
-- with 2 (wPlayerCoins) + 32 (wToggleableObjectFlags, flag_array $100) + 7
-- + 1 (wSavedSpriteImageIndex) + 33 (wToggleableObjectList) + 1 + 200
-- (wGameProgressFlags..End) + 56 + 14 (wObtainedHiddenItemsFlags,
-- flag_array MAX_HIDDEN_ITEMS = 112) + 2 (wObtainedHiddenCoinsFlags) + 1
-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well.
O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS)
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
@@ -375,6 +393,40 @@ function GenSave.crosswalks(data)
}
end
-- Gen1 has no "is nicknamed" bit. An un-nicknamed mon literally stores its
-- species' standard name in the nickname slot: engine/menus/naming_screen.asm
-- AskName's .declinedNickname copies wNameBuffer (the MonsterNames entry
-- GetMonName just loaded) straight over the mon's nickname field. The game
-- recovers "was it nicknamed?" by comparing the two --
-- engine/pokemon/evos_moves.asm RenameEvolvedMon rewrites the name on
-- evolution only while the stored one still equals the PRE-evolution
-- species' standard name ("Renames the mon to its new, evolved form's
-- standard name unless it had a nickname, in which case the nickname is
-- kept"). This project models that state as mon.nickname == nil instead:
-- every display site reads `mon.nickname or def.name` and
-- src/pokemon/Evolution.lua deliberately never touches the field. So the
-- two conventions must be translated at this boundary, or an imported
-- SQUIRTLE still reads "SQUIRTLE" after it becomes a WARTORTLE and an
-- engine-origin export writes the species CONSTANT ("NIDORAN_M", whose "_"
-- has no charmap glyph and encodes as "?") where the cartridge keeps the
-- display name. Both read back as a forced nickname (#257).
--
-- def.name is byte-for-byte what the cartridge stores: tools/extract/
-- pokemon.py parse_names reads pokered's data/pokemon/names.asm, the very
-- table GetMonName loads from, and all 151 names round-trip exactly through
-- src/save_convert/data/charmap.lua, so the equality test below is exact
-- and never mis-fires on a name the charmap mangles.
local function speciesName(cw, species)
local def = species and cw.speciesDefs[species]
return (def and def.name) or species or ""
end
-- stored fixed-length name -> save.lua nickname (nil when never nicknamed)
local function importedNickname(cw, species, stored)
if stored == speciesName(cw, species) then return nil end
return stored
end
-- ------------------------------------------------------------------
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
@@ -573,7 +625,10 @@ function GenSave.decode(bytes, data, opts)
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
if mon then
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)
-- a stored name equal to the species' standard name means NOT
-- nicknamed, which this project spells as nil (#257)
mon.nickname = importedNickname(cw, mon.species,
decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH))
save.party[#save.party + 1] = mon
end
end
@@ -586,7 +641,8 @@ function GenSave.decode(bytes, data, opts)
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
if mon then
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = importedNickname(cw, mon.species,
decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH))
table.insert(save.boxes[boxNum], mon)
end
end
@@ -610,6 +666,28 @@ function GenSave.decode(bytes, data, opts)
end
end
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
-- into de and rotates it right one bit per iteration with b counting up
-- from 0, storing b ("the map number of the town if it has been visited"),
-- so bit 0 = map 0 = PALLET_TOWN, LSB first; and
-- engine/overworld/toggleable_objects.asm
-- MarkTownVisitedAndLoadToggleableObjects sets bit [wCurMap] on entry for
-- any map below FIRST_ROUTE_MAP. Map indices 0-10 in
-- data/generated/maps.lua match PALLET_TOWN..SAFFRON_CITY one for one.
-- This project keeps the same set as save.visited[mapId]
-- (src/ui/FlyMenu.lua, src/ui/TownMap.lua, and the only writer,
-- src/world/OverworldController.lua's mark-on-map-entry), which an import
-- used to leave nil: FLY then listed only the town the player happened to
-- be standing in when the save was loaded (#263).
save.visited = {}
for townIdx = 0, NUM_CITY_MAPS - 1 do
if bitGet(bytes, O.townVisited, townIdx) then
local townId = cw.mapsByIndex[townIdx]
if townId then save.visited[townId] = true end
end
end
-- map + position
local mapIdx = u8(bytes, O.curMap)
local mapId = cw.mapsByIndex[mapIdx]
@@ -700,6 +778,18 @@ function GenSave.encode(save, data, template)
end
end
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
-- save exported from this port is flyable on hardware (#263). A save
-- table with no `visited` key at all says nothing about the set, so leave
-- the template's bits exactly as they are rather than blanking every town.
if type(save.visited) == "table" then
for townIdx = 0, NUM_CITY_MAPS - 1 do
local townId = cw.mapsByIndex[townIdx]
bitSet(buf, O.townVisited, townIdx,
(townId and save.visited[townId]) and true or false)
end
end
-- party
local party = save.party or {}
local partyN = math.min(#party, PARTY_LENGTH)
@@ -710,8 +800,10 @@ function GenSave.encode(save, data, template)
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
-- no nickname stores the species' DISPLAY name, not its ROM constant id
-- ("NIDORAN_M" would charmap the "_" to "?") (#257)
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
mon.nickname or speciesName(cw, mon.species))
end
-- $FF-terminate the species index list right after the last real mon. The
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
@@ -734,7 +826,7 @@ function GenSave.encode(save, data, template)
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
mon.nickname or speciesName(cw, mon.species)) -- #257, as above
end
-- $FF-terminate the species list after the last real mon; empty slots past
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
+30 -6
View File
@@ -41,10 +41,17 @@ local function showMessages(game, msgs, onDone)
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
end
-- run the use-flow for an item on a chosen target
local function useOn(game, battle, id, target, list, moveIndex)
-- run the use-flow for an item on a chosen target. `picker` is the party
-- menu when it was opened with keepOpen (HP medicine only): it is still on
-- the stack, so every exit that prints has to close it afterwards. For
-- every other item the picker popped itself first and closePicker's identity
-- check makes it a no-op (#252).
local function useOn(game, battle, id, target, list, moveIndex, picker)
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
battle, moveIndex, game.overworld)
local function closePicker()
if picker then picker:close() end
end
-- field POKé FLUTE: play the tune, then the no-effect text
if result == "flute_field" then
@@ -288,16 +295,29 @@ local function useOn(game, battle, id, target, list, moveIndex)
end
end
list.index = math.min(list.index, math.max(1, #list.items))
-- HP medicine: fill the bar in the still-open picker first, then print
-- and close, the order item_effects.asm .doneHealing runs in
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
-- picker is nil for every other item and for in-battle use, which keeps
-- the pop-then-print path below. #252
if picker and extra and extra.healedFrom and target then
picker:animateTo(target, extra.healedFrom, function()
showMessages(game, payload, closePicker)
end)
return
end
if battle then
list:close()
showMessages(game, payload, function() battle:itemUsed({}) end)
else
showMessages(game, payload)
showMessages(game, payload, closePicker)
end
return
end
showMessages(game, payload) -- failed
-- .healingItemNoEffect prints over the still-drawn party menu too, so the
-- refusal closes the picker the same way (#252)
showMessages(game, payload, closePicker) -- failed
end
local function pickTargetAndUse(game, battle, id, list)
@@ -308,9 +328,13 @@ local function pickTargetAndUse(game, battle, id, list)
local def = game.data.items[id]
local opts = {
pickOnly = true,
onSwitch = function(mon)
-- HP medicine animates its bar with the picker still up (#252). Only
-- out of battle: the in-battle tail closes the bag list underneath
-- first, which needs the picker already gone.
keepOpen = (not battle) and ItemEffects.healsHP(id),
onSwitch = function(mon, picker)
if not wantsMove then
useOn(game, battle, id, mon, list)
useOn(game, battle, id, mon, list, nil, picker)
return
end
local rows = {}
+9
View File
@@ -7,6 +7,7 @@ local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party")
local Stats = require("src.pokemon.Stats")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
@@ -73,6 +74,14 @@ local function withdraw(game)
list.footer = "The party is full!"
return
end
-- add_mon.asm _MoveMon's tail ("returning mon to party, compute
-- level and stats"): a box mon carries no stat block, because
-- box_struct stops before MON_LEVEL/MON_STATS, so the party copy
-- runs CalcStats. Without it a mon decoded out of an imported .sav
-- reaches the party menu with mon.stats nil and the HP bar draw
-- nil-indexes it (#304, same family as #233). Already-shaped mons
-- (everything the engine itself put in a box) pass through.
Stats.ensure(game.data.pokemon[mon.species], mon)
table.remove(box, item.value)
table.insert(game.save.party, mon)
local name = monName(game, mon)
+16
View File
@@ -20,6 +20,22 @@ EvolutionState.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function EvolutionState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
-- engine/movie/evolution.asm EvolveMon runs the back-and-forth flash with
-- the whole screen on PAL_BLACK -- `ld c, 1 ; set PAL_BLACK instead of mon
-- palette` right before .animLoop, then `ld c, 0` again at .done once the
-- loop is over -- so both forms read as silhouettes while they trade places
-- and only the settled form wears a mon palette (#279). PAL_BLACK is not
-- four blacks: data/sgb/sgb_palettes.asm gives it `RGB 31,29,31, 07,07,07,
-- 02,03,03, 03,02,02`, the usual paper white with the three darker shades
-- crushed, which is why a hardware capture shows a dark mon on an unchanged
-- background rather than an all-black screen. Going through P.pal keeps
-- every COLORS mode honest for free: OG RED short-circuits every name to the
-- one global boot-ROM palette (a Game Boy Color ignores the SGB packets, so
-- it never blacks out) and the mono modes replace it in effectiveColors.
if not self.done then
local black = P.pal(game.data, "BLACK")
if black then return { P.whole(black) } end
end
-- a cancelled evolution keeps the old species (never applied), so only
-- colorize with the new form once it has actually evolved
local species = (self.done and not self.canceled) and self.newSpecies
+1 -1
View File
@@ -75,7 +75,7 @@ function ListMenu.new(game, title, items, opts)
self.dialogue = opts.dialogue
-- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names
-- and PrintText footers ("How many?", stored/withdrew) use the standard
-- bottom text box same row budget as the mart, without the money box.
-- bottom text box -- same row budget as the mart, without the money box.
self.messageBox = opts.messageBox
self.money = opts.money -- () -> current money for the box
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
+198 -15
View File
@@ -23,9 +23,51 @@ local PartyMenu = {}
PartyMenu.__index = PartyMenu
PartyMenu.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
-- SGB (SetPal_PartyMenu, engine/gfx/palettes.asm:90): the party screen is
-- NOT a one-palette screen. data/sgb/sgb_packets.asm BlkPacket_PartyMenu
-- splits it into MEWMON over the mon-icon column with GREENBAR everywhere
-- else, plus one block per HP bar row whose palette
-- UpdatePartyMenuBlkPacket (engine/gfx/palettes.asm:299-325) sets from that
-- mon's GetHealthBarColor -- pal 1 GREENBAR / 2 YELLOWBAR / 3 REDBAR
-- (PalPacket_PartyMenu, sgb_packets.asm:219). Handing the whole screen
-- MEWMON instead painted every bar with MEWMON's shades, which is why a
-- full bar came out black and a low one purple (#274, absorbing #272).
--
-- Two rects differ from the packet's, both because this port draws pixels
-- where the hardware drew OAM over BG:
-- * the icon block is rows 0-11, not the packet's 0-12 -- row 12 is the
-- message box's top edge, which on hardware was BG under an OBJ-free
-- part of the block; here it would take MEWMON instead of the base.
-- * the bar blocks sit one tile right of the packet's 05-11 because this
-- port's bar starts at tile 5 where party_menu.asm:71-76 starts it at
-- 4; the span is the same "left cap + six fill tiles".
function PartyMenu:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
local P = require("src.render.PaletteFX")
local base = P.pal(game.data, "GREENBAR")
if not base then return nil end
local zones = { P.whole(base) }
local mew = P.pal(game.data, "MEWMON")
if mew then zones[#zones + 1] = P.zone(mew, 1, 0, 2, 11) end
-- the TM/HM list prints ABLE / NOT ABLE where the bar would be, so those
-- rows have no bar to color (party_menu.asm .teachMoveMenu; #210)
if not self.tmhm then
local party = self.party or (game.save and game.save.party) or {}
for i, mon in ipairs(party) do
-- While a medicine's bar fill runs the block palette is STALE, not
-- recomputed: SetPartyMenuHPBarColor (party_menu.asm:80/295) is only
-- reached from the party-menu redraw loop, never from hp_bar.asm, so
-- UpdateHPBar2 lengthens the bar under the PRE-heal color and
-- RedrawPartyMenu snaps it green when the message prints. Hold the
-- starting HP here for exactly that window (#252).
local hp = mon.hp
if self.heal and self.heal.mon == mon then hp = self.heal.from end
local bar = P.pal(game.data, P.barPalName(hp, mon.stats.hp))
if bar then
zones[#zones + 1] = P.zone(bar, 6, i * 2 - 1, 12, i * 2 - 1)
end
end
end
return zones
end
local function sameItems(_, items) return items end
@@ -50,7 +92,9 @@ local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
-- Frame1; SNAKE/QUADRUPED are the reverse. Sprite-reused icons draw
-- from 16x16x6 overworld sheets where index 3 is walk-down (tile 12):
-- MON/FAIRY/BIRD rest on the walk frame and animate to standing
-- (tile 0); WATER (Seel) is the reverse.
-- (tile 0); WATER (Seel) is the reverse. Only the frame's LEFT half
-- ever reaches the screen -- see PartyMenu.mirrorsIcon (#276) -- which
-- is why a walk frame does not look like a walk frame here.
PartyMenu.iconFrames = {
BUG = { rest = 1, alt = 0 }, -- BugIconFrame2 <-> BugIconFrame1
GRASS = { rest = 1, alt = 0 }, -- PlantIconFrame2 <-> PlantIconFrame1
@@ -71,7 +115,45 @@ function PartyMenu.frameFor(name, alt, ih)
return alt and ((ih or 0) >= 64 and 3 or 1) or 0
end
-- HELIX is the one icon WriteMonPartySpriteOAM sends down the asymmetric
-- path (engine/gfx/mon_icons.asm:246 `cp ICON_HELIX << 2 / jr z, .helix`);
-- every other built-in icon is drawn as a mirrored left half (see
-- drawIcon). A mod that supplies its own image instead of a built-in icon
-- name has no vanilla counterpart, so its art draws whole. #276
function PartyMenu.mirrorsIcon(name)
return name ~= nil and name ~= "HELIX"
end
local iconImages = {}
-- Party icons are OBJs (engine/gfx/mon_icons.asm WriteMonPartySpriteOAM
-- writes OAM blocks), so they render through OBP0, and GBPalNormal
-- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`)
-- holds OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1,
-- color 3 as shade 3. An object never displays shade 2. This canvas has
-- no OBJ layer, so bake that map into the icon art once per path (the same
-- CPU-remap trick as SpriteRenderer.getObpImage, and the same "#obp" cache
-- key convention) and let the screen's SGB zone color the result. Without
-- it every color-2 pixel took the zone palette's shade-2 color -- the
-- ADVANCED pack's MEWMON purple {115,33,165}, i.e. the "weirdly colored"
-- party sprites of #274.
local function obpIcon(path)
if not (love.image and love.image.newImageData) then
return love.graphics.newImage(Assets.resolve(path)) -- headless stub
end
local id = Assets.imageData(path)
id:mapPixel(function(_, _, r, _, _, a)
-- the extracted art is the four DMG grays, keyed off the red channel
-- exactly the way PaletteFX's shade-remap shader keys them
local v = 0
if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0
elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1
end -- OBJ color 3 -> shade 3
return v, v, v, a
end)
return love.graphics.newImage(id)
end
local function drawIcon(game, mon, x, y, selected, counter)
local icons = game.data.icons
if not icons then return end
@@ -98,14 +180,26 @@ local function drawIcon(game, mon, x, y, selected, counter)
end
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
if not path then return end
if iconImages[path] == nil then
-- Built-in icon classes are DMG 2bpp OBJ art and get the OBP0 bake; a
-- mod's own image (an entry table rather than an icon name) is authored
-- art with no hardware counterpart, so it loads untouched -- the same
-- split PartyMenu.mirrorsIcon makes for the OAM mirror. Both live in one
-- cache under different keys, so a mod pointing a table entry at a
-- built-in path still gets its unbaked copy. #274
local key = name and (path .. "#obp") or path
if iconImages[key] == nil then
-- resolve through Assets so an overrides/ or transform-derived icon
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
-- picked up the same way battle sprites are
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
iconImages[path] = ok and img or false
local ok, img
if name then
ok, img = pcall(obpIcon, path)
else
ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
end
iconImages[key] = ok and img or false
end
local img = iconImages[path]
local img = iconImages[key]
if not img then return end
local alt = false
if selected then
@@ -118,10 +212,28 @@ local function drawIcon(game, mon, x, y, selected, counter)
alt = false
end
local iw, ih = img:getDimensions()
if ih > 16 then
local frame = PartyMenu.frameFor(name, alt, ih)
-- a 16x16 sheet (BALL, HELIX) is its own only frame
local frame = ih > 16 and PartyMenu.frameFor(name, alt, ih) or 0
if PartyMenu.mirrorsIcon(name) then
-- WriteSymmetricMonPartySpriteOAM (engine/items/town_map.asm:494-534)
-- lays each icon out as 2x2 OAM blocks that use only the frame's LEFT
-- column of tiles (base+0, base+2): the inner loop writes the same
-- wOAMBaseTile twice with the attributes alternating 0 / OAM_XFLIP and
-- only then bumps the tile by 2, because "all the sprites other than
-- the helix one have a vertical line of symmetry". MON / FAIRY / BIRD
-- reuse overworld sheets whose walk-down frame is NOT symmetric, so
-- drawing the raw 16x16 showed a tucked-back foot the hardware never
-- displays (#276, absorbing #238).
local half = love.graphics.newQuad(0, frame * 16, 8, 16, iw, ih)
love.graphics.draw(img, half, x, y)
-- sx = -1 about the block's right edge, so the flipped copy lands on
-- x+8..x+16: the OAM_XFLIP half
love.graphics.draw(img, half, x + 16, y, 0, -1, 1)
elseif ih > 16 then
love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y)
else
-- HELIX and any mod art that is a single frame: drawn whole, at
-- whatever size the file is (unchanged path)
love.graphics.draw(img, x, y)
end
end
@@ -134,6 +246,11 @@ function PartyMenu.new(game, opts)
self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly
-- Medicine keeps the picker on screen: item_effects.asm .doneHealing
-- animates the party HP bar and then prints the message through
-- RedrawPartyMenu with the menu STILL up, so BagMenu asks for keepOpen and
-- calls :close() itself once the message is done (#252).
self.keepOpen = opts.keepOpen
-- TM/HM teaching: opts.tmhm = { move, kind } switches the list to Gen 1's
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
@@ -148,9 +265,47 @@ function PartyMenu.new(game, opts)
return self
end
-- UpdateHPBar2 (engine/gfx/hp_bar.asm, predef'd from item_effects.asm's
-- .doneHealing): UpdateHPBar_AnimateHPBar is documented "for (a) ticks (two
-- waiting frames each)" over a 48-pixel bar, so the shown HP walks
-- maxHP/96 per frame -- the same rate the battle HUD drains at
-- (BattleState:stepHPDrain). onDone fires on the frame it lands, which is
-- when the caller prints its message. #252
function PartyMenu:animateTo(mon, fromHP, onDone)
if not (mon and mon.stats) then
if onDone then onDone() end
return
end
local from = math.max(0, fromHP or mon.hp)
-- `from` outlives `shown`: sgbPalettes above needs the pre-heal HP for the
-- whole fill, because the SGB bar color does not move until the redraw.
self.heal = { mon = mon, from = from, shown = from, onDone = onDone }
end
-- Close a picker the caller kept open (see self.keepOpen). A TextBox pops
-- itself BEFORE it fires onDone (src/render/TextBox.lua), so this menu is
-- the top state by then; the identity check makes it a no-op for the pickers
-- that already popped themselves, and stops a double close eating the bag
-- underneath. #252
function PartyMenu:close()
if self.game.stack:top() == self then self.game.stack:pop() end
end
function PartyMenu:update(dt)
-- icon animation counter; 320 = a whole cycle at every HP speed
self.blink = ((self.blink or 0) + 1) % 320
-- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking
-- predef in item_effects.asm, so no button is read until it lands (#252).
local heal = self.heal
if heal then
heal.shown = math.min(heal.mon.hp,
heal.shown + math.max(1, heal.mon.stats.hp) / 96)
if heal.shown >= heal.mon.hp then
self.heal = nil
if heal.onDone then heal.onDone() end
end
return
end
local input = self.game.input
local party = self.party or self.game.save.party
@@ -369,8 +524,12 @@ function PartyMenu:update(dt)
end
self.swapFrom = nil
elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
self.game.stack:pop()
self.onSwitch(mon)
-- keepOpen callers (HP medicine) need the menu still drawn while the
-- bar fills and the message prints, and close it themselves; everyone
-- else keeps the old pop-then-call order. Popping first is what made
-- a POTION snap the picker shut before the item had even run (#252).
if not self.keepOpen then self.game.stack:pop() end
self.onSwitch(mon, self)
else
self.submenu = true
self.subIndex = 1
@@ -391,7 +550,7 @@ function PartyMenu:update(dt)
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
@@ -486,6 +645,16 @@ function PartyMenu:draw()
Font.draw(Strings("No POKéMON!"), 16, 64)
end
local HudTiles = require("src.render.HudTiles")
local PaletteFX = require("src.render.PaletteFX")
-- Each bar row carries its own GREENBAR / YELLOWBAR / REDBAR zone (see
-- sgbPalettes), so the fill must stay the raw DMG shade-2 gray and let
-- the zone color it -- but only when a zone pass will actually run.
-- Renderer's blit takes the shader path exactly when the zone list is
-- non-empty AND PaletteFX.shader() resolves, which is the same pair of
-- conditions tested here; with no shader the canvas blits unshaded and
-- drawHPBar's per-pixel tint is the only color the bar can get. #274
local barZoned = PaletteFX.shader() ~= nil
and PaletteFX.pal(self.game.data, "GREENBAR") ~= nil
for i, mon in ipairs(party) do
local def = self.game.data.pokemon[mon.species]
local y = PartyMenu.entryY(i)
@@ -525,11 +694,25 @@ function PartyMenu:draw()
elseif mon.status then
Font.draw(mon.status, 136, y)
end
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
-- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill:
-- tinting the fill AND running it through the row's zone
-- double-applies -- a green fill has red channel 0, so the tint
-- zeroes the bar's red and the zone's red-keyed shade shader then
-- maps every pixel to color 3, i.e. black. That is the #229 hazard
-- HudTiles documents; #274 (with #272) is this screen's instance.
--
-- While a medicine's UpdateHPBar2 fill runs, this row draws the HP the
-- animation has reached rather than the final value; drawHPBar reads
-- only .hp and .stats, so a shim table is enough and the real mon is
-- never mutated for display (#252).
local shown = mon
if self.heal and self.heal.mon == mon then
shown = { hp = math.floor(self.heal.shown), stats = mon.stats }
end
love.graphics.setColor(1, 1, 1, 1)
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, shown, nil, barZoned)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
Font.draw(("%3d/%3d"):format(shown.hp, mon.stats.hp), 104, y + 8)
end
-- home/pokemon.asm PartyMenuInit seeds wTopMenuItemY/X with 1/0, so the
-- cursor sits on the entry's *second* tile row (the level/HP line),
+71 -18
View File
@@ -13,6 +13,7 @@ local Font = require("src.render.Font")
-- battle move-type box already do (#214).
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local Stats = require("src.pokemon.Stats")
local SummaryMenu = {}
SummaryMenu.__index = SummaryMenu
@@ -30,6 +31,16 @@ function SummaryMenu:sgbPalettes(game)
end
function SummaryMenu.new(game, mon)
-- status_screen.asm:66-76: StatusScreen recalculates the stat block before
-- it draws anything when the mon came from a box or the daycare ("mon is
-- in a box or daycare" -> CalcStats), because box_struct carries none.
-- Bill's PC hands us that mon table directly (src/ui/BoxMenu.lua's STATS
-- submenu entry), and for a .sav imported through
-- src/save_convert/GenSave.lua it really does arrive with mon.stats nil,
-- which crashed the HP bar draw below (#233). Redundant once
-- SaveData.validate has run over a loaded save, but this is the site the
-- original recomputes at, and it also covers a mon handed in by a mod.
Stats.ensure(game.data.pokemon[mon.species], mon)
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
local Sprites = require("src.pokemon.Sprites")
local path = Sprites.path(game.data, mon.species, "front",
@@ -59,10 +70,31 @@ end
-- drawn from the same HUD tiles the original loads
local function drawLineBox(tx, ty, b, c)
local HudTiles = require("src.render.HudTiles")
for i = 0, b - 1 do HudTiles.tile(0x73, tx * 8, (ty + i) * 8) end
HudTiles.tile(0x77, tx * 8, (ty + b) * 8)
for i = 1, c do HudTiles.tile(0x76, (tx - i) * 8, (ty + b) * 8) end
HudTiles.tile(0x6F, (tx - c - 1) * 8, (ty + b) * 8)
-- Under the status screen's overlay the vertical is $78 -- DrawLineBox
-- writes `ld [hl], $78` (status_screen.asm:222), and :90-93 is what puts
-- hud_2's single bar tile there. $73 is the <ID> glyph on this screen,
-- not a line, so the whole box has to come off statusTile (#280). The
-- drawn shapes are unchanged: hud_2 tile 0 is the same bar the battle
-- layout parks at $73.
for i = 0, b - 1 do HudTiles.statusTile(0x78, tx * 8, (ty + i) * 8) end
HudTiles.statusTile(0x77, tx * 8, (ty + b) * 8)
for i = 1, c do HudTiles.statusTile(0x76, (tx - i) * 8, (ty + b) * 8) end
HudTiles.statusTile(0x6F, (tx - c - 1) * 8, (ty + b) * 8)
end
-- home/pokemon.asm:335-345 PrintLevel: the "<LV>" (":L") tile at (tx,ty)
-- then the level LEFT_ALIGNed after it; at level 100 hl is decremented so
-- the third digit is written back OVER the ":L" tile. Both status pages
-- print a level this way, and src/ui/PartyMenu.lua models the same rule for
-- its rows. #280
local function printLevel(tx, ty, level)
local HudTiles = require("src.render.HudTiles")
local x = tx * 8
if level < 100 then
HudTiles.statusTile(0x6E, x, ty * 8)
x = x + 8
end
Font.draw(tostring(level), x, ty * 8)
end
function SummaryMenu:draw()
@@ -73,21 +105,33 @@ function SummaryMenu:draw()
local data = game.data
local def = data.pokemon[mon.species]
-- shared header: pic (1,0), name (9,1), <LV> (14,2), No. (1,7)
-- shared header: pic (1,0), name (9,1), № + dex number (1,7). The pic is
-- MIRRORED -- status_screen.asm:170 draws it through
-- LoadFlippedFrontSpriteByMonIndex (home/pokemon.asm sets wSpriteFlipped),
-- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip:
-- negative x scale anchored at the pic's right edge). #280
if self.sprite then
love.graphics.draw(self.sprite, 8,
math.max(0, 56 - self.sprite:getHeight()))
love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(),
math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1)
end
local HudTiles = require("src.render.HudTiles")
love.graphics.setColor(0, 0, 0, 1)
Font.draw(mon.nickname or def.name, 72, 8)
HudTiles.tile(0x6E, 112, 16) -- <LV>
Font.draw(tostring(mon.level), 120, 16)
Font.draw(("No.%03d"):format(def.dex or 0), 8, 56)
-- status_screen.asm:109-113 backs hl up from DrawLineBox's end to write
-- the single-tile '№' at (1,7) and '<DOT>' at (2,7); :143-146 then
-- PrintNumbers the dex number (LEADING_ZEROES, 3 digits) at (3,7).
-- Spelling "No." out of three letter tiles pushed every digit a column
-- right of the original. #280
HudTiles.statusTile(0x74, 8, 56) -- №
Font.drawCode(0xF2, 16, 56) -- <DOT> (charmap.asm:182)
Font.draw(("%03d"):format(def.dex or 0), 24, 56)
if self.page == 1 then
-- HP bar (11,3) + numbers row 4, STATUS/ (9,6), the DrawLineBox
-- bracket around the name/HP block
-- bracket around the name/HP block, and PrintLevel at (14,2). The
-- level belongs to page 1 ONLY: StatusScreen2 opens with ClearScreenArea
-- over (9,2) 5x10 (status_screen.asm:303-305), which wipes it. #280
printLevel(14, 2, mon.level)
drawLineBox(19, 1, 6, 10)
HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
@@ -114,7 +158,12 @@ function SummaryMenu:draw()
Font.draw(Strings("TYPE2/"), 80, 88)
Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
end
Font.draw(Strings("IDNo/"), 80, 104)
-- TypesIDNoOTText's third row is "<ID>№/" (status_screen.asm:205-210):
-- two single-tile glyphs and a slash, three columns wide, not the five
-- letter tiles "IDNo/" this used to spell out. #280
HudTiles.statusTile(0x73, 80, 104) -- <ID>
HudTiles.statusTile(0x74, 88, 104) -- №
Font.draw("/", 96, 104)
-- the trainer ID is rolled at new game (SaveData.newGame) and
-- backfilled on load for old saves
Font.draw(("%05d"):format(mon.otId or game.save.player.id or 0), 96, 112)
@@ -124,17 +173,21 @@ function SummaryMenu:draw()
-- page 2: EXP + the moves with PP (StatusScreen2)
drawLineBox(19, 1, 6, 10)
Font.draw(Strings("EXP POINTS"), 72, 24)
Font.draw(("%d"):format(mon.exp), 96, 32)
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
-- PrintNumber at (12,4) with 7 columns: the exp is RIGHT-aligned into
-- cols 12-18 (status_screen.asm:400-403), not left-aligned from col 12.
-- #280
Font.draw(("%7d"):format(mon.exp), 96, 32)
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols at
-- (7,6); the narrow '<to>' tile at (14,6); PrintLevel at (16,6)
-- (status_screen.asm:393-403). The old "%d to L%d" string at x=88
-- overflowed the DrawLineBox edge.
Font.draw(Strings("LEVEL UP"), 72, 40)
local Growth = require("src.pokemon.Growth")
local nextExp = mon.level < 100
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
HudTiles.tile(0x6E, 128, 48) -- <LV>
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
printLevel(16, 6, math.min(100, mon.level + 1))
Font.drawBox(0, 8, 20, 10)
for i = 1, 4 do
local mv = mon.moves[i]
+153 -47
View File
@@ -47,6 +47,24 @@ local HEAL_BALL_XY = {
-- swaps the two middle shades of the monitor/ball art in place
local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
-- Fishing rod placement (FishingRodOAM, engine/overworld/player_animations
-- .asm). Those dbsprite rows are raw shadow-OAM bytes like HEAL_BALL_XY
-- above (screen = tile*8 + pixel - 8/16), measured against the player
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
-- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at
-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
-- garbage strip (#321).
local ROD_OAM = {
down = { dx = 4, dy = 15, tile = 0 }, -- dbsprite 9, 11, 4, 3, $fd
up = { dx = 4, dy = -8, tile = 0 }, -- dbsprite 9, 8, 4, 4, $fd
left = { dx = -8, dy = 4, tile = 1 }, -- dbsprite 8, 10, 0, 0, $fe
right = { dx = 16, dy = 4, tile = 1, flip = true }, -- dbsprite 11, 10, 0, 0, $fe, XFLIP
}
-- object_event spawn filter (toggleable_objects, items taken, beaten
-- static encounters), shared by the current map's real NPCs and the
-- visual-only ghosts on connected neighbor maps
@@ -555,6 +573,19 @@ function OverworldState:sgbWorldZones()
return zones
end
-- Whether the dark-map shade shift (PaletteFX.DARK_BGP, armed in drawWorld)
-- can actually reach this frame's world pass. It cannot in RED++: that mode
-- bakes real per-tile colour into the tileset atlas and sgbWorldZones returns
-- an EMPTY zone list above, so the world blits with no shade-remap shader at
-- all and there is no palette left to permute. Only then does fxDark
-- composite the darkness by hand (#322).
function OverworldState:darkNeedsOverlay()
if not self.dark then return false end
local renderer = self.map and self.map.renderer
return PaletteFX.usesGbcPack() and renderer ~= nil
and renderer.gbcAtlas ~= nil
end
function OverworldState:npcByIndex(index)
for _, n in ipairs(self.npcs) do
if n.def.index == index then return n end
@@ -980,9 +1011,15 @@ function OverworldState:handleInput()
end
-- Cycling Road's downhill pull: with no d-pad held the bike rolls
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN)
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN).
-- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B
-- brakes exactly like a held direction: what the Route 17 sign
-- promises ("Press the A or B Button to stay in place") and what the
-- edge-only wasPressed("a") above can never deliver, since a press
-- stalls the roll for one frame only (issue #255).
local fm = Game.data.field.forcedMovement
if fm and Game.save.onBike and not self.player.moving then
local braking = input:isDown("a") or input:isDown("b")
if fm and Game.save.onBike and not braking and not self.player.moving then
for _, m in ipairs(fm.slopeMaps or {}) do
if m == self.map.id then
self.player.facing = "down"
@@ -1070,8 +1107,28 @@ function OverworldState:checkLedgeHop(dir)
and ledge.facing == dir and ledge.input == dir
and ledge.standingTile == standing and ledge.ledgeTile == front then
local lx, ly = Collision.target(fx, fy, dir)
if self.map:inBounds(lx, ly)
and not Collision.occupied(self.entities, lx, ly, p)
if not self.map:inBounds(lx, ly) then
-- The landing is on the CONNECTED map. pokered never checks where a
-- hop lands (engine/overworld/ledges.asm HandleLedges just simulates
-- two presses in the hop direction) and the connection strip is
-- loaded, so ROUTE_4's bottom-row ledge at (12,17)/(13,17) really
-- does drop onto ROUTE_3 row 0 (south connection, offset -25 ->
-- destX = curX + 50; ROUTE_3 (62,0)/(63,0) are walkable $39/$23):
-- the one-way shortcut off the Mt Moon plaza that the in-bounds gate
-- was silently refusing, which is issue #223. Validate the seam
-- cell the way crossConnection does, hop the first cell onto the
-- ledge tile, and hand the second to checkEdgeExit, which owns the
-- crossing.
local dest, ts, cx, cy = self:connectionLanding(dir)
if not (dest and Map.defPassable(dest, ts, cx, cy, p.surfing)) then
return false
end
require("src.core.Sound").play(Game.data, "Ledge")
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end)
return true
end
if not Collision.occupied(self.entities, lx, ly, p)
and self.map:isWalkableCell(lx, ly) then
require("src.core.Sound").play(Game.data, "Ledge")
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
@@ -1322,12 +1379,18 @@ function OverworldState:goFishing(rod)
-- FishingInit dot animation); the rod pose draws in the meantime
self.fishing = { facing = self.player.facing }
Game.stack:push(TextBox.new(Game, ". . .", function()
self.fishing = nil
-- FishingAnim (engine/overworld/player_animations.asm) holds
-- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through
-- PrintText and only clears it once the verdict box is done, so the rod
-- must NOT vanish with the dots box (#321).
if not enc then
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!")))
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"), function()
self.fishing = nil
end))
return
end
Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function()
self.fishing = nil
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true })
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
@@ -2528,22 +2591,27 @@ function OverworldState:engageTrainer(npc, onDone)
local BattleState = require("src.battle.BattleState")
Game.stack:push(TextBox.new(Game, battleText, function()
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
-- PrintEndBattleText (home/trainers.asm:341) is called from
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
-- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer
-- back, and before MoneyForWinningText -- not in the overworld after the
-- battle screen has torn down. Handing the line to the battle also
-- stops a post-battle evolution being sandwiched between two overworld
-- cuts (#282). Substituted here because BattleState:say takes finished
-- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself.
battle.endBattleText = wonText and TextBox.substitute(Game, wonText) or nil
battle.onFinish = function(result)
if result == "win" then
Game.save.defeatedTrainers[npc.id] = true
if header and header.event then
Game.save.flags[header.event] = true
end
-- checkVictoryRewards pushes the badge/prize box and starts the map's
-- onVictory script UNDER whatever runs next, so the player still sees
-- EndBattle (now inside the battle), then the reward, then AfterBattle
self:checkVictoryRewards(d.trainerClass, d.trainerParty)
local after = function()
self:afterBattle(result, battle)
if onDone then onDone() end
end
if wonText then
Game.stack:push(TextBox.new(Game, wonText, after))
else
after()
end
self:afterBattle(result, battle)
if onDone then onDone() end
else
self:afterBattle(result, battle)
if onDone then onDone() end
@@ -2556,7 +2624,7 @@ end
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
-- SetEvent / SetEventRange do after the leader victory.
-- `hide` is { { mapId, objName }, ... } HideObject on those toggles
-- `hide` is { { mapId, objName }, ... } -- HideObject on those toggles
-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1).
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
local victories = require("data.scripts.victories")
@@ -2924,14 +2992,25 @@ function OverworldState:onStepComplete()
self.warpEntryCell = nil
entry = nil
end
if self.justWarped then
self.justWarped = false
elseif entry then
-- The arrival disable is POSITIONAL: warpEntryCell above is the whole
-- test. justWarped only records that an arrival happened (it still
-- backs onWarpArrivalCell's bonk guard for issue #230), so consuming a
-- completed step with it swallowed the warp under the player's feet,
-- which is why a second ladder one cell from the first did nothing
-- (Seafoam B3F has warp tiles on (25,3) and (25,4)) -- issue #265.
-- pokered has no such counter: every completed step runs
-- CheckWarpsNoCollision (home/overworld.asm), and BIT_STANDING_ON_WARP,
-- the flag the bonk path needs, is only set by
-- CheckWarpsNoCollisionLoop itself or by IsPlayerStandingOnWarp from
-- MapEntryAfterBattle, never on a plain warp arrival -- which is
-- exactly what warpEntryCell reproduces.
self.justWarped = false
if entry then
-- still standing on the warp we arrived through; do not re-trigger it
else
-- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise
-- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP
-- is set (Seafoam B3F currents home/overworld.asm).
-- is set (Seafoam B3F currents -- home/overworld.asm).
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
if not w and (self:dirHeld() or self.forcedWarp) then
w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
@@ -3017,7 +3096,7 @@ function OverworldState:runSpinnerMoves(moves, i)
-- Scripted steps skip onStepComplete while they run; once the RLE
-- finishes, re-enter the normal landing pipeline so chained spinners,
-- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP)
-- see the tile we stopped on same as pokered after simulated joypad.
-- see the tile we stopped on -- same as pokered after simulated joypad.
self:onStepComplete()
return
end
@@ -3754,6 +3833,13 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
end
function OverworldState:drawWorld()
-- Dark-map BG shade shift, armed for the whole frame before anything draws.
-- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the
-- characters standing on it and any dialog over them darken together (#322);
-- Renderer:beginFrame cleared it, so a battle or a full-screen menu -- which
-- draws with no map beneath it -- stays lit exactly like
-- init_battle_variables.asm's `ld [wMapPalOffset], a` leaves the original.
PaletteFX.setShadeMap(self.dark and PaletteFX.DARK_BGP or nil)
-- advance the water/flower tile animation (runs under dialogs too).
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
-- does not speed or slow the cycle (issue #4).
@@ -3970,19 +4056,20 @@ function OverworldState:drawWorld()
end
end
-- Rock Tunnel darkness: a small window of light around the player
-- until FLASH is used (the original darkens the palette instead);
-- fills the whole world view, so surveying doesn't peek past it
-- Rock Tunnel darkness. The original never cuts a window of light around
-- the player: it shifts the BG palette for the WHOLE screen (wMapPalOffset
-- = 6 -> home/fade.asm LoadGBPal -> FadePal2 `dc 3,3,3,2`) and FLASH shifts
-- it back (#322). PaletteFX.DARK_BGP does that for every shade-remapped
-- mode, armed at the top of drawWorld. RED++ is the one mode with no
-- palette left to shift -- TileRenderer bakes true colour into the tileset
-- atlas and sgbWorldZones hands the blit an EMPTY zone list, so no shader
-- runs over the world at all -- so there the darkness is composited instead:
-- a flat veil over the whole world view (surveying still cannot peek past
-- it) at the 85/255 brightness FadePal2 leaves DMG white on.
local function fxDark()
if not self.dark then return end
local px = self.player.px - cam.x + 8
local py = self.player.py - cam.y + 8
local r = 28
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, vw, math.max(0, py - r))
love.graphics.rectangle("fill", 0, py + r, vw, vh - (py + r))
love.graphics.rectangle("fill", 0, py - r, math.max(0, px - r), r * 2)
love.graphics.rectangle("fill", px + r, py - r, vw - (px + r), r * 2)
if not self:darkNeedsOverlay() then return end
love.graphics.setColor(0, 0, 0, 1 - 85 / 255)
love.graphics.rectangle("fill", 0, 0, vw, vh)
love.graphics.setColor(1, 1, 1, 1)
end
@@ -4016,11 +4103,25 @@ function OverworldState:drawWorld()
end
if self.rodImg then
local p = self.player
local vec = DIRVEC[self.fishing.facing] or DIRVEC.down
local rx = p.px - cam.x + 4 + vec[1] * 12
local ry = p.py - cam.y + 4 + vec[2] * 12
local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down
if not self.rodQuads then
-- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile)
local iw, ih = self.rodImg:getDimensions()
self.rodQuads = {}
for i = 0, math.floor(ih / 8) - 1 do
self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih)
end
end
local quad = self.rodQuads[oam.tile]
-- the sprite's top-left is 4px above its cell (SpriteRenderer:draw)
local rx = p.px - cam.x + oam.dx
local ry = p.py - cam.y - 4 + oam.dy
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.rodImg, rx, ry)
if quad and oam.flip then
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
elseif quad then
love.graphics.draw(self.rodImg, quad, rx, ry)
end
end
end
end
@@ -4100,10 +4201,12 @@ function OverworldState:drawWorld()
if self.fishing then
at(fxRod, self.player.px + 8, self.player.py + 16)
end
-- Rock Tunnel darkness is a screen-space light window, not a ground
-- object: draw it flat over the finished scene like the tilt path.
-- It fills the view in world-pixel units, so it only needs the scale.
if self.dark then
-- Rock Tunnel darkness is a screen-space veil, not a ground object:
-- draw it flat over the finished scene like the tilt path. It fills
-- the view in world-pixel units, so it only needs the scale, and it is
-- only needed at all in the mode whose palette cannot carry the
-- darkening itself (see fxDark).
if self:darkNeedsOverlay() then
love.graphics.push()
love.graphics.scale(scale, scale)
fxDark()
@@ -4134,9 +4237,12 @@ function OverworldState:drawWorld()
-- the pipeline owns the whole frame; nothing else draws into the world
elseif not tilt then
-- === FLAT PATH: everything into the one world canvas, as before =====
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
-- OBP-baked sprites replay after the zone pass in OG RED mode, so their
-- grass feet-overdraw must replay over them too, colorized with the
-- current map's palette (see PaletteFX.markSpriteRedraw)
-- current map's palette (see PaletteFX.markSpriteRedraw). SGB no longer
-- takes that path -- its characters are colorized by the zone just like
-- the ground under them (#301) -- so there the first overdraw is already
-- the final one.
local grassColors = PaletteFX.usesSpriteObp()
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
for _, g in ipairs(self.ghosts) do
@@ -4257,10 +4363,10 @@ function OverworldState:drawWorld()
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
end
-- Rock Tunnel darkness is a screen-space light window, not a ground
-- object -- draw it flat into the upright canvas so it darkens the
-- final composited scene uniformly (the subtle tilt keeps the
-- projected player near the flat light centre).
-- Rock Tunnel darkness is a screen-space veil, not a ground object --
-- draw it flat into the upright canvas so it darkens the final
-- composited scene uniformly. It no-ops unless this mode needs the
-- composited fallback (see fxDark).
fxDark()
Game.renderer:endUprightPass()
+1 -1
View File
@@ -152,7 +152,7 @@ function Player:update()
self.stepFlip = not self.stepFlip
-- keep animClock's pose on this frame (issue #82): bike steps land
-- mid-cycle (animClock % 16 == 8), and walkPhase used to snap to
-- stand whenever moving cleared a stand flash every tile on the
-- stand whenever moving cleared -- a stand flash every tile on the
-- bike, and sometimes after dismount when the clock is desynced
self.stepLanded = true
return true