mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 03:35:56 +02:00
big bug squash (#261)
This commit is contained in:
+126
-20
@@ -183,7 +183,22 @@ end
|
||||
|
||||
-- the image a battler pic actually draws with this frame
|
||||
function BattleState:picImage(img)
|
||||
if self.grayPics then return grayImage(img) end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
-- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that
|
||||
-- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone
|
||||
-- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the
|
||||
-- WHOLE finished frame through the shade shader a second time (keyed on the
|
||||
-- red channel). A pic baked with the species' SGB color is then remapped
|
||||
-- again and loses its warm mid shades -- REDMON's reds 1.0/0.839 both land in
|
||||
-- the c0 bucket, collapsing CHARMANDER's body into the white paper and
|
||||
-- leaving only the outline. Emit the raw DMG-gray build instead (exactly
|
||||
-- what the SE_WAVY_SCREEN grayPics path already does) so the downstream remap
|
||||
-- recolors 255->c0/170->c1/85->c2/0->c3 and all four shades survive; cool
|
||||
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
|
||||
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
|
||||
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
||||
or PaletteFX.mode == "classic"
|
||||
if self.grayPics or mono then return grayImage(img) end
|
||||
return fadeImage(img, self:activeBgp())
|
||||
end
|
||||
|
||||
@@ -666,16 +681,53 @@ local function shownHP(b)
|
||||
return math.floor(shown)
|
||||
end
|
||||
|
||||
-- Parse a battle message into its rendered lines. The extractor marks
|
||||
-- \n = next line and \v = CONT (home/text.asm ContText: draw the blinking
|
||||
-- ▼, WaitForTextScrollButtonPress, then ScrollTextUpOneLine); the boosted /
|
||||
-- EXP.ALL exp lines end in the CONT code in the ROM (data/generated/text.lua
|
||||
-- _BoostedText/_WithExpAllText = "...\011"). Each entry is { codes, cont },
|
||||
-- cont true when the line was preceded by \v. Splitting before Font.encode
|
||||
-- keeps the control chars out of the glyph stream. The box then types into
|
||||
-- a rolling 2-line window (self.shown) that scrolls when a 3rd line arrives
|
||||
-- instead of drawing it off-screen at y=144 (#216).
|
||||
function BattleState:startMessage(item)
|
||||
self.current = item
|
||||
self.lines = {}
|
||||
self.total = 0
|
||||
for chunk in (item.text .. "\n"):gmatch("(.-)\n") do
|
||||
local text = item.text or ""
|
||||
local pos, cont = 1, false
|
||||
while true do
|
||||
local npos = text:find("[\n\v]", pos)
|
||||
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
|
||||
local codes = Font.encode(chunk)
|
||||
table.insert(self.lines, codes)
|
||||
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
|
||||
self.total = self.total + #codes
|
||||
if not npos then break end
|
||||
cont = text:sub(npos, npos) == "\v"
|
||||
pos = npos + 1
|
||||
end
|
||||
self.shown = {} -- up to two visible lines of revealed glyph codes
|
||||
self.lineIndex = 0
|
||||
-- self.charIndex counts glyphs typed across the WHOLE message (drivers read
|
||||
-- it against self.total); the current line's revealed count is #shown[last]
|
||||
self.charIndex = 0
|
||||
self.msgWaiting = nil
|
||||
self.scrollPx = nil
|
||||
self:beginMsgLine()
|
||||
end
|
||||
|
||||
-- Start typing the next line into the rolling window. When the box already
|
||||
-- shows two lines, drop the top one and set the pixel scroll-up
|
||||
-- (ScrollTextUpOneLine), mirroring TextBox:beginLine.
|
||||
function BattleState:beginMsgLine()
|
||||
self.lineIndex = self.lineIndex + 1
|
||||
local ln = self.lines[self.lineIndex]
|
||||
self.codes = ln and ln.codes or {}
|
||||
if #self.shown >= 2 then
|
||||
table.remove(self.shown, 1)
|
||||
self.scrollPx = 8
|
||||
end
|
||||
self.shown[#self.shown + 1] = {}
|
||||
end
|
||||
|
||||
function BattleState:updateQueue()
|
||||
@@ -823,8 +875,32 @@ function BattleState:updateQueue()
|
||||
end
|
||||
self:startMessage(item)
|
||||
end
|
||||
if self.charIndex < self.total then
|
||||
self.charIndex = math.min(self.total, self.charIndex + 2)
|
||||
local input = self.game.input
|
||||
-- a \v CONT wait holds the box until A/B, then scrolls the next line in
|
||||
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
|
||||
if self.msgWaiting then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.msgWaiting = nil
|
||||
self:beginMsgLine()
|
||||
end
|
||||
return true
|
||||
end
|
||||
local cur = self.shown[#self.shown]
|
||||
if #cur < #self.codes then
|
||||
-- battle typewriter cadence: two glyphs per fixed step (as before)
|
||||
for _ = 1, 2 do
|
||||
if #cur >= #self.codes then break end
|
||||
cur[#cur + 1] = self.codes[#cur + 1]
|
||||
self.charIndex = self.charIndex + 1
|
||||
end
|
||||
elseif self.lineIndex < #self.lines then
|
||||
-- current line finished, more lines remain: \v waits for A/B + ▼ before
|
||||
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
|
||||
if self.lines[self.lineIndex + 1].cont then
|
||||
self.msgWaiting = true
|
||||
else
|
||||
self:beginMsgLine()
|
||||
end
|
||||
else
|
||||
local item = self.current
|
||||
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
|
||||
@@ -841,7 +917,6 @@ function BattleState:updateQueue()
|
||||
end))
|
||||
return true
|
||||
end
|
||||
local input = self.game.input
|
||||
if not (item and item.choice)
|
||||
and (input:wasPressed("a") or input:wasPressed("b")) then
|
||||
self.current = nil
|
||||
@@ -2715,12 +2790,15 @@ function BattleState:enemyMonFainted()
|
||||
-- _WithExpAllText / _BoostedText / _ExpPointsText; the EXP.ALL
|
||||
-- pass beats the traded boost (wBoostExpByExpAll checks first),
|
||||
-- and _ExpPointsText prints wExpAmountGained -- the raw share,
|
||||
-- captured before the max-level cap (experience.asm:92-100)
|
||||
-- captured before the max-level cap (experience.asm:92-100).
|
||||
-- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011"
|
||||
-- in data/generated/text.lua): the box waits for A/B + ▼ then scrolls
|
||||
-- the amount line in, so it stays on-screen instead of at y=144 (#216).
|
||||
local tail = "%d EXP. Points!"
|
||||
if announce == "expAll" then
|
||||
tail = "with EXP.ALL,\n" .. tail
|
||||
tail = "with EXP.ALL,\v" .. tail
|
||||
elseif mon.traded then
|
||||
tail = "a boosted\n" .. tail
|
||||
tail = "a boosted\v" .. tail
|
||||
end
|
||||
self:sayNext(("%s gained\n" .. tail):format(name, gained))
|
||||
end
|
||||
@@ -2733,6 +2811,17 @@ function BattleState:enemyMonFainted()
|
||||
require("src.core.Sound").play(game.data, "Level_Up")
|
||||
return StatBox.new(game, mon)
|
||||
end)
|
||||
-- After PrintStatsBox, experience.asm reloads the active battler's
|
||||
-- wBattleMon and runs DrawHUDsAndHPBars, so its HP bar reflects the
|
||||
-- higher current HP. Experience.lua:84 already raised mon.hp by
|
||||
-- (newMaxHP - oldMaxHP); the party mon and the battler share one table
|
||||
-- (makeBattler), so mon.stats.hp (the bar's denominator) jumps to the
|
||||
-- new max instantly while the battler's shownHP numerator lags at the
|
||||
-- old current HP -- the bar SHRINKS (#224). Animate shownHP up to the
|
||||
-- new current HP (house convention: potions drain the bar too, see
|
||||
-- itemUsed) so the bar grows instead. Only the active player battler
|
||||
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
|
||||
if mon == self.player.mon then self:drainNext() end
|
||||
for _, moveId in ipairs(Experience.movesLearnedAt(
|
||||
self.data.pokemon[mon.species], lv)) do
|
||||
self:learnMove(mon, moveId)
|
||||
@@ -4114,7 +4203,12 @@ function BattleState:drawHUDs(slide)
|
||||
-- the HUD clears with the send-out text (ClearScreenArea,
|
||||
-- core.asm:1414-1417) and DrawEnemyHUDAndHPBar (1435) only redraws
|
||||
-- it after the grow-in + cry
|
||||
local barData = self:colorMode() and {} or self.data -- gray fill when zoned
|
||||
-- In colorized modes the zone pass (drawZonePass over BATTLE_ZONES pal 0/1)
|
||||
-- recolors the bar's DMG gray fill by region, so drawHPBar must skip its
|
||||
-- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill
|
||||
-- double-applies and the zone shade shader maps the whole bar to black (#229).
|
||||
local grayFill = self:colorMode()
|
||||
local barData = self.data
|
||||
local fx = self.fx
|
||||
local hudShake = (fx and fx.hudShakeX) or 0
|
||||
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
|
||||
@@ -4139,7 +4233,8 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
hudTile(0x73, 8, 16)
|
||||
drawHPBar(barData, 2, 2,
|
||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats })
|
||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
||||
nil, grayFill)
|
||||
hudTile(0x74, 8, 24)
|
||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||
hudTile(0x78, 80, 24)
|
||||
@@ -4187,7 +4282,7 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
drawHPBar(barData, 10, 9,
|
||||
{ hp = shownHP(self.player), stats = self.player.mon.stats },
|
||||
1) -- wHPBarType 1: the $6D cap
|
||||
1, grayFill) -- wHPBarType 1: the $6D cap
|
||||
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
|
||||
hudTile(0x73, 144, 80)
|
||||
hudTile(0x77, 144, 88)
|
||||
@@ -4200,16 +4295,27 @@ function BattleState:drawTextArea()
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.phase == "messages" and self.current then
|
||||
local shown = 0
|
||||
for li, codes in ipairs(self.lines) do
|
||||
-- battle text uses every other tile row (hlcoord *,14 / *,16)
|
||||
local y = 112 + (li - 1) * 16
|
||||
for i = 1, #codes do
|
||||
if shown >= self.charIndex then break end
|
||||
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
|
||||
shown = shown + 1
|
||||
-- rolling 2-line window: shown[1] at row y=112, shown[2] at y=128 (battle
|
||||
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
|
||||
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
|
||||
-- view instead of drawing off-screen at y=144 (#216).
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
local off = self.scrollPx or 0
|
||||
local ys = { 112, 128 }
|
||||
for li, line in ipairs(self.shown or {}) do
|
||||
local y = (ys[li] or 128) + off
|
||||
for i = 1, #line do
|
||||
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
|
||||
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
|
||||
end
|
||||
elseif self.phase == "menu" and self.demo then
|
||||
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
|
||||
-- standard menu, with the '▶' hand drawn by the scripted keystrokes
|
||||
|
||||
@@ -102,10 +102,41 @@ function Data:seedDefaults()
|
||||
-- after-battle rows so Blaine's SetEventRange deactivation and talk
|
||||
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
|
||||
self:seedCinnabarGymTrainerHeaders()
|
||||
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
|
||||
-- writes no header for him -- seed one so he engages on sight and has
|
||||
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
|
||||
self:seedFightingDojoKarateMaster()
|
||||
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
||||
require("src.world.SsAnneLayout").apply(self.maps)
|
||||
end
|
||||
|
||||
-- The Karate Master (FightingDojo.asm) is a text_asm object: his object has
|
||||
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
|
||||
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
|
||||
-- object index [1] so he behaves like the real leader:
|
||||
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
|
||||
-- spots the player in his DOWN line and he challenges on sight (#197),
|
||||
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
|
||||
-- * after = the "Stay and train at Karate with us!" re-talk line.
|
||||
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
|
||||
-- victories.lua (OPP_BLACKBELT#1) exactly like the gym leaders, and
|
||||
-- engageTrainer sets header.event *before* checkVictoryRewards runs -- if
|
||||
-- this header also set it, the reward's flag guard would early-return and
|
||||
-- swallow the prize dialogue. trainerDefeated tracks him via
|
||||
-- defeatedTrainers[npc.id] like the leaders.
|
||||
function Data:seedFightingDojoKarateMaster()
|
||||
local headers = self.trainer_headers
|
||||
if not headers then return end
|
||||
headers.FightingDojo = headers.FightingDojo or {}
|
||||
if headers.FightingDojo[1] then return end
|
||||
headers.FightingDojo[1] = {
|
||||
range = 4,
|
||||
battle = "_FightingDojoKarateMasterText",
|
||||
won = "_FightingDojoKarateMasterDefeatedText",
|
||||
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
|
||||
}
|
||||
end
|
||||
|
||||
function Data:seedCinnabarGymTrainerHeaders()
|
||||
local headers = self.trainer_headers
|
||||
if not headers or headers.CinnabarGym then return end
|
||||
|
||||
+21
-2
@@ -31,7 +31,12 @@ local function indexOf(list, value)
|
||||
end
|
||||
|
||||
local function levelForWire(v)
|
||||
return v == ANY and nil or v
|
||||
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
|
||||
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
|
||||
-- true branch is nil, so it falls through to `or v` and returned the
|
||||
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
|
||||
if v == ANY then return nil end
|
||||
return v
|
||||
end
|
||||
|
||||
local function forceLevelLabel(v)
|
||||
@@ -493,6 +498,14 @@ function LinkState:updateTrade(input)
|
||||
if t.stage == "done" then
|
||||
local sent = t.party[t.myPick]
|
||||
local received, evoTo = t:apply(self.game)
|
||||
-- Autosave the instant the swap commits into game.save.party, matching the
|
||||
-- Cable Club: pokered engine/link/cable_club.asm calls SaveSAVtoSRAM
|
||||
-- (engine/menus/save.asm) right after every trade so the trade is on the
|
||||
-- cartridge before the animation runs. Without this the received mon
|
||||
-- lives only in memory until a manual START-menu save, so a force-quit
|
||||
-- would lose it and a reset would clone the sent mon (#222). Guarded so
|
||||
-- headless LinkBattle-style fake games with no writeSave are unaffected.
|
||||
if self.game.writeSave then self.game:writeSave() end
|
||||
local name = received.nickname or self.game.data.pokemon[received.species].name
|
||||
Runtime.emit("link.ended", { reason = "done" })
|
||||
self.net:close()
|
||||
@@ -510,7 +523,13 @@ function LinkState:updateTrade(input)
|
||||
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
|
||||
function()
|
||||
if evoTo then
|
||||
require("src.pokemon.Evolution").evolve(game, received, evoTo)
|
||||
-- via="TRADE": a trade evolution cannot be B-cancelled
|
||||
-- (pokered LINK_STATE_TRADING skips the flash B-poll) (#213).
|
||||
-- Re-save once the evolution movie finishes so the evolved
|
||||
-- species (not the pre-evo landed by t:apply) is what persists,
|
||||
-- keeping disk in step with the autosave above (#222).
|
||||
require("src.pokemon.Evolution").evolve(game, received, evoTo,
|
||||
function() if game.writeSave then game:writeSave() end end, "TRADE")
|
||||
end
|
||||
end))
|
||||
end,
|
||||
|
||||
+30
-4
@@ -43,7 +43,12 @@ Protocol.plainCopy = plainCopy
|
||||
|
||||
-- serialize a mon instance for the wire (plain data only). ppUps rides
|
||||
-- along because the real cable transmitted it and its absence silently
|
||||
-- capped a PP-Upped move at base PP on the receiving side.
|
||||
-- capped a PP-Upped move at base PP on the receiving side. ot/otId ride
|
||||
-- along too: pokered's trade sends the whole party block including each
|
||||
-- mon's OT ID (party_struct MON_OTID) and the OT-names block (wPartyMonOT),
|
||||
-- and the receiver keeps them verbatim -- a differing OT/ID is what marks a
|
||||
-- mon as traded (boosted EXP, high-level disobedience). Omitting them made
|
||||
-- a received mon show the receiver as its OT (#215).
|
||||
function Protocol.packMon(mon)
|
||||
local moves = {}
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
@@ -59,6 +64,8 @@ function Protocol.packMon(mon)
|
||||
dvs = mon.dvs,
|
||||
statExp = mon.statExp,
|
||||
moves = moves,
|
||||
ot = mon.ot,
|
||||
otId = mon.otId,
|
||||
extra = plainCopy(mon.extra),
|
||||
}
|
||||
end
|
||||
@@ -71,6 +78,14 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local strict = opts and opts.strict
|
||||
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
|
||||
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
|
||||
-- the LinkState/Tournament side (see levelForWire) that must mean "no
|
||||
-- forced level" here. Coerce once so a non-numeric level string -- the ANY
|
||||
-- sentinel, an old peer, or a mod (#204) -- can never reach math.floor
|
||||
-- below: tonumber("ANY") == nil, i.e. keep the packed real level, while
|
||||
-- tonumber(50)/tonumber("50") both give 50.
|
||||
local forceLevel = opts and tonumber(opts.forceLevel) or nil
|
||||
local def = data.pokemon[packed.species]
|
||||
if not def then
|
||||
if strict then return nil, "unknown POKéMON" end
|
||||
@@ -81,8 +96,8 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
||||
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
||||
-- the identical forceLevel for a given match, so this stays symmetric.
|
||||
if opts and opts.forceLevel then
|
||||
level = math.max(2, math.min(100, math.floor(opts.forceLevel)))
|
||||
if forceLevel then
|
||||
level = math.max(2, math.min(100, math.floor(forceLevel)))
|
||||
end
|
||||
local dvs = {}
|
||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
||||
@@ -115,10 +130,19 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
-- current HP/status (a different level's numbers, possibly mid-fight)
|
||||
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
|
||||
-- same as a standardized tournament format would
|
||||
local forced = opts and opts.forceLevel
|
||||
local forced = forceLevel
|
||||
local hp = forced and stats.hp
|
||||
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
||||
local status = forced and nil or packed.status
|
||||
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
|
||||
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
|
||||
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
|
||||
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
|
||||
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
|
||||
-- becomes a no-op so the sender's identity survives save/reload (#215).
|
||||
local otId = packed.otId
|
||||
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
|
||||
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
||||
return {
|
||||
species = packed.species,
|
||||
level = level,
|
||||
@@ -129,6 +153,8 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
hp = hp,
|
||||
status = status,
|
||||
nickname = packed.nickname,
|
||||
ot = ot,
|
||||
otId = otId,
|
||||
moves = moves,
|
||||
-- a namespace whose mod this install lacks survives untouched, so the
|
||||
-- mon keeps it for the trip home
|
||||
|
||||
@@ -57,7 +57,12 @@ local function levelLabel(v)
|
||||
end
|
||||
|
||||
local function levelForWire(v)
|
||||
return v == ANY and nil or v
|
||||
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
|
||||
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
|
||||
-- true branch is nil, so it falls through to `or v` and returned the
|
||||
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
|
||||
if v == ANY then return nil end
|
||||
return v
|
||||
end
|
||||
|
||||
local function forceLevelLabel(v)
|
||||
|
||||
@@ -106,11 +106,55 @@ function Evolution.apply(game, mon, newSpecies, via)
|
||||
})
|
||||
end
|
||||
|
||||
-- After the "evolved into" text, Gen1 re-runs the level-up learn check on
|
||||
-- the EVOLVED species (engine/pokemon/evos_moves.asm EvolveMon calls the
|
||||
-- LearnMoveFromLevelUp predef, engine/pokemon/learn_move.asm) -- a mon
|
||||
-- evolving at exactly a learnset level gains that move (GYARADOS learns
|
||||
-- BITE at 20, so MAGIKARP->GYARADOS @20 learns BITE, @21 does not) (#12).
|
||||
-- Mirrors the rare-candy learn loop in src/ui/BagMenu.lua so a full move
|
||||
-- list opens the forget prompt. mon.species is already the new species
|
||||
-- (Evolution.apply ran before the congrats text). onDone runs once the
|
||||
-- learn list is exhausted, replacing the caller's direct onDone.
|
||||
function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
local Experience = require("src.battle.Experience")
|
||||
local def = game.data.pokemon[mon.species]
|
||||
-- movesLearnedAt uses entry.level == level (exact Gen1 rule); do NOT use
|
||||
-- Pokemon.movesAtLevel (<= level), which would over-grant older moves.
|
||||
local moves = Experience.movesLearnedAt(def, mon.level)
|
||||
local i = 0
|
||||
local function nextStep()
|
||||
i = i + 1
|
||||
local moveId = moves[i]
|
||||
if not moveId then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == moveId then return nextStep() end
|
||||
end
|
||||
local mdef = game.data.moves[moveId]
|
||||
if not mdef then return nextStep() end
|
||||
local name = mon.nickname or def.name
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s learned\n%s!"):format(name, mdef.name), nextStep))
|
||||
else
|
||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||
end
|
||||
end
|
||||
nextStep()
|
||||
end
|
||||
|
||||
-- Play the evolution movie (flashing forms), then apply + text.
|
||||
-- Headless (no real graphics) falls back to the plain text flow.
|
||||
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
if love.image and love.image.newImageData then
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
|
||||
-- forward `via` so EvolutionState can keep trade evolutions
|
||||
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
|
||||
return
|
||||
end
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
@@ -120,7 +164,9 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
if onDone then onDone() end
|
||||
-- re-run the evolved species' level-up learn check before onDone
|
||||
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
|
||||
Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
end))
|
||||
end
|
||||
|
||||
|
||||
+22
-10
@@ -77,7 +77,17 @@ end
|
||||
-- one-pixel sliver. The fill is tinted with the SGB bar palettes at
|
||||
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
|
||||
-- red).
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType)
|
||||
--
|
||||
-- grayFill (#229): when the caller will colorize this bar with an SGB
|
||||
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
|
||||
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
|
||||
-- the per-pixel tint -- the DMG hardware bar is ONE gray shade recolored by
|
||||
-- the region palette (engine/gfx/palettes.asm SetPal_Battle,
|
||||
-- data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel repaint.
|
||||
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
|
||||
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
|
||||
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
|
||||
local x, y = tx * 8, ty * 8
|
||||
HudTiles.tile(0x71, x, y)
|
||||
HudTiles.tile(0x62, x + 8, y)
|
||||
@@ -86,15 +96,17 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
|
||||
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
|
||||
end
|
||||
local tint
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
local colors = PaletteFX.pal(data, name)
|
||||
if colors then
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
||||
-- the palette color exactly (the black outline stays black)
|
||||
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||
math.min(1, c[3] / 170), 1 }
|
||||
if not grayFill then
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
local colors = PaletteFX.pal(data, name)
|
||||
if colors then
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
||||
-- the palette color exactly (the black outline stays black)
|
||||
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||
math.min(1, c[3] / 170), 1 }
|
||||
end
|
||||
end
|
||||
for i = 0, 5 do
|
||||
local seg = math.min(8, math.max(0, px - i * 8))
|
||||
|
||||
+44
-17
@@ -50,14 +50,23 @@ PaletteFX.GBC_OBJ = {
|
||||
}
|
||||
|
||||
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
|
||||
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
|
||||
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
|
||||
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
|
||||
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
|
||||
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
|
||||
-- the green-character look on a Game Boy Color.
|
||||
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the boot
|
||||
-- ROM gives Blue its OWN entry rather than a recolored Red: a light-blue/blue
|
||||
-- BACKGROUND and -- unlike Red -- a PINK object palette (OBP0). Values from
|
||||
-- Bulbapedia's "List of color palettes ... Generation I" GBC boot-ROM table
|
||||
-- (BG 0x63A5FF/0x0000FF, OBJ 0xFF8484/0x943A3A), confirmed against a Gambatte
|
||||
-- hardware capture. The earlier code mirrored GBC_BG channel-for-channel
|
||||
-- (0x8484FF/0x3A3A94) and reused Red's green sprites for both versions on the
|
||||
-- premise that Red and Blue "share the green-character look"; both premises
|
||||
-- are wrong -- Blue's background is a genuinely different blue and its
|
||||
-- characters are pink (#155). Lightest shade first, like GBC_BG.
|
||||
PaletteFX.GBC_BG_BLUE = {
|
||||
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
|
||||
{ 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 },
|
||||
}
|
||||
-- Blue's OBJ palette (OBP0) is the red/pink ramp -- the very same colors OG
|
||||
-- RED uses for its BACKGROUND (GBC_BG), just applied to objects instead.
|
||||
PaletteFX.GBC_OBJ_BLUE = {
|
||||
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
|
||||
}
|
||||
|
||||
-- The active game's OG boot-ROM background palette: blue for a Blue
|
||||
@@ -69,6 +78,16 @@ function PaletteFX.ogBg()
|
||||
return PaletteFX.GBC_BG
|
||||
end
|
||||
|
||||
-- The active game's OG boot-ROM object palette (OBP0): Blue's pink ramp for a
|
||||
-- Blue playthrough, Red's green otherwise. Returns the colors AND a
|
||||
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
|
||||
-- its baked-image cache by (image path, group): a shared group would collide a
|
||||
-- Red bake with a Blue one and one version would show the other's colors.
|
||||
function PaletteFX.ogObj()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
|
||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||
end
|
||||
|
||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||
|
||||
function PaletteFX.shader()
|
||||
@@ -183,17 +202,25 @@ function PaletteFX.usesGbcPack(mode)
|
||||
end
|
||||
|
||||
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
|
||||
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
|
||||
-- NPCs stay green over the red background, exactly like Pokemon Red on a
|
||||
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
|
||||
-- palette of its own, so the characters tint with the whole-map region
|
||||
-- palette along with the terrain (the Super Game Boy never colored Pokemon
|
||||
-- Red's sprites separately -- baking a per-sprite palette there was the
|
||||
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through
|
||||
-- the usesGbcPack() path in SpriteRenderer instead.
|
||||
-- (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
|
||||
-- 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.
|
||||
function PaletteFX.usesSpriteObp(mode)
|
||||
return (mode or PaletteFX.mode) == "ogred"
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "ogred" or mode == "gbc"
|
||||
end
|
||||
|
||||
-- ------- post-zone sprite redraw (GBC mode)
|
||||
|
||||
+28
-4
@@ -46,18 +46,30 @@ Renderer.UPRIGHT_MARGIN = 160
|
||||
-- non-square "pixels", and movement judder (issue #87). Always derive the
|
||||
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
|
||||
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
||||
--
|
||||
-- `dpi` here must be the factor LOVE actually applies to every draw call
|
||||
-- (getDPIScale), NOT the drawable/unit size ratio pw/ww. On a normal device
|
||||
-- those are equal (getPixelDimensions == getDimensions * getDPIScale), but on
|
||||
-- the AYN Thor dual-screen surface in forced landscape they diverge: LOVE
|
||||
-- reports pw/ww ≈ 1 while its real transform is 1.5, so scaling by pw/ww lands
|
||||
-- each GB pixel on Sp*(getDPIScale/(pw/ww)) physical pixels -- a fractional,
|
||||
-- stretched/non-square count (issue #208). Prefer getDPIScale so draws land
|
||||
-- on whole physical pixels through LOVE's actual transform; fall back to pw/ww
|
||||
-- (then 1) only when getDPIScale is unavailable. Since getDPIScale == pw/ww
|
||||
-- on every normal device, #87's behavior is byte-identical there.
|
||||
local function displayMetrics()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local pw, ph = ww, wh
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
local dpi = 1
|
||||
if ww > 0 and pw > 0 then
|
||||
dpi = pw / ww
|
||||
elseif love.graphics.getDPIScale then
|
||||
local dpi
|
||||
if love.graphics.getDPIScale then
|
||||
dpi = love.graphics.getDPIScale()
|
||||
end
|
||||
if (not dpi or dpi < 1e-6) and ww > 0 and pw > 0 then
|
||||
dpi = pw / ww
|
||||
end
|
||||
if not dpi or dpi < 1e-6 then dpi = 1 end
|
||||
return ww, wh, pw, ph, dpi
|
||||
end
|
||||
@@ -99,6 +111,18 @@ function Renderer:fitScale()
|
||||
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
||||
end
|
||||
|
||||
-- The LOVE-unit draw scale endFrame uses for the UI blit: the integer
|
||||
-- framebuffer scale (fitScale) divided by the live coordinate->pixel factor,
|
||||
-- so a GB pixel lands on fitScale() whole PHYSICAL pixels once LOVE applies
|
||||
-- its own transform (fitScale() == drawScale() * dpi). Exposed so #208's
|
||||
-- regression can assert GB pixels stay square on divergent-DPI surfaces
|
||||
-- without reaching into endFrame's locals; endFrame recomputes the same value
|
||||
-- inline (`S = Sp / dpi`).
|
||||
function Renderer:drawScale()
|
||||
local _, _, _, _, dpi = displayMetrics()
|
||||
return self:fitScale() / dpi
|
||||
end
|
||||
|
||||
-- world-pass canvas size in world pixels: enough to fill the window at s'.
|
||||
-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so
|
||||
-- the projected ground plane still covers the whole window with no
|
||||
|
||||
@@ -105,7 +105,10 @@ function SpriteRenderer:resolveImage()
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then return getObpImage(self.def.image, colors, group) end
|
||||
elseif PaletteFX.usesSpriteObp() then
|
||||
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
||||
-- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObj
|
||||
-- returns colors + a version-distinct cache group so the two never
|
||||
-- collide in obpCache) -- see issue #155
|
||||
return getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
end
|
||||
return self.image
|
||||
end
|
||||
@@ -141,11 +144,13 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
image = getObpImage(self.def.image, colors, group)
|
||||
end
|
||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
|
||||
-- object palette. The red BG zone shader still runs over the world
|
||||
-- canvas, so the baked sprite is queued for a post-zone redraw
|
||||
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
|
||||
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
||||
-- OG RED (GBC boot-ROM look): every OBJ wears the one global object
|
||||
-- palette -- green over Red's red background, pink over Blue's blue
|
||||
-- background (PaletteFX.ogObj, #155). The BG zone shader still runs over
|
||||
-- the world canvas, so the baked sprite is queued for a post-zone redraw
|
||||
-- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on
|
||||
-- top.
|
||||
image = getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
redraw = true
|
||||
end
|
||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||
|
||||
+17
-7
@@ -214,11 +214,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
list:close()
|
||||
consume(game, id)
|
||||
require("src.core.Sound").play(game.data, "Teleport_Exit1")
|
||||
ow.player.surfing = false
|
||||
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
|
||||
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
-- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE
|
||||
-- the last Pokémon Center town door like Fly (#196), via the shared
|
||||
-- departure helper -- the same path Dig/Teleport take from the party menu
|
||||
ow:beginTeleportOut()
|
||||
else
|
||||
showMessages(game, { "OAK: " .. game.save.player.name
|
||||
.. "!\nThis isn't the\ntime to use that!" })
|
||||
@@ -303,7 +302,8 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
||||
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
|
||||
require("src.ui.Screens").push(game, "PartyMenu", {
|
||||
local def = game.data.items[id]
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
onSwitch = function(mon)
|
||||
if not wantsMove then
|
||||
@@ -326,7 +326,17 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
})
|
||||
}
|
||||
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
|
||||
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
|
||||
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
|
||||
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
|
||||
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
|
||||
-- strictly on def.machine. #210
|
||||
if def and def.machine then
|
||||
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
|
||||
end
|
||||
require("src.ui.Screens").push(game, "PartyMenu", opts)
|
||||
end
|
||||
|
||||
local function useItem(game, battle, id, list)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||
-- flashes back and forth with the evolved form, speeding up, then the
|
||||
-- new form appears with its cry and the congratulations text.
|
||||
-- B during the flash cancels ("Huh? ... stopped evolving!"? -- Gen 1
|
||||
-- has no cancel; the flash always completes).
|
||||
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) polls hJoyHeld during
|
||||
-- the flash: holding B aborts the evolution -- the mon keeps its species
|
||||
-- and _StoppedEvolvingText ("Huh? MON stopped evolving!") prints. The
|
||||
-- lone exception is trade evolutions (wLinkState == LINK_STATE_TRADING),
|
||||
-- which skip that poll and cannot be cancelled (#213).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
@@ -14,7 +17,10 @@ EvolutionState.isOpaque = true
|
||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||
function EvolutionState:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local species = self.done and self.newSpecies or self.mon.species
|
||||
-- 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
|
||||
or self.mon.species
|
||||
local c = P.monPal(game.data, species)
|
||||
if c then return { P.whole(c) } end
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
@@ -29,17 +35,22 @@ local function frontSprite(game, species)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
function EvolutionState.new(game, mon, newSpecies, onDone)
|
||||
function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
||||
local self = setmetatable({}, EvolutionState)
|
||||
self.game = game
|
||||
self.mon = mon
|
||||
self.newSpecies = newSpecies
|
||||
self.onDone = onDone
|
||||
self.via = via
|
||||
-- evos_moves.asm: only trade evolutions (LINK_STATE_TRADING) skip the
|
||||
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
|
||||
self.cancelable = (via ~= "TRADE")
|
||||
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
self.oldSprite = frontSprite(game, mon.species)
|
||||
self.newSprite = frontSprite(game, newSpecies)
|
||||
self.t = 0
|
||||
self.done = false
|
||||
self.canceled = false
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
return self
|
||||
end
|
||||
@@ -47,11 +58,28 @@ end
|
||||
function EvolutionState:update(dt)
|
||||
self.t = self.t + 1
|
||||
if self.done then return end
|
||||
local game = self.game
|
||||
-- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for
|
||||
-- a cancelable evolution, aborts when B is held -- the mon keeps its
|
||||
-- species (Evolution.apply never runs) and _StoppedEvolvingText prints.
|
||||
if self.cancelable and game.input:isDown("b") then
|
||||
self.done = true
|
||||
self.canceled = true
|
||||
local TextBox = require("src.render.TextBox")
|
||||
-- mirrors data/generated/text.lua _StoppedEvolvingText
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Huh? %s\nstopped evolving!"):format(self.oldName),
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
if self.onDone then self.onDone() end
|
||||
end))
|
||||
return
|
||||
end
|
||||
if self.t >= FLASH_FRAMES then
|
||||
self.done = true
|
||||
local game = self.game
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
Evolution.apply(game, self.mon, self.newSpecies)
|
||||
Evolution.apply(game, self.mon, self.newSpecies, self.via)
|
||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local newName = game.data.pokemon[self.newSpecies].name
|
||||
@@ -61,7 +89,12 @@ function EvolutionState:update(dt)
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
if self.onDone then self.onDone() end
|
||||
-- Gen1 re-runs the level-up learn check on the evolved species
|
||||
-- after the "evolved into" text (evos_moves.asm EvolveMon ->
|
||||
-- learn_move.asm LearnMoveFromLevelUp, #12). Pop the evo screen
|
||||
-- first so the "learned MOVE!" text / forget prompt push onto the
|
||||
-- overworld / battle-return, not this state.
|
||||
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
||||
end))
|
||||
end
|
||||
end
|
||||
@@ -73,7 +106,8 @@ function EvolutionState:draw()
|
||||
-- accelerating flash between the two forms
|
||||
local sprite
|
||||
if self.done then
|
||||
sprite = self.newSprite
|
||||
-- a cancelled evolution settles back on the original form
|
||||
sprite = self.canceled and self.oldSprite or self.newSprite
|
||||
else
|
||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
||||
local showNew = math.floor(self.t / period) % 2 == 1
|
||||
|
||||
+6
-2
@@ -11,9 +11,13 @@ function FlyMenu.new(game)
|
||||
local visited = game.save.visited or {}
|
||||
local seen = {}
|
||||
for _, mapId in ipairs(game.data.field.flyOrder or {}) do
|
||||
-- towns only (dungeon escape spots share the table), each listed once
|
||||
-- towns only (dungeon escape spots share the table), each listed once.
|
||||
-- Indigo Plateau (tileset PLATEAU) is a valid Fly destination too, so allow
|
||||
-- it past the OVERWORLD-only isOutdoor gate while the CAVERN/FACILITY escape
|
||||
-- spots stay excluded (LoadTownMap_Fly cycles it like any town, #203).
|
||||
local def = game.data.maps[mapId]
|
||||
if visited[mapId] and def and Map.isOutdoor(def) and not seen[mapId] then
|
||||
if visited[mapId] and def and not seen[mapId]
|
||||
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
|
||||
seen[mapId] = true
|
||||
table.insert(items, {
|
||||
value = mapId,
|
||||
|
||||
+96
-23
@@ -132,6 +132,10 @@ function PartyMenu.new(game, opts)
|
||||
self.onSwitch = opts.onSwitch
|
||||
self.onCancel = opts.onCancel
|
||||
self.pickOnly = opts.pickOnly
|
||||
-- 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
|
||||
self.tmhm = opts.tmhm
|
||||
self.forceSwitch = opts.forceSwitch
|
||||
self.battle = opts.battle
|
||||
self.party = opts.party -- link battles pass their clamped copies
|
||||
@@ -178,8 +182,15 @@ function PartyMenu:update(dt)
|
||||
elseif action == "switch" then
|
||||
self.swapFrom = self.index
|
||||
elseif action == "fly" then
|
||||
-- FLY opens the TOWN MAP with a cursor over the visited fly towns,
|
||||
-- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly).
|
||||
-- flyTo (OverworldController) validates the fly-warp + runs the
|
||||
-- departure/warp, so we just hand it the chosen mapId (#195).
|
||||
local ow = self.game.overworld
|
||||
self.game.stack:pop() -- close the party menu
|
||||
Screens.push(self.game, "FlyMenu")
|
||||
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
|
||||
if ow then ow:flyTo(mapId) end
|
||||
end })
|
||||
return
|
||||
elseif action == "flash" then -- FLASH lights dark tunnels
|
||||
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
|
||||
@@ -304,21 +315,15 @@ function PartyMenu:update(dt)
|
||||
-- 1/5 of the user's max HP to a chosen teammate
|
||||
self.softboiledFrom = self.index
|
||||
elseif action == "escape" then
|
||||
-- DIG / TELEPORT both warp to the last Pokémon Center town
|
||||
-- (wLastBlackoutMap, special_warps.asm escape warp); .dig/.teleport
|
||||
-- end with GBPalWhiteOutWithDelay3 + jp .goBackToMap
|
||||
-- DIG / TELEPORT warp to the last Pokémon Center TOWN (wLastBlackoutMap,
|
||||
-- special_warps.asm escape warp). pokered's .dig/.teleport spin the
|
||||
-- player up (LeaveMapAnim), white/fade out, then land it; this port
|
||||
-- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut
|
||||
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
|
||||
-- the exact departure; the fade + warp fire when the spin ends.
|
||||
local ow = self.game.overworld
|
||||
local heal = self.game.save.lastHeal
|
||||
local Transition = require("src.render.Transition")
|
||||
self.game.stack:pop()
|
||||
if ow and heal then
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
||||
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
|
||||
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
|
||||
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
end))
|
||||
end
|
||||
if ow then ow:beginTeleportOut() end
|
||||
return
|
||||
end
|
||||
self.submenu = nil
|
||||
@@ -436,6 +441,30 @@ function PartyMenu:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- The bottom-of-screen context message for the current menu state
|
||||
-- (pokered engine/menus/party_menu.asm PartyMenuMessage / RedrawPartyMenu_):
|
||||
-- the party menu always prints a message in the bottom text box. With the
|
||||
-- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?")
|
||||
-- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap /
|
||||
-- item / TM-HM ids print their own strings, which draw() handles inline.
|
||||
-- Pure (no side effects) so drivers can assert it. #147
|
||||
function PartyMenu:bottomMessage()
|
||||
if self.swapFrom then
|
||||
return "Move to where?"
|
||||
elseif self.softboiledFrom or self.pickOnly then
|
||||
return "Use on which one?"
|
||||
elseif self.tmhm then
|
||||
return self.game.data.text._PartyMenuUseTMText
|
||||
or "Use TM on which\nPOKéMON?"
|
||||
elseif self.battle then
|
||||
return self.game.data.text._PartyMenuBattleText
|
||||
or "Bring out which\nPOKéMON?"
|
||||
else
|
||||
return self.game.data.text._PartyMenuNormalText
|
||||
or "Choose a POKéMON."
|
||||
end
|
||||
end
|
||||
|
||||
function PartyMenu:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -462,16 +491,34 @@ function PartyMenu:draw()
|
||||
-- PrintLevel overwrites the <LV> tile with the third digit
|
||||
Font.draw(tostring(mon.level), 104, y)
|
||||
end
|
||||
if mon.hp <= 0 then
|
||||
Font.draw("FNT", 136, y)
|
||||
elseif mon.status then
|
||||
Font.draw(mon.status, 136, y)
|
||||
if self.tmhm then
|
||||
-- TM/HM teaching menu (engine/menus/party_menu.asm PrintPartyMenu):
|
||||
-- the second row shows the inline "ABLE" / "NOT ABLE" learnability
|
||||
-- strings in place of the HP bar and status, decided by CanLearnTM.
|
||||
-- The learnset scan mirrors ItemEffects.use so the display can never
|
||||
-- disagree with the actual teach. #210
|
||||
local can = false
|
||||
for _, m in ipairs(def.tmhm or {}) do
|
||||
if m == self.tmhm.move then can = true break end
|
||||
end
|
||||
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
|
||||
if can then
|
||||
Font.draw("ABLE", 120, y + 8)
|
||||
else
|
||||
Font.draw("NOT ABLE", 88, y + 8)
|
||||
end
|
||||
else
|
||||
if mon.hp <= 0 then
|
||||
Font.draw("FNT", 136, y)
|
||||
elseif mon.status then
|
||||
Font.draw(mon.status, 136, y)
|
||||
end
|
||||
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
|
||||
end
|
||||
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
|
||||
if i == self.index then
|
||||
Font.drawCode(Theme.cursor, 0, y)
|
||||
end
|
||||
@@ -483,8 +530,34 @@ function PartyMenu:draw()
|
||||
Font.draw("Move to where?", 8, 136)
|
||||
elseif self.softboiledFrom then
|
||||
Font.draw("Use on which one?", 8, 136)
|
||||
elseif self.tmhm then
|
||||
-- "Use TM on which\nPOKeMON?" in the standard bottom text box
|
||||
-- (party_menu.asm keeps the message box for the TM/HM menu); box + line
|
||||
-- geometry match TextBox's default (rows 12-17, text on rows 14/16). #210
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local prompt = self.game.data.text._PartyMenuUseTMText
|
||||
or "Use TM on which\nPOKéMON?"
|
||||
local ly = 112
|
||||
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, ly)
|
||||
ly = ly + 16
|
||||
end
|
||||
elseif self.pickOnly then
|
||||
Font.draw("Use on which one?", 8, 136)
|
||||
else
|
||||
-- default field party menu (StartMenu) and the battle voluntary-switch
|
||||
-- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText
|
||||
-- in the standard bottom text box (party_menu.asm PartyMenuMessage), not
|
||||
-- plain bottom-row text. Box + line geometry match the #210 TM/HM case and
|
||||
-- TextBox's default (rows 12-17, text on rows 14/16). #147
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local ly = 112
|
||||
for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, ly)
|
||||
ly = ly + 16
|
||||
end
|
||||
end
|
||||
if self.submenu then
|
||||
local n = #self.subItems
|
||||
|
||||
+5
-2
@@ -106,8 +106,11 @@ local function sell(game)
|
||||
onChoose = function(item)
|
||||
local def = game.data.items[item.value]
|
||||
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
|
||||
-- IsItemHM); zero-price items like ETHER sell for ¥0
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
-- IsItemHM); zero-price items like ETHER sell for ¥0. An unknown id
|
||||
-- (nil def) has no price, so treat it as unsellable too rather than
|
||||
-- indexing nil below -- guards saves that already picked up a bogus
|
||||
-- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11).
|
||||
if not def or def.keyItem or item.value:find("^HM_") then
|
||||
list.footer = txt(game, "_PokemartUnsellableItemText",
|
||||
"I can't put a\nprice on that.")
|
||||
return
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
-- A on page 2) closes.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
-- status_screen.asm PrintMonType prints the type's DISPLAY name from the
|
||||
-- TypeNames table, not the constant: species types are stored as pokered
|
||||
-- constants (RomExtractor:typesById) and PSYCHIC's is "PSYCHIC_TYPE" (so it
|
||||
-- won't collide with the PSYCHIC move), which would overflow the TYPE field.
|
||||
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
|
||||
-- battle move-type box already do (#214).
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local SummaryMenu = {}
|
||||
SummaryMenu.__index = SummaryMenu
|
||||
@@ -99,10 +106,10 @@ function SummaryMenu:draw()
|
||||
-- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10)
|
||||
drawLineBox(19, 9, 8, 6)
|
||||
Font.draw("TYPE1/", 80, 72)
|
||||
Font.draw(def.types[1] or "", 88, 80)
|
||||
Font.draw(def.types[1] and TypeChart.displayName(def.types[1]) or "", 88, 80)
|
||||
if def.types[2] then
|
||||
Font.draw("TYPE2/", 80, 88)
|
||||
Font.draw(def.types[2], 88, 96)
|
||||
Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
|
||||
end
|
||||
Font.draw("IDNo/", 80, 104)
|
||||
-- the trainer ID is rolled at new game (SaveData.newGame) and
|
||||
|
||||
+101
-10
@@ -7,6 +7,11 @@
|
||||
-- the selected name in a banner up top, and the player's current
|
||||
-- location blinking. List mode (townMap data missing): up/down through
|
||||
-- an ordered list of fly towns instead. B closes.
|
||||
--
|
||||
-- Fly mode (opts.fly + opts.onFly, LoadTownMap_Fly): the same Kanto map,
|
||||
-- but the cursor cycles ONLY the visited fly destinations (Up/Down, in fly
|
||||
-- order), the banner reads "To <NAME>", and A calls onFly(mapId) to depart.
|
||||
-- This is what the party-menu FLY field move opens (#195).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Sound = require("src.core.Sound")
|
||||
@@ -80,7 +85,10 @@ local function buildLocations(game)
|
||||
local seen = {}
|
||||
for _, mapId in ipairs(field.flyOrder or {}) do
|
||||
local def = game.data.maps and game.data.maps[mapId]
|
||||
if not seen[mapId] and def and Map.isOutdoor(def) then
|
||||
-- accept the PLATEAU tileset too so Indigo Plateau shows on the
|
||||
-- stale-asset list fallback, matching the fly-list filter (#203)
|
||||
if not seen[mapId] and def
|
||||
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
|
||||
seen[mapId] = true
|
||||
local loc = { name = mapId:gsub("_", " ") }
|
||||
table.insert(locs, loc)
|
||||
@@ -119,6 +127,42 @@ local function markerXY(loc)
|
||||
return loc.x * 8 + 16, loc.y * 8 + 8
|
||||
end
|
||||
|
||||
-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly
|
||||
-- (engine/menus/town_map.asm prints the destination as "To <NAME>")
|
||||
function TownMap:bannerText(loc)
|
||||
return (self.fly and "To " or "") .. loc.name
|
||||
end
|
||||
|
||||
-- Fly mode selection set (engine/menus/town_map.asm LoadTownMap_Fly): the
|
||||
-- cursor cycles ONLY the visited fly destinations, in fly order, each landing
|
||||
-- on its town square. Built from field.flyOrder filtered to visited outdoor
|
||||
-- towns that have a fly-warp spot, deduped, reusing the grid loc so the cursor
|
||||
-- lands on the town and its name shows in the banner.
|
||||
local function buildFlyList(game, byMap)
|
||||
local field = game.data.field or {}
|
||||
local visited = game.save.visited or {}
|
||||
local flyWarps = field.flyWarps or {}
|
||||
local Map = require("src.world.Map")
|
||||
local flyLocs, flyMapIds, seen = {}, {}, {}
|
||||
for _, mapId in ipairs(field.flyOrder or {}) do
|
||||
local def = game.data.maps and game.data.maps[mapId]
|
||||
-- INDIGO_PLATEAU is a normal Fly spot (engine/menus/town_map.asm
|
||||
-- LoadTownMap_Fly cycles it like any town), but its map uses tileset
|
||||
-- "PLATEAU" not OVERWORLD, so Map.isOutdoor() alone dropped it from the
|
||||
-- cursor even though it is visited and has a fly warp. Allow PLATEAU here
|
||||
-- while the CAVERN/FACILITY dungeon escape spots that share flyOrder still
|
||||
-- fail the gate and stay out (#203).
|
||||
if not seen[mapId] and visited[mapId] and flyWarps[mapId]
|
||||
and def and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
|
||||
seen[mapId] = true
|
||||
local loc = byMap[mapId] or { name = mapId:gsub("_", " ") }
|
||||
table.insert(flyLocs, loc)
|
||||
flyMapIds[#flyLocs] = mapId
|
||||
end
|
||||
end
|
||||
return flyLocs, flyMapIds
|
||||
end
|
||||
|
||||
-- opts.nestSpecies: the Pokédex AREA screen (LoadTownMap_Nest) --
|
||||
-- blink a nest icon on every map whose wild slots hold the species
|
||||
function TownMap.new(game, opts)
|
||||
@@ -152,6 +196,26 @@ function TownMap.new(game, opts)
|
||||
or "assets/generated/townmap/nest.png")
|
||||
self.nestIcon = ok and img or nil
|
||||
end
|
||||
if opts.fly then
|
||||
-- FLY picker (LoadTownMap_Fly): restrict the selectable set to the
|
||||
-- visited fly towns so Up/Down cycle only those and A knows the mapId.
|
||||
local flyLocs, flyMapIds = buildFlyList(game, self.byMap)
|
||||
if #flyLocs > 0 then
|
||||
self.fly = true
|
||||
self.onFly = opts.onFly
|
||||
self.locs = flyLocs
|
||||
self.flyMapIds = flyMapIds
|
||||
-- grid rendering needs coords on every entry; without them fall back to
|
||||
-- the name list so the fly screen still works on stale asset builds
|
||||
if self.mode == "grid" then
|
||||
for _, loc in ipairs(flyLocs) do
|
||||
if not (loc.x and loc.y) then self.mode = "list" break end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- with nothing visited yet there is nowhere to fly: leave self.fly unset
|
||||
-- so the screen degrades to a plain viewer (B closes)
|
||||
end
|
||||
-- the player's current location (guard: overworld may not be running)
|
||||
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
|
||||
self.playerLoc = mapId and self.byMap[mapId] or nil
|
||||
@@ -199,7 +263,19 @@ function TownMap:update(dt)
|
||||
self.game.stack:pop()
|
||||
return
|
||||
end
|
||||
if self.nestSpecies then
|
||||
if self.fly then
|
||||
-- LoadTownMap_Fly: Up/Down cycle the visited destinations, A flies there,
|
||||
-- B cancels (handled above). moveList walks self.locs, now the fly list.
|
||||
if input:wasPressed("a") then
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
local mapId = self.flyMapIds[self.sel]
|
||||
self.game.stack:pop()
|
||||
if mapId and self.onFly then self.onFly(mapId) end
|
||||
return
|
||||
elseif input:wasPressed("up") then self:moveList(-1)
|
||||
elseif input:wasPressed("down") then self:moveList(1)
|
||||
end
|
||||
elseif self.nestSpecies then
|
||||
if input:wasPressed("a") then
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
@@ -260,18 +336,28 @@ function TownMap:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
-- the player's current location blinks (slow phase)
|
||||
-- the player's current location blinks (slow phase). Paint it with a
|
||||
-- palette-safe DARK shade (red 0), not red: this screen composites through
|
||||
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
|
||||
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
|
||||
-- {165,214,255}, the exact light-blue used for the water and the town-square
|
||||
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
|
||||
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
local x, y = markerXY(self.playerLoc)
|
||||
love.graphics.setColor(0.75, 0.1, 0.1, 1)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
-- blinking cursor on the selected location
|
||||
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
|
||||
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
|
||||
-- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm
|
||||
-- draws the box cursor CENTERED on the selected location). Drawing it at
|
||||
-- the cell top-left put the square in the frame's top-left quadrant (#152).
|
||||
if selected and self.blink % 16 < 10 then
|
||||
local x, y = markerXY(selected)
|
||||
if self.bg.cursor then
|
||||
love.graphics.draw(self.bg.cursor, x, y)
|
||||
love.graphics.draw(self.bg.cursor, x - 4, y - 4)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7)
|
||||
@@ -281,7 +367,7 @@ function TownMap:draw()
|
||||
-- the name strip on row 0 (DisplayTownMap: ClearScreenArea + name)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if selected then Font.draw(selected.name, 8, 0) end
|
||||
if selected then Font.draw(self:bannerText(selected), 8, 0) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
@@ -294,7 +380,9 @@ function TownMap:draw()
|
||||
drawSquare(loc)
|
||||
end
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
love.graphics.setColor(0.75, 0.1, 0.1, 1)
|
||||
-- palette-safe dark, same red-channel shade-remap reason as the primary
|
||||
-- grid path above (#152); stale-asset builds hit this fallback square
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
|
||||
self.playerLoc.y * 8 + 2, 4, 4)
|
||||
end
|
||||
@@ -317,7 +405,10 @@ function TownMap:draw()
|
||||
end
|
||||
Font.draw(loc.name, 24, y)
|
||||
if loc == self.playerLoc and self.blink < 20 then
|
||||
-- blinking marker on the player's current town
|
||||
-- blinking marker on the player's current town; force the palette-safe
|
||||
-- dark shade explicitly so the red-channel shade-remap keeps it
|
||||
-- visible regardless of Font.draw's leftover color (#152)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 24 + #loc.name * 8 + 6, y + 2, 4, 4)
|
||||
end
|
||||
end
|
||||
@@ -327,7 +418,7 @@ function TownMap:draw()
|
||||
-- name banner across the top
|
||||
Font.drawBox(0, 0, 20, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if selected then Font.draw(selected.name, 8, 8) end
|
||||
if selected then Font.draw(self:bannerText(selected), 8, 8) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -204,6 +204,16 @@ function Map:isWalkableCell(cx, cy)
|
||||
end
|
||||
|
||||
function Map:isGrassCell(cx, cy)
|
||||
-- Off-map cells never count as tall grass (issue #217). cellTile
|
||||
-- border-extends out-of-bounds coordinates with the map's borderBlock,
|
||||
-- and some border blocks (e.g. ROUTE_1's block 11) have the grass tile
|
||||
-- ($52 = 82) in their bottom row -- filler scenery, never standable
|
||||
-- grass. During a map-connection seam step crossConnection parks the
|
||||
-- player one cell before the entry point (cellY = -1 crossing Viridian
|
||||
-- City -> Route 1), so without this guard the feet-overdraw painted an
|
||||
-- animated grass tuft over the player's head for the whole step. pokered
|
||||
-- only ever reads $52 from loaded map tiles, not the border filler.
|
||||
if not self:inBounds(cx, cy) then return false end
|
||||
local grass = self.tileset.grassTile
|
||||
return grass ~= nil and self:cellTile(cx, cy) == grass
|
||||
end
|
||||
|
||||
@@ -222,17 +222,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.map.renderer:rebuild()
|
||||
self.cutBlocks[mapId] = nil
|
||||
end
|
||||
-- Silph Co card key doors: the .blk layouts ship with the doorways
|
||||
-- open; each floor's map script stamps the closed door block on load
|
||||
-- until its unlock event is set (scripts/SilphCo2F.asm
|
||||
-- SilphCo2FGateCallbackScript et al., closed blocks $54/$5f/$20)
|
||||
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
|
||||
-- layouts ship with the doorways open; each floor's map script stamps
|
||||
-- the closed door block on load until its unlock event is set
|
||||
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
|
||||
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
|
||||
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
|
||||
-- the lift doorway). A door opens on its single `event`, or on `events`
|
||||
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
|
||||
-- needs both guard trainers beaten -- CheckBothEventsSet).
|
||||
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
|
||||
"closedDoors")
|
||||
local floorDoors = closedDoors and closedDoors[mapId]
|
||||
if floorDoors then
|
||||
local stamped = false
|
||||
for _, door in ipairs(floorDoors) do
|
||||
local want = Game.save.flags[door.event] and door.open or door.block
|
||||
local open
|
||||
if door.events then
|
||||
open = true
|
||||
for _, ev in ipairs(door.events) do
|
||||
if not Game.save.flags[ev] then open = false break end
|
||||
end
|
||||
else
|
||||
open = Game.save.flags[door.event]
|
||||
end
|
||||
local want = open and door.open or door.block
|
||||
if self.map:blockAt(door.bx, door.by) ~= want then
|
||||
self.map:setBlock(door.bx, door.by, want)
|
||||
stamped = true
|
||||
@@ -749,6 +763,27 @@ function OverworldState:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite
|
||||
-- spins UP out of the map before the fade (player_animations.asm
|
||||
-- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the
|
||||
-- mirror of Fly's flyAnim lead-in above. Only when the spin finishes does
|
||||
-- warpToHealPoint push the fade + warp, so the arrival spin-down lands the
|
||||
-- player OUTSIDE the last Pokemon Center door (#196). player.spinFrames
|
||||
-- decrements in lockstep in Player:update, so the rising spin ends here too.
|
||||
if self.teleportOut then
|
||||
self.teleportOut.frames = self.teleportOut.frames - 1
|
||||
if self.teleportOut.frames <= 0 then
|
||||
local onDone = self.teleportOut.onDone
|
||||
self.teleportOut = nil
|
||||
self.player.spinning = false
|
||||
self.player.spinFrames = nil
|
||||
self.player.spinRise = nil
|
||||
self.player.inputLocked = false
|
||||
self:warpToHealPoint(onDone, { arrive = "teleport" })
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- delayed one-shot SFX (the teleport-in spin's second note)
|
||||
if self.delaySfx then
|
||||
self.delaySfx.frames = self.delaySfx.frames - 1
|
||||
@@ -787,7 +822,7 @@ function OverworldState:update(dt)
|
||||
-- escort then walks an extra tile before PlayerEntryMovementRLE, and
|
||||
-- the player lands on desk Oak.
|
||||
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or self.engaging or self.emote
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
if not scripted and not self.transitioning then
|
||||
self:checkTrainerSight()
|
||||
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
|
||||
@@ -795,7 +830,7 @@ function OverworldState:update(dt)
|
||||
-- direction handling (JoypadOverworld runs the map script first) --
|
||||
-- the player can never start another step after being spotted.
|
||||
scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or self.engaging or self.emote
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
end
|
||||
if not scripted and not self.transitioning then
|
||||
self:handleInput()
|
||||
@@ -854,6 +889,21 @@ function OverworldState:dirHeld()
|
||||
or input:isDown("left") or input:isDown("right")
|
||||
end
|
||||
|
||||
-- The warp cell the player WARPED IN on is inert until they physically step
|
||||
-- off it: standing on it, or bonking a wall/edge from it, must not re-fire a
|
||||
-- warp (CheckWarpsNoCollision's arrival-disable; see setMap where
|
||||
-- warpEntryCell/justWarped are set and onStepComplete where they clear).
|
||||
-- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the
|
||||
-- blocked-step collision warp (handleInput) -- must consult this, or a corner
|
||||
-- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces
|
||||
-- floors every input frame (issue #230).
|
||||
function OverworldState:onWarpArrivalCell()
|
||||
if self.justWarped then return true end
|
||||
local entry = self.warpEntryCell
|
||||
return entry ~= nil and self.player.cellX == entry.x
|
||||
and self.player.cellY == entry.y
|
||||
end
|
||||
|
||||
function OverworldState:handleInput()
|
||||
local input = Game.input
|
||||
|
||||
@@ -875,10 +925,11 @@ function OverworldState:handleInput()
|
||||
if self:checkBoulderPush(dir) then return end
|
||||
end
|
||||
local result, why = self.player:tryMove(dir, self.map, self.entities)
|
||||
if result == "blocked" then
|
||||
-- a collision while standing on a warp square fires the warp
|
||||
-- when the extra check passes (CheckWarpsCollision: route-gate
|
||||
-- doorways, dock entrances, ...)
|
||||
-- a collision while standing on a warp square fires the warp when the
|
||||
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
|
||||
-- entrances, ...) -- but never on the inert cell we just warped in on
|
||||
-- (issue #230), which the completed-step path guards the same way.
|
||||
if result == "blocked" and not self:onWarpArrivalCell() then
|
||||
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
||||
self.player.cellX, self.player.cellY, dir)
|
||||
if w then
|
||||
@@ -1009,6 +1060,11 @@ function OverworldState:checkEdgeExit(dir)
|
||||
|
||||
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
|
||||
if w then
|
||||
-- ...but not while still standing on the warp cell we just arrived on
|
||||
-- (issue #230): fall through so pushing into the edge bonks (SFX +
|
||||
-- walk-in-place) instead of instantly re-warping. A real step onto an
|
||||
-- exit-carpet edge cleared warpEntryCell first, so those still fire.
|
||||
if self:onWarpArrivalCell() then return false end
|
||||
self:takeWarp(w.def)
|
||||
return true
|
||||
end
|
||||
@@ -1256,6 +1312,37 @@ function OverworldState:flyTo(mapId)
|
||||
self.flyDest = { map = mapId, x = spot.x, y = spot.y }
|
||||
end
|
||||
|
||||
-- Dig / Teleport / Escape Rope departure animation, then land OUTSIDE the
|
||||
-- last Pokemon Center door like Fly (#196). pokered's _LeaveMapAnim
|
||||
-- (engine/overworld/player_animations.asm) plays SFX_TELEPORT_EXIT_1 and
|
||||
-- spins the player while it rises up off the map (PlayerSpinWhileMovingUp)
|
||||
-- before the palettes fade; Fly's bird lead-in (flyTo/flyAnim) is the
|
||||
-- analogous departure this mirrors. When the spin finishes (the teleportOut
|
||||
-- countdown in OverworldState:update), warpToHealPoint pushes the fade + warp
|
||||
-- with arrive="teleport" so the sprite spins back DOWN in front of the town
|
||||
-- PC door. Shared by the party-menu DIG/TELEPORT action and BagMenu's
|
||||
-- ESCAPE ROPE so all three animate identically.
|
||||
function OverworldState:beginTeleportOut(onDone)
|
||||
if not Game.save.lastHeal then
|
||||
-- a save that has never visited a Pokemon Center has no heal point to
|
||||
-- warp to; skip the animation entirely (matches the old guard that did
|
||||
-- nothing when lastHeal was absent) instead of spinning into a nil warp
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
|
||||
self.player.surfing = false
|
||||
self.player.inputLocked = true
|
||||
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
|
||||
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
|
||||
self.player.spinning = true
|
||||
self.player.spinTimer = 0
|
||||
self.player.spinFrames = 48
|
||||
self.player.spinTotal = 48
|
||||
self.player.spinRise = true
|
||||
self.teleportOut = { frames = 48, onDone = onDone }
|
||||
end
|
||||
|
||||
function OverworldState:npcAtCell(cx, cy)
|
||||
for _, npc in ipairs(self.npcs) do
|
||||
if (npc.cellX == cx and npc.cellY == cy) or
|
||||
@@ -1474,7 +1561,17 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
-- Pokémon Center PCs and other PC tiles
|
||||
for _, h in ipairs(extras.pcTiles[self.map.id] or {}) do
|
||||
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
|
||||
self:openPC()
|
||||
if self.map.id == "REDS_HOUSE_2F" then
|
||||
-- The player's bedroom PC is the one location in Red/Blue whose PC
|
||||
-- callback is OpenRedsPC (engine/events/hidden_objects/players_pc.asm),
|
||||
-- which runs the PlayerPC predef directly -- item storage, no
|
||||
-- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other
|
||||
-- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228)
|
||||
require("src.core.Sound").play(Game.data, "Turn_On_PC")
|
||||
Screens.push(Game, "PlayerPC")
|
||||
else
|
||||
self:openPC()
|
||||
end
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -2007,8 +2104,12 @@ function OverworldState:talkTo(npc)
|
||||
return
|
||||
end
|
||||
|
||||
-- item balls (object_event item argument)
|
||||
if d.item then
|
||||
-- item balls (object_event item argument). A payload id of "0" is
|
||||
-- pokered's ITEM_NONE sentinel: the ROM object sets the 0x80 "has item"
|
||||
-- bit but names item 0, so it is a plain text object, not an item ball
|
||||
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
|
||||
-- the string "0" as truthy, so screen it out and fall through to text.
|
||||
if d.item and d.item ~= "0" and d.item ~= 0 then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
|
||||
Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!"))
|
||||
return
|
||||
@@ -3257,11 +3358,32 @@ function OverworldState:warpToHealPoint(onDone, opts)
|
||||
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
|
||||
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
|
||||
Game.save.forcedBike = nil
|
||||
if opts and opts.arrive == "teleport" then
|
||||
local map, x, y = heal.map, heal.x, heal.y
|
||||
local teleport = opts and opts.arrive == "teleport"
|
||||
if teleport then
|
||||
self.arriveWarp = "teleport"
|
||||
-- Dig/Teleport/Escape Rope land OUTSIDE at the last Pokemon Center TOWN
|
||||
-- door, like Fly (#196) -- NOT the interior heal cell a blackout returns
|
||||
-- to. pret routes escape-warp and blackout both through wLastBlackoutMap
|
||||
-- (both appear inside in front of the nurse), but this port has decided
|
||||
-- the escape-warp destination is the town PC door. Prefer the canonical
|
||||
-- Fly landing (field.flyWarps, one tile south of the PC door warp), else
|
||||
-- the remembered outdoor door cell; fall back to the interior heal cell
|
||||
-- only for an old save with no recorded outdoor.
|
||||
local out = heal.outdoor
|
||||
if out then
|
||||
local fw = (Game.data.field.flyWarps or {})[out.id]
|
||||
map = out.id
|
||||
x = fw and fw.x or out.x
|
||||
y = fw and fw.y or out.y
|
||||
end
|
||||
end
|
||||
self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone)
|
||||
if heal.outdoor then
|
||||
self:startWarpTo(map, x, y, "down", onDone)
|
||||
-- Blackouts land at the interior heal cell, so re-point LAST_MAP exits at
|
||||
-- the remembered town door. The teleport branch already lands ON that
|
||||
-- outdoor map, so startWarpTo remembers it on the next exit; re-pointing
|
||||
-- here would wrongly steer exits away from where the player now stands.
|
||||
if heal.outdoor and not teleport then
|
||||
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
|
||||
end
|
||||
end
|
||||
|
||||
+33
-1
@@ -61,16 +61,26 @@ function Player:tryMove(dir, map, entities)
|
||||
if self.facing ~= dir then
|
||||
self.facing = dir
|
||||
self.turnTimer = self.turnFrames or TURN_FRAMES
|
||||
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
|
||||
return "turned"
|
||||
end
|
||||
if self.turnTimer > 0 then return nil end
|
||||
local ok, why = Collision.canMove(map, entities, self, dir)
|
||||
if not ok then
|
||||
-- Gen1: a blocked step still animates the player walking in place --
|
||||
-- the collision path spends the step's worth of frames running
|
||||
-- UpdateSprites before returning control, so the legs cycle without
|
||||
-- the cell changing (home/overworld.asm collision handling; issue
|
||||
-- #230). Re-armed every frame the direction is held into the wall;
|
||||
-- Player:update ticks the walk clock while it counts down, so releasing
|
||||
-- returns to the standing pose within a step's length.
|
||||
self.bumpFrames = self.stepFrames or STEP_FRAMES
|
||||
return "blocked", why
|
||||
end
|
||||
local tx, ty = Collision.target(self.cellX, self.cellY, dir)
|
||||
self.targetX, self.targetY = tx, ty
|
||||
self.moving = true
|
||||
self.bumpFrames = nil -- a real step supersedes any in-place bonk
|
||||
self.progress = 0
|
||||
-- the bicycle doubles walking speed (8 frames per step)
|
||||
local save = require("src.core.Game").save
|
||||
@@ -97,9 +107,19 @@ function Player:update()
|
||||
if self.spinFrames <= 0 then
|
||||
self.spinFrames = nil
|
||||
self.spinDrop = nil
|
||||
self.spinRise = nil -- teleport-out departure lift (#196)
|
||||
self.spinning = false
|
||||
end
|
||||
end
|
||||
-- wall-bonk walk-in-place (issue #230): while pushing into a wall the
|
||||
-- collision path keeps the walk clock running without moving the cell,
|
||||
-- so the sprite animates against the wall. Guarded on not-moving so a
|
||||
-- real step (which clears bumpFrames and advances animClock itself
|
||||
-- below) can never double-tick the leg cadence.
|
||||
if not self.moving and self.bumpFrames and self.bumpFrames > 0 then
|
||||
self.bumpFrames = self.bumpFrames - 1
|
||||
self.animClock = (self.animClock or 0) + 1
|
||||
end
|
||||
if not self.moving then return false end
|
||||
local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES
|
||||
self.progress = self.progress + 1
|
||||
@@ -132,7 +152,12 @@ function Player:facingCell()
|
||||
end
|
||||
|
||||
function Player:walkPhase()
|
||||
if not self.moving and not self.stepLanded then return 0 end
|
||||
-- moving, the land-frame after a completed step, or an active wall-bonk
|
||||
-- (issue #230) animate; a standing sprite otherwise
|
||||
if not self.moving and not self.stepLanded
|
||||
and not (self.bumpFrames and self.bumpFrames > 0) then
|
||||
return 0
|
||||
end
|
||||
-- walk frame during the middle of each 16-frame animation cycle
|
||||
local p = (self.animClock or self.progress) % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
@@ -183,6 +208,13 @@ function Player:pose()
|
||||
-- (EnterMapAnim PlayerSpinWhileMovingDown)
|
||||
if self.spinFrames and self.spinDrop then
|
||||
py = py - math.floor(self.spinFrames * 24 / (self.spinTotal or 64))
|
||||
elseif self.spinFrames and self.spinRise then
|
||||
-- Dig/Teleport/Escape-Rope departures spin the sprite UP out of the
|
||||
-- map before the fade (LeaveMapAnim PlayerSpinWhileMovingUp) -- the
|
||||
-- mirror of the arrival spin-down: the lift grows from 0 as spinFrames
|
||||
-- counts down to 0 (#196), opposite sign to spinDrop above.
|
||||
local total = self.spinTotal or 64
|
||||
py = py - math.floor((total - self.spinFrames) * 24 / total)
|
||||
end
|
||||
end
|
||||
local sprite = (self.surfing and self.surfSprite)
|
||||
|
||||
Reference in New Issue
Block a user