mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 16:21:30 +02:00
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:
+340
-51
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user