mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 08:51:27 +02:00
Add Pokemon Crystal as a sixth supported version
Crystal boots from a user-supplied ROM, imports a full cache and is playable: copyright, the Crystal intro movie, the animated title, gender select, Oak, and out into Johto. 122 of the cart's 169 script specials are implemented. Import and data - tools/make_crystal_manifest.py derives the manifest by importing make_gold_manifest as a library, with three additive keyword seams. Gold and Silver still regenerate byte-identical, which is the standing requirement for touching that generator. - crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta: Crystal renames the credits mons, splits the trainer card, Pokegear and pack-pal blocks by gender, and replaces the intro and title outright. - Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the badge block sits one higher) and unownCharmap (the main charmap parser stops at the first newcharmap so the two cannot contaminate each other). Extractor - RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK 0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole, seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim steps with per-row degrade, and the column-major trainer card portraits. - New: animated front sprites (frames, bitmasks, play and idle scripts), the Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal intro and title via src/import/CrystalMovie.lua. Engine - GameVersion gains engine(id) and fixes(id). Gold and Silver keep their original bugs where the bug is not hardware dependent; Crystal gets the fixes Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the Reflect and Light Screen defence overflow. - Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower including the wInBattleTowerBattle badge-boost guard. - Kris and the gender flag, animated fronts in battle and the summary screen, and mon caught data. Verification - Every extracted asset is pixel-compared against pret's own source PNGs. - Gold caches are byte-identical before and after, file for file. - New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
This commit is contained in:
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
|
||||
-- be run from or Roared away.
|
||||
Battle.BATTLETYPE_FORCESHINY = 7
|
||||
Battle.BATTLETYPE_TRAP = 9
|
||||
-- ../pokecrystal/constants/battle_constants.asm:102-103, Crystal-only appends
|
||||
Battle.BATTLETYPE_CELEBI = 11
|
||||
Battle.BATTLETYPE_SUICUNE = 12
|
||||
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
|
||||
-- type whose loss still prints the trainer's own line instead of a whiteout.
|
||||
Battle.BATTLETYPE_CANLOSE = 1
|
||||
@@ -247,6 +250,10 @@ function Battle.new(opts)
|
||||
-- (BATTLETYPE_FISH is the one condition LureBallMultiplier reads), and the
|
||||
-- FORCESHINY / TRAP no-escape rules will hang off the same field.
|
||||
self.battleType = opts.battleType
|
||||
-- wInBattleTowerBattle (../pokecrystal/constants/ram_constants.asm:38), set
|
||||
-- around the Tower's own StartBattle (engine/events/battle_tower/
|
||||
-- battle_tower.asm:220-223) and cleared again at :253-254.
|
||||
self.inBattleTowerBattle = opts.battleTower and true or false
|
||||
self.events = {}
|
||||
self.turn = 0
|
||||
self.over = false
|
||||
@@ -765,6 +772,9 @@ end
|
||||
function Battle:battleStat(mon, key)
|
||||
local value = (mon.stats or {})[key] or 1
|
||||
if mon ~= self.player then return value end
|
||||
-- BadgeStatBoosts' second early return (engine/battle/core.asm:6786-6788):
|
||||
-- adventure badges do not follow the player into the standardised Tower.
|
||||
if self.inBattleTowerBattle then return value end
|
||||
local badge = Battle.BADGE_STAT_BOOSTS[key]
|
||||
if badge and self:hasBadge("badges", badge) then
|
||||
return Battle.boostStat(value)
|
||||
@@ -783,6 +793,8 @@ end
|
||||
-- boosts the damage. Each type appears once, so this is a plain scan.
|
||||
function Battle:badgeTypeBoost(attacker, moveType)
|
||||
if attacker ~= self.player or not moveType then return false end
|
||||
-- DoBadgeTypeBoosts' own tower guard (engine/battle/misc.asm:152-154).
|
||||
if self.inBattleTowerBattle then return false end
|
||||
for _, row in ipairs(Battle.BADGE_TYPE_BOOSTS) do
|
||||
if row.type == moveType then
|
||||
return self:hasBadge(row.store, row.badge)
|
||||
@@ -3965,6 +3977,16 @@ function Battle:usableMoves(mon)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/battle/core.asm:3687-3694 refuses TRAP, CELEBI,
|
||||
-- FORCESHINY and SUICUNE; pokegold's :3476-3479 has only the first and third.
|
||||
function Battle:noEscapeBattleType()
|
||||
local t = self.battleType
|
||||
return t == Battle.BATTLETYPE_FORCESHINY
|
||||
or t == Battle.BATTLETYPE_TRAP
|
||||
or t == Battle.BATTLETYPE_CELEBI
|
||||
or t == Battle.BATTLETYPE_SUICUNE
|
||||
end
|
||||
|
||||
-- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are
|
||||
-- based on the speed ratio and how many times you have tried this battle.
|
||||
-- Trainers never let you run.
|
||||
@@ -3978,8 +4000,7 @@ function Battle:tryRun(pSpd)
|
||||
-- BATTLETYPE_FORCESHINY jump straight to .cant_escape, ahead of the
|
||||
-- trainer check and any speed math. Without this, running from the Red
|
||||
-- Gyarados returned a WIN to the script and forfeited the one-shot shiny.
|
||||
if self.battleType == Battle.BATTLETYPE_FORCESHINY
|
||||
or self.battleType == Battle.BATTLETYPE_TRAP then
|
||||
if self:noEscapeBattleType() then
|
||||
self:emit({ kind = "message", text = "Can't escape!" })
|
||||
self.runRefused = true
|
||||
return false
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
-- names src/battle/BattleState.lua raises on Gen 1, with the same argument
|
||||
-- order and the same payload keys (docs/mod-api-gen2-compat.md).
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local Catching = {}
|
||||
|
||||
@@ -337,6 +338,47 @@ function Catching.statusBonus(status, opts)
|
||||
return bonuses[status] or 0
|
||||
end
|
||||
|
||||
-- constants/landmark_constants.asm:24, the fallback when no cache is passed.
|
||||
Catching.LANDMARK_NATIONAL_PARK = 19
|
||||
|
||||
local function landmarkIndex(opts, id, fallback)
|
||||
local data = opts and (opts.data or (opts.battle and opts.battle.data))
|
||||
local rows = (opts and opts.landmarks)
|
||||
or (data and data.gen2Landmarks and data.gen2Landmarks.landmarks)
|
||||
local row = rows and rows[id]
|
||||
return (row and row.index) or fallback
|
||||
end
|
||||
|
||||
-- GetWorldMapLocation with the POKECENTER_2F backup-map swap
|
||||
-- (engine/pokemon/caught_data.asm:177-193) and the Bug Contest override (:73-81).
|
||||
function Catching.caughtLandmark(opts)
|
||||
opts = opts or {}
|
||||
if opts.bugContest then
|
||||
return landmarkIndex(opts, "LANDMARK_NATIONAL_PARK",
|
||||
Catching.LANDMARK_NATIONAL_PARK)
|
||||
end
|
||||
if opts.landmark then return opts.landmark end
|
||||
local map = opts.map
|
||||
if map and map.id == "POKECENTER_2F" and opts.backupMap then
|
||||
map = opts.backupMap
|
||||
end
|
||||
return (map and map.landmark) or 0
|
||||
end
|
||||
|
||||
-- SetCaughtData (engine/pokemon/caught_data.asm:163-199); no-op off Crystal.
|
||||
function Catching.stampCaughtData(mon, opts)
|
||||
opts = opts or {}
|
||||
if not Mon.hasCaughtData(opts.version) then return mon end
|
||||
local save = opts.save or (opts.battle and opts.battle.save)
|
||||
return Mon.setCaughtData(mon, {
|
||||
level = opts.level or (type(mon) == "table" and mon.level) or 0,
|
||||
timeOfDay = opts.timeOfDay,
|
||||
landmark = Catching.caughtLandmark(opts),
|
||||
playerGender = opts.playerGender
|
||||
or (save and save.player and save.player.gender),
|
||||
})
|
||||
end
|
||||
|
||||
-- Does the ball catch? Returns caught and the final rate (wFinalCatchRate).
|
||||
-- A rate of 255 or a Master Ball is certain.
|
||||
--
|
||||
|
||||
@@ -21,8 +21,13 @@
|
||||
-- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's
|
||||
-- records carry that as `category`.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local Damage = {}
|
||||
|
||||
-- ../pokecrystal/constants/battle_constants.asm:78
|
||||
Damage.MAX_STAT_VALUE = 999
|
||||
|
||||
-- data/battle/critical_hit_chances.asm, as "1 in N".
|
||||
Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 }
|
||||
|
||||
@@ -60,7 +65,23 @@ end
|
||||
function Damage.applyStage(value, stage)
|
||||
local numerator, denominator = Damage.stageMultiplier(stage)
|
||||
local out = math.floor(value * numerator / denominator)
|
||||
return math.max(1, out)
|
||||
-- ../pokecrystal/engine/battle/core.asm:6739
|
||||
return math.max(1, math.min(Damage.MAX_STAT_VALUE, out))
|
||||
end
|
||||
|
||||
-- TruncateHL_BC: ../pokegold/engine/battle/effect_commands.asm:2625 runs one
|
||||
-- pass, ../pokecrystal/engine/battle/effect_commands.asm:2644 loops it.
|
||||
function Damage.truncateStats(attack, defense, fixed)
|
||||
local a = math.max(0, math.floor(attack or 0))
|
||||
local d = math.max(0, math.floor(defense or 0))
|
||||
while a > 255 or d > 255 do
|
||||
d = math.floor(d / 4)
|
||||
if d == 0 then d = 1 end
|
||||
a = math.floor(a / 4)
|
||||
if a == 0 then a = 1 end
|
||||
if not fixed then break end
|
||||
end
|
||||
return a % 256, d % 256
|
||||
end
|
||||
|
||||
-- Is this move physical? `types` is type_chart.lua's `types` table.
|
||||
@@ -169,6 +190,8 @@ end
|
||||
-- random(n) -- 0..n-1, for the variation roll
|
||||
-- screen -- Reflect/Light Screen active on the defender
|
||||
-- defenseHalved -- EFFECT_SELFDESTRUCT's srl c
|
||||
-- reflectOverflowFixed -- override GameVersion.fixes()'s TruncateHL_BC
|
||||
-- answer; ../pokecrystal/engine/battle/effect_commands.asm:2644
|
||||
--
|
||||
-- Returns damage, info where info carries the pieces a battle message needs:
|
||||
-- effectiveness (x10), critical, physical, variation.
|
||||
@@ -204,6 +227,12 @@ function Damage.calc(opts)
|
||||
defense = defense * 2
|
||||
end
|
||||
|
||||
-- PlayerAttackDamage hands DamageCalc one-byte stats
|
||||
-- (../pokecrystal/engine/battle/effect_commands.asm:2604).
|
||||
local fixed = opts.reflectOverflowFixed
|
||||
if fixed == nil then fixed = GameVersion.fixes().reflectOverflow == true end
|
||||
attack, defense = Damage.truncateStats(attack, defense, fixed)
|
||||
|
||||
-- BattleCommand_DamageCalc (effect_commands.asm:2905-2913): Selfdestruct and
|
||||
-- Explosion halve the defence, never below 1.
|
||||
if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end
|
||||
|
||||
+102
-1
@@ -17,6 +17,7 @@
|
||||
-- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per
|
||||
-- GROWTH_* so this needs no hardcoded table.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
-- The mod event bus. pokemon.level_up and pokemon.move_learned are the SAME
|
||||
-- names src/battle/Experience.lua and src/battle/BattleState.lua raise on
|
||||
@@ -32,6 +33,102 @@ Mon.PARTY_SIZE = 6
|
||||
-- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess.
|
||||
Mon.MAX_DV = 15
|
||||
|
||||
-- MON_CAUGHTDATA's two packed bytes and their masks --
|
||||
-- constants/pokemon_data_constants.asm:93-99, :120-130.
|
||||
Mon.CAUGHT_TIME_MASK = 0xc0
|
||||
Mon.CAUGHT_LEVEL_MASK = 0x3f
|
||||
Mon.CAUGHT_GENDER_MASK = 0x80
|
||||
Mon.CAUGHT_LOCATION_MASK = 0x7f
|
||||
Mon.CAUGHT_EGG_LEVEL = 1
|
||||
-- constants/landmark_constants.asm:111-113
|
||||
Mon.LANDMARK_EVENT = 0x7f
|
||||
Mon.LANDMARK_GIFT = 0x7e
|
||||
|
||||
-- Gold spends the same word on `rb_skip 2` and has no SetCaughtData --
|
||||
-- pokegold constants/pokemon_data_constants.asm:93.
|
||||
function Mon.hasCaughtData(version)
|
||||
return GameVersion.engine(version) == "crystal"
|
||||
end
|
||||
|
||||
-- wTimeOfDay is only MORN / DAY / NITE (engine/rtc/rtc.asm:48-55), stored
|
||||
-- `inc a`'d so 0 stays free -- engine/pokemon/caught_data.asm:169-172.
|
||||
local CAUGHT_TIME = { MORN = 1, DAY = 2, NITE = 3, DARK = 3 }
|
||||
|
||||
function Mon.caughtTimeOf(timeOfDay)
|
||||
if type(timeOfDay) == "string" then return CAUGHT_TIME[timeOfDay] or 0 end
|
||||
if type(timeOfDay) ~= "number" then return 0 end
|
||||
local id = math.floor(timeOfDay)
|
||||
if id < 0 or id > 3 then return 0 end
|
||||
if id > 2 then id = 2 end
|
||||
return id + 1
|
||||
end
|
||||
|
||||
-- wPlayerGender's bit 0 is PLAYERGENDER_FEMALE_F (constants/ram_constants.asm:177).
|
||||
local CAUGHT_GENDER = {
|
||||
girl = "girl", female = "girl", boy = "boy", male = "boy",
|
||||
}
|
||||
|
||||
function Mon.caughtGenderOf(gender)
|
||||
if gender == true then return "girl" end
|
||||
if gender == false then return "boy" end
|
||||
if type(gender) ~= "string" then return nil end
|
||||
return CAUGHT_GENDER[gender:lower()]
|
||||
end
|
||||
|
||||
local function landmarkByte(landmark)
|
||||
if type(landmark) ~= "number" then return 0 end
|
||||
return math.floor(landmark) % 0x80
|
||||
end
|
||||
|
||||
-- SetBoxmonOrEggmonCaughtData (engine/pokemon/caught_data.asm:168-199); the
|
||||
-- egg path hands CAUGHT_EGG_LEVEL in as `level` (:239-242).
|
||||
function Mon.setCaughtData(mon, opts)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
opts = opts or {}
|
||||
mon.caughtTime = Mon.caughtTimeOf(opts.timeOfDay)
|
||||
mon.caughtLevel = math.max(0, math.floor(opts.level or mon.level or 0))
|
||||
mon.caughtLocation = landmarkByte(opts.landmark)
|
||||
mon.caughtByGender = Mon.caughtGenderOf(opts.playerGender) or "boy"
|
||||
return mon
|
||||
end
|
||||
|
||||
-- CAUGHT_BY_UNKNOWN / GIRL / BOY, the code SetGiftMonCaughtData takes in `b`
|
||||
-- (constants/pokemon_data_constants.asm:126-128).
|
||||
Mon.CAUGHT_BY = { unknown = 0, girl = 1, boy = 2 }
|
||||
|
||||
-- SetGiftMonCaughtData (engine/pokemon/caught_data.asm:226-233): `rrc b / or
|
||||
-- LANDMARK_GIFT` puts CAUGHT_BY_BOY on $7f, LANDMARK_EVENT, not on a gender bit.
|
||||
function Mon.setGiftCaughtData(mon, caughtBy)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local code = Mon.CAUGHT_BY[tostring(caughtBy):lower()] or 0
|
||||
local rotated = math.floor(code / 2) + (code % 2) * Mon.CAUGHT_GENDER_MASK
|
||||
local unpacked = Mon.unpackCaughtData(0, Mon.LANDMARK_GIFT + rotated)
|
||||
mon.caughtTime, mon.caughtLevel = 0, 0
|
||||
mon.caughtLocation = unpacked.caughtLocation
|
||||
mon.caughtByGender = unpacked.caughtByGender
|
||||
return mon
|
||||
end
|
||||
|
||||
-- The packed pair, as engine/pokemon/caught_data.asm:169-199 stores it.
|
||||
function Mon.packCaughtData(mon)
|
||||
mon = type(mon) == "table" and mon or {}
|
||||
local time = math.floor(tonumber(mon.caughtTime) or 0) % 4
|
||||
local level = math.floor(tonumber(mon.caughtLevel) or 0) % 0x40
|
||||
local location = landmarkByte(tonumber(mon.caughtLocation) or 0)
|
||||
local female = Mon.caughtGenderOf(mon.caughtByGender) == "girl"
|
||||
return time * 0x40 + level, (female and Mon.CAUGHT_GENDER_MASK or 0) + location
|
||||
end
|
||||
|
||||
function Mon.unpackCaughtData(byte0, byte1)
|
||||
byte0, byte1 = math.floor(byte0 or 0) % 256, math.floor(byte1 or 0) % 256
|
||||
return {
|
||||
caughtTime = math.floor(byte0 / 0x40),
|
||||
caughtLevel = byte0 % 0x40,
|
||||
caughtLocation = byte1 % 0x80,
|
||||
caughtByGender = (byte1 >= Mon.CAUGHT_GENDER_MASK) and "girl" or "boy",
|
||||
}
|
||||
end
|
||||
|
||||
local function rand(a, b)
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(a, b)
|
||||
@@ -362,7 +459,11 @@ function Mon.new(data, species, level, opts)
|
||||
status = nil,
|
||||
-- 70 for a caught mon, 120 for a gift/hatched one.
|
||||
happiness = opts.happiness or 70,
|
||||
caughtLevel = level,
|
||||
caughtLevel = opts.caughtLevel or level,
|
||||
-- MON_CAUGHTDATA, absent on Gold -- constants/pokemon_data_constants.asm:93-99.
|
||||
caughtTime = opts.caughtTime,
|
||||
caughtLocation = opts.caughtLocation,
|
||||
caughtByGender = opts.caughtByGender,
|
||||
-- shiny.roll / gender.roll get the species and level as context; opts.shiny
|
||||
-- still wins, because a FORCED shiny battle (Red Gyarados) is the cart
|
||||
-- overriding the roll rather than a roll to be hooked.
|
||||
|
||||
+6
-3
@@ -19,6 +19,7 @@ local Clock = require("src.core.gen2.Clock")
|
||||
local FixedStep = require("src.core.FixedStep")
|
||||
local Font = require("src.render.Font")
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Input = require("src.core.Input")
|
||||
local Music = require("src.core.Music")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
@@ -288,8 +289,6 @@ function Game2:continueGame(save)
|
||||
self:startWorld()
|
||||
-- After the adopt and after the world is standing, which is where Gen 1
|
||||
-- emits it (src/core/Game.lua:1127, once the stack has been rebuilt).
|
||||
-- `meta` stays absent on a Gold save, which stamps no meta block; modsDiff
|
||||
-- is derived from it and so comes back empty rather than missing.
|
||||
if modsDiff then
|
||||
local notice = SaveData.modsDiffNotice(modsDiff, save.meta)
|
||||
if notice then require("src.core.Logger").warn("%s", notice) end
|
||||
@@ -354,10 +353,14 @@ function Game2:showTitle()
|
||||
})
|
||||
end
|
||||
|
||||
-- ../pokegold/engine/movie/intro.asm:1 GoldSilverIntro, and Crystal's own
|
||||
-- program at ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro.
|
||||
function Game2:showIntro()
|
||||
self.stack:clear()
|
||||
self.phase = "boot"
|
||||
Screens.push(self, "Gen2GoldSilverIntro", {
|
||||
local id = (GameVersion.engine() == "crystal")
|
||||
and "Gen2CrystalIntro" or "Gen2GoldSilverIntro"
|
||||
Screens.push(self, id, {
|
||||
onDone = function()
|
||||
self:showTitle()
|
||||
end,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Which game this process is running: Red (the historical default), Blue,
|
||||
-- Yellow, Gold, or Silver. One source of truth for everything that differs by
|
||||
-- version -- the accepted ROM hash, the import manifest, where the
|
||||
-- Yellow, Gold, Silver, or Crystal. One source of truth for everything that
|
||||
-- differs by version -- the accepted ROM hash, the import manifest, where the
|
||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||
-- cache mount, SaveData, title screen and palette all agree.
|
||||
--
|
||||
@@ -8,8 +8,9 @@
|
||||
-- saves are untouched, but its extracted cache lives under red/ like Blue,
|
||||
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
||||
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
|
||||
-- and selected side by side. Gold and Silver are Gen 2 (see
|
||||
-- docs/gold-phase1.md).
|
||||
-- and selected side by side. Gold, Silver and Crystal are Gen 2 (see
|
||||
-- docs/gold-phase1.md); `generation` splits Gen 1 from Gen 2 and `engine`
|
||||
-- splits Gold/Silver from Crystal within Gen 2.
|
||||
--
|
||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||
-- and tests. The active version is a process-global set once at boot from
|
||||
@@ -27,6 +28,8 @@ GameVersion.VERSIONS = {
|
||||
manifest = "tools/rom_manifest.json",
|
||||
cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
|
||||
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
|
||||
-- Absent reads as "gen1" (GameVersion.engine)
|
||||
engine = "gen1",
|
||||
},
|
||||
blue = {
|
||||
id = "blue",
|
||||
@@ -37,6 +40,7 @@ GameVersion.VERSIONS = {
|
||||
manifest = "tools/rom_manifest_blue.json",
|
||||
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
|
||||
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
|
||||
engine = "gen1",
|
||||
},
|
||||
yellow = {
|
||||
id = "yellow",
|
||||
@@ -47,6 +51,7 @@ GameVersion.VERSIONS = {
|
||||
manifest = "tools/rom_manifest_yellow.json",
|
||||
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
|
||||
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
|
||||
engine = "gen1",
|
||||
},
|
||||
-- Gen 2, Phase 1 (docs/gold-phase1.md): a 2 MiB cart, twice the size of
|
||||
-- the Gen 1 ROMs above, imported through RomExtractorGen2 instead of
|
||||
@@ -64,6 +69,7 @@ GameVersion.VERSIONS = {
|
||||
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
|
||||
-- Absent reads as 1 (GameVersion.generation)
|
||||
generation = 2,
|
||||
engine = "gs",
|
||||
},
|
||||
-- Gold's engine with edition-selected data; the manifest is derived from
|
||||
-- Gold's by tools/make_silver_manifest.py.
|
||||
@@ -77,11 +83,34 @@ GameVersion.VERSIONS = {
|
||||
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
|
||||
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
|
||||
generation = 2,
|
||||
engine = "gs",
|
||||
},
|
||||
crystal = {
|
||||
id = "crystal",
|
||||
label = "Crystal",
|
||||
displayName = "Pokemon Crystal",
|
||||
launcherName = "Crystal (Beta)",
|
||||
sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
|
||||
manifest = "tools/rom_manifest_crystal.json",
|
||||
cachePrefix = "crystal/", -- crystal/data/generated, crystal/assets/generated
|
||||
saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp
|
||||
generation = 2,
|
||||
engine = "crystal",
|
||||
fixes = {
|
||||
-- pokegold/docs/bugs_and_glitches.md:61
|
||||
luckyNumberBoxes = true,
|
||||
-- pokegold/docs/bugs_and_glitches.md:88
|
||||
surfOntoNpc = true,
|
||||
-- pokecrystal/engine/battle/effect_commands.asm:2614
|
||||
reflectOverflow = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Launcher column order.
|
||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
|
||||
local NO_FIXES = {}
|
||||
|
||||
-- Launcher column order. Append only (src/mods/ModProfile.lua encodes by index).
|
||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal" }
|
||||
|
||||
GameVersion.current = "red"
|
||||
|
||||
@@ -115,6 +144,17 @@ function GameVersion.generation(id)
|
||||
return GameVersion.info(id).generation or 1
|
||||
end
|
||||
|
||||
-- "gen1" | "gs" | "crystal": lineage within a generation.
|
||||
function GameVersion.engine(id)
|
||||
return GameVersion.info(id).engine or "gen1"
|
||||
end
|
||||
|
||||
-- Cart bugs a version FIXED, by fix name; an absent row reads {} and stays bugged.
|
||||
function GameVersion.fixes(id)
|
||||
local info = GameVersion.info(id)
|
||||
return (info and info.fixes) or NO_FIXES
|
||||
end
|
||||
|
||||
-- Metadata for a version id, defaulting to the active one.
|
||||
function GameVersion.info(id)
|
||||
return GameVersion.VERSIONS[id or GameVersion.current]
|
||||
|
||||
@@ -40,6 +40,7 @@ local function normalizeVersion(v)
|
||||
y = "yellow", yellow = "yellow",
|
||||
g = "gold", gold = "gold",
|
||||
s = "silver", silver = "silver",
|
||||
c = "crystal", crystal = "crystal",
|
||||
}
|
||||
v = alias[v] or v
|
||||
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
||||
|
||||
@@ -1486,8 +1486,10 @@ function SaveData.runMigrations(save, modChains, activeMods)
|
||||
-- every step whose from-format the save has not passed yet runs, in
|
||||
-- (from, registration) order; a save at the current format runs none
|
||||
local fmt = (save.meta and save.meta.format) or 1
|
||||
for _, m in ipairs(coreMigrations) do
|
||||
if m.from >= fmt then m.fn(save) end
|
||||
if GameVersion.generation() == 1 then
|
||||
for _, m in ipairs(coreMigrations) do
|
||||
if m.from >= fmt then m.fn(save) end
|
||||
end
|
||||
end
|
||||
-- a save that predates meta records an empty mod set: an old vanilla
|
||||
-- save becomes a v2 vanilla save
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 and
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27.
|
||||
|
||||
local Breeding = require("src.core.gen2.Breeding")
|
||||
local HallOfFame = require("src.core.gen2.HallOfFame")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
|
||||
local BattleTower = {}
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:1-7
|
||||
BattleTower.PARTY_LENGTH = 3
|
||||
BattleTower.STREAK_LENGTH = 7
|
||||
BattleTower.NUM_UNIQUE_MON = 21
|
||||
BattleTower.NUM_UNIQUE_TRAINERS = 70
|
||||
BattleTower.TRAINERDATALENGTH = 36
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:47 GS_BALL_AVAILABLE
|
||||
BattleTower.GS_BALL_AVAILABLE = 0x0b
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:55-61
|
||||
BattleTower.NO_CHALLENGE = 0
|
||||
BattleTower.SAVED_AND_LEFT = 1
|
||||
BattleTower.CHALLENGE_IN_PROGRESS = 2
|
||||
BattleTower.WON_CHALLENGE = 3
|
||||
BattleTower.RECEIVED_REWARD = 4
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:63-67
|
||||
BattleTower.REWARD_QUANTITY = 5
|
||||
BattleTower.MIN_REWARD = "HP_UP"
|
||||
BattleTower.MAX_REWARD = "CALCIUM"
|
||||
BattleTower.SKIPPED_REWARD = "LUCKY_PUNCH"
|
||||
BattleTower.FALLBACK_REWARD = "POTION"
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1492 bit 0,
|
||||
-- :978-1008 bit 1 of sBattleTowerSaveFileFlags.
|
||||
BattleTower.SAVEFILE_REGISTERED = 1
|
||||
BattleTower.SAVEFILE_EXPLANATION = 2
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:11-43
|
||||
BattleTower.ACTIONS = {
|
||||
CHECK_EXPLANATION_READ = 0,
|
||||
SET_EXPLANATION_READ = 1,
|
||||
GET_CHALLENGE_STATE = 2,
|
||||
SAVE_AND_QUIT = 3,
|
||||
CHALLENGECANCELED = 4,
|
||||
ACTION_05 = 5,
|
||||
ACTION_06 = 6,
|
||||
SAVELEVELGROUP = 7,
|
||||
LOADLEVELGROUP = 8,
|
||||
CHECKSAVEFILEISYOURS = 9,
|
||||
ACTION_0A = 10,
|
||||
GSBALL = 11,
|
||||
ACTION_0C = 12,
|
||||
ACTION_0D = 13,
|
||||
EGGTICKET = 14,
|
||||
ACTION_0F = 15,
|
||||
ACTION_10 = 16,
|
||||
ACTION_11 = 17,
|
||||
ACTION_12 = 18,
|
||||
ACTION_13 = 19,
|
||||
ACTION_14 = 20,
|
||||
ACTION_15 = 21,
|
||||
ACTION_16 = 22,
|
||||
ACTION_17 = 23,
|
||||
LEVEL_CHECK = 24,
|
||||
UBERS_CHECK = 25,
|
||||
RESETDATA = 26,
|
||||
GIVEREWARD = 27,
|
||||
ACTION_1C = 28,
|
||||
ACTION_1D = 29,
|
||||
CHOOSEREWARD = 30,
|
||||
SAVEOPTIONS = 31,
|
||||
}
|
||||
BattleTower.NUM_ACTIONS = 32
|
||||
|
||||
-- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers, which
|
||||
-- ../pokecrystal/maps/BattleTowerBattleRoom.asm:38 reads back with `readmem`.
|
||||
BattleTower.WRAM_NR_BEATEN = 0xcf64
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:49-53 `battletowertext`.
|
||||
BattleTower.TEXT_INTRO = 1
|
||||
BattleTower.TEXT_WIN = 2
|
||||
BattleTower.TEXT_LOSS = 3
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:39-55, in the order
|
||||
-- BattleTower_ExecuteJumptable prints them.
|
||||
BattleTower.RULE_HEADER_TEXT = "_ExcuseMeYoureNotReadyText"
|
||||
BattleTower.RULE_FAIL_TEXTS = {
|
||||
"_OnlyThreeMonMayBeEnteredText",
|
||||
"_TheMonMustAllBeDifferentKindsText",
|
||||
"_TheMonMustNotHoldTheSameItemsText",
|
||||
"_YouCantTakeAnEggText",
|
||||
}
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:61-68
|
||||
BattleTower.RULE_TAIL_TEXT = "_BattleTowerReturnWhenReadyText"
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2.
|
||||
BattleTower.RULE_PARTY_COUNT_TEXT = "3"
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3936-3958 BattleTower_UbersCheck.
|
||||
BattleTower.UBERS = {
|
||||
MEWTWO = true, MEW = true, LUGIA = true, HO_OH = true, CELEBI = true,
|
||||
}
|
||||
BattleTower.UBER_MIN_LEVEL = 70
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3869-3887 Strings_L10ToL100 and
|
||||
-- Strings_Ll0ToL40.
|
||||
BattleTower.MAX_LEVEL_GROUP = 10
|
||||
BattleTower.PRE_HOF_LEVEL_GROUPS = 4
|
||||
|
||||
local function counter(value)
|
||||
return math.max(0, math.floor(tonumber(value) or 0))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:147-172, on top of Save.battleTowerState.
|
||||
function BattleTower.state(save)
|
||||
local tower = Save.battleTowerState(save)
|
||||
-- ../pokecrystal/ram/sram.asm:156 sBTChoiceOfLevelGroup
|
||||
tower.levelGroup = counter(tower.levelGroup)
|
||||
-- ../pokecrystal/ram/sram.asm:159 sBattleTowerSaveFileFlags
|
||||
tower.saveFileFlags = counter(tower.saveFileFlags) % 256
|
||||
-- ../pokecrystal/ram/sram.asm:158 sBTTrainers, $ff for an unused slot
|
||||
if type(tower.trainers) ~= "table" then tower.trainers = {} end
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1469 s5_aa8d
|
||||
if tower.reentry == nil then tower.reentry = false end
|
||||
return tower
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483 and
|
||||
-- :984-989: `and 1` / `and 2`, so the answer is the MASKED byte, not a boolean.
|
||||
function BattleTower.saveFileFlag(save, mask)
|
||||
local tower = BattleTower.state(save)
|
||||
return (math.floor(tower.saveFileFlags / mask) % 2 == 1) and mask or 0
|
||||
end
|
||||
|
||||
function BattleTower.setSaveFileFlag(save, mask)
|
||||
local tower = BattleTower.state(save)
|
||||
if math.floor(tower.saveFileFlags / mask) % 2 == 0 then
|
||||
tower.saveFileFlags = tower.saveFileFlags + mask
|
||||
end
|
||||
return tower.saveFileFlags
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904
|
||||
function BattleTower.resetTrainers(save)
|
||||
local tower = BattleTower.state(save)
|
||||
tower.trainers = {}
|
||||
tower.streak = 0
|
||||
return tower
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1016-1022
|
||||
function BattleTower.setChallengeState(save, state)
|
||||
local tower = BattleTower.state(save)
|
||||
tower.challenge = counter(state)
|
||||
return tower.challenge
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:206-211
|
||||
function BattleTower.partyCountOk(party)
|
||||
return #party == BattleTower.PARTY_LENGTH
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:218-277
|
||||
-- CheckPartyValueIsUnique: eggs are skipped on both sides and a zero value
|
||||
-- never collides, because `ld a, [hl] / and a / jr z, .next` drops it first.
|
||||
local function valuesUnique(party, get)
|
||||
local count = #party
|
||||
for i = 1, count - 1 do
|
||||
local mon = party[i]
|
||||
if not Breeding.isEgg(mon) then
|
||||
local value = get(mon)
|
||||
if value ~= nil and value ~= 0 then
|
||||
for j = i + 1, count do
|
||||
local other = party[j]
|
||||
if not Breeding.isEgg(other) and get(other) == value then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function speciesOf(mon) return mon and mon.species or nil end
|
||||
local function itemOf(mon) return mon and mon.item or nil end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:213-216
|
||||
function BattleTower.speciesUnique(party)
|
||||
return valuesUnique(party, speciesOf)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:279-282
|
||||
function BattleTower.itemsUnique(party)
|
||||
return valuesUnique(party, itemOf)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:284-299
|
||||
function BattleTower.partyHasEgg(party)
|
||||
for _, mon in ipairs(party) do
|
||||
if Breeding.isEgg(mon) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27-37 and :94-103: every
|
||||
-- check runs, the first failure prints the header first, and a tail line
|
||||
-- follows any failure at all. Returns the text labels in printing order.
|
||||
function BattleTower.checkRules(party)
|
||||
party = party or {}
|
||||
local failed = {
|
||||
not BattleTower.partyCountOk(party),
|
||||
not BattleTower.speciesUnique(party),
|
||||
not BattleTower.itemsUnique(party),
|
||||
BattleTower.partyHasEgg(party),
|
||||
}
|
||||
local lines, any = {}, false
|
||||
for index = 1, #BattleTower.RULE_FAIL_TEXTS do
|
||||
if failed[index] then
|
||||
if not any then
|
||||
any = true
|
||||
lines[#lines + 1] = BattleTower.RULE_HEADER_TEXT
|
||||
end
|
||||
lines[#lines + 1] = BattleTower.RULE_FAIL_TEXTS[index]
|
||||
end
|
||||
end
|
||||
if any then lines[#lines + 1] = BattleTower.RULE_TAIL_TEXT end
|
||||
return lines, any
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:1156-1166: the Hall of Fame flag is what
|
||||
-- opens rooms above L40, and the last row is always CANCEL.
|
||||
function BattleTower.levelGroupCount(save)
|
||||
if HallOfFame.hasEntered(save) then return BattleTower.MAX_LEVEL_GROUP end
|
||||
return BattleTower.PRE_HOF_LEVEL_GROUPS
|
||||
end
|
||||
|
||||
function BattleTower.levelGroupRows(save)
|
||||
local rows = {}
|
||||
for group = 1, BattleTower.levelGroupCount(save) do
|
||||
rows[group] = { group = group, level = group * 10 }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:1264-1267 `dec a / and $fe / srl a`, the
|
||||
-- BATTLE ROOM pair the hallway walks to (../pokecrystal/maps/BattleTowerHallway.asm:40-47).
|
||||
function BattleTower.roomOf(group)
|
||||
return math.floor((math.max(1, counter(group)) - 1) / 2)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3892-3934 BattleTower_LevelCheck
|
||||
function BattleTower.levelCheck(party, group)
|
||||
local cap = counter(group) * 10
|
||||
for _, mon in ipairs(party or {}) do
|
||||
if (tonumber(mon.level) or 0) > cap then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3936-3989 BattleTower_UbersCheck: below
|
||||
-- the L70 rooms an uber under L70 is refused, and its name goes in wcd49.
|
||||
function BattleTower.ubersCheck(party, group)
|
||||
if counter(group) >= BattleTower.UBER_MIN_LEVEL / 10 then return nil end
|
||||
for _, mon in ipairs(party or {}) do
|
||||
if BattleTower.UBERS[mon.species]
|
||||
and (tonumber(mon.level) or 0) < BattleTower.UBER_MIN_LEVEL then
|
||||
return mon.species
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976: a
|
||||
-- `maskbits` roll over HP_UP..CALCIUM that folds the overshoot back to the
|
||||
-- bottom of the range and rerolls LUCKY_PUNCH.
|
||||
function BattleTower.rollReward(order, random)
|
||||
if type(order) ~= "table" then return nil end
|
||||
local low, high
|
||||
for index, name in pairs(order) do
|
||||
if name == BattleTower.MIN_REWARD then low = index end
|
||||
if name == BattleTower.MAX_REWARD then high = index end
|
||||
end
|
||||
if not (low and high) then return nil end
|
||||
local span = high - low + 1
|
||||
local mask = 1
|
||||
while mask < span do mask = mask * 2 end
|
||||
for _ = 1, 64 do
|
||||
local roll = random(mask) % mask
|
||||
if roll >= span then roll = roll - span end
|
||||
local item = order[low + roll]
|
||||
if item ~= BattleTower.SKIPPED_REWARD then return item end
|
||||
end
|
||||
return order[low]
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933: five of
|
||||
-- the reward only fit when the ITEM pocket has a free slot, or already holds
|
||||
-- the reward with room for five more. Otherwise the desk hands over a POTION,
|
||||
-- which the script reads as "your PACK is stuffed full".
|
||||
function BattleTower.rewardFits(slots, capacity, held)
|
||||
if counter(slots) < counter(capacity) then return true end
|
||||
if held == nil then return false end
|
||||
return counter(held) < 99 - BattleTower.REWARD_QUANTITY + 1
|
||||
end
|
||||
|
||||
-- The opponent draw, all of it ../pokecrystal/engine/events/battle_tower/
|
||||
-- load_trainer.asm, over RomExtractorGen2's Crystal-only `battleTower` block.
|
||||
|
||||
-- data/generated/trainers.lua `battleTower`, absent on Gold and Silver.
|
||||
function BattleTower.roster(data)
|
||||
local trainers = data and (data.gen2Trainers or data.trainers)
|
||||
local roster = trainers and trainers.battleTower
|
||||
if type(roster) ~= "table" then return nil end
|
||||
if type(roster.trainers) ~= "table" or type(roster.groups) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
return roster
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:162-173 sBTMonOfTrainers: the last team's three
|
||||
-- species and the one before that's, which the draw refuses to repeat.
|
||||
function BattleTower.prevTeams(save)
|
||||
local tower = BattleTower.state(save)
|
||||
local teams = tower.prevTeams
|
||||
if type(teams) ~= "table" then
|
||||
teams = {}
|
||||
tower.prevTeams = teams
|
||||
end
|
||||
if type(teams.prev) ~= "table" then teams.prev = {} end
|
||||
if type(teams.prevPrev) ~= "table" then teams.prevPrev = {} end
|
||||
return teams
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:104-105 reads the
|
||||
-- room back as `ld a, [wBTChoiceOfLvlGroup] / dec a`, so group 0 indexes
|
||||
-- BEFORE the table; battle_tower.asm:1129-1141 is what can only save 1..10.
|
||||
function BattleTower.opponentGroup(save, roster)
|
||||
local groups = (roster and roster.levelGroups) or BattleTower.MAX_LEVEL_GROUP
|
||||
local group = counter(BattleTower.state(save).levelGroup)
|
||||
if group < 1 then return 1 end
|
||||
if group > groups then return groups end
|
||||
return group
|
||||
end
|
||||
|
||||
-- load_trainer.asm:24-38 and :101-166 reroll until the roll passes; one draw
|
||||
-- from the survivors is the same distribution and cannot spin. An empty
|
||||
-- survivor set is where the cart's own loop would hang, so it draws unfiltered.
|
||||
local function drawFiltered(count, random, accept)
|
||||
local pool = {}
|
||||
for index = 1, count do
|
||||
if accept(index) then pool[#pool + 1] = index end
|
||||
end
|
||||
if #pool == 0 then return random(count) end
|
||||
return pool[random(#pool)]
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:22-60. The roll
|
||||
-- is refused while it names anybody already in sBTTrainers, and the winner is
|
||||
-- written into the slot sNrOfBeatenBattleTowerTrainers points at.
|
||||
function BattleTower.chooseTrainer(save, roster, random)
|
||||
local tower = BattleTower.state(save)
|
||||
-- :29-37, the ceiling read out of the cart: Crystal 1.0 masks with
|
||||
-- BATTLETOWER_NUM_UNIQUE_MON and can only ever draw the first 21 rows.
|
||||
local ceiling = counter(roster.sampleTrainers)
|
||||
if ceiling < 1 or ceiling > #roster.trainers then ceiling = #roster.trainers end
|
||||
local seen = {}
|
||||
for slot = 1, BattleTower.STREAK_LENGTH do
|
||||
local held = tonumber(tower.trainers[slot])
|
||||
if held then seen[held] = true end
|
||||
end
|
||||
local index = drawFiltered(ceiling, random, function(row)
|
||||
return not seen[row - 1]
|
||||
end)
|
||||
tower.trainers[math.min(counter(tower.streak), BattleTower.STREAK_LENGTH - 1)
|
||||
+ 1] = index - 1
|
||||
return roster.trainers[index]
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:94-208. Three
|
||||
-- draws out of the chosen level group, each refusing a species this team
|
||||
-- already holds, an ITEM this team already holds, and any species from the
|
||||
-- last two teams. wBT_OTTrainer was zero-filled and its three item slots set
|
||||
-- to $ff first (:7-17), which is why an unfilled slot collides with nothing.
|
||||
function BattleTower.chooseTeam(save, roster, group, random)
|
||||
local rows = roster.groups[group] or {}
|
||||
local teams = BattleTower.prevTeams(save)
|
||||
local picked, species, items = {}, {}, {}
|
||||
for _ = 1, BattleTower.PARTY_LENGTH do
|
||||
local index = drawFiltered(#rows, random, function(row)
|
||||
local mon = rows[row]
|
||||
if not mon then return false end
|
||||
if species[mon.species] then return false end
|
||||
if mon.item ~= nil and items[mon.item] then return false end
|
||||
for _, seen in ipairs(teams.prev) do
|
||||
if seen == mon.species then return false end
|
||||
end
|
||||
for _, seen in ipairs(teams.prevPrev) do
|
||||
if seen == mon.species then return false end
|
||||
end
|
||||
return true
|
||||
end)
|
||||
local mon = rows[index]
|
||||
if not mon then break end
|
||||
picked[#picked + 1] = mon
|
||||
species[mon.species] = true
|
||||
if mon.item ~= nil then items[mon.item] = true end
|
||||
end
|
||||
-- :195-206, after all three: this team becomes sBTMonPrevTrainer and the
|
||||
-- one it displaces becomes sBTMonPrevPrevTrainer.
|
||||
local prev = {}
|
||||
for slot, mon in ipairs(picked) do prev[slot] = mon.species end
|
||||
teams.prevPrev = teams.prev
|
||||
teams.prev = prev
|
||||
return picked
|
||||
end
|
||||
|
||||
-- The whole of `special LoadOpponentTrainerAndPokemon`, as one record. The
|
||||
-- cart keeps it in wBT_OTTrainer, which is WRAM bank 3 and is NOT saved --
|
||||
-- only the sBTTrainers slot and the two previous teams this walk writes are.
|
||||
function BattleTower.drawOpponent(data, save, random)
|
||||
local roster = BattleTower.roster(data)
|
||||
if not roster then return nil end
|
||||
local group = BattleTower.opponentGroup(save, roster)
|
||||
local trainer = BattleTower.chooseTrainer(save, roster, random)
|
||||
if not trainer then return nil end
|
||||
local rows = BattleTower.chooseTeam(save, roster, group, random)
|
||||
return {
|
||||
index = trainer.index,
|
||||
name = trainer.name,
|
||||
class = trainer.class,
|
||||
classId = trainer.classId,
|
||||
sprite = roster.classSprites and roster.classSprites[trainer.classId],
|
||||
group = group,
|
||||
rows = rows,
|
||||
}
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:549-570
|
||||
-- CopyBTTrainer_FromBT_OT_TowBT_OTTemp, which is the first thing
|
||||
-- ReadBTTrainerParty does: the challenge is now in progress and the streak
|
||||
-- counter steps BEFORE the battle, which is why beating the seventh opponent
|
||||
-- leaves the counter on BATTLETOWER_STREAK_LENGTH.
|
||||
function BattleTower.beginBattle(save)
|
||||
local tower = BattleTower.state(save)
|
||||
tower.challenge = BattleTower.CHALLENGE_IN_PROGRESS
|
||||
tower.streak = counter(tower.streak) + 1
|
||||
return tower.streak
|
||||
end
|
||||
|
||||
-- ReadBTTrainerParty's .otpartymon_loop (battle_tower.asm:349-376) copies the
|
||||
-- whole party_struct into wOTPartyMon, so nothing here is rolled: the stored
|
||||
-- stats go on top of Mon.new's, which agree with them anyway.
|
||||
-- The ROM nicknames never show: load_trainer.asm:171-190 overwrites each one
|
||||
-- with GetPokemonName, so the rows carry none.
|
||||
function BattleTower.battleParty(data, rows)
|
||||
local party = {}
|
||||
for _, row in ipairs(rows or {}) do
|
||||
local moves = nil
|
||||
if row.moves and #row.moves > 0 then
|
||||
moves = {}
|
||||
for slot, id in ipairs(row.moves) do
|
||||
local def = data and data.moves and data.moves[id]
|
||||
local max = (def and def.pp) or 0
|
||||
moves[slot] = {
|
||||
id = id,
|
||||
pp = (row.pp and row.pp[slot]) or max,
|
||||
maxPp = max,
|
||||
}
|
||||
end
|
||||
end
|
||||
local dvs = row.dvs or {}
|
||||
local statExp = row.statExp or {}
|
||||
local mon = Mon.new(data, row.species, row.level, {
|
||||
moves = moves,
|
||||
item = row.item,
|
||||
dvs = { attack = dvs.attack, defense = dvs.defense,
|
||||
speed = dvs.speed, special = dvs.special },
|
||||
statExp = { hp = statExp.hp, attack = statExp.attack,
|
||||
defense = statExp.defense, speed = statExp.speed,
|
||||
special = statExp.special },
|
||||
happiness = row.happiness,
|
||||
})
|
||||
if mon then
|
||||
if row.stats then
|
||||
mon.stats = {
|
||||
hp = row.stats.hp, attack = row.stats.attack,
|
||||
defense = row.stats.defense, speed = row.stats.speed,
|
||||
specialAttack = row.stats.specialAttack,
|
||||
specialDefense = row.stats.specialDefense,
|
||||
}
|
||||
end
|
||||
mon.maxHp = row.maxHp or mon.maxHp
|
||||
mon.hp = row.hp or mon.maxHp
|
||||
mon.experience = row.experience or mon.experience
|
||||
party[#party + 1] = mon
|
||||
end
|
||||
end
|
||||
return party
|
||||
end
|
||||
|
||||
return BattleTower
|
||||
@@ -883,6 +883,11 @@ function Breeding.withdraw(data, save, which)
|
||||
rebuilt.status = nil
|
||||
for _, move in ipairs(rebuilt.moves) do move.pp = move.maxPp end
|
||||
rebuilt.caughtLevel = mon.caughtLevel or rebuilt.caughtLevel
|
||||
-- RetrieveBreedmon copies the stored struct back whole, MON_CAUGHTDATA
|
||||
-- included -- engine/pokemon/move_mon.asm:805.
|
||||
rebuilt.caughtTime = mon.caughtTime
|
||||
rebuilt.caughtLocation = mon.caughtLocation
|
||||
rebuilt.caughtByGender = mon.caughtByGender
|
||||
rebuilt.ot, rebuilt.otId = mon.ot, mon.otId
|
||||
-- CalcExpAtLevel, which is the experience loss.
|
||||
rebuilt.experience = Mon.experienceForLevel(growthOf(data, def), newLevel)
|
||||
@@ -1077,7 +1082,9 @@ end
|
||||
-- Returns the new record plus a table of side effects the caller owes:
|
||||
-- { species = , togepi = bool } -- SetSeenAndCaughtMon, and the
|
||||
-- EVENT_TOGEPI_HATCHED flag the ASM sets by hand for exactly one species.
|
||||
function Breeding.hatch(data, save, index, nickname)
|
||||
-- `where` is the hatch site for SetEggMonCaughtData: landmark, timeOfDay and
|
||||
-- playerGender -- engine/pokemon/breeding.asm:228.
|
||||
function Breeding.hatch(data, save, index, nickname, where)
|
||||
local party = (save and save.party) or {}
|
||||
local egg = party[index]
|
||||
if not Breeding.isEgg(egg) then return nil end
|
||||
@@ -1097,6 +1104,18 @@ function Breeding.hatch(data, save, index, nickname)
|
||||
hatched.ot = egg.ot or (save.player and save.player.name)
|
||||
hatched.otId = egg.otId or (save.player and save.player.id)
|
||||
hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL
|
||||
-- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the
|
||||
-- shared setter -- engine/pokemon/caught_data.asm:235-246.
|
||||
if Mon.hasCaughtData(save and save.version) then
|
||||
where = where or {}
|
||||
Mon.setCaughtData(hatched, {
|
||||
level = Mon.CAUGHT_EGG_LEVEL,
|
||||
timeOfDay = where.timeOfDay,
|
||||
landmark = where.landmark,
|
||||
playerGender = where.playerGender
|
||||
or (save.player and save.player.gender),
|
||||
})
|
||||
end
|
||||
party[index] = hatched
|
||||
|
||||
Breeding.markPokedex(save, egg.species)
|
||||
|
||||
@@ -357,6 +357,7 @@ Evolution.MON_FIELDS = {
|
||||
experience = true, dvs = true, stats = true, hp = true, maxHp = true,
|
||||
types = true, moves = true, item = true, status = true, happiness = true,
|
||||
caughtLevel = true, shiny = true, gender = true,
|
||||
caughtTime = true, caughtLocation = true, caughtByGender = true,
|
||||
}
|
||||
|
||||
-- Turn `mon` into `entry.into`. Returns the NEW record; the caller writes it
|
||||
@@ -410,6 +411,11 @@ function Evolution.apply(data, mon, entry)
|
||||
evolved.experience = mon.experience
|
||||
evolved.status = mon.status
|
||||
evolved.caughtLevel = mon.caughtLevel
|
||||
-- Nothing between GetBaseData and the PARTYMON_STRUCT_LENGTH copy back
|
||||
-- touches MON_CAUGHTDATA -- engine/pokemon/evolve.asm:291-293.
|
||||
evolved.caughtTime = mon.caughtTime
|
||||
evolved.caughtLocation = mon.caughtLocation
|
||||
evolved.caughtByGender = mon.caughtByGender
|
||||
-- Anything a future field adds to a party record (mail, pokerus) rides along
|
||||
-- rather than being silently dropped. Only fields Mon.new does NOT own may
|
||||
-- be carried: copying `nickname` back would undo
|
||||
|
||||
@@ -86,8 +86,11 @@ Happiness.EVENT = {
|
||||
ENERGYROOT = 16, -- 10
|
||||
REVIVALHERB = 17, -- 11
|
||||
GROOMING = 18, -- 12
|
||||
-- Crystal only; Gold's enum stops at GROOMING
|
||||
-- (pokegold constants/pokemon_data_constants.asm:205).
|
||||
GAINLEVELATHOME = 19, -- 13
|
||||
}
|
||||
Happiness.NUM_EVENTS = 18
|
||||
Happiness.NUM_EVENTS = 19
|
||||
|
||||
-- data/events/happiness_changes.asm, transcribed row for row. The three
|
||||
-- columns are the three tiers below, in order.
|
||||
@@ -110,6 +113,7 @@ Happiness.CHANGES = {
|
||||
{ -10, -10, -15 }, -- 10 Used Energy Root (bitter)
|
||||
{ -15, -15, -20 }, -- 11 Used Revival Herb (bitter)
|
||||
{ 3, 3, 1 }, -- 12 Grooming
|
||||
{ 10, 6, 4 }, -- 13 Gained a level where it was caught (Crystal)
|
||||
}
|
||||
|
||||
-- Which of HappinessChanges' three columns a CURRENT value reads. The cart
|
||||
@@ -174,6 +178,23 @@ function Happiness.change(mon, event)
|
||||
return value
|
||||
end
|
||||
|
||||
-- LevelUpHappinessMod: the caught location masked with CAUGHT_LOCATION_MASK
|
||||
-- against the current landmark -- engine/pokemon/level_up_happiness.asm:1-20.
|
||||
function Happiness.levelUpEvent(mon, landmark)
|
||||
local caught = type(mon) == "table" and tonumber(mon.caughtLocation) or nil
|
||||
landmark = tonumber(landmark)
|
||||
if not (caught and landmark) then return "GAINLEVEL" end
|
||||
if math.floor(caught) % 0x80 ~= math.floor(landmark) % 0x80 then
|
||||
return "GAINLEVEL"
|
||||
end
|
||||
return "GAINLEVELATHOME"
|
||||
end
|
||||
|
||||
-- The `callfar ChangeHappiness` that follows -- level_up_happiness.asm:19.
|
||||
function Happiness.levelUp(mon, landmark)
|
||||
return Happiness.change(mon, Happiness.levelUpEvent(mon, landmark))
|
||||
end
|
||||
|
||||
-- The same event across a party, which is how the Gym Leader award is written
|
||||
-- out longhand in engine/battle/core.asm InitEnemyTrainer:
|
||||
--
|
||||
|
||||
+128
-18
@@ -1,5 +1,5 @@
|
||||
-- The two pieces of Gen 2 world state that override where a wild mon comes
|
||||
-- from: the three roaming legendaries, and swarms.
|
||||
-- from: the roaming legendaries, and swarms.
|
||||
--
|
||||
-- Both live in one module because they are the same KIND of thing -- a
|
||||
-- persistent record that sits in front of a map's own encounter table -- and
|
||||
@@ -26,6 +26,7 @@
|
||||
-- pair InitRoamMons never writes and BattleEnd_HandleRoamMons writes when a
|
||||
-- beast is caught or beaten -- is nil.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
@@ -37,7 +38,7 @@ local Roamers = {}
|
||||
-- map connection or a door triggers) and JumpRoamMons (the scatter a fly or a
|
||||
-- teleport triggers) -- so a tracker mod sees every hop.
|
||||
--
|
||||
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune
|
||||
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune (Gold only)
|
||||
-- slot that roamer record, already carrying the new map
|
||||
-- species the beast's species id
|
||||
-- from the map id it left
|
||||
@@ -68,9 +69,31 @@ Roamers.SPECIES = {
|
||||
{ species = "ENTEI", level = 40, map = "ROUTE_37" },
|
||||
{ species = "SUICUNE", level = 40, map = "ROUTE_38" },
|
||||
}
|
||||
Roamers.COUNT = 3
|
||||
Roamers.LEVEL = 40
|
||||
|
||||
-- pokecrystal/engine/overworld/wildmons.asm:493 seeds Raikou and Entei only,
|
||||
-- so the roster is read out of the cache when the cache carries one.
|
||||
local function startMapFor(species)
|
||||
for _, row in ipairs(Roamers.SPECIES) do
|
||||
if row.species == species then return row.map end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Roamers.roster(encounters)
|
||||
local extracted = encounters and encounters.roamMons
|
||||
if type(extracted) ~= "table" or #extracted == 0 then return Roamers.SPECIES end
|
||||
local rows = {}
|
||||
for _, row in ipairs(extracted) do
|
||||
rows[#rows + 1] = {
|
||||
species = row.species,
|
||||
level = row.level or Roamers.LEVEL,
|
||||
map = row.map or startMapFor(row.species),
|
||||
}
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
-- data/wild/roammon_maps.asm, entry for entry and in order. The order matters
|
||||
-- twice over: `.Update` picks a connection by a two-bit index into the list, so
|
||||
-- shuffling one row changes which route a beast walks to, and JumpRoamMon picks
|
||||
@@ -185,7 +208,7 @@ end
|
||||
-- The save record
|
||||
--------------------------------------------------------------------------
|
||||
--
|
||||
-- save.roamers is a three-slot array in the order above, written first by the
|
||||
-- save.roamers is a slot array in the roster order above, written first by the
|
||||
-- InitRoamMons special (src/script/gen2/Specials.lua) when the Burned Tower
|
||||
-- basement script fires. Each slot:
|
||||
--
|
||||
@@ -215,13 +238,15 @@ function Roamers.slot(save, index)
|
||||
end
|
||||
|
||||
-- InitRoamMons. Safe to call twice: the Burned Tower script is behind a scene
|
||||
-- flag, but a re-init would hand the player three fresh beasts, so this only
|
||||
-- writes when there is nothing there.
|
||||
-- flag, but a re-init would hand the player fresh beasts, so this only writes
|
||||
-- when there is nothing there.
|
||||
function Roamers.init(save, opts)
|
||||
if type(save) ~= "table" then return nil end
|
||||
if save.roamers and not (opts and opts.force) then return save.roamers end
|
||||
local encounters = opts and (opts.encounters
|
||||
or (opts.data and opts.data.gen2Encounters))
|
||||
local list = {}
|
||||
for _, row in ipairs(Roamers.SPECIES) do
|
||||
for _, row in ipairs(Roamers.roster(encounters)) do
|
||||
list[#list + 1] = {
|
||||
species = row.species,
|
||||
level = row.level,
|
||||
@@ -413,7 +438,23 @@ end
|
||||
-- -- the roaming battle is one attack long unless it is trapped. The other
|
||||
-- two lists are the same routine's 50% and 10% gates and live here so the
|
||||
-- battle engine has one place to read them from.
|
||||
Roamers.ALWAYS_FLEE = { RAIKOU = true, ENTEI = true, SUICUNE = true }
|
||||
-- pokegold/data/wild/flee_mons.asm:34 lists Suicune; pokecrystal's:34 ends the
|
||||
-- list at Entei, which is what makes maps/TinTower1F.asm:119 catchable.
|
||||
Roamers.ALWAYS_FLEE_BY_ENGINE = {
|
||||
gs = { RAIKOU = true, ENTEI = true, SUICUNE = true },
|
||||
crystal = { RAIKOU = true, ENTEI = true },
|
||||
}
|
||||
|
||||
function Roamers.alwaysFleeMons(versionId)
|
||||
return Roamers.ALWAYS_FLEE_BY_ENGINE[GameVersion.engine(versionId)]
|
||||
or Roamers.ALWAYS_FLEE_BY_ENGINE.gs
|
||||
end
|
||||
|
||||
-- src/battle/gen2/Battle.lua:4070 reads AlwaysFleeMons by species name.
|
||||
Roamers.ALWAYS_FLEE = setmetatable({}, {
|
||||
__index = function(_, species) return Roamers.alwaysFleeMons()[species] end,
|
||||
})
|
||||
|
||||
Roamers.OFTEN_FLEE = {
|
||||
CUBONE = true, ARTICUNO = true, ZAPDOS = true, MOLTRES = true,
|
||||
QUAGSIRE = true, DELIBIRD = true, PHANPY = true, TEDDIURSA = true,
|
||||
@@ -433,29 +474,79 @@ local Swarm = {}
|
||||
Roamers.Swarm = Swarm
|
||||
|
||||
-- The state, on the save:
|
||||
-- save.swarmMap wSwarmMapGroup / wSwarmMapNumber, as a map id
|
||||
-- save.swarmMaps the stored map pairs, keyed by swarm kind
|
||||
-- save.swarmMap the Gold single pair, kept as the legacy alias
|
||||
-- save.dailyFlags.swarm DAILYFLAGS1_SWARM_F
|
||||
-- save.dailyFlags.fishingSwarm wFishingSwarmFlag (FISHSWARM_* 0/1/2)
|
||||
-- save.dailyResetDay the day wDailyResetTimer was last restarted
|
||||
--
|
||||
-- src/world/gen2/World.lua:setSwarm and the ActivateFishingSwarm special
|
||||
-- already write the first three; this module is where they are READ and where
|
||||
-- they expire.
|
||||
-- already write the flags and the alias; this module is where they are READ
|
||||
-- and where they expire.
|
||||
|
||||
-- constants/script_constants.asm, ActivateFishingSwarm setval arguments.
|
||||
Swarm.FISH_NONE = 0
|
||||
Swarm.FISH_QWILFISH = 1
|
||||
Swarm.FISH_REMORAID = 2
|
||||
|
||||
-- pokecrystal/constants/script_constants.asm:256-257 -- the kind byte Crystal
|
||||
-- puts in front of a `swarm` map, choosing between its two stored pairs.
|
||||
Swarm.KIND_ORDER = { "DUNSPARCE", "YANMA" }
|
||||
Swarm.KINDS = { [0] = "DUNSPARCE", [1] = "YANMA" }
|
||||
Swarm.DEFAULT_KIND = "DUNSPARCE"
|
||||
|
||||
local KIND_NAMES = { DUNSPARCE = true, YANMA = true }
|
||||
|
||||
local function kindName(kind)
|
||||
if type(kind) == "number" then return Swarm.KINDS[kind] or Swarm.DEFAULT_KIND end
|
||||
if KIND_NAMES[kind] then return kind end
|
||||
return Swarm.DEFAULT_KIND
|
||||
end
|
||||
|
||||
-- pokegold/engine/events/specials.asm:288-293 has the one pair, so a Gold save
|
||||
-- (and every save written before this record was keyed) folds onto that key.
|
||||
local function goldShape(maps)
|
||||
return maps.YANMA == nil
|
||||
end
|
||||
|
||||
function Swarm.maps(save)
|
||||
if type(save) ~= "table" then return nil end
|
||||
local maps = save.swarmMaps
|
||||
if type(maps) ~= "table" then
|
||||
maps = {}
|
||||
save.swarmMaps = maps
|
||||
end
|
||||
if save.swarmMap ~= nil and goldShape(maps) then
|
||||
maps[Swarm.DEFAULT_KIND] = save.swarmMap
|
||||
end
|
||||
return maps
|
||||
end
|
||||
|
||||
local function anyMap(save)
|
||||
if type(save) ~= "table" then return false end
|
||||
if save.swarmMap ~= nil then return true end
|
||||
local maps = save.swarmMaps
|
||||
if type(maps) ~= "table" then return false end
|
||||
for _, name in ipairs(Swarm.KIND_ORDER) do
|
||||
if maps[name] ~= nil then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- StoreSwarmMapIndices, which FALLS THROUGH into SetSwarmFlag: one command
|
||||
-- writes the map pair AND the daily flag. A port that stored only the map
|
||||
-- would leave the Dunsparce call live for the rest of the game, because
|
||||
-- CheckSwarmFlag answers off the flag and clears the pair itself.
|
||||
function Swarm.set(save, mapId)
|
||||
--
|
||||
-- pokecrystal/engine/events/specials.asm:290-306 picks the pair off c instead.
|
||||
function Swarm.set(save, mapId, kind)
|
||||
if type(save) ~= "table" then return false end
|
||||
save.dailyFlags = save.dailyFlags or {}
|
||||
save.dailyFlags.swarm = true
|
||||
save.swarmMap = mapId
|
||||
local name = kindName(kind)
|
||||
local maps = Swarm.maps(save)
|
||||
maps[name] = mapId
|
||||
if name == Swarm.DEFAULT_KIND then save.swarmMap = mapId end
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -475,9 +566,22 @@ function Swarm.active(save)
|
||||
and save.dailyFlags.swarm == true
|
||||
end
|
||||
|
||||
function Swarm.mapId(save)
|
||||
function Swarm.mapId(save, kind)
|
||||
if not Swarm.active(save) then return nil end
|
||||
return save.swarmMap
|
||||
local maps = Swarm.maps(save)
|
||||
return maps and maps[kindName(kind)] or nil
|
||||
end
|
||||
|
||||
-- pokecrystal/engine/overworld/wildmons.asm:414-451 tests Dunsparce first and
|
||||
-- falls through to Yanma, so a map both are on answers Dunsparce.
|
||||
function Swarm.onMap(save, mapId)
|
||||
if mapId == nil or not Swarm.active(save) then return nil end
|
||||
local maps = Swarm.maps(save)
|
||||
if not maps then return nil end
|
||||
for _, name in ipairs(Swarm.KIND_ORDER) do
|
||||
if maps[name] == mapId then return name end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Swarm.fishing(save)
|
||||
@@ -493,6 +597,12 @@ function Swarm.check(save)
|
||||
if type(save) ~= "table" then return 1 end
|
||||
if Swarm.active(save) then return 0 end
|
||||
if save.dailyFlags then save.dailyFlags.fishingSwarm = nil end
|
||||
-- pokecrystal/engine/overworld/time.asm:103-112 zeroes wSwarmFlags whole,
|
||||
-- which strands both of Crystal's pairs on the one daily tick.
|
||||
local maps = save.swarmMaps
|
||||
if type(maps) == "table" then
|
||||
for _, name in ipairs(Swarm.KIND_ORDER) do maps[name] = nil end
|
||||
end
|
||||
save.swarmMap = nil
|
||||
return 1
|
||||
end
|
||||
@@ -518,10 +628,10 @@ end
|
||||
-- rather than needing its own timer. Returns true when the swarm ended on
|
||||
-- this call.
|
||||
function Swarm.timeEvents(save, day)
|
||||
local hadMap = anyMap(save)
|
||||
local reset = Swarm.checkDailyReset(save, day)
|
||||
local hadMap = save and save.swarmMap ~= nil
|
||||
Swarm.check(save)
|
||||
return reset and hadMap and (save.swarmMap == nil)
|
||||
return reset and hadMap and not anyMap(save)
|
||||
end
|
||||
|
||||
-- _SwarmWildmonCheck: the swarm table is searched BEFORE the Johto/Kanto one,
|
||||
@@ -538,7 +648,7 @@ end
|
||||
-- lookup here falls through to the map's own list.
|
||||
function Swarm.entry(save, encounters, mapId, kind)
|
||||
if not encounters then return nil end
|
||||
if Swarm.mapId(save) ~= mapId then return nil end
|
||||
if not Swarm.onMap(save, mapId) then return nil end
|
||||
local table_ = (kind == "water") and encounters.swarmWater
|
||||
or encounters.swarmGrass
|
||||
return table_ and table_[mapId] or nil
|
||||
|
||||
+85
-6
@@ -133,10 +133,33 @@ local function fs()
|
||||
return love.filesystem
|
||||
end
|
||||
|
||||
-- The blank-name fallback is the first PlayerNameArray row, which differs
|
||||
-- per edition -- data/player_names.asm:12-23.
|
||||
function Save.defaultPlayerName(version)
|
||||
return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD"
|
||||
-- The first PlayerNameArray row -- pokegold data/player_names.asm:12-22, and
|
||||
-- Crystal's gender-split arrays at data/player_names.asm:12-16, :31-35.
|
||||
Save.DEFAULT_PLAYER_NAMES = {
|
||||
gold = "GOLD",
|
||||
silver = "SILVER",
|
||||
crystal = "CHRIS",
|
||||
}
|
||||
|
||||
-- FemalePlayerNameArray's first row, the `.Kris` dname NamePlayer falls back to
|
||||
-- (data/player_names.asm:31-32, engine/menus/intro_menu.asm:768-781).
|
||||
Save.DEFAULT_PLAYER_NAMES_FEMALE = {
|
||||
crystal = "KRIS",
|
||||
}
|
||||
|
||||
-- wPlayerGender as this save spells it (constants/ram_constants.asm:176-177).
|
||||
function Save.isFemale(save)
|
||||
local player = type(save) == "table" and save.player
|
||||
return (player and player.gender) == "female"
|
||||
end
|
||||
|
||||
function Save.defaultPlayerName(version, gender)
|
||||
version = version or GameVersion.get()
|
||||
if gender == "female" then
|
||||
local female = Save.DEFAULT_PLAYER_NAMES_FEMALE[version]
|
||||
if female then return female end
|
||||
end
|
||||
return Save.DEFAULT_PLAYER_NAMES[version] or "GOLD"
|
||||
end
|
||||
|
||||
-- A fresh Gen 2 save. `opts` carries what the intro collected: player name,
|
||||
@@ -148,10 +171,13 @@ function Save.newGame(opts)
|
||||
version = GameVersion.get(),
|
||||
generation = 2,
|
||||
player = {
|
||||
name = opts.playerName or Save.defaultPlayerName(),
|
||||
name = opts.playerName
|
||||
or Save.defaultPlayerName(nil, opts.gender or "male"),
|
||||
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
|
||||
-- (engine/menus/intro_menu.asm:41-49).
|
||||
id = opts.trainerId or rand(0, 65535),
|
||||
-- InitCrystalData zeroes wPlayerGender before InitGender is even offered
|
||||
-- (engine/menus/init_gender.asm:1-6).
|
||||
gender = opts.gender or "male",
|
||||
money = 3000,
|
||||
coins = 0,
|
||||
@@ -379,6 +405,49 @@ local function normalizePokerus(mons)
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:140 sGSBallFlag. nil is the cleared byte.
|
||||
Save.GS_BALL_STATES = { have = true, given = true, used = true }
|
||||
|
||||
local function counter(value)
|
||||
return math.max(0, math.floor(tonumber(value) or 0))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:138 "SRAM Crystal Data", created on demand the
|
||||
-- way Mail.state creates sPartyMail, so a Crystal handler can index freely.
|
||||
function Save.crystalState(save)
|
||||
local crystal = save.crystal or {}
|
||||
save.crystal = crystal
|
||||
if crystal.celebiCaught == nil then crystal.celebiCaught = false end
|
||||
crystal.beasts = crystal.beasts or {}
|
||||
-- ../pokecrystal/ram/wram.asm:3342 wBuenasPassword, :3343 wBlueCardBalance.
|
||||
local buena = crystal.buenaPassword or {}
|
||||
crystal.buenaPassword = buena
|
||||
buena.prizesToday = counter(buena.prizesToday)
|
||||
buena.streak = counter(buena.streak)
|
||||
-- ../pokecrystal/engine/events/move_tutor.asm:1 MoveTutor.
|
||||
crystal.moveTutor = crystal.moveTutor or {}
|
||||
if crystal.moveTutor.used == nil then crystal.moveTutor.used = false end
|
||||
-- ../pokecrystal/ram/wram.asm:3445 wUnlockedUnowns.
|
||||
crystal.unownWords = crystal.unownWords or {}
|
||||
return crystal
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:147 "SRAM Battle Tower". `reward` stays nil
|
||||
-- until one is won, which is sBattleTowerReward's zero byte (:160).
|
||||
function Save.battleTowerState(save)
|
||||
local tower = save.battleTower or {}
|
||||
save.battleTower = tower
|
||||
-- ../pokecrystal/ram/sram.asm:155 sNrOfBeatenBattleTowerTrainers.
|
||||
tower.streak = counter(tower.streak)
|
||||
tower.best = counter(tower.best)
|
||||
-- ../pokecrystal/ram/sram.asm:150 sBattleTowerChallengeState: 0 normal, 2 tower.
|
||||
tower.challenge = counter(tower.challenge)
|
||||
-- ../pokecrystal/ram/sram.asm:162 sBTMonOfTrainers.
|
||||
tower.prevTeams = tower.prevTeams or {}
|
||||
if tower.inChallenge == nil then tower.inChallenge = false end
|
||||
return tower
|
||||
end
|
||||
|
||||
-- Fill in anything a save (or an older save) is missing, so callers can index
|
||||
-- freely. Runs on both newGame and load.
|
||||
function Save.normalize(save)
|
||||
@@ -390,7 +459,11 @@ function Save.normalize(save)
|
||||
end
|
||||
save.generation = 2
|
||||
save.player = save.player or {}
|
||||
save.player.name = save.player.name or Save.defaultPlayerName(save.version)
|
||||
-- _ResetWRAM leaves wPlayerGender at 0, PLAYERGENDER_MALE, and Gold never
|
||||
-- writes it -- constants/ram_constants.asm:177.
|
||||
save.player.gender = save.player.gender or "male"
|
||||
save.player.name = save.player.name
|
||||
or Save.defaultPlayerName(save.version, save.player.gender)
|
||||
save.player.id = save.player.id or rand(0, 65535)
|
||||
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
|
||||
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
|
||||
@@ -462,6 +535,12 @@ function Save.normalize(save)
|
||||
save.playTime = save.playTime
|
||||
or { hours = 0, minutes = 0, seconds = 0, frames = 0 }
|
||||
save.rtc = save.rtc or {}
|
||||
-- ../pokecrystal/ram/sram.asm:138,147: both regions are Crystal's own, so a
|
||||
-- Gold or Silver file never grows either key.
|
||||
if GameVersion.engine(save.version) == "crystal" then
|
||||
Save.crystalState(save)
|
||||
Save.battleTowerState(save)
|
||||
end
|
||||
-- HallOfFame.record fills in the count and the roster list, and trims a
|
||||
-- roster that a corrupt file grew past NUM_HOF_TEAMS -- the same guard the
|
||||
-- party gets below, for the same reason.
|
||||
|
||||
@@ -249,12 +249,13 @@ function SwitchDiagnostics.probeAssets(version)
|
||||
end
|
||||
|
||||
-- Shallow listing so we can see if the extract tree exists at all.
|
||||
local roots = { "yellow", "blue", "gold", "silver", "assets",
|
||||
local roots = { "yellow", "blue", "gold", "silver", "crystal", "assets",
|
||||
"yellow/assets/generated",
|
||||
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
||||
"gold/assets/generated", "gold/assets/generated/sprites",
|
||||
"gold/data/generated", "silver/assets/generated",
|
||||
"silver/data/generated" }
|
||||
"silver/data/generated", "crystal/assets/generated",
|
||||
"crystal/data/generated" }
|
||||
for _, dir in ipairs(roots) do
|
||||
local info = filesystem.getInfo(dir)
|
||||
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
-- Crystal's intro movie and title screen extraction (pokecrystal
|
||||
-- engine/movie/intro.asm:1678-1777, engine/movie/title.asm:364-374).
|
||||
-- Called from RomExtractorGen2's extractIntro/extractTitle stages when
|
||||
-- edition == "crystal"; reads through the passed-in extractor and never
|
||||
-- mutates it.
|
||||
|
||||
local ImageWriter = require("src.import.ImageWriter")
|
||||
|
||||
local CrystalMovie = {}
|
||||
|
||||
local TILE_BYTES = 16
|
||||
local SHEET_TILES = 16
|
||||
|
||||
-- vBGMap tilemaps and attrmaps are one full 32x32 map
|
||||
-- (engine/movie/intro.asm:1611-1628 decompresses $40 tiles = 1024 bytes).
|
||||
local MAP_BYTES = 1024
|
||||
|
||||
local function padTiles(raw, count)
|
||||
local length = count * TILE_BYTES
|
||||
while #raw > length do table.remove(raw) end
|
||||
while #raw < length do raw[#raw + 1] = 0 end
|
||||
return raw
|
||||
end
|
||||
|
||||
-- Lay `count` tiles of `source` (from tile `from`, 0-based) over `target`
|
||||
-- starting at tile `at` (0-based).
|
||||
local function overlayTiles(target, source, at, from, count)
|
||||
for offset = 1, count * TILE_BYTES do
|
||||
target[at * TILE_BYTES + offset] = source[from * TILE_BYTES + offset] or 0
|
||||
end
|
||||
return target
|
||||
end
|
||||
|
||||
local function blankTiles(count)
|
||||
local out = {}
|
||||
for index = 1, count * TILE_BYTES do out[index] = 0 end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Sheets are 16 tiles per row so tile id N resolves to (N % 16, N / 16),
|
||||
-- the same layout data/sprite_anims/oam.asm:120-136 assumes.
|
||||
local function writeSheet(self, raw, count, relative)
|
||||
padTiles(raw, count)
|
||||
local rows = math.ceil(count / SHEET_TILES)
|
||||
padTiles(raw, rows * SHEET_TILES)
|
||||
self:write2bpp(raw, SHEET_TILES * 8, rows * 8, relative, true)
|
||||
return "assets/generated/" .. relative
|
||||
end
|
||||
|
||||
local function readMap(self, label)
|
||||
local bytes = self:decompressLz3Symbol(label)
|
||||
while #bytes > MAP_BYTES do table.remove(bytes) end
|
||||
while #bytes < MAP_BYTES do bytes[#bytes + 1] = 0 end
|
||||
return bytes
|
||||
end
|
||||
|
||||
-- 16 palettes: 8 BG then 8 OBJ (engine/movie/intro.asm:123-130 copies
|
||||
-- `16 palettes` over wBGPals1, which wOBPals1 follows).
|
||||
local function readPalettes(self, label)
|
||||
local symbol = self:symbol(label)
|
||||
local flat = self:colors(symbol.bank, symbol.address, 64)
|
||||
local bg, obj = {}, {}
|
||||
for pal = 0, 7 do
|
||||
local a, b = {}, {}
|
||||
for slot = 1, 4 do
|
||||
a[slot] = flat[pal * 4 + slot]
|
||||
b[slot] = flat[(pal + 8) * 4 + slot]
|
||||
end
|
||||
bg[pal + 1], obj[pal + 1] = a, b
|
||||
end
|
||||
return { bg = bg, obj = obj }
|
||||
end
|
||||
|
||||
local function readColors(self, label, count)
|
||||
local symbol = self:symbol(label)
|
||||
return self:colors(symbol.bank, symbol.address, count)
|
||||
end
|
||||
|
||||
function CrystalMovie.extractIntro(self)
|
||||
self:beginStage("Intro movie")
|
||||
local out = {
|
||||
generation = 2,
|
||||
layout = "crystal",
|
||||
source = "ROM:CrystalIntro (engine/movie/intro.asm)",
|
||||
}
|
||||
|
||||
local unowns = padTiles(self:decompressLz3Symbol("IntroUnownsGFX"), 128)
|
||||
local grassSym = self:symbol("IntroGrass4GFX")
|
||||
local grass4 = self.rom:bytes(grassSym.bank, grassSym.address, TILE_BYTES)
|
||||
|
||||
local unownsPath = writeSheet(self, unowns, 128, "intro/unowns_tiles.png")
|
||||
local pulsePath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroPulseGFX"), 16, "intro/pulse_sprites.png")
|
||||
local backgroundPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroBackgroundGFX"), 128,
|
||||
"intro/background_tiles.png")
|
||||
local runPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroSuicuneRunGFX"), 192,
|
||||
"intro/suicune_run_sprites.png")
|
||||
local pichuPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroPichuWooperGFX"), 128,
|
||||
"intro/pichu_wooper_sprites.png")
|
||||
self:tick("Intro movie", 1, 4)
|
||||
|
||||
-- IntroScene15: jump BG at vTiles2 plus IntroGrass4GFX at vTiles1 tile 0,
|
||||
-- i.e. BG id $80 (engine/movie/intro.asm:744-753).
|
||||
local jump = blankTiles(256)
|
||||
overlayTiles(jump, self:decompressLz3Symbol("IntroSuicuneJumpGFX"), 0, 0, 128)
|
||||
overlayTiles(jump, grass4, 0x80, 0, 1)
|
||||
local jumpPath = writeSheet(self, jump, 256, "intro/suicune_jump_tiles.png")
|
||||
|
||||
-- The same grass tile is the whole of the SUICUNE_AWAY object's sheet
|
||||
-- (data/sprite_anims/oam.asm:136 base $80; engine/movie/intro.asm:750-753).
|
||||
local unownBack = blankTiles(144)
|
||||
overlayTiles(unownBack, self:decompressLz3Symbol("IntroUnownBackGFX"), 0, 0, 48)
|
||||
overlayTiles(unownBack, grass4, 0x80, 0, 1)
|
||||
local unownBackPath = writeSheet(self, unownBack, 144,
|
||||
"intro/unown_back_sprites.png")
|
||||
|
||||
-- IntroScene17 loads 255 tiles from vTiles1, so BG id $80 is close tile 0
|
||||
-- and ids wrap through $00-$7e (engine/movie/intro.asm:825-828).
|
||||
local closeRaw = padTiles(self:decompressLz3Symbol("IntroSuicuneCloseGFX"), 255)
|
||||
local close = blankTiles(256)
|
||||
for id = 0, 255 do
|
||||
local from = (id + 128) % 256
|
||||
if from < 255 then overlayTiles(close, closeRaw, id, from, 1) end
|
||||
end
|
||||
local closePath = writeSheet(self, close, 256, "intro/suicune_close_tiles.png")
|
||||
|
||||
-- IntroScene19: suicune_back at vTiles2, the Unown ring at vTiles1, and
|
||||
-- grass over vTiles1 tile $7f = BG id $ff (engine/movie/intro.asm:890-901).
|
||||
local back = blankTiles(256)
|
||||
overlayTiles(back, self:decompressLz3Symbol("IntroSuicuneBackGFX"), 0, 0, 128)
|
||||
overlayTiles(back, unowns, 128, 0, 128)
|
||||
overlayTiles(back, grass4, 0xff, 0, 1)
|
||||
local backPath = writeSheet(self, back, 256, "intro/suicune_back_tiles.png")
|
||||
|
||||
local crystalUnownsPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroCrystalUnownsGFX"), 32,
|
||||
"intro/crystal_unowns_tiles.png")
|
||||
|
||||
-- Intro_RustleGrass swaps 4 tiles at vTiles2 tile $09 through grass1/2/3
|
||||
-- (engine/movie/intro.asm:1521-1547); one 16-tile row, frame f at f*4.
|
||||
local grassStrip = blankTiles(16)
|
||||
for frame, label in ipairs({ "IntroGrass1GFX", "IntroGrass2GFX",
|
||||
"IntroGrass3GFX" }) do
|
||||
local sym = self:symbol(label)
|
||||
overlayTiles(grassStrip,
|
||||
self.rom:bytes(sym.bank, sym.address, 4 * TILE_BYTES), (frame - 1) * 4,
|
||||
0, 4)
|
||||
end
|
||||
out.grassFrames = writeSheet(self, grassStrip, 16, "intro/grass_anim.png")
|
||||
self:tick("Intro movie", 2, 4)
|
||||
|
||||
local unownPals = readPalettes(self, "IntroUnownsPalette")
|
||||
local backgroundPals = readPalettes(self, "IntroBackgroundPalette")
|
||||
local suicunePals = readPalettes(self, "IntroSuicunePalette")
|
||||
local closePals = readPalettes(self, "IntroSuicuneClosePalette")
|
||||
local crystalUnownsPals = readPalettes(self, "IntroCrystalUnownsPalette")
|
||||
|
||||
local function act(tiles, sprites, tilemapLabel, attrmapLabel, palettes)
|
||||
return {
|
||||
tiles = tiles,
|
||||
sprites = sprites,
|
||||
tilemap = readMap(self, tilemapLabel),
|
||||
attrmap = readMap(self, attrmapLabel),
|
||||
palettes = palettes,
|
||||
}
|
||||
end
|
||||
|
||||
out.acts = {
|
||||
unownA = act(unownsPath, pulsePath,
|
||||
"IntroUnownATilemap", "IntroUnownAAttrmap", unownPals),
|
||||
unownHI = act(unownsPath, pulsePath,
|
||||
"IntroUnownHITilemap", "IntroUnownHIAttrmap", unownPals),
|
||||
unowns = act(unownsPath, pulsePath,
|
||||
"IntroUnownsTilemap", "IntroUnownsAttrmap", unownPals),
|
||||
background = act(backgroundPath, runPath,
|
||||
"IntroBackgroundTilemap", "IntroBackgroundAttrmap", backgroundPals),
|
||||
suicuneJump = act(jumpPath, unownBackPath,
|
||||
"IntroSuicuneJumpTilemap", "IntroSuicuneJumpAttrmap", suicunePals),
|
||||
suicuneClose = act(closePath, nil,
|
||||
"IntroSuicuneCloseTilemap", "IntroSuicuneCloseAttrmap", closePals),
|
||||
suicuneBack = act(backPath, unownBackPath,
|
||||
"IntroSuicuneBackTilemap", "IntroSuicuneBackAttrmap", suicunePals),
|
||||
crystalUnowns = act(crystalUnownsPath, nil,
|
||||
"IntroCrystalUnownsTilemap", "IntroCrystalUnownsAttrmap",
|
||||
crystalUnownsPals),
|
||||
}
|
||||
-- IntroScene7/10 load pichu_wooper into VRAM bank 1
|
||||
-- (engine/movie/intro.asm:340-348); OAM_BANK1 picks this sheet.
|
||||
out.acts.background.sprites1 = pichuPath
|
||||
self:tick("Intro movie", 3, 4)
|
||||
|
||||
out.fades = {
|
||||
toWhite = {},
|
||||
unownAppear = readColors(self, "Intro_Scene20_AppearUnown.pal1", 4),
|
||||
unownAppear2 = readColors(self, "Intro_Scene20_AppearUnown.pal2", 4),
|
||||
wordFast = readColors(self, "Intro_FadeUnownWordPals.FastFadePalettes", 16),
|
||||
wordSlow = readColors(self, "Intro_FadeUnownWordPals.SlowFadePalettes", 16),
|
||||
}
|
||||
-- Intro_Scene24_ApplyPaletteFade steps 8 rows of one palette each
|
||||
-- (engine/movie/intro.asm:1155-1189).
|
||||
local fade = readColors(self, "Intro_Scene24_ApplyPaletteFade.FadePals", 32)
|
||||
for row = 0, 7 do
|
||||
local pal = {}
|
||||
for slot = 1, 4 do pal[slot] = fade[row * 4 + slot] end
|
||||
out.fades.toWhite[row + 1] = pal
|
||||
end
|
||||
|
||||
self:write("intro", out)
|
||||
self:tick("Intro movie", 4, 4)
|
||||
return out
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
local function shadeOf(r)
|
||||
if r > 0.9 then return 0 end
|
||||
if r > 0.5 then return 1 end
|
||||
if r > 0.2 then return 2 end
|
||||
return 3
|
||||
end
|
||||
|
||||
local function tilesFrom2bpp(raw)
|
||||
local tiles = {}
|
||||
for offset = 1, #raw - (#raw % TILE_BYTES), TILE_BYTES do
|
||||
local one = {}
|
||||
for index = offset, offset + TILE_BYTES - 1 do one[#one + 1] = raw[index] end
|
||||
tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, true)
|
||||
end
|
||||
return tiles
|
||||
end
|
||||
|
||||
local function blitTile(target, tile, tx, ty)
|
||||
if not tile then return end
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local r, g, b, a = tile:getPixel(x, y)
|
||||
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function colorize(image, palFor)
|
||||
local w, h = image:getDimensions()
|
||||
local out = ImageWriter.blank(w, h, 0, 0, 0, 0)
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local r, _, _, a = image:getPixel(x, y)
|
||||
if a ~= 0 then
|
||||
local pal = palFor(x, y)
|
||||
local c = pal[shadeOf(r) + 1] or pal[4] or { 0, 0, 0 }
|
||||
out:setPixel(x, y, c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function CrystalMovie.extractTitle(self)
|
||||
self:beginStage("Title screen")
|
||||
|
||||
-- TitleLogoGFX decompresses to vTiles1, so BG id $80 is logo tile 0 and
|
||||
-- ids $00-$1b wrap on past it (engine/movie/title.asm:91-94).
|
||||
local logoTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleLogoGFX"))
|
||||
-- TitleSuicuneGFX fills vTiles4-vTiles5: bank-1 ids $80-$ff then $00-$7f
|
||||
-- (engine/movie/title.asm:24-27).
|
||||
local suicuneTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleSuicuneGFX"))
|
||||
local gemTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleCrystalGFX"))
|
||||
local pals = readPalettes(self, "TitleScreenPalettes")
|
||||
self:tick("Title screen", 1, 5)
|
||||
|
||||
-- Attribute regions from _TitleScreen's ByteFills
|
||||
-- (engine/movie/title.asm:39-85): logo gradient rows, the version strip,
|
||||
-- pal 7 on the window copyright line, pal 0 everywhere else.
|
||||
local function bgPalAt(col, row)
|
||||
if row == 9 and col >= 5 and col <= 15 then return 1 end
|
||||
if row >= 3 and row <= 4 then return 2 end
|
||||
if row == 5 then return 3 end
|
||||
if row == 6 then return 4 end
|
||||
if row == 7 then return 5 end
|
||||
if row >= 8 and row <= 9 then return 6 end
|
||||
if row == 17 then return 7 end
|
||||
return 0
|
||||
end
|
||||
|
||||
local shade = ImageWriter.blank(160, 144, 0, 0, 0, 0)
|
||||
-- DrawTitleGraphic lays 7 rows of 20 running tiles from d=$80
|
||||
-- (engine/movie/title.asm:107-112,275-302).
|
||||
for row = 0, 6 do
|
||||
for col = 0, 19 do
|
||||
blitTile(shade, logoTiles[row * 20 + col + 1], col * 8, (row + 3) * 8)
|
||||
end
|
||||
end
|
||||
-- Copyright line: 13 tiles from d=$c on the window's row 0, shown at
|
||||
-- hWY = $88 (engine/movie/title.asm:114-119; engine/menus/intro_menu.asm:1121-1122).
|
||||
for index = 0, 12 do
|
||||
blitTile(shade, logoTiles[128 + 12 + index + 1], (3 + index) * 8, 17 * 8)
|
||||
end
|
||||
|
||||
local colored = colorize(shade, function(x, y)
|
||||
return pals.bg[bgPalAt(math.floor(x / 8), math.floor(y / 8)) + 1]
|
||||
end)
|
||||
self:save(colored, "title/crystal_screen.png")
|
||||
self:save(shade, "title/crystal_screen_gray.png")
|
||||
|
||||
local logo = ImageWriter.blank(160, 56, 0, 0, 0, 0)
|
||||
ImageWriter.blit(logo, colored, 0, 0, 0, 24, 160, 56)
|
||||
self:save(logo, "title/crystal_logo.png")
|
||||
local wordmark = ImageWriter.blank(88, 8, 0, 0, 0, 0)
|
||||
ImageWriter.blit(wordmark, colored, 0, 0, 40, 72, 88, 8)
|
||||
self:save(wordmark, "title/crystal_wordmark.png")
|
||||
self:tick("Title screen", 2, 5)
|
||||
|
||||
-- LoadSuicuneFrame: 6 rows of 8 tiles at hlcoord 6,12, row stride 16;
|
||||
-- frame bases $80/$88/$00/$08 (engine/movie/title.asm:245-273).
|
||||
local suicunePaths, suicuneGrayPaths = {}, {}
|
||||
for index, base in ipairs({ 0x00, 0x08, 0x80, 0x88 }) do
|
||||
local frame = ImageWriter.blank(64, 48, 0, 0, 0, 0)
|
||||
for row = 0, 5 do
|
||||
for col = 0, 7 do
|
||||
blitTile(frame, suicuneTiles[base + row * 16 + col + 1],
|
||||
col * 8, row * 8)
|
||||
end
|
||||
end
|
||||
local tinted = colorize(frame, function() return pals.bg[1] end)
|
||||
local rel = ("title/crystal_suicune_%d.png"):format(index)
|
||||
self:save(tinted, rel)
|
||||
suicunePaths[index] = "assets/generated/" .. rel
|
||||
local grayRel = ("title/crystal_suicune_%d_gray.png"):format(index)
|
||||
self:save(frame, grayRel)
|
||||
suicuneGrayPaths[index] = "assets/generated/" .. grayRel
|
||||
if index == 1 then self:save(tinted, "title/crystal_suicune.png") end
|
||||
end
|
||||
self:tick("Title screen", 3, 5)
|
||||
|
||||
-- InitializeBackground: five 48x16 strips of six 8x16 OBJs, consecutive
|
||||
-- tile pairs, OBJ pal 0, OAM_PRIO (engine/movie/title.asm:304-338).
|
||||
local gem = ImageWriter.blank(48, 80, 0, 0, 0, 0)
|
||||
for strip = 0, 4 do
|
||||
for slot = 0, 5 do
|
||||
local tile = strip * 12 + slot * 2
|
||||
blitTile(gem, gemTiles[tile + 1], slot * 8, strip * 16)
|
||||
blitTile(gem, gemTiles[tile + 2], slot * 8, strip * 16 + 8)
|
||||
end
|
||||
end
|
||||
local gemTinted = colorize(gem, function() return pals.obj[1] end)
|
||||
self:save(gemTinted, "title/crystal_gem.png")
|
||||
self:save(gem, "title/crystal_gem_gray.png")
|
||||
self:tick("Title screen", 4, 5)
|
||||
|
||||
local function unit(color) return { color[1] / 255, color[2] / 255, color[3] / 255 } end
|
||||
|
||||
local data = {
|
||||
generation = 2,
|
||||
layout = "crystal_title",
|
||||
source = "ROM:TitleSuicuneGFX + TitleLogoGFX + TitleCrystalGFX"
|
||||
.. " + TitleScreenPalettes",
|
||||
screen = "assets/generated/title/crystal_screen.png",
|
||||
screenGray = "assets/generated/title/crystal_screen_gray.png",
|
||||
image = "assets/generated/title/crystal_logo.png",
|
||||
wordmark = "assets/generated/title/crystal_wordmark.png",
|
||||
suicune = "assets/generated/title/crystal_suicune.png",
|
||||
suicuneFrames = suicunePaths,
|
||||
suicuneFramesGray = suicuneGrayPaths,
|
||||
-- hlcoord 6, 12 (engine/movie/title.asm:252) in screen pixels.
|
||||
suicuneX = 48,
|
||||
suicuneY = 96,
|
||||
-- SuicuneFrameIterator advances every 8 frames (engine/movie/title.asm:217-243).
|
||||
suicuneEvery = 8,
|
||||
gem = "assets/generated/title/crystal_gem.png",
|
||||
gemGray = "assets/generated/title/crystal_gem_gray.png",
|
||||
-- Strip 0 starts at OAM (64, -$22) and stops at OAM y 22, i.e. screen
|
||||
-- (56, 6) (engine/movie/title.asm:306-311,340-362).
|
||||
gemX = 56,
|
||||
gemY = 6,
|
||||
gemFromY = -50,
|
||||
gemStep = 2,
|
||||
-- TitleScreenEntrance: hSCX from +112 to 0 by 4 with alternating-line
|
||||
-- signage over the logo's 80 lines; the copyright window sits at
|
||||
-- hWY = $88 only after it lands (engine/menus/intro_menu.asm:1078-1111).
|
||||
entrance = { scx = 112, step = 4, lines = 80, hideBelow = 136 },
|
||||
entranceSfx = "Sfx_TitleScreenEntrance",
|
||||
-- TitleScreenTimer (engine/menus/intro_menu.asm:1125-1136).
|
||||
timeoutFrames = 73 * 60 + 36,
|
||||
sky = unit(pals.bg[1][1]),
|
||||
below = unit(pals.bg[1][1]),
|
||||
palettes = { bg = pals.bg, obj = pals.obj },
|
||||
}
|
||||
self:write("title", data)
|
||||
self:tick("Title screen", 5, 5)
|
||||
return data
|
||||
end
|
||||
|
||||
return CrystalMovie
|
||||
+26
-10
@@ -405,6 +405,7 @@ end
|
||||
local CART_COLOR = {
|
||||
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
|
||||
gold = PAL.railAmber, silver = PAL.railSilver,
|
||||
crystal = PAL.railCrystal,
|
||||
}
|
||||
local function cartColor(version)
|
||||
return CART_COLOR[version] or PAL.green
|
||||
@@ -1062,6 +1063,8 @@ local GAME_TABS = {
|
||||
label = "Gold" },
|
||||
{ id = "silver", key = "tab-silver", letter = "S", color = PAL.railSilver,
|
||||
label = "Silver" },
|
||||
{ id = "crystal", key = "tab-crystal", letter = "C",
|
||||
color = PAL.railCrystal, label = "Crystal" },
|
||||
}
|
||||
|
||||
local HEADER_TABS = {
|
||||
@@ -3555,24 +3558,37 @@ end
|
||||
-- also what a controller reaches after the tab row.
|
||||
local function buildGameModal(imp, m)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local w = math.floor(360 * m.s)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ #GAME_TABS * (m.btnH + gap) + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local headH = Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
local avail = m.H - 2 * m.pad
|
||||
local cols, gap, btnH = 1, math.floor(8 * m.s), m.btnH
|
||||
local function rows() return math.ceil(#GAME_TABS / cols) + 1 end
|
||||
local function total() return 2 * pad + headH + rows() * btnH
|
||||
+ (rows() - 1) * gap end
|
||||
if total() > avail then cols = 2 end
|
||||
if total() > avail then gap = math.max(2, math.floor(3 * m.s)) end
|
||||
if total() > avail then
|
||||
btnH = math.max(Kit.tapMin(),
|
||||
btnH - math.ceil((total() - avail) / rows()))
|
||||
end
|
||||
local nrows = rows() - 1
|
||||
local w = math.floor((cols > 1 and 440 or 360) * m.s)
|
||||
local px, py, pw = modalPanel(m, w, total())
|
||||
local cy = py + pad
|
||||
Kit.text("button", Strings("Choose game"), px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
cy = cy + headH
|
||||
local chrome = headerChrome(imp)
|
||||
for _, g in ipairs(GAME_TABS) do
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-" .. g.id,
|
||||
local colW = math.floor((pw - 2 * pad - (cols - 1) * gap) / cols)
|
||||
for i, g in ipairs(GAME_TABS) do
|
||||
local bx = px + pad + ((i - 1) % cols) * (colW + gap)
|
||||
local by = cy + math.floor((i - 1) / cols) * (btnH + gap)
|
||||
btn(imp, bx, by, colW, btnH, "gamepop-" .. g.id,
|
||||
Strings(g.label), {
|
||||
face = "tab", font = "small", letter = g.letter, color = g.color,
|
||||
active = imp.tab == g.id,
|
||||
action = chrome.tab[g.id] })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-close",
|
||||
cy = cy + nrows * (btnH + gap)
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, btnH, "gamepop-close",
|
||||
Strings("Close"), { font = "small",
|
||||
action = function() imp._gamePopup = nil end })
|
||||
end
|
||||
|
||||
+1011
-111
File diff suppressed because it is too large
Load Diff
@@ -155,6 +155,53 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173)
|
||||
"assets/generated/pc/mail_item.png",
|
||||
},
|
||||
crystal = {
|
||||
"data/generated/constants.lua",
|
||||
"data/generated/maps.lua",
|
||||
"data/generated/roofs.lua",
|
||||
"data/generated/sprites.lua",
|
||||
"data/generated/scripts.lua",
|
||||
"data/generated/text.lua",
|
||||
"data/generated/rom_text.lua",
|
||||
"data/generated/pokemon.lua",
|
||||
"data/generated/encounters.lua",
|
||||
"data/generated/tilesets.lua",
|
||||
"data/generated/landmarks.lua",
|
||||
"data/generated/audio.lua",
|
||||
"data/generated/marts.lua",
|
||||
"data/generated/oak_speech.lua",
|
||||
"data/generated/title.lua",
|
||||
"data/generated/intro.lua",
|
||||
"assets/generated/fonts/font.png",
|
||||
"assets/generated/fonts/frames.png",
|
||||
"assets/generated/title/crystal_logo.png",
|
||||
"assets/generated/title/crystal_wordmark.png",
|
||||
"assets/generated/title/crystal_suicune.png",
|
||||
"assets/generated/splash/ditto.png",
|
||||
"assets/generated/intro/chris.png",
|
||||
"assets/generated/intro/kris.png",
|
||||
"assets/generated/intro/suicune_run_sprites.png",
|
||||
"assets/generated/intro/unowns_tiles.png",
|
||||
"assets/generated/intro/oak.png",
|
||||
"assets/generated/tilesets/johto.png",
|
||||
"assets/generated/tilesets/roofs/new_bark.png",
|
||||
"assets/generated/sprites/chris.png",
|
||||
"assets/generated/sprites/kris.png",
|
||||
"assets/generated/battle/front/chikorita.png",
|
||||
"assets/generated/battle/front/wooper.png",
|
||||
"assets/generated/battle/front/pikachu.png",
|
||||
"assets/generated/battle/trainers/falkner.png",
|
||||
"assets/generated/battle/hud/balls.png",
|
||||
"assets/generated/audio/programs.bin",
|
||||
"assets/generated/slots/gold_slots_1.png",
|
||||
"assets/generated/card_flip/card_flip_1.png",
|
||||
"assets/generated/pc/mail_item.png",
|
||||
"assets/generated/trainer_card/card_f.png",
|
||||
"data/generated/mobile_gfx.lua",
|
||||
"assets/generated/battle/player_back_female.png",
|
||||
"assets/generated/battle/trainers/kris.png",
|
||||
"assets/generated/battle/trainers/chris.png",
|
||||
},
|
||||
}
|
||||
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
|
||||
VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold
|
||||
@@ -605,6 +652,27 @@ local function isAcceptedRomSize(n)
|
||||
return n == ROM_BYTES_GEN1 or n == ROM_BYTES_GEN2
|
||||
end
|
||||
|
||||
local function cartLabels(generation)
|
||||
local out = {}
|
||||
for _, id in ipairs(GameVersion.ORDER) do
|
||||
if generation == nil or GameVersion.generation(id) == generation then
|
||||
out[#out + 1] = GameVersion.info(id).label
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function cartsSlashed(generation)
|
||||
return table.concat(cartLabels(generation), "/")
|
||||
end
|
||||
|
||||
local function cartsProse()
|
||||
local names = cartLabels(nil)
|
||||
local last = table.remove(names)
|
||||
if #names == 0 then return last end
|
||||
return table.concat(names, ", ") .. ", or " .. last
|
||||
end
|
||||
|
||||
local function savesInboxDir(version)
|
||||
return SAVES_INBOX_DIR .. "/" .. tostring(version)
|
||||
end
|
||||
@@ -1849,18 +1917,18 @@ function RomImporter:startData(data, displayName)
|
||||
return
|
||||
end
|
||||
if not isAcceptedRomSize(#data) then
|
||||
self:setError(("Expected a 1 MiB Game Boy ROM (Red/Blue/Yellow) or a "
|
||||
.. "2 MiB Game Boy Color ROM (Gold/Silver); this file is %.2f MiB.")
|
||||
:format(#data / 1024 / 1024))
|
||||
self:setError(("Expected a 1 MiB Game Boy ROM (%s) or a "
|
||||
.. "2 MiB Game Boy Color ROM (%s); this file is %.2f MiB.")
|
||||
:format(cartsSlashed(1), cartsSlashed(2), #data / 1024 / 1024))
|
||||
return
|
||||
end
|
||||
local actualHash = sha1(data)
|
||||
local version = GameVersion.forSha1(actualHash)
|
||||
if not version then
|
||||
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
|
||||
.. "Red, Blue, Yellow, Gold, or Silver dump; patched, trimmed or "
|
||||
.. "%s dump; patched, trimmed or "
|
||||
.. "\"fixed\" dumps "
|
||||
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
|
||||
.. "(tagged [b] or [BF]) never verify."):format(actualHash, cartsProse()))
|
||||
return
|
||||
end
|
||||
local info = GameVersion.info(version)
|
||||
@@ -2965,8 +3033,10 @@ function RomImporter:resumeAfterOverlay()
|
||||
end
|
||||
|
||||
function RomImporter:_cycleTab(delta)
|
||||
local order = { "red", "blue", "yellow", "gold", "silver",
|
||||
"mods", "find", "skins", "bug" }
|
||||
local order = { "mods", "find", "skins", "bug" }
|
||||
for i = #GameVersion.ORDER, 1, -1 do
|
||||
table.insert(order, 1, GameVersion.ORDER[i])
|
||||
end
|
||||
local idx = 1
|
||||
for i, id in ipairs(order) do
|
||||
if id == self.tab then idx = i; break end
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:544 ConvertAndApplyBitmask
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:356 PokeAnim_DoAnimScript
|
||||
|
||||
local MonAnim = {}
|
||||
MonAnim.__index = MonAnim
|
||||
|
||||
-- .Sizes db 4, 5, 7: bitmask bytes for a 5x5, 6x6 and 7x7 pic.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:534
|
||||
MonAnim.BITMASK_BYTES = { [5] = 4, [6] = 5, [7] = 7 }
|
||||
|
||||
-- ../pokecrystal/macros/scripts/pic_anims.asm:13-27
|
||||
MonAnim.END = 0xff
|
||||
MonAnim.SETREPEAT = 0xfe
|
||||
MonAnim.DOREPEAT = 0xfd
|
||||
|
||||
-- .NextBit walks the row first, so bit i is row i % height, column i / height.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:746
|
||||
function MonAnim.tileMap(data, frame)
|
||||
local tiles = data and data.tiles
|
||||
if not tiles then return nil end
|
||||
local count = tiles * tiles
|
||||
local out = {}
|
||||
for i = 1, count do out[i] = i - 1 end
|
||||
if not frame or frame <= 0 then return out end
|
||||
local row = data.frames and data.frames[frame]
|
||||
local mask = row and data.bitmasks and data.bitmasks[row.bitmask]
|
||||
if not (row and mask) then return nil end
|
||||
local cursor = 1
|
||||
for i = 0, count - 1 do
|
||||
local byte = mask[math.floor(i / 8) + 1] or 0
|
||||
if math.floor(byte / 2 ^ (i % 8)) % 2 == 1 then
|
||||
local id = row.tiles[cursor]
|
||||
if id == nil then return nil end
|
||||
out[i + 1] = id
|
||||
cursor = cursor + 1
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16), truncated to 8 bits.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:413
|
||||
function MonAnim.duration(param, speed)
|
||||
param = param % 256
|
||||
local scaled = math.floor(param * (speed or 0) / 16) % 256
|
||||
return (scaled + param) % 256
|
||||
end
|
||||
|
||||
-- The PokeAnims programs less their cry commands; "wait" is SetWait's 18.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:69-77, :150-164
|
||||
local SCENES = {
|
||||
battle = { "setup", "play" },
|
||||
battleSlow = { "setup2", "play" },
|
||||
menu = { "setup", "play", "wait", "idle", "play" },
|
||||
}
|
||||
|
||||
MonAnim.SCENE_WAIT = 18
|
||||
|
||||
function MonAnim.scenes() return SCENES end
|
||||
|
||||
function MonAnim.new(data, scene)
|
||||
local steps = SCENES[scene or "battle"]
|
||||
if not (data and steps and data.play and #data.play > 0) then return nil end
|
||||
return setmetatable({
|
||||
data = data,
|
||||
steps = steps,
|
||||
step = 1,
|
||||
frame = 0,
|
||||
speed = 0,
|
||||
script = nil,
|
||||
pc = 1,
|
||||
repeatTimer = 0,
|
||||
waiting = false,
|
||||
waitCounter = 0,
|
||||
sceneWait = nil,
|
||||
done = false,
|
||||
}, MonAnim)
|
||||
end
|
||||
|
||||
function MonAnim:finished() return self.done end
|
||||
|
||||
-- PokeAnim_GetFrame's `and a / ret z`: command 0 is the base picture.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:431-435
|
||||
function MonAnim:currentFrame() return self.frame end
|
||||
|
||||
function MonAnim:beginScript(rows, speed)
|
||||
self.script = rows or {}
|
||||
self.speed = speed
|
||||
self.pc = 1
|
||||
self.repeatTimer = 0
|
||||
self.waiting = false
|
||||
self.waitCounter = 0
|
||||
end
|
||||
|
||||
-- One tick of PokeAnim_DoAnimScript; true once the script has hit endanim.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:370-411
|
||||
function MonAnim:runScript()
|
||||
if self.waiting then
|
||||
self.waitCounter = (self.waitCounter - 1) % 256
|
||||
if self.waitCounter == 0 then self.waiting = false end
|
||||
return false
|
||||
end
|
||||
for _ = 1, 256 do
|
||||
local row = self.script[self.pc]
|
||||
self.pc = self.pc + 1
|
||||
if row == nil then return true end
|
||||
local command = row[1]
|
||||
if command == MonAnim.END then
|
||||
return true
|
||||
elseif command == MonAnim.SETREPEAT then
|
||||
self.repeatTimer = row[2]
|
||||
elseif command == MonAnim.DOREPEAT then
|
||||
-- .DoRepeat returns on both `ret z` (:397-406).
|
||||
if self.repeatTimer == 0 then return false end
|
||||
self.repeatTimer = self.repeatTimer - 1
|
||||
if self.repeatTimer == 0 then return false end
|
||||
self.pc = row[2] + 1
|
||||
else
|
||||
self.frame = command
|
||||
self.waiting = true
|
||||
self.waitCounter = MonAnim.duration(row[2], self.speed)
|
||||
-- StartWaitAnim falls through into .WaitAnim (:383-388), so the frame
|
||||
-- it places is the frame the counter first ticks on.
|
||||
self.waitCounter = (self.waitCounter - 1) % 256
|
||||
if self.waitCounter == 0 then self.waiting = false end
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- One iteration of AnimateFrontpic's .loop: one scene command per frame.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89
|
||||
function MonAnim:update()
|
||||
if self.done then return end
|
||||
local step = self.steps[self.step]
|
||||
if step == nil then
|
||||
-- PokeAnim_Finish's DeinitFrames puts the base picture back (:224-228).
|
||||
self.frame = 0
|
||||
self.done = true
|
||||
return
|
||||
end
|
||||
if step == "setup" or step == "setup2" or step == "idle" then
|
||||
local rows = (step == "idle") and self.data.idle or self.data.play
|
||||
self:beginScript(rows, step == "setup2" and 4 or 0)
|
||||
self.step = self.step + 1
|
||||
return
|
||||
end
|
||||
if step == "wait" then
|
||||
self.sceneWait = (self.sceneWait or MonAnim.SCENE_WAIT) - 1
|
||||
if self.sceneWait <= 0 then
|
||||
self.sceneWait = nil
|
||||
self.step = self.step + 1
|
||||
end
|
||||
return
|
||||
end
|
||||
if self:runScript() then
|
||||
-- PokeAnim_Play redraws the base picture as the script ends (:196-205).
|
||||
self.frame = 0
|
||||
self.step = self.step + 1
|
||||
end
|
||||
end
|
||||
|
||||
return MonAnim
|
||||
@@ -21,6 +21,7 @@
|
||||
-- require alone cannot see them there (#420).
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SaveConvert = {}
|
||||
|
||||
@@ -228,10 +229,11 @@ SaveConvert.mergeDefaults = mergeDefaults
|
||||
-- codec exists yet. Both directions answer with a plain message the
|
||||
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
|
||||
-- table through Gen 1 offsets and surfacing a codec traceback.
|
||||
local GEN2_SAV_UNSUPPORTED = {
|
||||
gold = "Pokemon Gold",
|
||||
silver = "Pokemon Silver",
|
||||
}
|
||||
local function gen2CartName(gameVersion)
|
||||
if not GameVersion.VERSIONS[gameVersion] then return nil end
|
||||
if GameVersion.generation(gameVersion) ~= 2 then return nil end
|
||||
return GameVersion.info(gameVersion).displayName
|
||||
end
|
||||
|
||||
-- importSav(bytes, version, gameVersion) -> saveTable, err
|
||||
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
||||
@@ -245,7 +247,7 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
if type(bytes) ~= "string" then
|
||||
return nil, "expected raw save bytes as a string"
|
||||
end
|
||||
local gen2Name = GEN2_SAV_UNSUPPORTED[gameVersion]
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
|
||||
end
|
||||
@@ -281,7 +283,7 @@ function SaveConvert.exportSav(saveTable, gameVersion)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
end
|
||||
local gen2Name = GEN2_SAV_UNSUPPORTED[gameVersion]
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
|
||||
end
|
||||
|
||||
+129
-5
@@ -173,6 +173,113 @@ local Opcodes = {
|
||||
[0xa1] = { name = "warpfacing", size = 5 },
|
||||
}
|
||||
|
||||
-- pokegold/macros/scripts/events.asm:1015 DEF NUM_EVENT_COMMANDS EQU $a2
|
||||
Opcodes.NUM_EVENT_COMMANDS = 0xa2
|
||||
|
||||
-- pokecrystal/macros/scripts/events.asm:1-539 is identical to pokegold's, so
|
||||
-- $00..$51 is copied; :541 inserts farjumptext and shifts everything after it.
|
||||
local CRYSTAL = {}
|
||||
for byte = 0x00, 0x51 do
|
||||
local row = Opcodes[byte]
|
||||
CRYSTAL[byte] = { name = row.name, size = row.size }
|
||||
end
|
||||
|
||||
-- pokecrystal/macros/scripts/events.asm:541-1066, cross-checked against
|
||||
-- ScriptCommandTable at pokecrystal/engine/overworld/scripting.asm:64-237.
|
||||
CRYSTAL[0x52] = { name = "farjumptext", size = 3 }
|
||||
CRYSTAL[0x53] = { name = "jumptext", size = 2 }
|
||||
CRYSTAL[0x54] = { name = "waitbutton", size = 0 }
|
||||
CRYSTAL[0x55] = { name = "promptbutton", size = 0 }
|
||||
CRYSTAL[0x56] = { name = "pokepic", size = 1 }
|
||||
CRYSTAL[0x57] = { name = "closepokepic", size = 0 }
|
||||
CRYSTAL[0x58] = { name = "_2dmenu", size = 0 }
|
||||
CRYSTAL[0x59] = { name = "verticalmenu", size = 0 }
|
||||
CRYSTAL[0x5a] = { name = "loadpikachudata", size = 0 }
|
||||
CRYSTAL[0x5b] = { name = "randomwildmon", size = 0 }
|
||||
CRYSTAL[0x5c] = { name = "loadtemptrainer", size = 0 }
|
||||
CRYSTAL[0x5d] = { name = "loadwildmon", size = 2 }
|
||||
CRYSTAL[0x5e] = { name = "loadtrainer", size = 2 }
|
||||
CRYSTAL[0x5f] = { name = "startbattle", size = 0 }
|
||||
CRYSTAL[0x60] = { name = "reloadmapafterbattle", size = 0 }
|
||||
CRYSTAL[0x61] = { name = "catchtutorial", size = 1 }
|
||||
CRYSTAL[0x62] = { name = "trainertext", size = 1 }
|
||||
CRYSTAL[0x63] = { name = "trainerflagaction", size = 1 }
|
||||
CRYSTAL[0x64] = { name = "winlosstext", size = 4 }
|
||||
CRYSTAL[0x65] = { name = "scripttalkafter", size = 0 }
|
||||
CRYSTAL[0x66] = { name = "endifjustbattled", size = 0 }
|
||||
CRYSTAL[0x67] = { name = "checkjustbattled", size = 0 }
|
||||
CRYSTAL[0x68] = { name = "setlasttalked", size = 1 }
|
||||
CRYSTAL[0x69] = { name = "applymovement", size = 3 }
|
||||
CRYSTAL[0x6a] = { name = "applymovementlasttalked", size = 2 }
|
||||
CRYSTAL[0x6b] = { name = "faceplayer", size = 0 }
|
||||
CRYSTAL[0x6c] = { name = "faceobject", size = 2 }
|
||||
CRYSTAL[0x6d] = { name = "variablesprite", size = 2 }
|
||||
CRYSTAL[0x6e] = { name = "disappear", size = 1 }
|
||||
CRYSTAL[0x6f] = { name = "appear", size = 1 }
|
||||
CRYSTAL[0x70] = { name = "follow", size = 2 }
|
||||
CRYSTAL[0x71] = { name = "stopfollow", size = 0 }
|
||||
CRYSTAL[0x72] = { name = "moveobject", size = 3 }
|
||||
CRYSTAL[0x73] = { name = "writeobjectxy", size = 1 }
|
||||
CRYSTAL[0x74] = { name = "loademote", size = 1 }
|
||||
CRYSTAL[0x75] = { name = "showemote", size = 3 }
|
||||
CRYSTAL[0x76] = { name = "turnobject", size = 2 }
|
||||
CRYSTAL[0x77] = { name = "follownotexact", size = 2 }
|
||||
CRYSTAL[0x78] = { name = "earthquake", size = 1 }
|
||||
CRYSTAL[0x79] = { name = "changemapblocks", size = 3 }
|
||||
CRYSTAL[0x7a] = { name = "changeblock", size = 3 }
|
||||
CRYSTAL[0x7b] = { name = "reloadmap", size = 0 }
|
||||
CRYSTAL[0x7c] = { name = "refreshmap", size = 0 }
|
||||
CRYSTAL[0x7d] = { name = "writecmdqueue", size = 2 }
|
||||
CRYSTAL[0x7e] = { name = "delcmdqueue", size = 1 }
|
||||
CRYSTAL[0x7f] = { name = "playmusic", size = 2 }
|
||||
CRYSTAL[0x80] = { name = "encountermusic", size = 0 }
|
||||
CRYSTAL[0x81] = { name = "musicfadeout", size = 3 }
|
||||
CRYSTAL[0x82] = { name = "playmapmusic", size = 0 }
|
||||
CRYSTAL[0x83] = { name = "dontrestartmapmusic", size = 0 }
|
||||
CRYSTAL[0x84] = { name = "cry", size = 2 }
|
||||
CRYSTAL[0x85] = { name = "playsound", size = 2 }
|
||||
CRYSTAL[0x86] = { name = "waitsfx", size = 0 }
|
||||
CRYSTAL[0x87] = { name = "warpsound", size = 0 }
|
||||
CRYSTAL[0x88] = { name = "specialsound", size = 0 }
|
||||
CRYSTAL[0x89] = { name = "autoinput", size = 3 }
|
||||
CRYSTAL[0x8a] = { name = "newloadmap", size = 1 }
|
||||
CRYSTAL[0x8b] = { name = "pause", size = 1 }
|
||||
CRYSTAL[0x8c] = { name = "deactivatefacing", size = 1 }
|
||||
CRYSTAL[0x8d] = { name = "sdefer", size = 2 }
|
||||
CRYSTAL[0x8e] = { name = "warpcheck", size = 0 }
|
||||
CRYSTAL[0x8f] = { name = "stopandsjump", size = 2 }
|
||||
CRYSTAL[0x90] = { name = "endcallback", size = 0 }
|
||||
CRYSTAL[0x91] = { name = "end", size = 0 }
|
||||
CRYSTAL[0x92] = { name = "reloadend", size = 1 }
|
||||
CRYSTAL[0x93] = { name = "endall", size = 0 }
|
||||
CRYSTAL[0x94] = { name = "pokemart", size = 3 }
|
||||
CRYSTAL[0x95] = { name = "elevator", size = 2 }
|
||||
CRYSTAL[0x96] = { name = "trade", size = 1 }
|
||||
CRYSTAL[0x97] = { name = "askforphonenumber", size = 1 }
|
||||
CRYSTAL[0x98] = { name = "phonecall", size = 2 }
|
||||
CRYSTAL[0x99] = { name = "hangup", size = 0 }
|
||||
CRYSTAL[0x9a] = { name = "describedecoration", size = 1 }
|
||||
CRYSTAL[0x9b] = { name = "fruittree", size = 1 }
|
||||
CRYSTAL[0x9c] = { name = "specialphonecall", size = 2 }
|
||||
CRYSTAL[0x9d] = { name = "checkphonecall", size = 0 }
|
||||
CRYSTAL[0x9e] = { name = "verbosegiveitem", size = 2 }
|
||||
CRYSTAL[0x9f] = { name = "verbosegiveitemvar", size = 2 }
|
||||
-- events.asm:1003-1008: Crystal's swarm is `db flag` then `map_id`, not a bare
|
||||
-- map_id; Script_swarm makes three GetScriptByte calls (scripting.asm:654-662).
|
||||
CRYSTAL[0xa0] = { name = "swarm", size = 3 }
|
||||
CRYSTAL[0xa1] = { name = "halloffame", size = 0 }
|
||||
CRYSTAL[0xa2] = { name = "credits", size = 0 }
|
||||
CRYSTAL[0xa3] = { name = "warpfacing", size = 5 }
|
||||
CRYSTAL[0xa4] = { name = "battletowertext", size = 1 }
|
||||
CRYSTAL[0xa5] = { name = "getlandmarkname", size = 2 }
|
||||
CRYSTAL[0xa6] = { name = "gettrainerclassname", size = 2 }
|
||||
CRYSTAL[0xa7] = { name = "getname", size = 3 }
|
||||
CRYSTAL[0xa8] = { name = "wait", size = 1 }
|
||||
CRYSTAL[0xa9] = { name = "checksave", size = 0 }
|
||||
|
||||
-- pokecrystal/macros/scripts/events.asm:1068 DEF NUM_EVENT_COMMANDS EQU $aa
|
||||
CRYSTAL.NUM_EVENT_COMMANDS = 0xaa
|
||||
|
||||
-- Commands that end the current linear path (jumps transfer control).
|
||||
--
|
||||
-- `fruittree` and `describedecoration` are ScriptJumps (Script_fruittree does
|
||||
@@ -185,9 +292,13 @@ local Opcodes = {
|
||||
-- these four regardless. `catchtutorial` is deliberately NOT here: it ends on
|
||||
-- `jp Script_reloadmap` and the script really does continue afterwards, the
|
||||
-- same way `reloadmap` itself does.
|
||||
--
|
||||
-- `farjumptext` is Crystal-only and joins them for the same reason `jumptext`
|
||||
-- is here: pokecrystal/engine/overworld/scripting.asm:318-327 ends on
|
||||
-- `jp ScriptJump` into JumpTextScript.
|
||||
Opcodes.TERMINATORS = {
|
||||
sjump = true, farsjump = true, memjump = true, jumpstd = true,
|
||||
jumptext = true, jumptextfaceplayer = true,
|
||||
jumptext = true, farjumptext = true, jumptextfaceplayer = true,
|
||||
stopandsjump = true, ["end"] = true, endall = true, endcallback = true,
|
||||
reloadend = true,
|
||||
fruittree = true, describedecoration = true,
|
||||
@@ -198,10 +309,12 @@ Opcodes.TERMINATORS = {
|
||||
-- behind it. Every row above is keyed by the opcode byte the cart carries, and
|
||||
-- src/import/RomExtractorGen2.lua resolves a command as Opcodes[byte]: a byte
|
||||
-- that is not in that table breaks the pointer walk into an `unknown` row. Give
|
||||
-- a mod verb one of the free bytes ($a2..$ff) and ROM data that happens to start
|
||||
-- with it would decode as a mod call instead of ending the walk, so the free
|
||||
-- space stays free and the extension op is reachable only by NAME -- which is to
|
||||
-- say, only from a row a mod wrote, never from one the extractor did.
|
||||
-- a mod verb one of the free bytes and ROM data that happens to start with it
|
||||
-- would decode as a mod call instead of ending the walk, so the free space stays
|
||||
-- free and the extension op is reachable only by NAME -- which is to say, only
|
||||
-- from a row a mod wrote, never from one the extractor did. The free space is
|
||||
-- per dialect and the intersection is what matters: Gold leaves $a2..$ff
|
||||
-- (events.asm:1015), Crystal only $aa..$ff (events.asm:1068).
|
||||
--
|
||||
-- src/script/gen2/Vm.lua:runModCommand is the only reader; the contract for the
|
||||
-- row shapes and the verb table is documented there.
|
||||
@@ -211,4 +324,15 @@ function Opcodes.key(bank, address)
|
||||
return string.format("%02x:%04x", bank, address)
|
||||
end
|
||||
|
||||
CRYSTAL.TERMINATORS = Opcodes.TERMINATORS
|
||||
CRYSTAL.MOD_COMMAND = Opcodes.MOD_COMMAND
|
||||
CRYSTAL.key = Opcodes.key
|
||||
|
||||
-- Gold and Silver share one dialect (pokegold/macros/scripts/events.asm:540);
|
||||
-- Crystal renumbers from $52 (pokecrystal/macros/scripts/events.asm:541).
|
||||
function Opcodes.forEdition(edition)
|
||||
if edition == "crystal" then return CRYSTAL end
|
||||
return Opcodes
|
||||
end
|
||||
|
||||
return Opcodes
|
||||
|
||||
+339
-39
@@ -1,7 +1,7 @@
|
||||
-- The `special` command's handlers (data/events/special_pointers.asm).
|
||||
--
|
||||
-- Lifted out of src/script/gen2/Vm.lua because the two are different kinds of
|
||||
-- code: the VM is one interpreter with a shared control flow, and this is 112
|
||||
-- code: the VM is one interpreter with a shared control flow, and this is 169
|
||||
-- INDEPENDENT routines that happen to share a dispatch table. Growing them
|
||||
-- inside runList's else-chain would have buried the interpreter.
|
||||
--
|
||||
@@ -9,9 +9,9 @@
|
||||
-- which the extractor turns into constants.specialOrder, and Vm:specialName
|
||||
-- resolves the one into the other; keying on the label rather than on the
|
||||
-- number means a repointed table cannot silently call the wrong routine, and
|
||||
-- it means a test can assert the mapping against the cache. This cache's
|
||||
-- order has 112 rows -- the asm file's 113 `add_special` matches include the
|
||||
-- MACRO line itself.
|
||||
-- it means a test can assert the mapping against the cache. A Gold cache's
|
||||
-- order has 112 rows and a Crystal one 169 -- the asm files' `add_special`
|
||||
-- match counts are one higher each, because they include the MACRO line.
|
||||
--
|
||||
-- Three kinds of entry live here:
|
||||
--
|
||||
@@ -52,6 +52,7 @@
|
||||
-- it, plus the fruit trees and the daily rollover Kurt waits on.
|
||||
local Apricorns = require("src.core.gen2.Apricorns")
|
||||
local BugContest = require("src.core.gen2.BugContest")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Happiness = require("src.core.gen2.Happiness")
|
||||
local Phone = require("src.core.gen2.Phone")
|
||||
local Pokerus = require("src.core.gen2.Pokerus")
|
||||
@@ -175,6 +176,20 @@ local function selectMon(vm, prompt)
|
||||
return picked.index, picked.mon
|
||||
end
|
||||
|
||||
Specials.shared = {
|
||||
TRUE = TRUE,
|
||||
FALSE = FALSE,
|
||||
block = Specials.block,
|
||||
hooks = hooks,
|
||||
party = party,
|
||||
save = save,
|
||||
data = data,
|
||||
answer = answer,
|
||||
nameMon = nameMon,
|
||||
selectMon = selectMon,
|
||||
showRawHeld = showRawHeld,
|
||||
}
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- Magikarp lengths (engine/events/magikarp.asm)
|
||||
--------------------------------------------------------------------------
|
||||
@@ -1058,7 +1073,7 @@ end
|
||||
-- -- is drawn on the cartridge itself, and only the A press farcalls
|
||||
-- PrintUnownStamp. src/ui/gen2/UnownPrinter.lua is that viewer and takes the
|
||||
-- A press nowhere, which is what a cartridge with nothing in its link port
|
||||
-- does; PrintDiploma next door has no viewer half at all and stays a stub.
|
||||
-- does; PrintDiploma next door shows its page and prints nothing either.
|
||||
--
|
||||
-- `ld a, [wUnownDex] / and a / ret z` is the gate: with no Unown caught the
|
||||
-- special returns before it draws anything. The routine never writes
|
||||
@@ -1117,6 +1132,10 @@ H.FadeOutToBlack = function(vm) fade(vm, "outBlack") end
|
||||
H.FadeInFromWhite = function(vm) fade(vm, "inWhite") end
|
||||
H.FadeInFromBlack = function(vm) fade(vm, "inBlack") end
|
||||
|
||||
-- engine/tilesets/timeofday_pals.asm:130: FillWhiteBGColor, then the same
|
||||
-- c=$9 / b=4 time-pal walk FadeInFromWhite runs, stepped by hand.
|
||||
H.BattleTowerFade = function(vm) fade(vm, "inWhite") end
|
||||
|
||||
-- ClearBGPalettes / ClearBGPalettesBufferScreen / ClearTilemap: the screen is
|
||||
-- blanked to the background colour under a fade that is already down. The
|
||||
-- port fades with a flat sheet, so the sheet IS the cleared screen.
|
||||
@@ -1152,6 +1171,20 @@ H.LoadUsedSpritesGFX = function(vm)
|
||||
if h.reloadSprites then h.reloadSprites(false) end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/overworld/warp_connection.asm:311, `ld b, SCGB_MAPPALS / jp
|
||||
-- GetSGBLayout`: the map's own palette layout, reapplied and nothing else.
|
||||
H.LoadMapPalettes = function(vm)
|
||||
local h = hooks(vm)
|
||||
if h.reloadSprites then h.reloadSprites(true) end
|
||||
end
|
||||
|
||||
-- engine/overworld/overworld.asm:40: the used-sprite list rebuilt and its
|
||||
-- VRAM pack reloaded, with no palette pass -- LoadUsedSpritesGFX's arm.
|
||||
H.RefreshSprites = function(vm)
|
||||
local h = hooks(vm)
|
||||
if h.reloadSprites then h.reloadSprites(false) end
|
||||
end
|
||||
|
||||
-- UpdatePlayerSprite: the player's sheet is a pure function of wPlayerState
|
||||
-- (data/sprites/player_sprites.asm ChrisStateSprites), which is what makes
|
||||
-- getting on and off a Lapras a one-byte change rather than an animation.
|
||||
@@ -1160,6 +1193,16 @@ H.UpdatePlayerSprite = function(vm)
|
||||
if h.updatePlayerSprite then h.updatePlayerSprite() end
|
||||
end
|
||||
|
||||
-- engine/events/specials.asm:21 -> engine/overworld/map_objects.asm:2515:
|
||||
-- bit 7 of wScriptVar gates the routine, bits 6-4 are the OBJ palette.
|
||||
H.SetPlayerPalette = function(vm)
|
||||
local value = (vm.scriptVar or 0) % 0x100
|
||||
if value < 0x80 then return end
|
||||
vm.playerPalette = math.floor(value / 0x10) % 8
|
||||
local h = hooks(vm)
|
||||
if h.setPlayerPalette then h.setPlayerPalette(vm.playerPalette) end
|
||||
end
|
||||
|
||||
-- ---- 58-62 sound and the water --------------------------------------------
|
||||
|
||||
-- WaitSFX: hold until the sound effect that is playing finishes. The VM has
|
||||
@@ -1445,6 +1488,22 @@ local function trailingDigitsShared(a, b)
|
||||
end
|
||||
Specials.trailingDigitsShared = trailingDigitsShared
|
||||
|
||||
-- pokegold/constants/pokemon_data_constants.asm:122-123
|
||||
local NUM_BOXES, NUM_BOXES_JP = 14, 9
|
||||
|
||||
-- pokegold/engine/events/lucky_number.asm:22-102: sBox (the OPEN box) is walked
|
||||
-- before .BoxesLoop skips it, and .BoxesLoop stops at NUM_BOXES_JP not NUM_BOXES.
|
||||
local function luckyNumberBoxOrder(record)
|
||||
local current = math.floor(tonumber(record and record.currentBox) or 1)
|
||||
local last = GameVersion.fixes().luckyNumberBoxes and NUM_BOXES or NUM_BOXES_JP
|
||||
local order = { current }
|
||||
for index = 1, last do
|
||||
if index ~= current then order[#order + 1] = index end
|
||||
end
|
||||
return order
|
||||
end
|
||||
Specials.luckyNumberBoxOrder = luckyNumberBoxOrder
|
||||
|
||||
local function luckyPrizeFor(shared)
|
||||
if shared >= 5 then return 1 end
|
||||
if shared >= 3 then return 2 end
|
||||
@@ -1508,8 +1567,9 @@ H.CheckForLuckyNumberWinners = function(vm)
|
||||
end
|
||||
end
|
||||
for _, mon in ipairs(party(vm)) do consider(mon, false) end
|
||||
for _, box in pairs((record and record.boxes) or {}) do
|
||||
for _, mon in ipairs(box or {}) do consider(mon, true) end
|
||||
local boxes = (record and record.boxes) or {}
|
||||
for _, index in ipairs(luckyNumberBoxOrder(record)) do
|
||||
for _, mon in ipairs(boxes[index] or {}) do consider(mon, true) end
|
||||
end
|
||||
answer(vm, best)
|
||||
if bestMon then
|
||||
@@ -1586,9 +1646,17 @@ local KURT_MENU_FLAGS = 0x80 + 0x20
|
||||
-- CANCEL. Both refusals -- FindApricornsInBag's `scf` for a pack with no
|
||||
-- apricorn in it at all, and the `jr c, .nope` for pressing B -- answer
|
||||
-- `xor a`, which is the FALSE `.Cancel` waits on.
|
||||
--
|
||||
-- ../pokecrystal/engine/events/kurt.asm:19-45 is the same routine with a
|
||||
-- quantity menu bolted on, and its `xor a / ld [wKurtApricornQuantity], a`
|
||||
-- (:24) and `ld a, [wItemQuantityChange] / ld [...], a` (:45) are what
|
||||
-- ../pokecrystal/maps/KurtsHouse.asm:197's `verbosegiveitemvar LEVEL_BALL,
|
||||
-- VAR_KURT_APRICORNS` counts out. Kurt_SelectQuantity is not ported, so the
|
||||
-- count written here is the one apricorn Apricorns.takeApricorn tosses.
|
||||
H.SelectApricornForKurt = function(vm)
|
||||
local h = hooks(vm)
|
||||
local record = save(vm)
|
||||
if h.setKurtApricornQuantity then h.setKurtApricornQuantity(0) end
|
||||
local list = Apricorns.bagList(record and record.inventory)
|
||||
if list.empty then return answer(vm, FALSE) end
|
||||
|
||||
@@ -1612,7 +1680,10 @@ H.SelectApricornForKurt = function(vm)
|
||||
local item = h.itemIndex and h.itemIndex(apricorn)
|
||||
if not item or item == 0 then return answer(vm, FALSE) end
|
||||
answer(vm, item)
|
||||
Apricorns.takeApricorn(record, apricorn)
|
||||
if Apricorns.takeApricorn(record, apricorn)
|
||||
and h.setKurtApricornQuantity then
|
||||
h.setKurtApricornQuantity(1)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- 88-89 the first party slot -------------------------------------------
|
||||
@@ -1917,7 +1988,7 @@ local OAK_RATINGS = {
|
||||
-- Celadon Mansion 3F reads VAR_DEXCAUGHT for itself and is what runs
|
||||
-- `special Diploma` (H.Diploma below, src/ui/gen2/Diploma.lua) and
|
||||
-- then sets EVENT_ENABLE_DIPLOMA_PRINTING for the Graphic Artist's
|
||||
-- `special PrintDiploma` (also stubbed: "printer: no Game Boy Printer").
|
||||
-- `special PrintDiploma` (H.PrintDiploma, specials/crystal_extras.lua).
|
||||
-- A finished #DEX here just means every rating after this one is this
|
||||
-- same line.
|
||||
{ max = 255, sfx = "Sfx_DexFanfare230Plus", text = Strings.source(
|
||||
@@ -2027,7 +2098,7 @@ Specials.ROAMERS = Roamers.SPECIES
|
||||
H.InitRoamMons = function(vm)
|
||||
local record = save(vm)
|
||||
if not record then return end
|
||||
Roamers.init(record, { force = true })
|
||||
Roamers.init(record, { force = true, data = data(vm) })
|
||||
end
|
||||
|
||||
-- ---- the #DEX-completion diploma -------------------------------------------
|
||||
@@ -2328,7 +2399,7 @@ end
|
||||
-- layout, PrintPartyMonPage1) and then SendScreenToPrinter walks it out the
|
||||
-- serial port to a physical Game Boy Printer. There is no peripheral for it
|
||||
-- to reach here -- the same reason H.UnownPrinter's A press goes nowhere and
|
||||
-- PrintDiploma stays stubbed below -- so `ldh a, [hPrinter] / and a / jr nz,
|
||||
-- PrintDiploma's own print goes nowhere -- so `ldh a, [hPrinter] / and a / jr nz,
|
||||
-- .cancel` is
|
||||
-- hardwired to the nz arm below: the portrait shows, then the print always
|
||||
-- comes back as though the printer errored, which is the honest answer for
|
||||
@@ -2370,11 +2441,137 @@ H.PhotoStudio = function(vm)
|
||||
vm:showRaw(Strings(PHOTO_STUDIO_TEXT.noPhoto))
|
||||
end
|
||||
|
||||
-- ---- 21 the quick save -----------------------------------------------------
|
||||
--
|
||||
-- TryQuickSave (engine/link/link.asm:2356) is filed with the cable club but is
|
||||
-- not a link routine: it is `farcall Link_SaveGame`, TRUE on carry clear and
|
||||
-- FALSE on carry, then `ld c, 30 / call DelayFrames`. Link_SaveGame
|
||||
-- (engine/menus/save.asm:63) is AskOverwriteSaveFile (:169) plus the ordinary
|
||||
-- write, so the FALSE arm is a refusal at the overwrite prompt -- which is
|
||||
-- what ../pokecrystal/maps/BattleTower1F.asm:84-85 backs a challenge out on,
|
||||
-- and what the four PokeCenter2F cable rows share.
|
||||
--
|
||||
-- Two differences from the cart, neither observable in the answer.
|
||||
-- AskOverwriteSaveFile's mismatched-ID arm runs ErasePreviousSave (:333) before
|
||||
-- saving; Save.save replaces the whole file anyway. And SFX_SAVE rings at the
|
||||
-- write here, where src/ui/gen2/SaveMenu.lua:107 already puts it, rather than
|
||||
-- after the saved page has typed.
|
||||
|
||||
-- data/text/common_2.asm:1279-1299.
|
||||
local SAVE_TEXT = {
|
||||
already = Strings.source(
|
||||
"There is already a\nsave file. Is it\vOK to overwrite?"),
|
||||
another = Strings.source(
|
||||
"There is another\nsave file. Is it\vOK to overwrite?"),
|
||||
saving = Strings.source("SAVING… DON'T TURN\nOFF THE POWER."),
|
||||
saved = Strings.source("%s saved\nthe game."),
|
||||
}
|
||||
|
||||
-- engine/menus/save.asm:247 the 16 frames under SAVING and :251 the 32 the
|
||||
-- write is followed by; :269 the 30 after the saved page, and link.asm:2367 the
|
||||
-- 30 TryQuickSave adds on top. Both pages are `hold`s rather than `wait`s
|
||||
-- because the world does not tick while a box owns the stack (Vm:showRaw).
|
||||
local SAVING_HOLD = 16 + 32
|
||||
local SAVED_HOLD = 30 + 30
|
||||
|
||||
H.TryQuickSave = function(vm)
|
||||
local h = hooks(vm)
|
||||
-- `ld a, [wSaveFileExists] / and a / jr z, .erase`, then
|
||||
-- CompareLoadedAndSavedPlayerID (:212) picking which question is asked.
|
||||
local exists, sameId = false, false
|
||||
if h.saveFileState then exists, sameId = h.saveFileState() end
|
||||
if exists then
|
||||
showRawHeld(vm, Strings(sameId and SAVE_TEXT.already or SAVE_TEXT.another))
|
||||
if not coroutine.yield({ kind = "yesorno" }) then
|
||||
return answer(vm, FALSE)
|
||||
end
|
||||
end
|
||||
vm:showRaw(Strings(SAVE_TEXT.saving), true, SAVING_HOLD)
|
||||
-- _SaveGameData (:273). A veto from the save.write mod hook is the one way
|
||||
-- this port can refuse a write the cart always completes, and a refusal is
|
||||
-- the same FALSE the overwrite prompt's NO gives.
|
||||
if not (h.writeSave and h.writeSave() ~= false) then
|
||||
return answer(vm, FALSE)
|
||||
end
|
||||
if h.playSfxNamed then h.playSfxNamed("Sfx_Save") end
|
||||
local record = save(vm)
|
||||
local name = (record and record.player and record.player.name) or ""
|
||||
vm:showRaw(Strings(SAVE_TEXT.saved, name), true, SAVED_HOLD)
|
||||
answer(vm, TRUE)
|
||||
end
|
||||
|
||||
-- ---- 111 the dummy --------------------------------------------------------
|
||||
-- UnusedDummySpecial is a bare `ret`. Listed so the name resolves to a
|
||||
-- handler rather than to the unimplemented ledger.
|
||||
H.UnusedDummySpecial = function() end
|
||||
|
||||
-- ---- 109-165 the Crystal rows ---------------------------------------------
|
||||
-- data/events/special_pointers.asm:124-181, the rows only Crystal has.
|
||||
|
||||
-- ../pokecrystal/engine/pokemon/search_owned.asm:48 CheckOwnMonAnywhere: party then boxes,
|
||||
-- matching species, OT id and OT name; `ld a, [wPartyCount] / and a / ret z`.
|
||||
local function ownsMonAnywhere(vm, wanted)
|
||||
local h = hooks(vm)
|
||||
local list = party(vm)
|
||||
if #list == 0 then return false end
|
||||
local record = save(vm)
|
||||
local player = record and record.player
|
||||
local function owns(mon)
|
||||
if not mon then return false end
|
||||
if not (h.monIndex and h.monIndex(mon.species) == wanted) then return false end
|
||||
if player and player.id and mon.otId and mon.otId ~= player.id then
|
||||
return false
|
||||
end
|
||||
if player and player.name and mon.ot and mon.ot ~= player.name then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
for _, mon in ipairs(list) do
|
||||
if owns(mon) then return true end
|
||||
end
|
||||
for _, box in pairs((record and record.boxes) or {}) do
|
||||
for _, mon in ipairs(box or {}) do
|
||||
if owns(mon) then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/pokemon/search_owned.asm:31
|
||||
H.MonCheck = function(vm)
|
||||
answer(vm, ownsMonAnywhere(vm, vm.scriptVar) and TRUE or FALSE)
|
||||
end
|
||||
|
||||
-- home/init.asm:1 falls into Init (home/init.asm:35) and on to the copyright
|
||||
-- splash, which is Game2:softReset rather than Game2:returnToTitle.
|
||||
H.Reset = function(vm)
|
||||
local h = hooks(vm)
|
||||
if h.softReset then h.softReset() end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_41.asm:320 is a bare `ret` with its SRAM counter left behind
|
||||
-- it as dead code, so a no-op is the whole routine.
|
||||
H.StubbedTrainerRankings_Healings = function() end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_41.asm:792: the international ROM answers 0 outright, which
|
||||
-- is what sends every Pokecenter 2F mobile branch down its cable arm.
|
||||
H.CheckMobileAdapterStatusSpecial = function(vm)
|
||||
answer(vm, FALSE)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:187 and :1580, both bare `ret`.
|
||||
H.UnusedBattleTowerDummySpecial1 = function() end
|
||||
H.UnusedBattleTowerDummySpecial2 = function() end
|
||||
|
||||
-- ../pokecrystal/engine/overworld/time.asm:136 SampleKenjiBreakCountdown:
|
||||
-- `call Random / and %11 / add 3`, three to six days into wKenjiBreakTimer,
|
||||
-- which ../pokecrystal/maps/Route45.asm:50 reads back through VAR_KENJI_BREAK.
|
||||
H.SampleKenjiBreakCountdown = function(vm)
|
||||
local h = hooks(vm)
|
||||
if h.setKenjiBreak then h.setKenjiBreak(Specials.random(4) - 1 + 3) end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- The deliberate stubs
|
||||
--------------------------------------------------------------------------
|
||||
@@ -2394,7 +2591,6 @@ local STUB_ROWS = {
|
||||
{ "SetBitsForLinkTradeRequest", nil, "link cable: no Gen 2 cable club" },
|
||||
{ "WaitForLinkedFriend", 0, "link cable: nobody ever connects" },
|
||||
{ "CheckLinkTimeout_Receptionist", 1, "link cable: always times out" },
|
||||
{ "TryQuickSave", 0, "link cable: the cable club's own save is not ported" },
|
||||
{ "CheckBothSelectedSameRoom", 0, "link cable: no second player" },
|
||||
{ "FailedLinkToPast", 1, "link cable: the Time Capsule is not ported" },
|
||||
{ "CloseLink", nil, "link cable: nothing to close" },
|
||||
@@ -2415,45 +2611,149 @@ local STUB_ROWS = {
|
||||
{ "CheckMysteryGift", 0, "Mystery Gift: no infrared, so no gift is waiting" },
|
||||
{ "GetMysteryGiftItem", 0, "Mystery Gift: nothing to hand over" },
|
||||
{ "UnlockMysteryGift", nil, "Mystery Gift: nothing to unlock" },
|
||||
-- The Game Boy Printer. A second peripheral again, and two of the three
|
||||
-- specials that want it are not in this table: PhotoStudio ports the
|
||||
-- conversation and the portrait screen (H.PhotoStudio above) and
|
||||
-- UnownPrinter ports the stamp viewer (H.UnownPrinter above), with only the
|
||||
-- print itself stubbed inside each. PrintDiploma is the one that is
|
||||
-- nothing BUT the print -- the diploma's own page is `special Diploma`, a
|
||||
-- separate row -- so it is all that is left here.
|
||||
-- The Game Boy Printer. A second peripheral again, and none of the three
|
||||
-- specials that want it is stubbed any more: PhotoStudio ports the
|
||||
-- conversation and the portrait screen (H.PhotoStudio above), UnownPrinter
|
||||
-- ports the stamp viewer (H.UnownPrinter above) and PrintDiploma opens
|
||||
-- PlaceDiplomaOnScreen (specials/crystal_extras.lua), with only the print
|
||||
-- itself going nowhere inside each. The row below is superseded by that
|
||||
-- handler and survives only as the reason.
|
||||
{ "PrintDiploma", nil, "printer: no Game Boy Printer" },
|
||||
-- Screens the port has not built. Each names what it needs; the value is
|
||||
-- the arm a cancel takes, so the script backs out rather than proceeding
|
||||
-- through a transaction that never happened.
|
||||
-- OverworldTownMap's row is superseded by H.OverworldTownMap
|
||||
-- (specials/crystal_extras.lua) and survives only as the reason.
|
||||
-- ../pokecrystal/data/events/special_pointers.asm:58 and pokegold's :63 both
|
||||
-- carry `add_special UnusedMemoryGame ; unused`, and no script names it.
|
||||
{ "OverworldTownMap", nil, "needs the POKeGEAR map card in view mode" },
|
||||
{ "UnusedMemoryGame", nil, "unused in Gold; needs the memory game screen" },
|
||||
{ "UnusedMemoryGame", nil, "unused on both carts; no script reaches _MemoryGame" },
|
||||
-- data/events/special_pointers.asm:124-181, the rows only Crystal has.
|
||||
{ "BattleTowerRoomMenu", 10, "Battle Tower: $a is the menu's back-out arm" },
|
||||
{ "BattleTowerBattle", nil, "Battle Tower: no tower battle to run" },
|
||||
{ "BattleTowerAction", 0, "Battle Tower: 0 is sGSBallFlag clear" },
|
||||
{ "CheckForBattleTowerRules", 0, "Battle Tower: no challenge in progress" },
|
||||
{ "Menu_ChallengeExplanationCancel", 0, "Battle Tower: 0 ends the talk" },
|
||||
{ "LoadOpponentTrainerAndPokemonWithOTSprite", 0, "Battle Tower: no roster" },
|
||||
{ "BattleTowerMobileError", nil, "Battle Tower: no mobile error to report" },
|
||||
{ "Function1700ba", nil, "Battle Tower: mobile challenge setup" },
|
||||
{ "Function170114", nil, "Battle Tower: mobile challenge setup" },
|
||||
{ "Function1704e1", nil, "Battle Tower: mobile challenge setup" },
|
||||
{ "AskMobileOrCable", 0, "Mobile System GB: 0 is a B press off the menu" },
|
||||
{ "Mobile_SelectThreeMons", 0, "Mobile System GB: no three mons picked" },
|
||||
{ "Function1011f1", nil, "Mobile System GB: enters LINK_MOBILE" },
|
||||
{ "Function101220", nil, "Mobile System GB: leaves LINK_MOBILE" },
|
||||
{ "Function101225", 0, "Mobile System GB: mobile trade room teardown" },
|
||||
{ "Function101231", 0, "Mobile System GB: mobile battle room teardown" },
|
||||
{ "Function102142", nil, "Mobile System GB: mobile news feed" },
|
||||
{ "Function103780", 0, "Mobile System GB: the mobile save never happens" },
|
||||
{ "Function1037c2", 0, "Mobile System GB: no rematch on same settings" },
|
||||
{ "Function1037eb", 0, "Mobile System GB: no battle time is left" },
|
||||
{ "Function10383c", 0, "Mobile System GB: the three-mon pick cancels" },
|
||||
{ "Function10387b", nil, "Mobile System GB: adapter status readback" },
|
||||
{ "TradeCornerHoldMon", nil, "Mobile System GB: no mobile trade corner" },
|
||||
{ "Function11ac3e", nil, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11b5e8", nil, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11b7e5", nil, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11b879", 0, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11b920", nil, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11b93b", nil, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function11ba38", 0, "Mobile System GB: trade corner submenu" },
|
||||
{ "Function17d2b6", nil, "Mobile System GB: mobile menu chrome" },
|
||||
{ "Function17d2ce", 0, "Mobile System GB: mobile menu chrome" },
|
||||
{ "Function11c1ab", nil, "Mobile System GB: the fixed-word entry screen" },
|
||||
{ "UnusedFindItemInPCOrBag", 0, "Mobile System GB: unreferenced" },
|
||||
{ "GiveOddEgg", nil, "the Odd Egg roster is not ported; Route 34 is later" },
|
||||
{ "DisplayUnownWords", nil, "needs the Unown wall word box" },
|
||||
{ "HoOhChamber", nil, "the Ruins of Alph secret chambers are not ported" },
|
||||
{ "OmanyteChamber", nil, "the Ruins of Alph secret chambers are not ported" },
|
||||
{ "PokeSeer", nil, "needs the Seer's caught-data page" },
|
||||
{ "BeastsCheck", 0, "the three beasts cannot all be owned this early" },
|
||||
{ "BuenasPassword", 0, "Buena's show is not ported; 0 is a wrong guess" },
|
||||
{ "BuenaPrize", nil, "Buena's prize counter is not ported" },
|
||||
{ "AskRememberPassword", 0, "Buena's show is not ported; 0 declines" },
|
||||
{ "CelebiShrineEvent", nil, "the GS Ball event needs the mobile stadium" },
|
||||
{ "CheckCaughtCelebi", 0, "the GS Ball event never runs, so Celebi is free" },
|
||||
{ "GiveDratini", nil, "the Dragon Shrine moveset swap is past Phase 1" },
|
||||
{ "MoveTutor", 255, "the tutor is past Phase 1; -1 is its cancel arm" },
|
||||
}
|
||||
|
||||
Specials.HANDLERS = H
|
||||
|
||||
-- data/events/special_pointers.asm:124-181, the "; Crystal only" block: one
|
||||
-- module per owner under src/script/gen2/specials/, merged into HANDLERS here.
|
||||
Specials.MODULES = {
|
||||
"crystal_story",
|
||||
"battle_tower",
|
||||
"crystal_extras",
|
||||
"unown_words",
|
||||
}
|
||||
|
||||
Specials.HANDLER_SOURCE = {}
|
||||
for name in pairs(H) do Specials.HANDLER_SOURCE[name] = "Specials.lua" end
|
||||
|
||||
Specials.STUBS = {}
|
||||
Specials.STUB_REASONS = {}
|
||||
|
||||
for _, row in ipairs(STUB_ROWS) do
|
||||
local name, value, reason = row[1], row[2], row[3]
|
||||
Specials.STUB_REASONS[name] = reason
|
||||
Specials.STUBS[name] = function(vm)
|
||||
if value ~= nil then vm.scriptVar = value end
|
||||
end
|
||||
end
|
||||
Specials.SUPERSEDED_STUBS = {}
|
||||
|
||||
-- The dispatch table Vm.SPECIALS is. Built rather than written out so the two
|
||||
-- sets cannot drift, and so a name that ends up in both is a hard error here
|
||||
-- rather than a silent shadow at runtime.
|
||||
Specials.ALL = {}
|
||||
for name, fn in pairs(Specials.HANDLERS) do
|
||||
Specials.ALL[name] = fn
|
||||
end
|
||||
for name, fn in pairs(Specials.STUBS) do
|
||||
if Specials.ALL[name] then
|
||||
error("gen2 special '" .. name .. "' is both implemented and stubbed", 0)
|
||||
end
|
||||
Specials.ALL[name] = fn
|
||||
|
||||
local function clear(t)
|
||||
for key in pairs(t) do t[key] = nil end
|
||||
end
|
||||
|
||||
local function rebuild()
|
||||
clear(Specials.STUBS)
|
||||
clear(Specials.STUB_REASONS)
|
||||
clear(Specials.SUPERSEDED_STUBS)
|
||||
clear(Specials.ALL)
|
||||
for _, row in ipairs(STUB_ROWS) do
|
||||
local name, value, reason = row[1], row[2], row[3]
|
||||
if H[name] then
|
||||
if Specials.HANDLER_SOURCE[name] == "Specials.lua" then
|
||||
error("gen2 special '" .. name .. "' is both implemented and stubbed", 0)
|
||||
end
|
||||
Specials.SUPERSEDED_STUBS[name] = reason
|
||||
else
|
||||
Specials.STUB_REASONS[name] = reason
|
||||
Specials.STUBS[name] = function(vm)
|
||||
if value ~= nil then vm.scriptVar = value end
|
||||
end
|
||||
end
|
||||
end
|
||||
for name, fn in pairs(H) do Specials.ALL[name] = fn end
|
||||
for name, fn in pairs(Specials.STUBS) do Specials.ALL[name] = fn end
|
||||
end
|
||||
|
||||
function Specials.merge(handlers, source)
|
||||
if type(handlers) ~= "table" then
|
||||
error("gen2 specials module '" .. tostring(source) .. "' returned "
|
||||
.. type(handlers) .. ", expected a table of name -> function", 0)
|
||||
end
|
||||
for name, fn in pairs(handlers) do
|
||||
if type(name) ~= "string" or type(fn) ~= "function" then
|
||||
error("gen2 specials module '" .. tostring(source)
|
||||
.. "' entry [" .. tostring(name) .. "] is not name -> function", 0)
|
||||
end
|
||||
local owner = Specials.HANDLER_SOURCE[name]
|
||||
if owner then
|
||||
error("gen2 special '" .. name .. "' is defined twice: "
|
||||
.. owner .. " and " .. tostring(source), 0)
|
||||
end
|
||||
H[name] = fn
|
||||
Specials.HANDLER_SOURCE[name] = source
|
||||
end
|
||||
rebuild()
|
||||
return handlers
|
||||
end
|
||||
|
||||
package.loaded["src.script.gen2.Specials"] = Specials
|
||||
|
||||
for _, name in ipairs(Specials.MODULES) do
|
||||
Specials.merge(require("src.script.gen2.specials." .. name),
|
||||
"specials/" .. name .. ".lua")
|
||||
end
|
||||
|
||||
rebuild()
|
||||
|
||||
return Specials
|
||||
|
||||
+84
-6
@@ -49,6 +49,14 @@ local MAX_MONEY, MAX_COINS = 999999, 9999
|
||||
local SFX_ITEM, SFX_HANG_UP = 0x01, 0x6b
|
||||
-- constants/script_constants.asm: EMOTE_FROM_MEM is -1, i.e. the byte $ff.
|
||||
local EMOTE_FROM_MEM = 0xff
|
||||
-- constants/item_constants.asm:300 DEF ITEM_FROM_MEM EQU $ff
|
||||
local ITEM_FROM_MEM = 0xff
|
||||
-- pokecrystal/constants/script_constants.asm:254-257 StoreSwarmMapIndices args.
|
||||
local SWARM_DUNSPARCE = 0
|
||||
-- pokecrystal/constants/text_constants.asm:13-19 wNamedObjectType values.
|
||||
local NAMED_MON, NAMED_ITEM, NAMED_TRAINER = 1, 4, 7
|
||||
-- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait
|
||||
local WAIT_FRAMES_PER_UNIT = 6
|
||||
-- constants/misc_constants.asm GS_VERSION: 0 Gold, 1 Silver.
|
||||
local GS_VERSION_GOLD = 0
|
||||
-- engine/overworld/variables.asm .VarActionTable rows for wMapGroup and
|
||||
@@ -204,7 +212,9 @@ local function runCmd(self, cmd, op)
|
||||
local value = self.callAsmFn(cmd.label, arg1(cmd) or 0, wordArg(cmd, 2))
|
||||
if value ~= nil then self.scriptVar = value % 256 end
|
||||
end
|
||||
elseif op == "jumptext" then
|
||||
elseif op == "jumptext" or op == "farjumptext" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:318-327 Script_farjumptext:
|
||||
-- Script_jumptext with a `dba` for the `dw`, same JumpTextScript.
|
||||
self:emitFace(false)
|
||||
self:showText(cmd.text)
|
||||
return "end"
|
||||
@@ -519,6 +529,36 @@ local function runCmd(self, cmd, op)
|
||||
-- town map draws them two rows deep).
|
||||
local name = self.getLandmarkNameFn and self.getLandmarkNameFn()
|
||||
if name then self:setStringBuffer(name) end
|
||||
elseif op == "getlandmarkname" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:1615-1623: the landmark id
|
||||
-- comes off the script, then ConvertLandmarkToText and a buffer byte.
|
||||
local id = cmd.landmark or arg1(cmd) or 0
|
||||
local name = self.getLandmarkNameFn and self.getLandmarkNameFn(id)
|
||||
if name then self:setStringBuffer(name) end
|
||||
elseif op == "gettrainerclassname" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:1644-1647: TRAINER_NAME is
|
||||
-- preset, so the one id byte ContinueToGetName reads is a trainer group.
|
||||
if self.getTrainerClassNameFn then
|
||||
local name = self.getTrainerClassNameFn(cmd.class or arg1(cmd) or 0)
|
||||
if name then self:setStringBuffer(name) end
|
||||
end
|
||||
elseif op == "getname" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:1633-1641: a
|
||||
-- wNamedObjectType byte, an id byte, then GetStringBuffer's buffer byte.
|
||||
local args = cmd.args or {}
|
||||
local kind = cmd.kind or args[1] or 0
|
||||
local id = cmd.id or args[2] or 0
|
||||
local name
|
||||
if kind == NAMED_MON and self.getMonNameFn then
|
||||
name = self.getMonNameFn(id)
|
||||
elseif kind == NAMED_ITEM and self.getItemNameFn then
|
||||
name = self.getItemNameFn(id)
|
||||
elseif kind == NAMED_TRAINER and self.getTrainerClassNameFn then
|
||||
name = self.getTrainerClassNameFn(id)
|
||||
elseif self.getNameFn then
|
||||
name = self.getNameFn(kind, id)
|
||||
end
|
||||
if name then self:setStringBuffer(name) end
|
||||
elseif op == "getnum" then
|
||||
-- Script_getnum: PrintNum of wScriptVar (PRINTNUM_LEFTALIGN | 1 byte,
|
||||
-- 3 chars) into wStringBuffer1, then GetStringBuffer copies that into
|
||||
@@ -599,9 +639,17 @@ local function runCmd(self, cmd, op)
|
||||
else
|
||||
self.scriptVar = POKEMAIL_REFUSED
|
||||
end
|
||||
elseif op == "giveitem" or op == "verbosegiveitem" then
|
||||
elseif op == "giveitem" or op == "verbosegiveitem"
|
||||
or op == "verbosegiveitemvar" then
|
||||
local item = cmd.item or arg1(cmd) or 0
|
||||
local qty = cmd.quantity or (cmd.args and cmd.args[2]) or 1
|
||||
if op == "verbosegiveitemvar" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:486-510: ITEM_FROM_MEM
|
||||
-- takes the item from wScriptVar, and byte two is a VAR_* id.
|
||||
if item == ITEM_FROM_MEM then item = (self.scriptVar or 0) % 256 end
|
||||
local varId = cmd.var or (cmd.args and cmd.args[2]) or 0
|
||||
qty = self.readVarFn and self.readVarFn(varId) or 0
|
||||
end
|
||||
-- Script_giveitem's own `ld [wCurItem], a` (scripting.asm:1612). It is
|
||||
-- what the standalone `specialsound` inside GiveItemScript reads back:
|
||||
-- CheckItemPocket runs on wCurItem, not on anything the opcode carries.
|
||||
@@ -611,7 +659,7 @@ local function runCmd(self, cmd, op)
|
||||
ok = self.giveItemFn(item, qty) ~= false
|
||||
end
|
||||
self.scriptVar = ok and 1 or 0
|
||||
if op == "verbosegiveitem" then
|
||||
if op == "verbosegiveitem" or op == "verbosegiveitemvar" then
|
||||
local name = self.getItemNameFn and self.getItemNameFn(item) or "?"
|
||||
self:setStringBuffer(name)
|
||||
-- GiveItemScript (engine/overworld/scripting.asm:441-449), command for
|
||||
@@ -1048,10 +1096,20 @@ local function runCmd(self, cmd, op)
|
||||
-- SetSwarmFlag -> DAILYFLAGS1_SWARM. Both halves matter: CheckSwarmFlag
|
||||
-- is what makes the swarm expire, so a port that only stores the map
|
||||
-- leaves the Dunsparce call permanently live.
|
||||
--
|
||||
-- pokecrystal/macros/scripts/events.asm:1003-1008 adds a leading flag byte
|
||||
-- and specials.asm:290-298 picks the index pair off it, so three operand
|
||||
-- bytes means the map_id has moved along one.
|
||||
local args = cmd.args or {}
|
||||
local group = cmd.group or args[1]
|
||||
local mapNum = cmd.map or args[2]
|
||||
if self.setSwarmFn then self.setSwarmFn(group, mapNum) end
|
||||
local kind, group, mapNum
|
||||
if #args >= 3 then
|
||||
kind, group, mapNum = args[1], args[2], args[3]
|
||||
else
|
||||
kind = SWARM_DUNSPARCE
|
||||
group = cmd.group or args[1]
|
||||
mapNum = cmd.map or args[2]
|
||||
end
|
||||
if self.setSwarmFn then self.setSwarmFn(group, mapNum, kind) end
|
||||
elseif op == "reloadmapafterbattle" or op == "reloadmap"
|
||||
or op == "refreshmap" then
|
||||
-- Losing ENDS the script. Script_reloadmapafterbattle reads wBattleResult
|
||||
@@ -1617,6 +1675,21 @@ local function runCmd(self, cmd, op)
|
||||
coroutine.yield({ kind = "credits" })
|
||||
end
|
||||
return "end"
|
||||
-- ---- Crystal-only verbs ------------------------------------------------
|
||||
elseif op == "wait" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait: SIX
|
||||
-- frames of DelayFrames per operand unit, not Script_pause's two.
|
||||
self:waitFrames((cmd.frames or arg1(cmd) or 0) * WAIT_FRAMES_PER_UNIT)
|
||||
elseif op == "checksave" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:2349-2353 writes CheckSave's
|
||||
-- c: 1 when both sCheckValue bytes match (events/checksave.asm:1-20).
|
||||
local ok = true
|
||||
if self.checkSaveFn then ok = self.checkSaveFn() and true or false end
|
||||
self.scriptVar = ok and 1 or 0
|
||||
elseif op == "battletowertext" then
|
||||
-- pokecrystal/engine/overworld/scripting.asm:447-452 BattleTowerText.
|
||||
-- Unported: the table consumes the operand and the verb warns once.
|
||||
self:noteUnknownOp(op)
|
||||
-- ---- commands with no engine behind them yet ---------------------------
|
||||
elseif op == "deactivatefacing" then
|
||||
-- Script_deactivatefacing: wScriptDelay = the byte (left ALONE when the
|
||||
@@ -1960,6 +2033,11 @@ function Vm.new(scripts, text, events, hooks)
|
||||
givePokeMailFn = hooks.givePokeMail,
|
||||
checkPokeMailFn = hooks.checkPokeMail,
|
||||
getLandmarkNameFn = hooks.getLandmarkName,
|
||||
-- pokecrystal/engine/overworld/scripting.asm:1633-1647, and 2349-2353.
|
||||
-- Absent on a Gold boot, where no opcode reaches them.
|
||||
getTrainerClassNameFn = hooks.getTrainerClassName,
|
||||
getNameFn = hooks.getName,
|
||||
checkSaveFn = hooks.checkSave,
|
||||
-- loadmenu stashes a header for the verticalmenu / _2dmenu that follows;
|
||||
-- openMenu is the blocking half, modelled on yesorno.
|
||||
openMenuFn = hooks.openMenu,
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
-- The Battle Tower specials: ../pokecrystal/data/events/special_pointers.asm:132-140 and
|
||||
-- :150 BattleTowerAction, :152 Menu_ChallengeExplanationCancel.
|
||||
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
local S = Specials.shared
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local BattleTower = require("src.core.gen2.BattleTower")
|
||||
local RomText = require("src.core.RomText")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local M = {}
|
||||
|
||||
local TRUE, FALSE = S.TRUE, S.FALSE
|
||||
local A = BattleTower.ACTIONS
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:190-196
|
||||
-- InitBattleTowerChallengeRAM. wNrOfBeatenBattleTowerTrainers is the byte the
|
||||
-- room script `readmem`s, so it is zeroed in the VM's own memory store.
|
||||
local function initChallengeRam(vm)
|
||||
vm.btBattleEnded = 0
|
||||
vm.btBeaten = 0
|
||||
if vm.mem then vm.mem[BattleTower.WRAM_NR_BEATEN] = 0 end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:57-92, the far labels
|
||||
-- data/generated/rom_text.lua carries.
|
||||
local RULE_FALLBACKS = {
|
||||
_ExcuseMeYoureNotReadyText =
|
||||
Strings.source("Excuse me.\nYou're not ready."),
|
||||
_OnlyThreeMonMayBeEnteredText =
|
||||
Strings.source("Only three POKéMON\nmay be entered."),
|
||||
_TheMonMustAllBeDifferentKindsText =
|
||||
Strings.source("The {STRBUF} POKéMON\nmust all be different kinds."),
|
||||
_TheMonMustNotHoldTheSameItemsText =
|
||||
Strings.source("The {STRBUF} POKéMON\nmust not hold the same items."),
|
||||
_YouCantTakeAnEggText = Strings.source("You can't take an\nEGG!"),
|
||||
_BattleTowerReturnWhenReadyText =
|
||||
Strings.source("Please return when\nyou're ready."),
|
||||
}
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171
|
||||
-- BattleTower_CheckSaveFileExistsAndIsYours; the running game IS the loaded
|
||||
-- save here, so CompareLoadedAndSavedPlayerID can only match.
|
||||
local function saveFileIsYours(vm)
|
||||
local record = S.save(vm)
|
||||
if not (record and record.version) then return FALSE end
|
||||
return Save.exists(record.version) and TRUE or FALSE
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1024-1127 and
|
||||
-- :1187-1242, :1314-1447: SRAM bank 5 and WRAM bank 3 belong to the Mobile
|
||||
-- System GB adapter. A cartridge that never linked reads them as zero, and
|
||||
-- these are the values that zero produces.
|
||||
local MOBILE_ARMS = {
|
||||
[A.ACTION_05] = { value = 0, why = "s5_be46 is 0 until a mobile challenge" },
|
||||
[A.ACTION_06] = { why = "clears the mobile challenge bytes" },
|
||||
[A.ACTION_0C] = { why = "stamps the mobile challenge day" },
|
||||
[A.ACTION_0D] = { value = FALSE, why = "s5_aa47 is 0, so `and a / ret z`" },
|
||||
[A.ACTION_0F] = { value = 0, why = "w3_d090 is the adapter's status byte" },
|
||||
[A.ACTION_10] = { value = FALSE, why = "s5_a800 is 0, the .NoAction row" },
|
||||
[A.ACTION_16] = { why = "stamps the mobile news day" },
|
||||
[A.ACTION_17] = { value = FALSE, why = "s5_b2f9 is 0, so `and a / ret z`" },
|
||||
[A.LEVEL_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" },
|
||||
[A.UBERS_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" },
|
||||
}
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:951-953
|
||||
-- BattleTower_SaveOptions writes options.lua, which no `special` hook reaches.
|
||||
local UNHOOKED_ARMS = {
|
||||
[A.SAVEOPTIONS] = "needs a saveOptions hook in World:specialHooks",
|
||||
}
|
||||
|
||||
local ACTIONS = {}
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:978-990
|
||||
ACTIONS[A.CHECK_EXPLANATION_READ] = function(vm, tower, record)
|
||||
local yours = saveFileIsYours(vm)
|
||||
S.answer(vm, yours)
|
||||
if yours == 0 then return end
|
||||
S.answer(vm, BattleTower.saveFileFlag(record,
|
||||
BattleTower.SAVEFILE_EXPLANATION))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1001-1008
|
||||
ACTIONS[A.SET_EXPLANATION_READ] = function(_vm, _tower, record)
|
||||
BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_EXPLANATION)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:992-999
|
||||
ACTIONS[A.GET_CHALLENGE_STATE] = function(vm, tower)
|
||||
S.answer(vm, tower.challenge)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1010-1012
|
||||
ACTIONS[A.SAVE_AND_QUIT] = function(_vm, _tower, record)
|
||||
BattleTower.setChallengeState(record, BattleTower.SAVED_AND_LEFT)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1014-1022
|
||||
ACTIONS[A.CHALLENGECANCELED] = function(_vm, _tower, record)
|
||||
BattleTower.setChallengeState(record, BattleTower.NO_CHALLENGE)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1129-1141
|
||||
ACTIONS[A.SAVELEVELGROUP] = function(vm, tower)
|
||||
tower.levelGroup = math.max(0, math.floor(tonumber(vm.btLevelGroup) or 0))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1143-1155
|
||||
ACTIONS[A.LOADLEVELGROUP] = function(vm, tower)
|
||||
vm.btLevelGroup = tower.levelGroup
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171
|
||||
ACTIONS[A.CHECKSAVEFILEISYOURS] = function(vm)
|
||||
S.answer(vm, saveFileIsYours(vm))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1173-1177: the
|
||||
-- pending fade is dropped and the volume goes back to full, which at both
|
||||
-- call sites follows a `musicfadeout MUSIC_NONE`.
|
||||
ACTIONS[A.ACTION_0A] = function(vm)
|
||||
local h = S.hooks(vm)
|
||||
if h.stopMusic then h.stopMusic() end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1179-1185:
|
||||
-- sGSBallFlag reads back as GS_BALL_AVAILABLE once the ball has been offered,
|
||||
-- and the Goldenrod scene gates itself on its own event flag afterwards.
|
||||
ACTIONS[A.GSBALL] = function(vm, _tower, record)
|
||||
local crystal = record and Save.crystalState(record)
|
||||
local flag = crystal and crystal.gsBall
|
||||
S.answer(vm, Save.GS_BALL_STATES[flag] and BattleTower.GS_BALL_AVAILABLE or 0)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1311-1312
|
||||
-- String_MysteryJP, the OT the Mobile Stadium stamps on an Odd Egg.
|
||||
local MYSTERY_OT = "\227\129\170\227\129\158\227\131\138\227\131\142"
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1244-1309: the
|
||||
-- EGG_TICKET is spent only on a party egg carrying String_MysteryJP.
|
||||
ACTIONS[A.EGGTICKET] = function(vm, _tower, record)
|
||||
S.answer(vm, FALSE)
|
||||
local held = record and record.inventory and record.inventory.EGG_TICKET
|
||||
if not held or held <= 0 then return end
|
||||
for _, mon in ipairs(S.party(vm)) do
|
||||
if mon.isEgg and mon.otName == MYSTERY_OT then
|
||||
mon.otName = ""
|
||||
Bag.remove(record, "EGG_TICKET", 1)
|
||||
return S.answer(vm, TRUE)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1461
|
||||
ACTIONS[A.ACTION_11] = function(_vm, tower) tower.reentry = false end
|
||||
ACTIONS[A.ACTION_12] = function(_vm, tower) tower.reentry = true end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1463-1469
|
||||
ACTIONS[A.ACTION_13] = function(vm, tower)
|
||||
S.answer(vm, tower.reentry and TRUE or FALSE)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483
|
||||
ACTIONS[A.ACTION_14] = function(vm, _tower, record)
|
||||
local yours = saveFileIsYours(vm)
|
||||
S.answer(vm, yours)
|
||||
if yours == 0 then return end
|
||||
S.answer(vm, BattleTower.saveFileFlag(record,
|
||||
BattleTower.SAVEFILE_REGISTERED))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1485-1492
|
||||
ACTIONS[A.ACTION_15] = function(_vm, _tower, record)
|
||||
BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_REGISTERED)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904
|
||||
ACTIONS[A.RESETDATA] = function(_vm, _tower, record)
|
||||
BattleTower.resetTrainers(record)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933
|
||||
ACTIONS[A.GIVEREWARD] = function(vm, tower, record)
|
||||
local h = S.hooks(vm)
|
||||
local reward = tower.reward or BattleTower.FALLBACK_REWARD
|
||||
local data = S.data(vm)
|
||||
local fits = false
|
||||
if record then
|
||||
record.inventory = record.inventory or {}
|
||||
fits = BattleTower.rewardFits(Bag.slots(record, data, "ITEM"),
|
||||
Bag.capacity(data, "ITEM"), record.inventory[reward])
|
||||
end
|
||||
if not fits then reward = BattleTower.FALLBACK_REWARD end
|
||||
S.answer(vm, (h.itemIndex and h.itemIndex(reward)) or 0)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:935-949
|
||||
ACTIONS[A.ACTION_1C] = function(_vm, _tower, record)
|
||||
BattleTower.setChallengeState(record, BattleTower.WON_CHALLENGE)
|
||||
end
|
||||
ACTIONS[A.ACTION_1D] = function(_vm, _tower, record)
|
||||
BattleTower.setChallengeState(record, BattleTower.RECEIVED_REWARD)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976.
|
||||
-- Specials.random answers 1..n, and `% mask` turns that into the 0..mask-1
|
||||
-- byte `maskbits` leaves in a.
|
||||
ACTIONS[A.CHOOSEREWARD] = function(vm, tower)
|
||||
local data = S.data(vm)
|
||||
local order = data and data.gen2Constants and data.gen2Constants.itemOrder
|
||||
tower.reward = BattleTower.rollReward(order, Specials.random)
|
||||
or BattleTower.FALLBACK_REWARD
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:852-887, the
|
||||
-- jumptable `special BattleTowerAction` dispatches on wScriptVar.
|
||||
M.BattleTowerAction = function(vm)
|
||||
local id = math.floor(tonumber(vm.scriptVar) or 0)
|
||||
-- ../pokecrystal/ram/sram.asm:147-172, read as the zeroes a fresh cart holds.
|
||||
local record = S.save(vm) or {}
|
||||
local tower = BattleTower.state(record)
|
||||
local run = ACTIONS[id]
|
||||
if run then return run(vm, tower, record) end
|
||||
local mobile = MOBILE_ARMS[id]
|
||||
if mobile then
|
||||
if mobile.value ~= nil then S.answer(vm, mobile.value) end
|
||||
return
|
||||
end
|
||||
if UNHOOKED_ARMS[id] then return end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1583-1594: the
|
||||
-- carry _CheckForBattleTowerRules returns means the party FAILED, and TRUE is
|
||||
-- what the script's `ifnotequal FALSE` reads as "stop here".
|
||||
M.CheckForBattleTowerRules = function(vm)
|
||||
local lines, failed = BattleTower.checkRules(S.party(vm))
|
||||
local data = S.data(vm)
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2
|
||||
vm:setStringBuffer(BattleTower.RULE_PARTY_COUNT_TEXT)
|
||||
for _, label in ipairs(lines) do
|
||||
vm:showRaw(RomText(data, label, RULE_FALLBACKS[label] or label))
|
||||
end
|
||||
S.answer(vm, failed and TRUE or FALSE)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_5f.asm:425-468 and its MenuData at :490-495.
|
||||
-- wScriptVar comes in TRUE for the English rows and leaves holding the row
|
||||
-- number, or 4 for a B press.
|
||||
local CHALLENGE_MENU_ROWS = {
|
||||
Strings.source("Challenge"),
|
||||
Strings.source("Explanation"),
|
||||
Strings.source("Cancel"),
|
||||
}
|
||||
local CHALLENGE_MENU_CANCEL = 4
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_5f.asm:484-488 MenuHeader, `menu_coords 0, 0,
|
||||
-- 14, 7` with STATICMENU_CURSOR | STATICMENU_WRAP.
|
||||
local CHALLENGE_MENU_FLAGS = 0x80 + 0x20
|
||||
|
||||
M.Menu_ChallengeExplanationCancel = function(vm)
|
||||
local h = S.hooks(vm)
|
||||
local choice = Specials.block(vm, function(done)
|
||||
if not h.scriptMenu then return done(0) end
|
||||
h.scriptMenu({ items = CHALLENGE_MENU_ROWS, left = 0, top = 0,
|
||||
right = 14, bottom = 7, dataFlags = CHALLENGE_MENU_FLAGS, cursor = 1 },
|
||||
done)
|
||||
end)
|
||||
choice = math.floor(tonumber(choice) or 0)
|
||||
if choice < 1 or choice > #CHALLENGE_MENU_ROWS then
|
||||
return S.answer(vm, CHALLENGE_MENU_CANCEL)
|
||||
end
|
||||
S.answer(vm, choice)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1534-1550, whose
|
||||
-- first act is `farcall LoadOpponentTrainerAndPokemon`: the trainer row, the
|
||||
-- three mons and the two SRAM tables that stop either repeating. wScriptVar
|
||||
-- comes in holding the object the opponent walks in as and is NOT written.
|
||||
M.LoadOpponentTrainerAndPokemonWithOTSprite = function(vm)
|
||||
local record = S.save(vm)
|
||||
if not record then return end
|
||||
-- wBT_OTTrainer is WRAM bank 3, so the drawn opponent rides the VM and not
|
||||
-- the save; only the sBTTrainers slot and the two previous teams persist.
|
||||
local opponent = BattleTower.drawOpponent(
|
||||
S.data(vm), record, Specials.random)
|
||||
vm.btOpponent = opponent
|
||||
if not opponent then return end
|
||||
-- :1552-1575: BTTrainerClassSprites[class - 1] goes into the map object
|
||||
-- wScriptVar names, and GetUsedSprite loads the sheet.
|
||||
local h = S.hooks(vm)
|
||||
if h.setObjectSprite and opponent.sprite then
|
||||
h.setObjectSprite(math.floor(tonumber(vm.scriptVar) or 0), opponent.sprite)
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:181-185 and the
|
||||
-- RunBattleTowerTrainer arm its jumptable runs at :214-259.
|
||||
--
|
||||
-- The order there is: SET battle mode forced on, wInBattleTowerBattle set,
|
||||
-- HealParty, ReadBTTrainerParty (which is what steps the streak counter and
|
||||
-- arms the challenge), StartBattle, HealParty again, and wBattleResult into
|
||||
-- wScriptVar. A win then copies sNrOfBeatenBattleTowerTrainers into
|
||||
-- wNrOfBeatenBattleTowerTrainers -- the byte the room script `readmem`s to
|
||||
-- decide whether all seven are done -- and leaves `count + 1` as the digit
|
||||
-- Text_NextUpOpponentNo prints.
|
||||
M.BattleTowerBattle = function(vm)
|
||||
vm.btBattleEnded = 0
|
||||
local record = S.save(vm)
|
||||
local data = S.data(vm)
|
||||
local h = S.hooks(vm)
|
||||
local opponent = vm.btOpponent
|
||||
if not opponent and record then
|
||||
opponent = BattleTower.drawOpponent(data, record, Specials.random)
|
||||
vm.btOpponent = opponent
|
||||
end
|
||||
-- A cache with no `battleTower` block on trainers.lua has nobody to send
|
||||
-- out. LOSE is the room script's own back-out arm
|
||||
-- (../pokecrystal/maps/BattleTowerBattleRoom.asm:34-37), so the challenge
|
||||
-- ends rather than looping on an opponent that never appears.
|
||||
if not (opponent and record) then
|
||||
vm.btBattleEnded = 1
|
||||
return S.answer(vm, 1)
|
||||
end
|
||||
-- :229 ReadBTTrainerParty -> CopyBTTrainer_FromBT_OT_TowBT_OTTemp (:549-570)
|
||||
local streak = BattleTower.beginBattle(record)
|
||||
-- :228 farcall HealParty, before the send-out.
|
||||
if h.healParty then h.healParty() end
|
||||
local classes = data and data.trainers and data.trainers.classes
|
||||
local class = classes and classes[opponent.classId]
|
||||
local className = (class and class.name) or opponent.classId
|
||||
local party = BattleTower.battleParty(data, opponent.rows)
|
||||
local outcome = Specials.block(vm, function(done)
|
||||
if not h.startTowerBattle then return done("lose") end
|
||||
local started = h.startTowerBattle({
|
||||
class = opponent.class,
|
||||
classId = opponent.classId,
|
||||
className = className,
|
||||
-- PlaceEnemysName prints the class then the trainer's own name, the
|
||||
-- same pair World:startScriptedBattle builds for an overworld trainer.
|
||||
name = className and (className .. " " .. opponent.name) or opponent.name,
|
||||
trainerName = opponent.name,
|
||||
party = party,
|
||||
-- wOtherTrainerClass is a real class here, so the AI weights, the two
|
||||
-- item slots and the payout all come off its own attributes row.
|
||||
attributes = class and class.attributes,
|
||||
baseMoney = class and class.baseMoney,
|
||||
items = class and class.items,
|
||||
}, done)
|
||||
if not started then done("lose") end
|
||||
end)
|
||||
-- :235 farcall HealParty, on the way out whichever way it went.
|
||||
if h.healParty then h.healParty() end
|
||||
-- :236-237 wBattleResult: WIN is 0.
|
||||
local won = outcome ~= "lose"
|
||||
S.answer(vm, won and 0 or 1)
|
||||
if won and vm.mem then
|
||||
-- :240-250
|
||||
vm.mem[BattleTower.WRAM_NR_BEATEN] = streak % 256
|
||||
vm:setStringBuffer(tostring(streak + 1))
|
||||
end
|
||||
-- :257-258 wBattleTowerBattleEnded = TRUE, which ends _BattleTowerBattle.
|
||||
vm.btBattleEnded = 1
|
||||
vm.btOpponent = nil
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1-5, which is
|
||||
-- InitBattleTowerChallengeRAM plus _BattleTowerRoomMenu
|
||||
-- (../pokecrystal/mobile/mobile_46.asm:137-177). wScriptVar leaves 0 for a
|
||||
-- chosen room and $a for the cancel the desk loops back on.
|
||||
M.BattleTowerRoomMenu = function(vm)
|
||||
initChallengeRam(vm)
|
||||
local h = S.hooks(vm)
|
||||
local record = S.save(vm)
|
||||
local result = Specials.block(vm, function(done)
|
||||
if not h.pushScreen then return done(nil) end
|
||||
local ok = h.pushScreen("Gen2BattleTowerMenu", {
|
||||
save = record,
|
||||
party = S.party(vm),
|
||||
rows = BattleTower.levelGroupRows(record),
|
||||
monName = h.monName,
|
||||
onDone = done,
|
||||
})
|
||||
if not ok then done(nil) end
|
||||
end)
|
||||
local group = math.floor(tonumber(result) or 0)
|
||||
if group < 1 or group > BattleTower.MAX_LEVEL_GROUP then
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:4609-4610
|
||||
return S.answer(vm, 0x0a)
|
||||
end
|
||||
vm.btLevelGroup = group
|
||||
S.answer(vm, 0)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,508 @@
|
||||
-- ../pokecrystal/data/events/special_pointers.asm:147 MoveTutor, :161 PokeSeer,
|
||||
-- :162 BuenasPassword, :163 BuenaPrize, :179 AskRememberPassword,
|
||||
-- :181 UnusedFindItemInPCOrBag, plus the two rows both carts share:
|
||||
-- :57 OverworldTownMap and :126 PrintDiploma.
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local BugContest = require("src.core.gen2.BugContest")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Nests = require("src.core.gen2.Nests")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local S = Specials.shared
|
||||
|
||||
local M = {}
|
||||
|
||||
-- engine/overworld/variables.asm:65 wBlueCardBalance, :66 wBuenasPassword.
|
||||
local VAR_BLUECARDBALANCE = 0x18
|
||||
local VAR_BUENASPASSWORD = 0x19
|
||||
|
||||
-- maps/RadioTower2F.asm:1 BLUE_CARD_POINT_CAP.
|
||||
local BLUE_CARD_POINT_CAP = 30
|
||||
|
||||
local function hooks(vm)
|
||||
return (vm and vm.specials) or {}
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- MoveTutor -- ../pokecrystal/engine/events/move_tutor.asm:1
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- .GetMoveTutorMove (../pokecrystal/engine/events/move_tutor.asm:36) maps
|
||||
-- MOVETUTOR_FLAMETHROWER..ICE_BEAM onto MT01..MT03, i.e. pokemon.tutorMoves.
|
||||
local function tutorMove(vm, index)
|
||||
local d = S.data(vm)
|
||||
local list = d and d.pokemon and d.pokemon.tutorMoves
|
||||
if type(list) ~= "table" then return nil end
|
||||
if index ~= 1 and index ~= 2 then index = 3 end
|
||||
return list[index]
|
||||
end
|
||||
|
||||
M.MoveTutor = function(vm)
|
||||
local h = hooks(vm)
|
||||
local moveId = tutorMove(vm, vm.scriptVar or 0)
|
||||
-- ../pokecrystal/engine/events/move_tutor.asm:29 .cancel, which is
|
||||
-- maps/GoldenrodCity.asm:72 .Incompatible.
|
||||
if not (moveId and h.pushScreen) then
|
||||
vm.scriptVar = 255
|
||||
return
|
||||
end
|
||||
local d = S.data(vm)
|
||||
local moveDef = d and d.moves and d.moves[moveId]
|
||||
local learned = Specials.block(vm, function(done)
|
||||
local ok = h.pushScreen("Gen2MoveTutor", {
|
||||
move = moveId,
|
||||
moveName = (moveDef and moveDef.name) or moveId,
|
||||
onDone = function(taught) done(taught and true or false) end,
|
||||
})
|
||||
if not ok then done(false) end
|
||||
end)
|
||||
-- ../pokecrystal/engine/events/move_tutor.asm:25 `xor a ; FALSE` on the learn arm.
|
||||
vm.scriptVar = learned and 0 or 255
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- Buena -- ../pokecrystal/engine/events/buena.asm:1, ../pokecrystal/engine/events/buena_menu.asm:1
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- data/radio/buenas_passwords.asm, in table order. `kind` is the BUENA_*
|
||||
-- function of ../pokecrystal/constants/radio_constants.asm:128-131; `points` is both the
|
||||
-- Blue Card value and the menu width ../pokecrystal/engine/events/buena.asm:9 adds.
|
||||
local BUENA_PASSWORDS = {
|
||||
{ kind = "mon", points = 10,
|
||||
words = { "CYNDAQUIL", "TOTODILE", "CHIKORITA" } },
|
||||
{ kind = "item", points = 12,
|
||||
words = { "FRESH_WATER", "SODA_POP", "LEMONADE" } },
|
||||
{ kind = "item", points = 12,
|
||||
words = { "POTION", "ANTIDOTE", "PARLYZ_HEAL" } },
|
||||
{ kind = "item", points = 12,
|
||||
words = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" } },
|
||||
{ kind = "mon", points = 10,
|
||||
words = { "PIKACHU", "RATTATA", "GEODUDE" } },
|
||||
{ kind = "mon", points = 10,
|
||||
words = { "HOOTHOOT", "SPINARAK", "DROWZEE" } },
|
||||
{ kind = "string", points = 16,
|
||||
words = { Strings.source("NEW BARK TOWN"), Strings.source("CHERRYGROVE CITY"),
|
||||
Strings.source("AZALEA TOWN") } },
|
||||
{ kind = "string", points = 6,
|
||||
words = { Strings.source("FLYING"), Strings.source("BUG"),
|
||||
Strings.source("GRASS") } },
|
||||
{ kind = "move", points = 12,
|
||||
words = { "TACKLE", "GROWL", "MUD_SLAP" } },
|
||||
{ kind = "item", points = 12,
|
||||
words = { "X_ATTACK", "X_DEFEND", "X_SPEED" } },
|
||||
{ kind = "string", points = 13,
|
||||
words = { Strings.source("#MON Talk"), Strings.source("#MON Music"),
|
||||
Strings.source("Lucky Channel") } },
|
||||
}
|
||||
|
||||
-- ../pokecrystal/constants/radio_constants.asm:120-121.
|
||||
local NUM_PASSWORD_CATEGORIES = #BUENA_PASSWORDS
|
||||
local NUM_PASSWORDS_PER_CATEGORY = 3
|
||||
|
||||
-- GetBuenasPassword.StringFunctionJumptable (../pokecrystal/engine/pokegear/radio.asm:1534).
|
||||
local function passwordWord(vm, group, index)
|
||||
local row = BUENA_PASSWORDS[group + 1]
|
||||
if not row then return "?" end
|
||||
local word = row.words[index + 1]
|
||||
if not word then return "?" end
|
||||
if row.kind == "string" then return Strings(word) end
|
||||
local d = S.data(vm)
|
||||
if row.kind == "mon" then
|
||||
local def = d and d.pokemon and d.pokemon[word]
|
||||
return (def and def.name) or word
|
||||
end
|
||||
if row.kind == "item" then
|
||||
local def = d and d.items and d.items[word]
|
||||
return (def and def.name) or word
|
||||
end
|
||||
local def = d and d.moves and d.moves[word]
|
||||
return (def and def.name) or word
|
||||
end
|
||||
|
||||
-- BuenasPassword4's two rejection rolls, packed group-high over word-low
|
||||
-- (../pokecrystal/engine/pokegear/radio.asm:1470-1487).
|
||||
local function rollPassword(save, day)
|
||||
local buena = Save.crystalState(save).buenaPassword
|
||||
buena.word = (Specials.random(NUM_PASSWORD_CATEGORIES) - 1) * 16
|
||||
+ (Specials.random(NUM_PASSWORDS_PER_CATEGORY) - 1)
|
||||
buena.day = day
|
||||
return buena.word
|
||||
end
|
||||
|
||||
-- DAILYFLAGS2_BUENAS_PASSWORD_F is what makes the roll once a day
|
||||
-- (../pokecrystal/engine/pokegear/radio.asm:1467, :1489); the day stamp stands in for it.
|
||||
local function currentPassword(vm)
|
||||
local record = S.save(vm)
|
||||
if not record then return 0 end
|
||||
local buena = Save.crystalState(record).buenaPassword
|
||||
local today = BugContest.now().day
|
||||
if buena.word == nil or buena.day ~= today then rollPassword(record, today) end
|
||||
if vm.writeVarFn then vm.writeVarFn(VAR_BUENASPASSWORD, buena.word % 256) end
|
||||
return buena.word % 256
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/buena_menu.asm:1-9: carry (NO or B) is 0 and a YES is 1. The
|
||||
-- question is the script's own writetext at maps/RadioTower2F.asm:119.
|
||||
M.AskRememberPassword = function(vm)
|
||||
local yes = coroutine.yield({ kind = "yesorno" })
|
||||
S.answer(vm, yes and 1 or 0)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/buena.asm:19-23 `ld a, [wBuenasPassword] / maskbits 3 / cp c`,
|
||||
-- and :44-49 .PasswordIndices, which makes the menu's answer zero based.
|
||||
M.BuenasPassword = function(vm)
|
||||
local h = hooks(vm)
|
||||
local packed = currentPassword(vm)
|
||||
local group = math.floor(packed / 16) % 16
|
||||
if group >= NUM_PASSWORD_CATEGORIES then group = 0 end
|
||||
local answer = packed % 4
|
||||
local words = {}
|
||||
for row = 0, NUM_PASSWORDS_PER_CATEGORY - 1 do
|
||||
words[row + 1] = passwordWord(vm, group, row)
|
||||
end
|
||||
if not h.pushScreen then
|
||||
S.answer(vm, 0)
|
||||
return
|
||||
end
|
||||
local picked = Specials.block(vm, function(done)
|
||||
local ok = h.pushScreen("Gen2BuenaPassword", {
|
||||
mode = "password",
|
||||
words = words,
|
||||
width = BUENA_PASSWORDS[group + 1].points,
|
||||
onDone = function(index) done(index or -1) end,
|
||||
})
|
||||
if not ok then done(-1) end
|
||||
end)
|
||||
S.answer(vm, (picked == answer) and 1 or 0)
|
||||
end
|
||||
|
||||
-- data/items/buena_prizes.asm.
|
||||
local BUENA_PRIZES = {
|
||||
{ item = "ULTRA_BALL", cost = 2 },
|
||||
{ item = "FULL_RESTORE", cost = 2 },
|
||||
{ item = "NUGGET", cost = 3 },
|
||||
{ item = "RARE_CANDY", cost = 3 },
|
||||
{ item = "PROTEIN", cost = 5 },
|
||||
{ item = "IRON", cost = 5 },
|
||||
{ item = "CARBOS", cost = 5 },
|
||||
{ item = "CALCIUM", cost = 5 },
|
||||
{ item = "HP_UP", cost = 5 },
|
||||
}
|
||||
|
||||
-- ../pokecrystal/data/text/common_3.asm:1082-1116, the six pages BuenaPrintText cycles.
|
||||
local PRIZE_TEXT = {
|
||||
which = Strings.source("Which prize would\nyou like?"),
|
||||
confirm = Strings.source("{STRBUF}?\nIs that right?"),
|
||||
hereYouGo = Strings.source("Here you go!"),
|
||||
notEnough = Strings.source("You don't have\nenough points."),
|
||||
noRoom = Strings.source("You have no room\nfor it."),
|
||||
comeAgain = Strings.source("Oh. Please come\nback again!"),
|
||||
}
|
||||
|
||||
-- wBlueCardBalance is a RETVAR_ADDR_DE var, so the point the script awards at
|
||||
-- maps/RadioTower2F.asm:144-146 and this counter read one store.
|
||||
local function blueCardBalance(vm)
|
||||
if not vm.readVarFn then return 0 end
|
||||
local value = math.floor(tonumber(vm.readVarFn(VAR_BLUECARDBALANCE)) or 0)
|
||||
if value < 0 then value = 0 end
|
||||
return math.min(value, BLUE_CARD_POINT_CAP)
|
||||
end
|
||||
|
||||
local function setBlueCardBalance(vm, value)
|
||||
if vm.writeVarFn then
|
||||
vm.writeVarFn(VAR_BLUECARDBALANCE, math.max(0, value) % 256)
|
||||
end
|
||||
end
|
||||
|
||||
-- ReceiveItem into wNumItems (../pokecrystal/engine/events/buena.asm:104-110).
|
||||
local function receiveItem(vm, itemId)
|
||||
local record = S.save(vm)
|
||||
if not record then return false end
|
||||
record.inventory = record.inventory or {}
|
||||
return Bag.add(record, itemId, 1, S.data(vm)) and true or false
|
||||
end
|
||||
|
||||
M.BuenaPrize = function(vm)
|
||||
local h = hooks(vm)
|
||||
if not h.pushScreen then return end
|
||||
local d = S.data(vm)
|
||||
local items = d and d.items
|
||||
local rows = {}
|
||||
for i, prize in ipairs(BUENA_PRIZES) do
|
||||
local def = items and items[prize.item]
|
||||
rows[i] = {
|
||||
item = prize.item,
|
||||
cost = prize.cost,
|
||||
name = (def and def.name) or prize.item,
|
||||
}
|
||||
end
|
||||
|
||||
while true do
|
||||
-- ../pokecrystal/engine/events/buena.asm:71-83: the page is printed and the menu opens
|
||||
-- over it, so the box is held the way a `yesorno` page is held.
|
||||
S.showRawHeld(vm, Strings(PRIZE_TEXT.which))
|
||||
local pick = Specials.block(vm, function(done)
|
||||
local ok = h.pushScreen("Gen2BuenaPassword", {
|
||||
mode = "prize",
|
||||
prizes = rows,
|
||||
balance = blueCardBalance(vm),
|
||||
onDone = function(index) done(index or 0) end,
|
||||
})
|
||||
if not ok then done(0) end
|
||||
end)
|
||||
-- ../pokecrystal/engine/events/buena.asm:84 `jr z, .done`: 0 is the B press.
|
||||
if not (type(pick) == "number" and rows[pick]) then break end
|
||||
|
||||
local row = rows[pick]
|
||||
vm:setStringBuffer(row.name)
|
||||
S.showRawHeld(vm, Strings(PRIZE_TEXT.confirm))
|
||||
local sure = coroutine.yield({ kind = "yesorno" })
|
||||
if sure then
|
||||
-- ../pokecrystal/engine/events/buena.asm:95-119: the cost is checked, then ReceiveItem,
|
||||
-- and only a delivered item spends the points.
|
||||
local balance = blueCardBalance(vm)
|
||||
if balance < row.cost then
|
||||
vm:showRaw(Strings(PRIZE_TEXT.notEnough))
|
||||
else
|
||||
if not receiveItem(vm, row.item) then
|
||||
vm:showRaw(Strings(PRIZE_TEXT.noRoom))
|
||||
else
|
||||
setBlueCardBalance(vm, balance - row.cost)
|
||||
if h.playSfxNamed then h.playSfxNamed("Sfx_Transaction") end
|
||||
vm:showRaw(Strings(PRIZE_TEXT.hereYouGo))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- ../pokecrystal/engine/events/buena.asm:138-145 .done.
|
||||
vm:showRaw(Strings(PRIZE_TEXT.comeAgain))
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- PokeSeer -- ../pokecrystal/engine/events/poke_seer.asm:18
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- data/text/common_3.asm:281-445, SeerTexts (../pokecrystal/engine/events/poke_seer.asm:289)
|
||||
-- in jumptable order, with each text_ram buffer spelled as a directive.
|
||||
local SEER_TEXT = {
|
||||
intro = Strings.source("I see all.\nI know all…\fCertainly, I know\nof your #MON!"),
|
||||
cantTell = Strings.source(
|
||||
"Whaaaat? I can't\ntell a thing!\fHow could I not\nknow of this?"),
|
||||
nameLocation = Strings.source("Hm… I see you met\n%s here:\v%s!"),
|
||||
timeLevel = Strings.source(
|
||||
"The time was\n%s!\fIts level was %s!\fAm I good or what?"),
|
||||
trade = Strings.source(
|
||||
"Hm… %s\ncame from %s\vin a trade?\f%s\nwas where %s\vmet %s!"),
|
||||
noLocation = Strings.source(
|
||||
"What!? Incredible!\fI don't understand\nhow, but it is\fincredible!\n"
|
||||
.. "You are special.\fI can't tell where\nyou met it, but it\v"
|
||||
.. "was at level %s.\fAm I good or what?"),
|
||||
egg = Strings.source("Hey!\fThat's an EGG!\fYou can't say that\nyou've met it yet…"),
|
||||
doNothing = Strings.source("Fufufu! I saw that\nyou'd do nothing!"),
|
||||
}
|
||||
|
||||
-- SeerAdviceTexts (../pokecrystal/engine/events/poke_seer.asm:357), `dbw level, text`. Field
|
||||
-- three marks the rows that splice the nickname in.
|
||||
local SEER_ADVICE = {
|
||||
{ 9, Strings.source(
|
||||
"Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") },
|
||||
{ 29, Strings.source(
|
||||
"Incidentally…\fIt seems to have\ngrown a little.\f%s seems\nto be becoming\v"
|
||||
.. "more confident."), true },
|
||||
{ 59, Strings.source(
|
||||
"Incidentally…\f%s has\ngrown. It's gained\vmuch strength."), true },
|
||||
{ 89, Strings.source(
|
||||
"Incidentally…\fIt certainly has\ngrown mighty!\fThis %s\nmust have come\f"
|
||||
.. "through numerous\n#MON battles.\fIt looks brimming\nwith confidence."), true },
|
||||
{ 100, Strings.source(
|
||||
"Incidentally…\fI'm impressed by\nyour dedication.\fIt's been a long\n"
|
||||
.. "time since I've\fseen a #MON as\nmighty as this\v%s.\fI'm sure that\n"
|
||||
.. "seeing %s\fin battle would\nexcite anyone."), true },
|
||||
{ 255, Strings.source(
|
||||
"Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") },
|
||||
}
|
||||
|
||||
-- GetCaughtTime's .times (../pokecrystal/engine/events/poke_seer.asm:203) and
|
||||
-- UnknownCaughtData's "Unknown@" (:215).
|
||||
local SEER_TIMES = {
|
||||
Strings.source("Morning"), Strings.source("Day"), Strings.source("Night"),
|
||||
}
|
||||
local SEER_UNKNOWN = Strings.source("Unknown")
|
||||
local SEER_NO_LEVEL = Strings.source("???")
|
||||
|
||||
-- constants/pokemon_data_constants.asm:130 CAUGHT_EGG_LEVEL and
|
||||
-- constants/battle_constants.asm:4 EGG_LEVEL.
|
||||
local CAUGHT_EGG_LEVEL, EGG_LEVEL = 1, 5
|
||||
|
||||
-- GetCaughtLevel (../pokecrystal/engine/events/poke_seer.asm:148-179).
|
||||
local function caughtLevel(byte0)
|
||||
local level = byte0 % 0x40
|
||||
if level == 0 then return nil, Strings(SEER_NO_LEVEL) end
|
||||
if level == CAUGHT_EGG_LEVEL then level = EGG_LEVEL end
|
||||
return level, tostring(level)
|
||||
end
|
||||
|
||||
-- GetCaughtLocation (../pokecrystal/engine/events/poke_seer.asm:217-249); the second answer
|
||||
-- is the SEERACTION_* its two sentinel arms override wSeerAction with.
|
||||
local function caughtLocation(vm, byte1)
|
||||
local landmark = byte1 % 0x80
|
||||
if landmark == 0 then return Strings(SEER_UNKNOWN), nil end
|
||||
if landmark == Mon.LANDMARK_EVENT then return nil, "level_only" end
|
||||
if landmark == Mon.LANDMARK_GIFT then return nil, "cant_tell" end
|
||||
local record = Nests.landmark(S.data(vm), landmark)
|
||||
local name = record and record.name
|
||||
if not name then return Strings(SEER_UNKNOWN), nil end
|
||||
-- engine/overworld/landmarks.asm:16 GetLandmarkName copies the break byte
|
||||
-- through; engine/pokegear/townmap_convertlinebreakcharacters.asm:1 is it.
|
||||
return (tostring(name):gsub("\n", " ")), nil
|
||||
end
|
||||
|
||||
-- SeerAdvice (../pokecrystal/engine/events/poke_seer.asm:331-355); `sub c` is one byte.
|
||||
local function seerAdvice(vm, mon, level)
|
||||
local diff = ((mon.level or 0) - (level or 0)) % 256
|
||||
local name = Mon.displayName(mon)
|
||||
for _, row in ipairs(SEER_ADVICE) do
|
||||
if diff <= row[1] then
|
||||
if row[3] then return vm:showRaw(Strings(row[2], name, name)) end
|
||||
return vm:showRaw(Strings(row[2]))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
M.PokeSeer = function(vm)
|
||||
vm:showRaw(Strings(SEER_TEXT.intro))
|
||||
local _, mon = S.selectMon(vm, "choose")
|
||||
-- ../pokecrystal/engine/events/poke_seer.asm:38 .cancel.
|
||||
if not mon then
|
||||
vm:showRaw(Strings(SEER_TEXT.doNothing))
|
||||
return
|
||||
end
|
||||
-- :28-29 `cp EGG / jr z, .egg`, plus the IsAPokemon test at :31.
|
||||
if mon.isEgg then
|
||||
vm:showRaw(Strings(SEER_TEXT.egg))
|
||||
return
|
||||
end
|
||||
|
||||
local byte0, byte1 = Mon.packCaughtData(mon)
|
||||
-- ReadCaughtData's `.error` (../pokecrystal/engine/events/poke_seer.asm:104-105, :133).
|
||||
if byte0 == 0 and byte1 == 0 then
|
||||
vm:showRaw(Strings(SEER_TEXT.cantTell))
|
||||
return
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/poke_seer.asm:110-119: the `cp [hl]` on the OT id's second
|
||||
-- byte is commented out, so only the HIGH byte decides "traded".
|
||||
local record = S.save(vm)
|
||||
local playerId = (record and record.player and record.player.id) or 0
|
||||
local traded = math.floor((mon.otId or 0) / 256) % 256
|
||||
~= math.floor(playerId / 256) % 256
|
||||
|
||||
local level, levelText = caughtLevel(byte0)
|
||||
local place, override = caughtLocation(vm, byte1)
|
||||
local name = Mon.displayName(mon)
|
||||
|
||||
-- SeerAction2 / SeerAction3 (../pokecrystal/engine/events/poke_seer.asm:81-89).
|
||||
if override == "cant_tell" then
|
||||
vm:showRaw(Strings(SEER_TEXT.cantTell))
|
||||
return
|
||||
end
|
||||
-- SeerAction4 (../pokecrystal/engine/events/poke_seer.asm:91-95).
|
||||
if override == "level_only" then
|
||||
vm:showRaw(Strings(SEER_TEXT.noLocation, levelText))
|
||||
return seerAdvice(vm, mon, level)
|
||||
end
|
||||
|
||||
local time = math.floor(byte0 / 0x40)
|
||||
local timeText = (time > 0 and Strings(SEER_TIMES[time])) or Strings(SEER_UNKNOWN)
|
||||
|
||||
if traded then
|
||||
-- SeerAction1 (../pokecrystal/engine/events/poke_seer.asm:72-79).
|
||||
local ot = mon.otName or mon.ot or Strings(SEER_UNKNOWN)
|
||||
vm:showRaw(Strings(SEER_TEXT.trade, name, ot, place, ot, name))
|
||||
else
|
||||
-- SeerAction0 (../pokecrystal/engine/events/poke_seer.asm:64-70).
|
||||
vm:showRaw(Strings(SEER_TEXT.nameLocation, name, place))
|
||||
end
|
||||
vm:showRaw(Strings(SEER_TEXT.timeLevel, timeText, levelText))
|
||||
seerAdvice(vm, mon, level)
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- UnusedFindItemInPCOrBag -- ../pokecrystal/mobile/mobile_12_2.asm:191
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- CheckItem against wNumPCItems, then wNumItems (../pokecrystal/mobile/mobile_12_2.asm:194,
|
||||
-- :201); either hit is TRUE.
|
||||
M.UnusedFindItemInPCOrBag = function(vm)
|
||||
local h = hooks(vm)
|
||||
local index = vm.scriptVar or 0
|
||||
local record = S.save(vm)
|
||||
local d = S.data(vm)
|
||||
local id
|
||||
for key, def in pairs((d and d.items) or {}) do
|
||||
if type(def) == "table" and def.index == index then id = key break end
|
||||
end
|
||||
local pc = record and record.pcItems
|
||||
if id and type(pc) == "table" and (tonumber(pc[id]) or 0) > 0 then
|
||||
S.answer(vm, 1)
|
||||
return
|
||||
end
|
||||
if h.hasItem and h.hasItem(index) then
|
||||
S.answer(vm, 1)
|
||||
return
|
||||
end
|
||||
S.answer(vm, 0)
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- The wall map -- engine/events/specials.asm:100
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- OverworldTownMap is `FadeToMenu / farcall _TownMap / ExitAllMenus`, so it
|
||||
-- never writes wScriptVar; both callers (engine/events/std_scripts.asm:145
|
||||
-- TownMapScript and engine/overworld/decorations.asm:1005
|
||||
-- DecorationDesc_TownMapPoster) follow it with a bare `closetext`.
|
||||
--
|
||||
-- _TownMap (engine/pokegear/pokegear.asm:1709) draws the SAME region map the
|
||||
-- POKeGEAR's MAP card draws -- it calls Pokegear_LoadGFX and PokegearMap for
|
||||
-- it -- with no card strip, no ENGINE_MAP_CARD gate and no A press, which is
|
||||
-- src/ui/gen2/Pokegear.lua's `townMap` mode.
|
||||
M.OverworldTownMap = function(vm)
|
||||
local h = S.hooks(vm)
|
||||
if not h.pushScreen then return end
|
||||
Specials.block(vm, function(done)
|
||||
local ok = h.pushScreen("Gen2Pokegear", {
|
||||
townMap = true,
|
||||
onClose = function() done(true) end,
|
||||
})
|
||||
if not ok then done(false) end
|
||||
end)
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- The printed diploma -- engine/events/specials.asm:448
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- _PrintDiploma (engine/printer/printer.asm:382) opens with the very page
|
||||
-- `special Diploma` shows -- `farcall PlaceDiplomaOnScreen`,
|
||||
-- engine/events/diploma.asm:12 -- and only then reaches for the serial port.
|
||||
-- The second sheet is built with hBGMapMode zeroed and SafeLoadTempTilemapToTilemap
|
||||
-- puts page 1 straight back, so PrintDiplomaPage2 never reaches the screen on
|
||||
-- the cartridge either: page 1 IS the whole visible routine.
|
||||
--
|
||||
-- The two SendScreenToPrinter passes are the same missing peripheral
|
||||
-- H.PhotoStudio and H.UnownPrinter degrade around, and .CancelPrinting
|
||||
-- (maps/CeladonMansion3F.asm:60) is unreferenced, so the cart has no text for
|
||||
-- a failed print and neither does this.
|
||||
M.PrintDiploma = function(vm)
|
||||
local h = S.hooks(vm)
|
||||
if not h.showDiploma then return end
|
||||
Specials.block(vm, function(done)
|
||||
h.showDiploma(function() done(true) end)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,298 @@
|
||||
-- ../pokecrystal/data/events/special_pointers.asm:141 GiveOddEgg, :148
|
||||
-- OmanyteChamber, :157 HoOhChamber, :159 CelebiShrineEvent, :160
|
||||
-- CheckCaughtCelebi, :164 GiveDratini, :166 BeastsCheck.
|
||||
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local UnownWords = require("src.world.gen2.UnownWords")
|
||||
|
||||
local S = Specials.shared
|
||||
local M = {}
|
||||
|
||||
-- ../pokecrystal/constants/script_constants.asm:51 VAR_BATTLETYPE.
|
||||
local VAR_BATTLETYPE = 0x03
|
||||
-- ../pokecrystal/constants/battle_constants.asm:102 BATTLETYPE_CELEBI.
|
||||
local BATTLETYPE_CELEBI = 11
|
||||
|
||||
-- ../pokecrystal/engine/pokemon/search_owned.asm:6, :11, :16 -- the order the
|
||||
-- routine writes into wScriptVar before each CheckOwnMonAnywhere.
|
||||
local BEASTS = { "RAIKOU", "ENTEI", "SUICUNE" }
|
||||
|
||||
-- ../pokecrystal/engine/pokemon/search_owned.asm:1; MonCheck is
|
||||
-- CheckOwnMonAnywhere (:48) with the answer already written.
|
||||
M.BeastsCheck = function(vm)
|
||||
local monCheck = Specials.HANDLERS.MonCheck
|
||||
local h = S.hooks(vm)
|
||||
for _, name in ipairs(BEASTS) do
|
||||
vm.scriptVar = (h.monIndex and h.monIndex(name)) or name
|
||||
monCheck(vm)
|
||||
if vm.scriptVar ~= S.TRUE then
|
||||
S.answer(vm, S.FALSE)
|
||||
return
|
||||
end
|
||||
end
|
||||
S.answer(vm, S.TRUE)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/dratini.asm:72 .Moveset0, :79 .Moveset1.
|
||||
local DRATINI = "DRATINI"
|
||||
local DRATINI_MOVESETS = {
|
||||
[0] = { "WRAP", "THUNDER_WAVE", "TWISTER", "EXTREMESPEED" },
|
||||
[1] = { "WRAP", "LEER", "THUNDER_WAVE", "TWISTER" },
|
||||
}
|
||||
|
||||
-- ../pokecrystal/engine/events/dratini.asm:1: `cp $2 / ret nc`, then :16
|
||||
-- .CheckForDratini walks the party BACKWARDS from the last slot.
|
||||
M.GiveDratini = function(vm)
|
||||
local set = DRATINI_MOVESETS[vm.scriptVar or 0]
|
||||
if not set then return end
|
||||
local list = S.party(vm)
|
||||
local target
|
||||
for index = #list, 1, -1 do
|
||||
local mon = list[index]
|
||||
if mon and mon.species == DRATINI then
|
||||
target = mon
|
||||
break
|
||||
end
|
||||
end
|
||||
if not target then return end
|
||||
-- ../pokecrystal/engine/events/dratini.asm:54: each new move's PP comes from
|
||||
-- Moves + MOVE_PP, so a PP Up on the replaced slot is dropped.
|
||||
local defs = S.data(vm)
|
||||
local moves = defs and defs.moves
|
||||
target.moves = target.moves or {}
|
||||
for index, id in ipairs(set) do
|
||||
local pp = (moves and moves[id] and moves[id].pp) or 0
|
||||
target.moves[index] = { id = id, pp = pp, maxPp = pp }
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/data/events/odd_eggs.asm:14-33, the `odd_egg_prob` arguments in
|
||||
-- order; the macro (:5) accumulates them and stores total * $ffff / 100.
|
||||
local ODD_EGG_PERCENTS = { 8, 1, 16, 3, 16, 3, 14, 2, 10, 2, 12, 2, 10, 1 }
|
||||
|
||||
local ODD_EGG_PROBABILITIES = {}
|
||||
do
|
||||
local total = 0
|
||||
for index, percent in ipairs(ODD_EGG_PERCENTS) do
|
||||
total = total + percent
|
||||
ODD_EGG_PROBABILITIES[index] = math.floor(total * 0xffff / 100)
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/data/events/odd_eggs.asm:37 OddEggs, one row per
|
||||
-- NICKNAMED_MON_STRUCT.
|
||||
local ODD_EGGS = {
|
||||
{ species = "PICHU", otId = 2048, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 30, 20, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 17, attack = 9, defense = 6, speed = 11,
|
||||
specialAttack = 8, specialDefense = 8 } },
|
||||
{ species = "PICHU", otId = 256, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 30, 20, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 17, attack = 9, defense = 7, speed = 12,
|
||||
specialAttack = 9, specialDefense = 9 } },
|
||||
{ species = "CLEFFA", otId = 4096, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "POUND", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 35, 20, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 20, attack = 7, defense = 7, speed = 6,
|
||||
specialAttack = 9, specialDefense = 10 } },
|
||||
{ species = "CLEFFA", otId = 768, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "POUND", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 35, 20, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 20, attack = 7, defense = 8, speed = 7,
|
||||
specialAttack = 10, specialDefense = 11 } },
|
||||
{ species = "IGGLYBUFF", otId = 4096, experience = 125, level = 5,
|
||||
eggSteps = 20,
|
||||
moves = { "SING", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 15, 20, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 24, attack = 8, defense = 6, speed = 6,
|
||||
specialAttack = 9, specialDefense = 7 } },
|
||||
{ species = "IGGLYBUFF", otId = 768, experience = 125, level = 5,
|
||||
eggSteps = 20,
|
||||
moves = { "SING", "CHARM", "DIZZY_PUNCH" },
|
||||
pp = { 15, 20, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 24, attack = 8, defense = 7, speed = 7,
|
||||
specialAttack = 10, specialDefense = 8 } },
|
||||
{ species = "SMOOCHUM", otId = 3584, experience = 125, level = 5,
|
||||
eggSteps = 20,
|
||||
moves = { "POUND", "LICK", "DIZZY_PUNCH" },
|
||||
pp = { 35, 30, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 19, attack = 8, defense = 6, speed = 11,
|
||||
specialAttack = 13, specialDefense = 11 } },
|
||||
{ species = "SMOOCHUM", otId = 512, experience = 125, level = 5,
|
||||
eggSteps = 20,
|
||||
moves = { "POUND", "LICK", "DIZZY_PUNCH" },
|
||||
pp = { 35, 30, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 19, attack = 8, defense = 7, speed = 12,
|
||||
specialAttack = 14, specialDefense = 12 } },
|
||||
{ species = "MAGBY", otId = 2560, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "EMBER", "DIZZY_PUNCH" },
|
||||
pp = { 25, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 19, attack = 12, defense = 8, speed = 13,
|
||||
specialAttack = 12, specialDefense = 10 } },
|
||||
{ species = "MAGBY", otId = 512, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "EMBER", "DIZZY_PUNCH" },
|
||||
pp = { 25, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 19, attack = 12, defense = 9, speed = 14,
|
||||
specialAttack = 13, specialDefense = 11 } },
|
||||
{ species = "ELEKID", otId = 3072, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" },
|
||||
pp = { 30, 30, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 19, attack = 11, defense = 8, speed = 14,
|
||||
specialAttack = 11, specialDefense = 10 } },
|
||||
{ species = "ELEKID", otId = 512, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" },
|
||||
pp = { 30, 30, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 19, attack = 11, defense = 9, speed = 15,
|
||||
specialAttack = 12, specialDefense = 11 } },
|
||||
{ species = "TYROGUE", otId = 2560, experience = 125, level = 5,
|
||||
eggSteps = 20,
|
||||
moves = { "TACKLE", "DIZZY_PUNCH" },
|
||||
pp = { 35, 10 },
|
||||
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 18, attack = 8, defense = 8, speed = 8,
|
||||
specialAttack = 8, specialDefense = 8 } },
|
||||
{ species = "TYROGUE", otId = 256, experience = 125, level = 5, eggSteps = 20,
|
||||
moves = { "TACKLE", "DIZZY_PUNCH" },
|
||||
pp = { 35, 10 },
|
||||
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
|
||||
stats = { hp = 18, attack = 8, defense = 9, speed = 9,
|
||||
specialAttack = 9, specialDefense = 9 } },
|
||||
}
|
||||
|
||||
-- ../pokecrystal/engine/events/odd_egg.asm:93 `.Odd` is the OT NAME
|
||||
-- (../pokecrystal/mobile/mobile_46.asm:7561); wOddEggName ("EGG") is the nickname.
|
||||
local ODD_EGG_OT = "ODD"
|
||||
local ODD_EGG_NICKNAME = "EGG"
|
||||
local EGG_TICKET = "EGG_TICKET"
|
||||
|
||||
-- ../pokecrystal/engine/events/odd_egg.asm:5-38, one Random word against the
|
||||
-- cumulative table; the $ffff break is :17.
|
||||
local function oddEggIndex(roll)
|
||||
for index = 1, #ODD_EGG_PROBABILITIES do
|
||||
local probability = ODD_EGG_PROBABILITIES[index]
|
||||
if probability >= 0xffff then return index end
|
||||
if roll <= probability then return index end
|
||||
end
|
||||
return #ODD_EGG_PROBABILITIES
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/odd_egg.asm:40-48, NICKNAMED_MON_STRUCT_LENGTH
|
||||
-- bytes copied verbatim.
|
||||
local function buildOddEgg(data, row, save)
|
||||
if not (data and row) then return nil end
|
||||
local moves = {}
|
||||
for index, id in ipairs(row.moves) do
|
||||
local pp = row.pp[index] or 0
|
||||
moves[index] = { id = id, pp = pp, maxPp = pp }
|
||||
end
|
||||
local dvs = {
|
||||
attack = row.dvs.attack, defense = row.dvs.defense,
|
||||
speed = row.dvs.speed, special = row.dvs.special,
|
||||
}
|
||||
local egg = Mon.new(data, row.species, row.level, {
|
||||
dvs = dvs,
|
||||
moves = moves,
|
||||
-- ../pokecrystal/data/events/odd_eggs.asm:57 `bigdw 0 ; HP`
|
||||
hp = 0,
|
||||
nickname = ODD_EGG_NICKNAME,
|
||||
})
|
||||
if not egg then return nil end
|
||||
egg.experience = row.experience
|
||||
egg.stats = {
|
||||
hp = row.stats.hp, attack = row.stats.attack,
|
||||
defense = row.stats.defense, speed = row.stats.speed,
|
||||
specialAttack = row.stats.specialAttack,
|
||||
specialDefense = row.stats.specialDefense,
|
||||
}
|
||||
egg.maxHp = row.stats.hp
|
||||
egg.hp = 0
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:7528 writes EGG into wPartySpecies while the struct
|
||||
-- keeps the hatchling's own species.
|
||||
egg.isEgg = true
|
||||
-- ../pokecrystal/data/events/odd_eggs.asm:53 `db 20 ; Step cycles to hatch`,
|
||||
-- the byte MON_HAPPINESS holds while the thing is an egg.
|
||||
egg.eggSteps = row.eggSteps
|
||||
egg.ot = ODD_EGG_OT
|
||||
egg.otName = ODD_EGG_OT
|
||||
egg.otId = row.otId
|
||||
if save then Mon.stampOT(save, egg) end
|
||||
return egg
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/odd_egg.asm:1. The party-full refusal is the
|
||||
-- caller's (../pokecrystal/maps/DayCare.asm:31-32), not the routine's.
|
||||
M.GiveOddEgg = function(vm)
|
||||
local record = S.save(vm)
|
||||
local data = S.data(vm)
|
||||
if not (record and data) then return end
|
||||
local party = record.party or {}
|
||||
record.party = party
|
||||
if #party >= Mon.PARTY_SIZE then return end
|
||||
local row = ODD_EGGS[oddEggIndex(Specials.random(0x10000) - 1)]
|
||||
local egg = buildOddEgg(data, row, record)
|
||||
if not egg then return end
|
||||
-- ../pokecrystal/engine/events/odd_egg.asm:50-57 TossItem on the EGG TICKET,
|
||||
-- ahead of the party write.
|
||||
local h = S.hooks(vm)
|
||||
local ticket = h.itemIndex and h.itemIndex(EGG_TICKET)
|
||||
if ticket and h.takeItem then h.takeItem(ticket, 1) end
|
||||
party[#party + 1] = egg
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:1, run by
|
||||
-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:10.
|
||||
M.HoOhChamber = function(vm)
|
||||
if not UnownWords.leadIsHoOh(S.party(vm)) then return end
|
||||
UnownWords.openWall(vm.events, "HO_OH")
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:13, run by
|
||||
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:10; :25 CheckItem then :38
|
||||
-- MON_ITEM.
|
||||
M.OmanyteChamber = function(vm)
|
||||
if UnownWords.wallOpened(vm.events, "OMANYTE") then return end
|
||||
local h = S.hooks(vm)
|
||||
local index = h.itemIndex and h.itemIndex(UnownWords.WATER_STONE)
|
||||
local inPack = index and h.hasItem and h.hasItem(index)
|
||||
if not inPack and not UnownWords.waterStoneSlot(S.party(vm)) then return end
|
||||
UnownWords.openWall(vm.events, "OMANYTE")
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/celebi.asm:9; :296 CelebiEvent_SetBattleType is
|
||||
-- the only state it leaves, and src/world/gen2/World.lua:6161 clears that.
|
||||
M.CelebiShrineEvent = function(vm)
|
||||
if vm.writeVarFn then vm.writeVarFn(VAR_BATTLETYPE, BATTLETYPE_CELEBI) end
|
||||
vm.celebiArmed = true
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/celebi.asm:301 reads the bit
|
||||
-- ../pokecrystal/engine/items/item_effects.asm:545 sets, gated on :542.
|
||||
M.CheckCaughtCelebi = function(vm)
|
||||
local caught = vm.celebiArmed == true and vm.battleOutcome == "caught"
|
||||
vm.celebiArmed = nil
|
||||
if caught then
|
||||
local record = S.save(vm)
|
||||
if record then
|
||||
local Save = require("src.core.gen2.Save")
|
||||
Save.crystalState(record).celebiCaught = true
|
||||
end
|
||||
end
|
||||
S.answer(vm, caught and S.TRUE or S.FALSE)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,29 @@
|
||||
-- ../pokecrystal/data/events/special_pointers.asm:151 DisplayUnownWords, run by all four
|
||||
-- chambers: ../pokecrystal/maps/RuinsOfAlphKabutoChamber.asm:123,
|
||||
-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:86, ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:86,
|
||||
-- ../pokecrystal/maps/RuinsOfAlphAerodactylChamber.asm:85.
|
||||
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
local UnownWords = require("src.world.gen2.UnownWords")
|
||||
|
||||
local S = Specials.shared
|
||||
|
||||
local M = {}
|
||||
|
||||
M.DisplayUnownWords = function(vm)
|
||||
local h = S.hooks(vm)
|
||||
local world = h.world
|
||||
local game = world and world.game
|
||||
if not (game and game.stack) then return end
|
||||
local wall = UnownWords.wallFor(game.data, vm.scriptVar or 0)
|
||||
if not wall then return end
|
||||
S.block(vm, function(done)
|
||||
local screen = UnownWords.new(game, {
|
||||
wall = wall, world = world, onClose = function() done(true) end,
|
||||
})
|
||||
local ok = pcall(game.stack.push, game.stack, screen)
|
||||
if not ok then done(false) end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -7,7 +7,7 @@ SyncMods.MAX_OPTION_TEXT = 256
|
||||
local function versions()
|
||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
|
||||
return { "red", "blue", "yellow", "gold", "silver" }
|
||||
return { "red", "blue", "yellow", "gold", "silver", "crystal" }
|
||||
end
|
||||
|
||||
local function defaultDeps()
|
||||
|
||||
+44
-5
@@ -39,14 +39,30 @@ local BUILTIN = {
|
||||
-- screens and do have ids.
|
||||
local GEN2 = {
|
||||
"BankOfMom",
|
||||
"BattleState", "BattleTransition", "BoxMenu", "CardFlip",
|
||||
"BattleState",
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 the CHALLENGE
|
||||
-- / EXPLANATION / CANCEL desk menu, Crystal only.
|
||||
"BattleTowerMenu",
|
||||
"BattleTransition", "BoxMenu",
|
||||
-- ../pokecrystal/engine/events/buena.asm:1 the radio-password prompt behind
|
||||
-- `special BuenasPassword`, Crystal only.
|
||||
"BuenaPassword",
|
||||
"CardFlip",
|
||||
-- The Pokecenter PC's whose-PC top menu and the player's item PC behind it;
|
||||
-- Gen2PcMenu below is the storage system both BILL's PC rows open.
|
||||
"CenterPcMenu",
|
||||
"ContestMenu",
|
||||
"CopyrightSplash", "Credits", "DayCareMenu", "DecorationMenu", "Diploma",
|
||||
"CopyrightSplash", "Credits",
|
||||
-- ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro, the Crystal-only
|
||||
-- peer of GoldSilverIntro below.
|
||||
"CrystalIntro",
|
||||
"DayCareMenu", "DecorationMenu", "Diploma",
|
||||
"EggHatchAnim", "ElevatorMenu", "EvolutionAnim",
|
||||
"GameFreakPresents", "GoldSilverIntro", "HallOfFame", "HeldItemMenu",
|
||||
"GameFreakPresents",
|
||||
-- ../pokecrystal/engine/menus/init_gender.asm:23 InitGender, the Crystal-only
|
||||
-- screen PlayerProfileSetup runs before Oak's speech.
|
||||
"GenderSelect",
|
||||
"GoldSilverIntro", "HallOfFame", "HeldItemMenu",
|
||||
-- Gen2InitClock is both timeset.asm screens: the new-game hour/minute pair
|
||||
-- OakSpeech opens with, and Mom's day-of-week wheel.
|
||||
"InitClock",
|
||||
@@ -57,7 +73,11 @@ local GEN2 = {
|
||||
"MailCompose", "MailMenu", "MailRead", "MailboxMenu",
|
||||
-- Gen2MapRadio is the in-house wall radio (`special MapRadio`), not a card.
|
||||
"MapRadio",
|
||||
"MainMenu", "MartMenu", "MoveDeleter", "NamePick", "NamingScreen", "OakSpeech",
|
||||
"MainMenu", "MartMenu", "MoveDeleter",
|
||||
-- ../pokecrystal/engine/events/move_tutor.asm:1 the Goldenrod tutor's own
|
||||
-- move/party pages, Crystal only.
|
||||
"MoveTutor",
|
||||
"NamePick", "NamingScreen", "OakSpeech",
|
||||
"OptionsMenu", "PackMenu", "PartyMenu", "PcMenu", "PhotoStudio", "PokedexMenu",
|
||||
"Pokegear", "SaveMenu", "ScriptMenu", "SlotMachine",
|
||||
"StartMenu", "SummaryMenu", "TitleState", "TradeAnim", "TradeMenu",
|
||||
@@ -66,12 +86,31 @@ local GEN2 = {
|
||||
"UnownPrinter", "UnownPuzzle",
|
||||
}
|
||||
|
||||
local GEN2_PENDING = {
|
||||
BattleTowerMenu = true,
|
||||
BuenaPassword = true,
|
||||
MoveTutor = true,
|
||||
}
|
||||
|
||||
local function moduleExists(name)
|
||||
local path = "src/ui/gen2/" .. name .. ".lua"
|
||||
local handle = io.open(path, "r")
|
||||
if handle then
|
||||
handle:close()
|
||||
return true
|
||||
end
|
||||
local lfs = love and love.filesystem
|
||||
return (lfs and lfs.getInfo and lfs.getInfo(path)) ~= nil
|
||||
end
|
||||
|
||||
-- The full ids, in the same order, for tests and for the mod docs.
|
||||
Screens.GEN2_IDS = {}
|
||||
for _, name in ipairs(GEN2) do
|
||||
local id = "Gen2" .. name
|
||||
BUILTIN[id] = "src.ui.gen2." .. name
|
||||
Screens.GEN2_IDS[#Screens.GEN2_IDS + 1] = id
|
||||
if not GEN2_PENDING[name] or moduleExists(name) then
|
||||
Screens.GEN2_IDS[#Screens.GEN2_IDS + 1] = id
|
||||
end
|
||||
end
|
||||
|
||||
local cache = {}
|
||||
|
||||
@@ -25,10 +25,12 @@ local Catching = require("src.battle.gen2.Catching")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Evolution = require("src.core.gen2.Evolution")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Gen2Save = require("src.core.gen2.Save")
|
||||
local Font = require("src.render.Font")
|
||||
local HpBar = require("src.battle.gen2.HpBar")
|
||||
local ItemEffects = require("src.core.gen2.ItemEffects")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local MonAnim = require("src.render.MonAnim")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local Pokerus = require("src.core.gen2.Pokerus")
|
||||
local Prize = require("src.battle.gen2.Prize")
|
||||
@@ -306,6 +308,11 @@ function BattleState.new(game, opts)
|
||||
self.playerBackTrueColor = false
|
||||
local hudGfx = data.gen2MenuGfx and data.gen2MenuGfx.battleHud
|
||||
local backPath = hudGfx and hudGfx.playerBack
|
||||
-- GetTrainerBackpic's gender arm, under the Dude exception
|
||||
-- (../pokecrystal/engine/battle/core.asm:8984-9008).
|
||||
if hudGfx and hudGfx.playerBackFemale and Gen2Save.isFemale(self.save) then
|
||||
backPath = hudGfx.playerBackFemale
|
||||
end
|
||||
if self.tutorial and hudGfx and hudGfx.dudeBack then
|
||||
backPath = hudGfx.dudeBack
|
||||
end
|
||||
@@ -586,6 +593,65 @@ function BattleState:pic(mon, back)
|
||||
return cached or nil, trueColor, path
|
||||
end
|
||||
|
||||
-- Crystal's animated front pics. A cache with no `anim` row -- every Gold
|
||||
-- and Silver one -- gets nil here and the static pic is drawn as before.
|
||||
function BattleState:animData(mon)
|
||||
local def = self.pokemon and mon and self.pokemon[mon.species]
|
||||
if not def then return nil end
|
||||
if mon.species == Unown.SPECIES and def.letters then
|
||||
local entry = def.letters[Unown.monLetter(mon)]
|
||||
if entry and entry.anim then return entry.anim end
|
||||
end
|
||||
return def.anim
|
||||
end
|
||||
|
||||
-- ANIM_MON_NORMAL, the scene BattleStartMessage runs on the enemy's frontpic.
|
||||
-- ../pokecrystal/engine/battle/core.asm:9112-9113
|
||||
function BattleState:startFrontAnim(mon)
|
||||
self.frontAnim = nil
|
||||
local data = self:animData(mon)
|
||||
if not data then return end
|
||||
local cached = self.picCache[data.sheet]
|
||||
if cached == nil then
|
||||
local ok, image = pcall(Assets.image, data.sheet)
|
||||
cached = ok and image or false
|
||||
self.picCache[data.sheet] = cached
|
||||
end
|
||||
if not cached then return end
|
||||
local runner = MonAnim.new(data, "battle")
|
||||
if not runner then return end
|
||||
local size = data.tiles * 8
|
||||
self.frontAnim = { mon = mon, runner = runner, sheet = cached, size = size,
|
||||
quads = {} }
|
||||
end
|
||||
|
||||
-- AnimateFrontpic's .loop, one scene command per frame.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89
|
||||
function BattleState:stepFrontAnim()
|
||||
local state = self.frontAnim
|
||||
if not state then return end
|
||||
state.runner:update()
|
||||
if state.runner:finished() then self.frontAnim = nil end
|
||||
end
|
||||
|
||||
-- The sheet is one column of whole pictures, base first, so a frame is one
|
||||
-- quad at the size the static pic would have been.
|
||||
function BattleState:frontAnimFrame(mon)
|
||||
local state = self.frontAnim
|
||||
if not (state and mon and state.mon == mon) then return nil end
|
||||
local frame = state.runner:currentFrame()
|
||||
if frame <= 0 then return nil end
|
||||
local quad = state.quads[frame]
|
||||
if not quad then
|
||||
local w, h = state.sheet:getDimensions()
|
||||
if (frame + 1) * state.size > h then return nil end
|
||||
quad = love.graphics.newQuad(0, frame * state.size, state.size, state.size,
|
||||
w, h)
|
||||
state.quads[frame] = quad
|
||||
end
|
||||
return state.sheet, quad, state.size
|
||||
end
|
||||
|
||||
-- The battle_sprite_scales registry: record id -> { path, scale }, keyed by the
|
||||
-- ASSET PATH the pic is drawn from rather than by species, which is the only
|
||||
-- handle there is on the pics that are not a species' own (the player's
|
||||
@@ -696,6 +762,13 @@ function BattleState:drawPic(mon, back)
|
||||
end
|
||||
local G = love.graphics
|
||||
local w, h = image:getDimensions()
|
||||
-- Crystal only: the frame the animation is showing replaces the static
|
||||
-- picture in the same box, at the same size.
|
||||
local animSheet, animQuad, animSize
|
||||
if not (back or trainerBack or enemyTrainer or doll) then
|
||||
animSheet, animQuad, animSize = self:frontAnimFrame(mon)
|
||||
if animSheet then w, h = animSize, animSize end
|
||||
end
|
||||
local px, py
|
||||
local boxTiles
|
||||
if back then
|
||||
@@ -772,6 +845,10 @@ function BattleState:drawPic(mon, back)
|
||||
scale, scale)
|
||||
return
|
||||
end
|
||||
if animQuad then
|
||||
G.draw(animSheet, animQuad, px, py, 0, scale, scale)
|
||||
return
|
||||
end
|
||||
G.draw(image, px, py, 0, scale, scale)
|
||||
end
|
||||
-- A mod-supplied pic that says it is already coloured is drawn as it is:
|
||||
@@ -1588,7 +1665,10 @@ function BattleState:advanceQueue()
|
||||
if event.intro then self.introTextShown = true end
|
||||
-- BattleStartMessage's `.not_shiny` cries the wild mon before its own line
|
||||
-- (engine/battle/core.asm:8718-8721).
|
||||
if event.cry then self:playCry(event.cry) end
|
||||
if event.cry then
|
||||
self:playCry(event.cry)
|
||||
self:startFrontAnim(event.cry)
|
||||
end
|
||||
-- A text_asm tail that plays its own sound, the way Text_BallCaught's
|
||||
-- sound_caught_mon rides the "Gotcha!" line rather than following it.
|
||||
if event.sfx then
|
||||
@@ -1694,6 +1774,7 @@ function BattleState:finishSendOut(after)
|
||||
if not after then return end
|
||||
self:playCry(after.mon)
|
||||
if after.side == "enemy" then
|
||||
self:startFrontAnim(after.mon)
|
||||
self.showEnemyHud = true
|
||||
else
|
||||
self.showPlayerHud = true
|
||||
@@ -1964,6 +2045,7 @@ function BattleState:update(_dt)
|
||||
-- this state is only still here because ExitBattle has not cleaned up yet.
|
||||
if self.phase == "evolving" or self.phase == "done" then return end
|
||||
self:updateAlarm()
|
||||
self:stepFrontAnim()
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
|
||||
@@ -2489,6 +2571,7 @@ function BattleState:openTutorialPack()
|
||||
end
|
||||
Screens.push(self.game, "Gen2PackMenu", {
|
||||
battle = true,
|
||||
tutorial = true,
|
||||
save = CatchTutorial.dudeSave(),
|
||||
-- An empty world rather than the real one: a DUDE pocket must not reach
|
||||
-- World:useFieldItem, because these buffers are not the player's bag and
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:137-177 _BattleTowerRoomMenu and the
|
||||
-- jumptable at :625-641 it drives.
|
||||
|
||||
local BattleTower = require("src.core.gen2.BattleTower")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local BattleTowerMenu = {}
|
||||
BattleTowerMenu.__index = BattleTowerMenu
|
||||
BattleTowerMenu.isOpaque = false
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3854-3858 MenuHeader_119cf7,
|
||||
-- `menu_coords 12, 7, SCREEN_WIDTH - 1, TEXTBOX_Y - 1`.
|
||||
local PICK_X, PICK_Y, PICK_W, PICK_H = 12, 7, 8, 5
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:1178-1183, :1222 and :1147
|
||||
local ROW_X, ROW_Y = 13, 9
|
||||
local ARROW_X, UP_Y, DOWN_Y = 16, 8, 10
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:4635-4639 MenuHeader_11a2de and
|
||||
-- :4516-4530 BattleTowerRoomMenu2_PlaceYesNoMenu.
|
||||
local YN_X, YN_Y, YN_W, YN_H = 14, 7, 6, 5
|
||||
local YN_TEXT_X, YES_Y, NO_Y = 16, 8, 10
|
||||
|
||||
-- ../pokecrystal/constants/text_constants.asm:25-32, the box SpeechTextbox
|
||||
-- fills at ../pokecrystal/mobile/mobile_46.asm:5267.
|
||||
local SAY_X, SAY_Y, SAY_W, SAY_H = 0, 12, 18, 4
|
||||
local SAY_TEXT_X, SAY_TEXT_Y = 1, 14
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3803-3813, `ld a, $80 / ld [wcd50]`
|
||||
-- counted down one per frame before the menu restarts.
|
||||
local MESSAGE_FRAMES = 0x80
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:4609-4610, the code
|
||||
-- BattleTowerRoomMenu_Cleanup (:512-513) copies into wScriptVar. The chosen
|
||||
-- room is a separate byte (w3_d800 at :1285), so onDone answers the level
|
||||
-- group and nil for the cancel.
|
||||
BattleTowerMenu.CANCELLED = 0x0a
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:5488-5491
|
||||
local PICK_TEXT = Strings.source("What level do you\nwant to challenge?")
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:5459-5462
|
||||
local TOPS_TEXT = Strings.source("A party POKéMON\ntops this level.")
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:5464-5471
|
||||
local UBER_TEXT = Strings.source(
|
||||
"%s may go\nonly to BATTLE\nROOMS that are\nLv.70 or higher.")
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:5473-5476
|
||||
local QUIT_TEXT = Strings.source("Cancel your BATTLE\nROOM challenge?")
|
||||
-- ../pokecrystal/constants/charmap.asm:89 and :192, tiles $61 and $ee.
|
||||
local UP_ARROW = "\xe2\x96\xb2"
|
||||
local DOWN_ARROW = "\xe2\x96\xbc"
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3880 and :4623-4627
|
||||
local CANCEL_LABEL = Strings.source("CANCEL")
|
||||
local YES_LABEL = Strings.source("YES")
|
||||
local NO_LABEL = Strings.source("NO")
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3869-3879 Strings_L10ToL100, six tiles
|
||||
-- a row.
|
||||
function BattleTowerMenu.levelLabel(group)
|
||||
return string.format(" L:%-3d", group * 10):sub(1, 6)
|
||||
end
|
||||
|
||||
-- opts: save, party, rows (BattleTower.levelGroupRows), monName,
|
||||
-- onDone(levelGroup) with nil for the cancel
|
||||
function BattleTowerMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, BattleTowerMenu)
|
||||
self.game = game
|
||||
self.data = (game and game.data) or {}
|
||||
self.save = opts.save or (game and game.save)
|
||||
self.party = opts.party or (self.save and self.save.party) or {}
|
||||
self.rows = opts.rows or BattleTower.levelGroupRows(self.save)
|
||||
self.monName = opts.monName
|
||||
self.onDone = opts.onDone
|
||||
-- `ld a, $1 / ld [wcd4f], a` (../pokecrystal/mobile/mobile_46.asm:1152-1153)
|
||||
self.cursor = 1
|
||||
self.phase = "pick"
|
||||
self.message = PICK_TEXT
|
||||
return self
|
||||
end
|
||||
|
||||
function BattleTowerMenu:wantsFillScale() return true end
|
||||
|
||||
function BattleTowerMenu:playSfx(name)
|
||||
local sfx = self.data.audio and self.data.audio.sfx
|
||||
if sfx and sfx[Sound.resolve(self.data, name)] then
|
||||
Sound.play(self.data, name)
|
||||
end
|
||||
end
|
||||
|
||||
-- The last row is CANCEL (../pokecrystal/mobile/mobile_46.asm:1160, :1165).
|
||||
function BattleTowerMenu:rowCount()
|
||||
return #self.rows + 1
|
||||
end
|
||||
|
||||
function BattleTowerMenu:finish(group)
|
||||
if self.done then return end
|
||||
self.done = true
|
||||
local game = self.game
|
||||
if game and game.stack and game.stack:top() == self then game.stack:pop() end
|
||||
if self.onDone then self.onDone(group) end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3794-3816: the refusal is printed, held
|
||||
-- for $80 frames and the menu restarts at jumptable index 0.
|
||||
function BattleTowerMenu:refuse(text)
|
||||
self.phase = "message"
|
||||
self.message = text
|
||||
self.wait = MESSAGE_FRAMES
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:1258-1286 `.a_button`
|
||||
function BattleTowerMenu:confirm()
|
||||
local row = self.rows[self.cursor]
|
||||
if not row then
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:1291-1303 `.asm_118a3c`
|
||||
self.phase = "quit"
|
||||
self.message = QUIT_TEXT
|
||||
self.yes = true
|
||||
return
|
||||
end
|
||||
if BattleTower.levelCheck(self.party, row.group) then
|
||||
return self:refuse(TOPS_TEXT)
|
||||
end
|
||||
local uber = BattleTower.ubersCheck(self.party, row.group)
|
||||
if uber then
|
||||
local name = (self.monName and self.monName(uber)) or uber
|
||||
return self:refuse(string.format(UBER_TEXT, name))
|
||||
end
|
||||
self:finish(row.group)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:1240-1256: UP walks the level up and
|
||||
-- rolls over to the first row, DOWN walks it back and rolls over to CANCEL.
|
||||
function BattleTowerMenu:updatePick()
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
local count = self:rowCount()
|
||||
if input:wasPressed("up") then
|
||||
self.cursor = (self.cursor < count) and self.cursor + 1 or 1
|
||||
elseif input:wasPressed("down") then
|
||||
self.cursor = (self.cursor > 1) and self.cursor - 1 or count
|
||||
elseif input:wasPressed("a") then
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self:confirm()
|
||||
elseif input:wasPressed("b") then
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self.phase = "quit"
|
||||
self.message = QUIT_TEXT
|
||||
self.yes = true
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:4535-4621
|
||||
-- BattleTowerRoomMenu2_UpdateYesNoMenu.
|
||||
function BattleTowerMenu:updateQuit()
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
if input:wasPressed("up") then
|
||||
self.yes = true
|
||||
elseif input:wasPressed("down") then
|
||||
self.yes = false
|
||||
elseif input:wasPressed("a") then
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
if self.yes then return self:finish(nil) end
|
||||
self.phase = "pick"
|
||||
self.message = PICK_TEXT
|
||||
self.cursor = 1
|
||||
elseif input:wasPressed("b") then
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self.phase = "pick"
|
||||
self.message = PICK_TEXT
|
||||
self.cursor = 1
|
||||
end
|
||||
end
|
||||
|
||||
function BattleTowerMenu:update(_dt)
|
||||
if self.done then return end
|
||||
if self.phase == "message" then
|
||||
self.wait = (self.wait or 0) - 1
|
||||
if self.wait > 0 then return end
|
||||
self.phase = "pick"
|
||||
self.message = PICK_TEXT
|
||||
self.cursor = 1
|
||||
return
|
||||
end
|
||||
if self.phase == "quit" then return self:updateQuit() end
|
||||
return self:updatePick()
|
||||
end
|
||||
|
||||
function BattleTowerMenu:drawPanel()
|
||||
Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H)
|
||||
Chrome.printWrapped(self.message, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, SAY_H)
|
||||
if self.phase == "message" then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
if self.phase == "quit" then
|
||||
Chrome.box(YN_X, YN_Y, YN_W, YN_H)
|
||||
Chrome.print(YES_LABEL, YN_TEXT_X, YES_Y)
|
||||
Chrome.print(NO_LABEL, YN_TEXT_X, NO_Y)
|
||||
Chrome.cursor(YN_TEXT_X - 1, self.yes and YES_Y or NO_Y)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
Chrome.box(PICK_X, PICK_Y, PICK_W, PICK_H)
|
||||
local row = self.rows[self.cursor]
|
||||
Chrome.print(row and BattleTowerMenu.levelLabel(row.group) or CANCEL_LABEL,
|
||||
ROW_X, ROW_Y)
|
||||
Chrome.print(UP_ARROW, ARROW_X, UP_Y)
|
||||
Chrome.print(DOWN_ARROW, ARROW_X, DOWN_Y)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function BattleTowerMenu:draw()
|
||||
self:drawPanel()
|
||||
end
|
||||
|
||||
return BattleTowerMenu
|
||||
@@ -0,0 +1,170 @@
|
||||
-- Buena's two windows, Crystal only, chosen by `mode`:
|
||||
--
|
||||
-- password ../pokecrystal/engine/events/buena.asm:1 BuenasPassword, whose
|
||||
-- .MenuHeader is `menu_coords 0, 0, 10, 7` with the right edge
|
||||
-- moved to left + the category's points byte + 2 (:9-12), and
|
||||
-- whose .PasswordIndices answer is zero based (:44-49).
|
||||
-- prize :64 BuenaPrize -- Buena_PlacePrizeMenuBox (:219),
|
||||
-- Buena_PrizeMenu's scrolling list (:249-261) and
|
||||
-- PrintBlueCardBalance's "Points" box (:210). 0 is a B press
|
||||
-- (:236-245).
|
||||
--
|
||||
-- Neither is opaque: engine/events/buena.asm:71-83 prints the prize question
|
||||
-- BEFORE the list goes up over it, and the password menu stands on the map.
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local BuenaPassword = {}
|
||||
BuenaPassword.__index = BuenaPassword
|
||||
BuenaPassword.isOpaque = false
|
||||
|
||||
-- GetMenuTextStartCoord (../pokecrystal/home/menu.asm:214) on STATICMENU_CURSOR
|
||||
-- with no STATICMENU_NO_TOP_SPACING: labels at box + (2,2), cursor one left.
|
||||
local WORD_X, WORD_Y, WORD_SPACING = 2, 2, 2
|
||||
local WORD_BOX_H = 8
|
||||
|
||||
-- engine/events/buena.asm:219 and :249, as menu_coords lays them down.
|
||||
local FIELD_X, FIELD_Y, FIELD_W, FIELD_H = 0, 0, 18, 12
|
||||
local LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H = 1, 1, 16, 9
|
||||
local LIST_X, LIST_Y, LIST_SPACING = 2, 2, 2
|
||||
-- ScrollingMenu_CallFunctions1and2's `add hl, de` on
|
||||
-- wMenuData_ScrollingMenuWidth (engine/menus/scrolling_menu.asm:424-431).
|
||||
local POINTS_X = LIST_X + 13
|
||||
local VISIBLE_ROWS = 4
|
||||
local ARROW_X = LIST_BOX_X + LIST_BOX_W - 1
|
||||
local ARROW_UP_Y, ARROW_DOWN_Y = LIST_BOX_Y, LIST_BOX_Y + LIST_BOX_H - 1
|
||||
|
||||
-- BlueCardBalanceMenuHeader (engine/events/buena.asm:208) and .DrawBox's
|
||||
-- PlaceString / two spaces / `lb bc, 1, 2` PrintNum (:181-203).
|
||||
local BALANCE_BOX_X, BALANCE_BOX_Y, BALANCE_BOX_W, BALANCE_BOX_H = 0, 11, 12, 3
|
||||
local BALANCE_LABEL_X, BALANCE_LABEL_Y = 1, 12
|
||||
local BALANCE_NUM_X = 8
|
||||
local POINTS_LABEL = Strings.source("Points")
|
||||
|
||||
local UP_ARROW = "\xe2\x96\xb2"
|
||||
local DOWN_ARROW = "\xe2\x96\xbc"
|
||||
|
||||
-- opts: mode, and then words/width/onDone(0..2) or prizes/balance/onDone(row)
|
||||
function BuenaPassword.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, BuenaPassword)
|
||||
self.game = game
|
||||
self.data = (game and game.data) or {}
|
||||
self.mode = opts.mode == "prize" and "prize" or "password"
|
||||
self.words = opts.words or {}
|
||||
self.width = math.max(1, math.floor(tonumber(opts.width) or 10))
|
||||
self.prizes = opts.prizes or {}
|
||||
self.balance = math.max(0, math.floor(tonumber(opts.balance) or 0))
|
||||
self.onDone = opts.onDone
|
||||
-- engine/events/buena.asm:34 `db 1 ; default option` and :67-68
|
||||
-- `ld a, $1 / ld [wMenuSelection], a`.
|
||||
self.index = 1
|
||||
self.scroll = 0
|
||||
return self
|
||||
end
|
||||
|
||||
function BuenaPassword:wantsFillScale() return true end
|
||||
|
||||
function BuenaPassword:count()
|
||||
if self.mode == "prize" then return #self.prizes end
|
||||
return #self.words
|
||||
end
|
||||
|
||||
function BuenaPassword:playSfx(name)
|
||||
local sfx = self.data.audio and self.data.audio.sfx
|
||||
if sfx and sfx[Sound.resolve(self.data, name)] then
|
||||
Sound.play(self.data, name)
|
||||
end
|
||||
end
|
||||
|
||||
function BuenaPassword:finish(value)
|
||||
if self.done then return end
|
||||
self.done = true
|
||||
local stack = self.game and self.game.stack
|
||||
if stack then stack:pop() end
|
||||
if self.onDone then self.onDone(value) end
|
||||
end
|
||||
|
||||
function BuenaPassword:ensureVisible()
|
||||
local rows = math.min(VISIBLE_ROWS, self:count())
|
||||
if rows <= 0 then return end
|
||||
if self.index <= self.scroll then
|
||||
self.scroll = self.index - 1
|
||||
elseif self.index > self.scroll + rows then
|
||||
self.scroll = self.index - rows
|
||||
end
|
||||
self.scroll = math.max(0, math.min(self.scroll, self:count() - rows))
|
||||
end
|
||||
|
||||
function BuenaPassword:update(_dt)
|
||||
if self.done then return end
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
local total = self:count()
|
||||
if total <= 0 then return self:finish(self.mode == "prize" and 0 or -1) end
|
||||
-- Neither header sets STATICMENU_WRAP (engine/events/buena.asm:39, :256).
|
||||
if input:wasPressed("up") then
|
||||
if self.index > 1 then self.index = self.index - 1 end
|
||||
elseif input:wasPressed("down") then
|
||||
if self.index < total then self.index = self.index + 1 end
|
||||
elseif input:wasPressed("a") then
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
if self.mode == "prize" then return self:finish(self.index) end
|
||||
-- engine/events/buena.asm:44-49 .PasswordIndices is zero based.
|
||||
return self:finish(self.index - 1)
|
||||
elseif input:wasPressed("b") then
|
||||
-- STATICMENU_DISABLE_B (engine/events/buena.asm:39).
|
||||
if self.mode == "prize" then
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
return self:finish(0)
|
||||
end
|
||||
end
|
||||
self:ensureVisible()
|
||||
end
|
||||
|
||||
function BuenaPassword:drawPasswordPanel()
|
||||
Chrome.box(0, 0, self.width + 3, WORD_BOX_H)
|
||||
for i, word in ipairs(self.words) do
|
||||
local ty = WORD_Y + (i - 1) * WORD_SPACING
|
||||
if i == self.index then Chrome.cursor(WORD_X - 1, ty) end
|
||||
Chrome.print(word, WORD_X, ty)
|
||||
end
|
||||
end
|
||||
|
||||
function BuenaPassword:drawPrizePanel()
|
||||
Chrome.box(FIELD_X, FIELD_Y, FIELD_W, FIELD_H)
|
||||
Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H)
|
||||
for row = 1, VISIBLE_ROWS do
|
||||
local i = row + self.scroll
|
||||
local prize = self.prizes[i]
|
||||
if prize then
|
||||
local ty = LIST_Y + (row - 1) * LIST_SPACING
|
||||
if i == self.index then Chrome.cursor(LIST_X - 1, ty) end
|
||||
Chrome.print(prize.name, LIST_X, ty)
|
||||
-- .PrintPrizePoints writes one char, `'0' + cost` (buena.asm:281-289).
|
||||
Chrome.print(tostring(prize.cost % 10), POINTS_X, ty)
|
||||
end
|
||||
end
|
||||
-- SCROLLINGMENU_DISPLAY_ARROWS: the ▲ only once scrolled, the ▼ every pass
|
||||
-- (engine/menus/scrolling_menu.asm:348-358, :387-395).
|
||||
if self.scroll > 0 then Chrome.print(UP_ARROW, ARROW_X, ARROW_UP_Y) end
|
||||
Chrome.print(DOWN_ARROW, ARROW_X, ARROW_DOWN_Y)
|
||||
|
||||
Chrome.box(BALANCE_BOX_X, BALANCE_BOX_Y, BALANCE_BOX_W, BALANCE_BOX_H)
|
||||
Chrome.print(Strings(POINTS_LABEL), BALANCE_LABEL_X, BALANCE_LABEL_Y)
|
||||
Chrome.print(Chrome.number(self.balance, 2), BALANCE_NUM_X, BALANCE_LABEL_Y)
|
||||
end
|
||||
|
||||
function BuenaPassword:drawPanel()
|
||||
if self.mode == "prize" then return self:drawPrizePanel() end
|
||||
return self:drawPasswordPanel()
|
||||
end
|
||||
|
||||
function BuenaPassword:draw()
|
||||
self:drawPanel()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return BuenaPassword
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,9 @@
|
||||
-- reached through `special Diploma` after Celadon Mansion 3F's game designer
|
||||
-- checks VAR_DEXCAUGHT == 251). Only page 1 is ever shown in play --
|
||||
-- PrintDiplomaPage2 is the Game Boy Printer's second sheet
|
||||
-- (engine/printer/printer.asm _PrintDiploma), stubbed separately as
|
||||
-- "printer: no Game Boy Printer" -- so this transcribes PlaceDiplomaOnScreen
|
||||
-- alone, not diploma2.asm.
|
||||
-- (engine/printer/printer.asm:420), built with hBGMapMode zeroed and undone by
|
||||
-- SafeLoadTempTilemapToTilemap, so it never reaches the screen on the cart
|
||||
-- either -- so this transcribes PlaceDiplomaOnScreen alone, not diploma2.asm.
|
||||
--
|
||||
-- THE CERTIFICATE IS A TILEMAP, NOT A TEXT BOX. PlaceDiplomaOnScreen
|
||||
-- decompresses DiplomaGFX into vTiles2 and then CopyBytes' DiplomaPage1Tilemap
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
-- ../pokecrystal/engine/menus/init_gender.asm:23-41 InitGender, which
|
||||
-- PlayerProfileSetup runs before OakSpeech (engine/menus/intro_menu.asm:61-83).
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Music = require("src.core.Music")
|
||||
local RomText = require("src.core.RomText")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local GenderSelect = {}
|
||||
GenderSelect.__index = GenderSelect
|
||||
GenderSelect.isOpaque = true
|
||||
|
||||
-- .MenuData's two items (../pokecrystal/engine/menus/init_gender.asm:50-53),
|
||||
-- and the wPlayerGender byte each writes (`ld a, [wMenuCursorY] / dec a`).
|
||||
GenderSelect.OPTIONS = {
|
||||
{ label = "Boy", gender = "male" },
|
||||
{ label = "Girl", gender = "female" },
|
||||
}
|
||||
|
||||
-- menu_coords 6, 4, 12, 9 -- inclusive, so 7 columns by 6 rows.
|
||||
local BOX_X, BOX_Y, BOX_W, BOX_H = 6, 4, 7, 6
|
||||
local TEXT_X, TEXT_Y = BOX_X + 2, BOX_Y + 2
|
||||
local CURSOR_X = TEXT_X - 1
|
||||
local ROW_STEP = 2
|
||||
|
||||
-- TEXTBOX_X / TEXTBOX_Y / TEXTBOX_INNERX / TEXTBOX_INNERY
|
||||
-- (../pokecrystal/constants/text_constants.asm:25-32), the box PrintText fills.
|
||||
local SAY_X, SAY_Y, SAY_W, SAY_H = 0, 12, 18, 4
|
||||
local SAY_TEXT_X, SAY_TEXT_Y = 1, 14
|
||||
|
||||
-- gfx/new_game/gender_screen.pal:1-4, the one palette LoadGenderScreenPal
|
||||
-- writes; colour 1 is the tile the screen is filled with.
|
||||
GenderSelect.GROUND = { 74, 247, 255 }
|
||||
|
||||
-- `ld a, $10 / ld [wMusicFade]` with MUSIC_NONE as the fade target
|
||||
-- (../pokecrystal/engine/menus/init_gender.asm:59-65).
|
||||
local FADE_CONTROL = 0x10
|
||||
-- `ld c, 10 / call DelayFrames` on the way out
|
||||
-- (../pokecrystal/engine/menus/init_gender.asm:39-40).
|
||||
local EXIT_FRAMES = 10
|
||||
|
||||
local FALLBACK = Strings.source("Are you a boy?\nOr are you a girl?")
|
||||
|
||||
function GenderSelect:wantsFillScale() return true end
|
||||
function GenderSelect:drawsWidescreen() return true end
|
||||
|
||||
-- opts: onDone(gender), save
|
||||
function GenderSelect.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, GenderSelect)
|
||||
self.game = game
|
||||
self.save = opts.save or (game and game.save)
|
||||
self.onDone = opts.onDone
|
||||
self.data = (game and game.data) or {}
|
||||
-- `db 1 ; default option`: the cursor opens on Boy.
|
||||
self.cursor = 1
|
||||
self.exit = nil
|
||||
self.text = RomText(self.data, "_AreYouABoyOrAreYouAGirlText",
|
||||
FALLBACK)
|
||||
return self
|
||||
end
|
||||
|
||||
function GenderSelect:enter()
|
||||
Music.fadeOut(FADE_CONTROL)
|
||||
end
|
||||
|
||||
function GenderSelect:playSfx(name)
|
||||
local sfx = self.data.audio and self.data.audio.sfx
|
||||
if sfx and sfx[Sound.resolve(self.data, name)] then
|
||||
Sound.play(self.data, name)
|
||||
end
|
||||
end
|
||||
|
||||
function GenderSelect:choose(index)
|
||||
local option = GenderSelect.OPTIONS[index] or GenderSelect.OPTIONS[1]
|
||||
if self.save and self.save.player then
|
||||
self.save.player.gender = option.gender
|
||||
end
|
||||
self.chosen = option.gender
|
||||
self.exit = EXIT_FRAMES
|
||||
end
|
||||
|
||||
function GenderSelect:update(_dt)
|
||||
if self.exit then
|
||||
self.exit = self.exit - 1
|
||||
if self.exit > 0 then return end
|
||||
self.exit = nil
|
||||
if self.onDone then self.onDone(self.chosen) end
|
||||
return
|
||||
end
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
-- STATICMENU_WRAP, and STATICMENU_DISABLE_B: no `b` arm at all.
|
||||
if input:wasPressed("up") then
|
||||
self.cursor = self.cursor > 1 and self.cursor - 1 or #GenderSelect.OPTIONS
|
||||
elseif input:wasPressed("down") then
|
||||
self.cursor = self.cursor < #GenderSelect.OPTIONS and self.cursor + 1 or 1
|
||||
elseif input:wasPressed("a") or input:wasPressed("start") then
|
||||
-- MenuClickSound (../pokecrystal/home/menu.asm:793-803).
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self:choose(self.cursor)
|
||||
end
|
||||
end
|
||||
|
||||
function GenderSelect:drawPanel()
|
||||
local G = love.graphics
|
||||
local ground = GenderSelect.GROUND
|
||||
G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H)
|
||||
Chrome.printWrapped(self.text, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, SAY_H - 1)
|
||||
Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H)
|
||||
for i, option in ipairs(GenderSelect.OPTIONS) do
|
||||
local row = TEXT_Y + (i - 1) * ROW_STEP
|
||||
Chrome.print(option.label, TEXT_X, row)
|
||||
if i == self.cursor then Chrome.cursor(CURSOR_X, row) end
|
||||
end
|
||||
end
|
||||
|
||||
function GenderSelect:draw()
|
||||
self:drawPanel()
|
||||
end
|
||||
|
||||
function GenderSelect:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.push()
|
||||
G.translate(ox, oy)
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
end
|
||||
|
||||
return GenderSelect
|
||||
@@ -31,10 +31,11 @@
|
||||
-- can assert them without a graphics device (the same shape
|
||||
-- src/ui/gen2/SummaryMenu.lua uses).
|
||||
--
|
||||
-- WHAT THE CACHE DOES NOT HAVE. GetTrainerPic for TRAINER_CLASS CAL -- the
|
||||
-- 7x7 picture of the player HOF_AnimatePlayerPic ends on -- is not extracted.
|
||||
-- The player's own 5x7 portrait from the trainer card is, and it is the same
|
||||
-- character, so that stands in and is padded into the 7x7 block the way
|
||||
-- WHAT THE CACHE MAY NOT HAVE. Gold's HOF_AnimatePlayerPic ends on
|
||||
-- GetTrainerPic for TRAINER_CLASS CAL, which is not extracted; Crystal's
|
||||
-- HOF_LoadTrainerFrontpic ends on ChrisPic / KrisPic, which are extracted when
|
||||
-- the cache is new enough. Failing both, the player's own 5x7 portrait from
|
||||
-- the trainer card stands in and is padded into the 7x7 block the way
|
||||
-- PlaceGraphic would. ProfOaksPCRating, which the cart prints into the bottom
|
||||
-- box afterwards, needs Oak's PC (still a stub in src/script/gen2/Specials.lua)
|
||||
-- and so the box is drawn empty, exactly as it is before that farcall.
|
||||
@@ -45,6 +46,7 @@ local CommonText = require("src.core.gen2.CommonText")
|
||||
local Core = require("src.core.gen2.HallOfFame")
|
||||
local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Gen2Save = require("src.core.gen2.Save")
|
||||
local Music = require("src.core.Music")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local Sound = require("src.core.Sound")
|
||||
@@ -250,15 +252,26 @@ function HallOfFame.new(game, opts)
|
||||
-- The player's own two pictures. The backpic is a plain image (the battle
|
||||
-- HUD's), the front is the trainer card's 5x7 portrait tile sheet.
|
||||
local menuGfx = data.gen2MenuGfx or {}
|
||||
-- HOF_AnimatePlayerPic calls GetPlayerBackpic (../pokecrystal/engine/events/
|
||||
-- halloffame.asm:520-530, ../pokecrystal/engine/gfx/player_gfx.asm:123-128).
|
||||
local female = Gen2Save.isFemale(self.save)
|
||||
local hud = menuGfx.battleHud or {}
|
||||
-- player.sprite, the same hook Gen 1's Hall of Fame raises through
|
||||
-- Sprites.playerPath (src/ui/HallOfFame.lua:100).
|
||||
self.playerBackPath = require("src.pokemon.Sprites").playerPic(
|
||||
(menuGfx.battleHud or {}).playerBack,
|
||||
(female and hud.playerBackFemale) or hud.playerBack,
|
||||
{ side = "back", kind = "hof", data = data })
|
||||
-- HOF_LoadTrainerFrontpic's ChrisPic / KrisPic under class CHRIS / KRIS
|
||||
-- (../pokecrystal/engine/gfx/player_gfx.asm:138-168).
|
||||
local pics = hud.trainerPics or {}
|
||||
self.trainerPicPath = (female and pics.KRIS) or pics.CHRIS
|
||||
local card = menuGfx.trainerCard
|
||||
if card and card.card then
|
||||
-- GetCardPic's KrisCardPic arm
|
||||
-- (../pokecrystal/engine/gfx/player_gfx.asm:96-101).
|
||||
self.portrait = TileSheet.new({
|
||||
path = card.card, wide = card.cardTilesWide or 16, firstTile = 0,
|
||||
path = (female and card.cardFemale) or card.card,
|
||||
wide = card.cardTilesWide or 16, firstTile = 0,
|
||||
})
|
||||
self.portraitWide = card.portraitWide or 5
|
||||
self.portraitTiles = card.portraitTiles or 35
|
||||
@@ -559,6 +572,8 @@ end
|
||||
-- run of tile ids TrainerCard_PrintTopHalfOfCard uses, translated by the
|
||||
-- scroll rather than drawn at a tile coordinate.
|
||||
function HallOfFame:drawPortrait(tileX, tileY)
|
||||
local pic = self:image(self.trainerPicPath)
|
||||
if pic then return self:drawScrolled(pic, tileX, tileY, nil) end
|
||||
if not (self.portrait and self.portrait:available()) then return end
|
||||
local G = love.graphics
|
||||
local wide = self.portraitWide
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local MagnetTrain = require("src.core.gen2.MagnetTrain")
|
||||
local Music = require("src.core.Music")
|
||||
@@ -82,6 +83,8 @@ function MagnetTrainRide.new(game, opts)
|
||||
self.data = game and game.data
|
||||
self.onDone = opts.onDone
|
||||
self.finished = false
|
||||
local save = opts.save or (game and game.save)
|
||||
self.gender = save and save.player and save.player.gender or nil
|
||||
|
||||
local field = self.data and self.data.gen2Field
|
||||
local gfx = field and field.magnetTrain
|
||||
@@ -155,8 +158,7 @@ end
|
||||
-- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because
|
||||
-- the OAM data addresses the four tiles of a frame individually.
|
||||
function MagnetTrainRide:playerSheet()
|
||||
local sprites = self.data and self.data.gen2Sprites
|
||||
local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS)
|
||||
local def = self:playerSpriteDef()
|
||||
local path = def and def.image
|
||||
if not path then return nil end
|
||||
local ok, image = pcall(Assets.image, path)
|
||||
@@ -357,14 +359,23 @@ function MagnetTrainRide:drawBands(canvas)
|
||||
end
|
||||
end
|
||||
|
||||
-- GetPlayerIcon's sheet, which .InitPlayerSpriteAnim pairs with
|
||||
-- SPRITE_ANIM_OBJ_MAGNET_TRAIN_RED or _BLUE
|
||||
-- (../pokecrystal/engine/events/magnet_train.asm:131-141, :291-305).
|
||||
function MagnetTrainRide:playerSpriteDef()
|
||||
local sprites = self.data and self.data.gen2Sprites
|
||||
if not sprites then return nil end
|
||||
return sprites[FieldMoves.playerSprite(self.gender)]
|
||||
or sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS
|
||||
end
|
||||
|
||||
-- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry
|
||||
-- names.
|
||||
-- names, or PAL_OW_BLUE for the _BLUE object Kris rides under.
|
||||
function MagnetTrainRide:playerPalette()
|
||||
local palettes = self.data and self.data.gen2Palettes
|
||||
local sprites = self.data and self.data.gen2Sprites
|
||||
if not palettes then return nil end
|
||||
return Palettes.spritePalette(palettes, Palettes.clockDaytime(),
|
||||
sprites and sprites.SPRITE_CHRIS)
|
||||
self:playerSpriteDef())
|
||||
end
|
||||
|
||||
function MagnetTrainRide:drawPlayer()
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
-- ../pokecrystal/engine/events/move_tutor.asm:1 MoveTutor, the submenu
|
||||
-- FadeToMenu / ChooseMonToLearnTMHM / CloseSubmenu wraps, and :54
|
||||
-- CheckCanLearnMoveTutorMove, whose refusals print into a Textbox over the
|
||||
-- party list (:101-103 `menu_coords 0, 12, SCREEN_WIDTH - 1, SCREEN_HEIGHT - 1`).
|
||||
--
|
||||
-- ChooseMonToLearnTMHM (../pokecrystal/engine/items/tmhm.asm:73) is
|
||||
-- InitPartyMenuWithCancel with PARTYMENUACTION_TEACH_TMHM, which is the list
|
||||
-- src/ui/gen2/PartyMenu.lua already draws when it is handed `tmhm`.
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Happiness = require("src.core.gen2.Happiness")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local PartyMenu = require("src.ui.gen2.PartyMenu")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local MoveTutor = {}
|
||||
MoveTutor.__index = MoveTutor
|
||||
MoveTutor.isOpaque = true
|
||||
|
||||
-- ../pokecrystal/data/text/common_2.asm:187 _TMHMNotCompatibleText and
|
||||
-- common_3.asm:1424 _KnowsMoveText. CopyName1 leaves the MOVE in
|
||||
-- wStringBuffer2 and GetNickname the nickname in wStringBuffer1.
|
||||
local NOT_COMPATIBLE = Strings.source("%s is\nnot compatible\vwith %s.")
|
||||
local KNOWS_MOVE = Strings.source("%s knows\n%s.")
|
||||
|
||||
-- CanLearnTMHMMove's bitfield: `add_mt` gives the three tutor moves TMHM
|
||||
-- flags 58-60 (../pokecrystal/constants/item_constants.asm:295-307), which
|
||||
-- the cache splits out as `tutorMoves`.
|
||||
function MoveTutor.canLearn(species, moveId)
|
||||
if type(species) ~= "table" or not moveId then return false end
|
||||
for _, id in ipairs(species.tmhm or {}) do
|
||||
if id == moveId then return true end
|
||||
end
|
||||
for _, id in ipairs(species.tutorMoves or {}) do
|
||||
if id == moveId then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- The same bitfield as a pokemon.lua view, for PartyMenu's ABLE / NOT ABLE
|
||||
-- column (src/ui/gen2/PartyMenu.lua:735, which walks `tmhm` only).
|
||||
function MoveTutor.speciesView(pokemon)
|
||||
local cache = {}
|
||||
return setmetatable({}, { __index = function(_, key)
|
||||
local hit = cache[key]
|
||||
if hit ~= nil then return hit or nil end
|
||||
local def = pokemon and pokemon[key]
|
||||
if type(def) ~= "table" then
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
local merged = {}
|
||||
for field, value in pairs(def) do merged[field] = value end
|
||||
local list = {}
|
||||
for _, id in ipairs(def.tmhm or {}) do list[#list + 1] = id end
|
||||
for _, id in ipairs(def.tutorMoves or {}) do list[#list + 1] = id end
|
||||
merged.tmhm = list
|
||||
cache[key] = merged
|
||||
return merged
|
||||
end })
|
||||
end
|
||||
|
||||
-- opts: move, moveName, onDone(learned)
|
||||
function MoveTutor.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, MoveTutor)
|
||||
self.game = game
|
||||
local data = (game and game.data) or {}
|
||||
self.move = opts.move
|
||||
self.moveName = opts.moveName or opts.move or "?"
|
||||
self.onDone = opts.onDone
|
||||
self.pokemon = opts.pokemon or data.pokemon
|
||||
self.view = MoveTutor.speciesView(self.pokemon)
|
||||
self.list = self:buildList()
|
||||
return self
|
||||
end
|
||||
|
||||
function MoveTutor:wantsFillScale() return true end
|
||||
function MoveTutor:drawsWidescreen() return true end
|
||||
|
||||
function MoveTutor:buildList()
|
||||
return PartyMenu.new(self.game, {
|
||||
prompt = "teach",
|
||||
tmhm = { move = self.move },
|
||||
pokemon = self.view,
|
||||
onChoose = function(_, mon) self:picked(mon) end,
|
||||
onCancel = function() self:finish(false) end,
|
||||
})
|
||||
end
|
||||
|
||||
function MoveTutor:say(body, onDone)
|
||||
if self.game and self.game.say then return self.game:say(body, onDone) end
|
||||
if onDone then onDone() end
|
||||
end
|
||||
|
||||
function MoveTutor:playSfx(name)
|
||||
local world = self.game and self.game.world
|
||||
if world and world.playSfxNamed then world:playSfxNamed(name) end
|
||||
end
|
||||
|
||||
-- CheckCanLearnMoveTutorMove (../pokecrystal/engine/events/move_tutor.asm:54);
|
||||
-- all three failures fall back into the list, `jr nc, .loop` at :24.
|
||||
function MoveTutor:picked(mon)
|
||||
if not mon then return end
|
||||
local name = Mon.displayName(mon)
|
||||
if not MoveTutor.canLearn(self.view[mon.species], self.move) then
|
||||
self:playSfx("Sfx_Wrong")
|
||||
return self:say(Strings(NOT_COMPATIBLE, self.moveName, name))
|
||||
end
|
||||
-- ../pokecrystal/engine/pokemon/knows_move.asm:16 sets carry and prints,
|
||||
-- which is the same .didnt_learn arm.
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == self.move then
|
||||
return self:say(Strings(KNOWS_MOVE, name, self.moveName))
|
||||
end
|
||||
end
|
||||
local game = self.game
|
||||
if not (game and game.learnMoveOn) then return self:finish(false) end
|
||||
game:learnMoveOn(mon, self.move, function(learned)
|
||||
if not learned then return end
|
||||
-- :87-88 `ld c, HAPPINESS_LEARNMOVE / callfar ChangeHappiness`. The 4000
|
||||
-- coins are the script's own takecoins (maps/GoldenrodCity.asm:124).
|
||||
Happiness.change(mon, "LEARNMOVE")
|
||||
self:finish(true)
|
||||
end)
|
||||
end
|
||||
|
||||
function MoveTutor:finish(learned)
|
||||
if self.done then return end
|
||||
self.done = true
|
||||
local stack = self.game and self.game.stack
|
||||
if stack then stack:pop() end
|
||||
if self.onDone then self.onDone(learned) end
|
||||
end
|
||||
|
||||
function MoveTutor:update(dt)
|
||||
if self.done then return end
|
||||
self.list:update(dt)
|
||||
end
|
||||
|
||||
function MoveTutor:drawPanel()
|
||||
self.list:drawPanel()
|
||||
end
|
||||
|
||||
function MoveTutor:draw()
|
||||
self.list:draw()
|
||||
end
|
||||
|
||||
-- PartyMenu:drawWidescreen's own blit: the party page is white to the window
|
||||
-- edge, at the one integer scale Chrome.fitScale picks.
|
||||
function MoveTutor:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
end
|
||||
|
||||
return MoveTutor
|
||||
+33
-15
@@ -34,13 +34,25 @@ local NamePick = {}
|
||||
NamePick.__index = NamePick
|
||||
NamePick.isOpaque = true
|
||||
|
||||
-- data/player_names.asm PlayerNameArray, one half of the IF per edition.
|
||||
local PRESETS = { "GOLD", "HIRO", "TAYLOR", "KARL" }
|
||||
local PRESETS_SILVER = { "SILVER", "KAMON", "OSCAR", "MAX" }
|
||||
-- data/player_names.asm PlayerNameArray, one half of the IF per edition, and
|
||||
-- Crystal's two arrays at ../pokecrystal/data/player_names.asm:12-16, :31-35.
|
||||
local PRESETS = {
|
||||
gold = { "GOLD", "HIRO", "TAYLOR", "KARL" },
|
||||
silver = { "SILVER", "KAMON", "OSCAR", "MAX" },
|
||||
crystal = { "CHRIS", "MAT", "ALLAN", "JON" },
|
||||
}
|
||||
local PRESETS_FEMALE = {
|
||||
crystal = { "KRIS", "AMANDA", "JUANA", "JODI" },
|
||||
}
|
||||
|
||||
local function presetsFor()
|
||||
local silver = require("src.core.GameVersion").get() == "silver"
|
||||
return silver and PRESETS_SILVER or PRESETS
|
||||
-- ShowPlayerNamingChoices picks the header off wPlayerGender
|
||||
-- (../pokecrystal/engine/gfx/player_gfx.asm:57-62).
|
||||
local function presetsFor(gender)
|
||||
local version = require("src.core.GameVersion").get()
|
||||
if gender == "female" and PRESETS_FEMALE[version] then
|
||||
return PRESETS_FEMALE[version]
|
||||
end
|
||||
return PRESETS[version] or PRESETS.gold
|
||||
end
|
||||
|
||||
-- menu_coords 0, 0, 10, TEXTBOX_Y - 1 (TEXTBOX_Y = 12).
|
||||
@@ -59,14 +71,16 @@ function NamePick:wantsFillScale() return true end
|
||||
function NamePick:drawsWidescreen() return true end
|
||||
|
||||
-- opts: onDone(name), font, pic (the CAL frontpic already loaded by the Oak
|
||||
-- speech), picColors, presets
|
||||
-- speech), picColors, presets, gender
|
||||
function NamePick.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, NamePick)
|
||||
self.game = game
|
||||
self.onDone = opts.onDone
|
||||
self.gender = opts.gender
|
||||
or (game and game.save and game.save.player and game.save.player.gender)
|
||||
self.items = { "NEW NAME" }
|
||||
for _, name in ipairs(opts.presets or presetsFor()) do
|
||||
for _, name in ipairs(opts.presets or presetsFor(self.gender)) do
|
||||
self.items[#self.items + 1] = name
|
||||
end
|
||||
-- `db 1 ; default option`: the cursor starts on NEW NAME, not on a preset.
|
||||
@@ -96,15 +110,19 @@ end
|
||||
function NamePick:openNaming()
|
||||
local data = self.game and self.game.data or {}
|
||||
local sprites = data.gen2Sprites
|
||||
local chris = sprites and sprites.SPRITE_CHRIS
|
||||
local NamingScreen = require("src.ui.gen2.NamingScreen")
|
||||
local def = sprites and sprites[NamingScreen.playerSprite(self.gender)]
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
Screens.push(self.game, "Gen2NamingScreen", {
|
||||
type = "player",
|
||||
gender = self.gender,
|
||||
menuGfx = data.gen2MenuGfx,
|
||||
iconPath = chris and chris.image or nil,
|
||||
-- Chris is PAL_OW_RED; the naming screen is lit like day.
|
||||
iconPath = def and def.image or nil,
|
||||
-- Chris is PAL_OW_RED and Kris PAL_OW_BLUE
|
||||
-- (../pokecrystal/engine/overworld/player_object.asm:32-39); the naming
|
||||
-- screen is lit like day.
|
||||
iconColors = data.gen2Palettes
|
||||
and Palettes.spritePalette(data.gen2Palettes, "DAY", chris) or nil,
|
||||
and Palettes.spritePalette(data.gen2Palettes, "DAY", def) or nil,
|
||||
onDone = function(name)
|
||||
-- An empty name keeps the default, the way ending entry with nothing
|
||||
-- typed leaves wPlayerName at its preset.
|
||||
@@ -112,7 +130,7 @@ function NamePick:openNaming()
|
||||
if name and #name > 0 then
|
||||
self:choose(name)
|
||||
else
|
||||
self:choose(self.items[2] or presetsFor()[1])
|
||||
self:choose(self.items[2] or presetsFor(self.gender)[1])
|
||||
end
|
||||
end,
|
||||
})
|
||||
@@ -152,7 +170,7 @@ function NamePick:update(_dt)
|
||||
if self.fontOk then
|
||||
self:openNaming()
|
||||
else
|
||||
self:choose(self.items[2] or presetsFor()[1])
|
||||
self:choose(self.items[2] or presetsFor(self.gender)[1])
|
||||
end
|
||||
else
|
||||
-- A preset returns through MovePlayerPicLeft, so the pic walks back
|
||||
@@ -232,7 +250,7 @@ function NamePick:drawWidescreen(winW, winH)
|
||||
end
|
||||
|
||||
NamePick.PRESETS = PRESETS
|
||||
NamePick.PRESETS_SILVER = PRESETS_SILVER
|
||||
NamePick.PRESETS_FEMALE = PRESETS_FEMALE
|
||||
NamePick.presetsFor = presetsFor
|
||||
|
||||
return NamePick
|
||||
|
||||
@@ -81,19 +81,28 @@ local BOTTOM_CURSOR_TILES = 5
|
||||
-- NAME_* types (constants/menu_constants.asm order) as prompts + field sizes.
|
||||
-- Lengths are the ASM's *_NAME_LENGTH - 1, i.e. usable characters.
|
||||
NamingScreen.TYPES = {
|
||||
player = { prompt = "YOUR NAME?", maxLength = 7, sprite = "SPRITE_CHRIS" },
|
||||
player = { prompt = "YOUR NAME?", maxLength = 7, sprite = "SPRITE_CHRIS",
|
||||
spriteFemale = "SPRITE_KRIS" },
|
||||
rival = { prompt = "RIVAL'S NAME?", maxLength = 7, sprite = "SPRITE_RIVAL" },
|
||||
mom = { prompt = "MOTHER'S NAME?", maxLength = 7, sprite = "SPRITE_MOM" },
|
||||
box = { prompt = "BOX NAME?", maxLength = 8, isBox = true },
|
||||
nickname = { prompt = nil, maxLength = 10 },
|
||||
}
|
||||
|
||||
-- GetPlayerIcon's two sheets (../pokecrystal/engine/gfx/player_gfx.asm:85-93),
|
||||
-- which is also the pair the header icon here is cut from.
|
||||
function NamingScreen.playerSprite(gender)
|
||||
local kind = NamingScreen.TYPES.player
|
||||
if gender == "female" and kind.spriteFemale then return kind.spriteFemale end
|
||||
return kind.sprite
|
||||
end
|
||||
|
||||
function NamingScreen:wantsFillScale() return true end
|
||||
function NamingScreen:drawsWidescreen() return true end
|
||||
|
||||
-- opts: type ("player"/"rival"/"mom"/"box"/"nickname"), prompt, maxLength,
|
||||
-- initial, monName (nickname header), icon/sprite image path, onDone(name),
|
||||
-- onCancel().
|
||||
-- initial, monName (nickname header), icon/sprite image path, gender,
|
||||
-- onDone(name), onCancel().
|
||||
function NamingScreen.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, NamingScreen)
|
||||
@@ -101,6 +110,8 @@ function NamingScreen.new(game, opts)
|
||||
local kind = NamingScreen.TYPES[opts.type or "player"]
|
||||
or NamingScreen.TYPES.player
|
||||
self.kind = kind
|
||||
self.gender = opts.gender
|
||||
or (game and game.save and game.save.player and game.save.player.gender)
|
||||
self.isBox = opts.isBox or kind.isBox or false
|
||||
self.maxLength = opts.maxLength or kind.maxLength or 7
|
||||
self.prompt = opts.prompt or kind.prompt or "NICKNAME?"
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
-- love.graphics.newImage skips overrides/ and AssetTransform output.
|
||||
local Assets = require("src.render.Assets")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local Font = require("src.render.Font")
|
||||
local Gen2Save = require("src.core.gen2.Save")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Music = require("src.core.Music")
|
||||
@@ -98,6 +100,11 @@ function OakSpeech.new(game, opts)
|
||||
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPic(
|
||||
data.playerPic or "assets/generated/intro/cal.png",
|
||||
{ side = "front", kind = "intro", data = game and game.data }))
|
||||
-- DrawIntroPlayerPic's other arm, KrisPic under trainer class KRIS
|
||||
-- (../pokecrystal/engine/gfx/player_gfx.asm:170-188).
|
||||
self.playerPicFemale = tryImage(require("src.pokemon.Sprites").playerPic(
|
||||
data.playerPicFemale or "assets/generated/intro/kris.png",
|
||||
{ side = "front", kind = "intro", data = game and game.data }))
|
||||
self.marillPic = tryImage(data.marillPic
|
||||
or "assets/generated/battle/front/marill.png")
|
||||
self.shrinkPic1 = tryImage(data.shrink1 or "assets/generated/intro/shrink1.png")
|
||||
@@ -114,6 +121,9 @@ function OakSpeech.new(game, opts)
|
||||
self.palettes = palettes
|
||||
self.oakColors = Palettes.trainerColors(palettes, "POKEMON_PROF")
|
||||
self.playerColors = Palettes.trainerColors(palettes, "CAL")
|
||||
-- KrisPalette is Falkner's row, shared rather than shipped
|
||||
-- (../pokecrystal/data/trainers/palettes.asm:11-12).
|
||||
self.playerColorsFemale = Palettes.trainerColors(palettes, "FALKNER")
|
||||
self.marillColors = Palettes.monColors(palettes, self.demoSpecies)
|
||||
self.picColors = nil
|
||||
self.fontOk = false
|
||||
@@ -134,6 +144,23 @@ function OakSpeech.new(game, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
-- wPlayerGender, the byte the gender step below writes
|
||||
-- (../pokecrystal/engine/menus/init_gender.asm:36-38).
|
||||
function OakSpeech:gender()
|
||||
local save = self.game and self.game.save
|
||||
return save and save.player and save.player.gender or nil
|
||||
end
|
||||
|
||||
-- DrawIntroPlayerPic reads the byte every time it runs, so the pic follows the
|
||||
-- answer rather than whatever was loaded when the speech opened
|
||||
-- (../pokecrystal/engine/gfx/player_gfx.asm:170-188).
|
||||
function OakSpeech:playerPicNow()
|
||||
if self:gender() == "female" and self.playerPicFemale then
|
||||
return self.playerPicFemale, self.playerColorsFemale or self.playerColors
|
||||
end
|
||||
return self.playerPic, self.playerColors
|
||||
end
|
||||
|
||||
function OakSpeech:text(key)
|
||||
local t = self.texts[key]
|
||||
if type(t) == "string" and #t > 0 then return t end
|
||||
@@ -144,8 +171,8 @@ end
|
||||
|
||||
-- The vanilla beat list. Ids are the stable anchors a build wrapper inserts
|
||||
-- around; see the header for which of them are shared with Gen 1.
|
||||
function OakSpeech.defaultSteps(_speech)
|
||||
return {
|
||||
function OakSpeech.defaultSteps(speech)
|
||||
local steps = {
|
||||
-- `farcall InitClock` is the first line of OakSpeech.
|
||||
{ id = "init_clock", kind = "initclock" },
|
||||
-- Intro_PrepTrainerPic POKEMON_PROF, FadeInIntroPic, OakText1.
|
||||
@@ -170,6 +197,14 @@ function OakSpeech.defaultSteps(_speech)
|
||||
{ id = "legend", kind = "say", textKey = "_OakText7", pic = "player" },
|
||||
{ id = "shrink", kind = "shrink", textKey = "_OakText7" },
|
||||
}
|
||||
-- PlayerProfileSetup's `farcall InitGender` runs before OakSpeech is called.
|
||||
-- ../pokecrystal/engine/menus/intro_menu.asm:61-67, :79-83
|
||||
local data = speech and speech.game and speech.game.data
|
||||
if FieldMoves.hasGenderChoice(data and data.gen2Sprites) then
|
||||
table.insert(steps, 1, { id = "gender_select", kind = "gender",
|
||||
saveKey = "gender" })
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
local function sameSteps(steps) return steps end
|
||||
@@ -190,14 +225,24 @@ function OakSpeech:buildSteps()
|
||||
return hooked
|
||||
end
|
||||
|
||||
function OakSpeech:enter()
|
||||
-- `ld de, MUSIC_ROUTE_30 / call PlayMusic`, after InitGender's fade.
|
||||
-- ../pokecrystal/engine/menus/intro_menu.asm:632-633, init_gender.asm:59-65
|
||||
function OakSpeech:startMusic()
|
||||
if self.musicStarted then return end
|
||||
self.musicStarted = true
|
||||
local data = self.game and self.game.data
|
||||
if data and data.audio and data.audio.runtime then
|
||||
Music.play(data, self.music, true, { reason = "oak_speech" })
|
||||
end
|
||||
end
|
||||
|
||||
function OakSpeech:enter()
|
||||
self.step = 0
|
||||
self.answers = {}
|
||||
self.steps = self:buildSteps()
|
||||
if not (self.steps[1] and self.steps[1].kind == "gender") then
|
||||
self:startMusic()
|
||||
end
|
||||
if Runtime.wants("intro.oak_speech.started") then
|
||||
Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps })
|
||||
end
|
||||
@@ -211,7 +256,7 @@ end
|
||||
-- same way every pic on this screen does (two shipped colours, bracketed).
|
||||
function OakSpeech:resolvePic(desc)
|
||||
if desc == "oak" then return self.oakPic, self.oakColors end
|
||||
if desc == "player" then return self.playerPic, self.playerColors end
|
||||
if desc == "player" then return self:playerPicNow() end
|
||||
if desc == "demo" then return self.marillPic, self.marillColors end
|
||||
if type(desc) ~= "table" then return nil, nil end
|
||||
if desc.type == "pokemon" then
|
||||
@@ -339,21 +384,59 @@ function OakSpeech:openInitClock()
|
||||
end
|
||||
end
|
||||
|
||||
-- InitGender, pushed where PlayerProfileSetup farcalls it: before the speech
|
||||
-- says anything and before its music starts
|
||||
-- (../pokecrystal/engine/menus/intro_menu.asm:79-83).
|
||||
function OakSpeech:openGenderSelect(step)
|
||||
local game = self.game
|
||||
if not (game and game.stack) then
|
||||
self:startMusic()
|
||||
return self:advance()
|
||||
end
|
||||
self.busy = true
|
||||
local pushed = Screens.push(game, "Gen2GenderSelect", {
|
||||
save = game.save,
|
||||
onDone = function(gender)
|
||||
game.stack:pop()
|
||||
self.busy = false
|
||||
-- `ld hl, wPlayerName / ld de, .Chris|.Kris / call InitName`, the default
|
||||
-- NamePlayer lays down before the menu opens
|
||||
-- (../pokecrystal/engine/menus/intro_menu.asm:768-781).
|
||||
local save = game.save
|
||||
if save and save.player then
|
||||
save.player.name = Gen2Save.defaultPlayerName(save.version, gender)
|
||||
end
|
||||
self:recordAnswer(step, gender == "female" and 2 or 1,
|
||||
gender == "female" and "Girl" or "Boy", gender)
|
||||
self:startMusic()
|
||||
self:advance()
|
||||
end,
|
||||
})
|
||||
if not pushed then
|
||||
self.busy = false
|
||||
self:startMusic()
|
||||
self:advance()
|
||||
end
|
||||
end
|
||||
|
||||
function OakSpeech:openNamePick(step)
|
||||
self.busy = true
|
||||
local NamePick = require("src.ui.gen2.NamePick")
|
||||
local gender = self:gender()
|
||||
local pic, picColors = self:playerPicNow()
|
||||
Screens.push(self.game, "Gen2NamePick", {
|
||||
font = self.game.fontData,
|
||||
gender = gender,
|
||||
-- NamePlayer opens with MovePlayerPicRight, so the name menu owns the
|
||||
-- pic while it is up: it is the same CAL frontpic this speech has been
|
||||
-- showing, walked over to make room for the box.
|
||||
pic = self.playerPic,
|
||||
picColors = self.playerColors,
|
||||
pic = pic,
|
||||
picColors = picColors,
|
||||
presets = step.presets
|
||||
or namePresets(self.game, step.presetsWho or step.who or "player",
|
||||
step.presetsFallback or NamePick.presetsFor()),
|
||||
step.presetsFallback or NamePick.presetsFor(gender)),
|
||||
onDone = function(name)
|
||||
name = name or NamePick.presetsFor()[1]
|
||||
name = name or NamePick.presetsFor(gender)[1]
|
||||
self.game.save.player.name = name
|
||||
self.game.stack:pop() -- NamePick
|
||||
self.busy = false
|
||||
@@ -450,7 +533,9 @@ end
|
||||
|
||||
function OakSpeech:runStep(step)
|
||||
local kind = step.kind or "say"
|
||||
if kind == "initclock" then
|
||||
if kind == "gender" then
|
||||
self:openGenderSelect(step)
|
||||
elseif kind == "initclock" then
|
||||
self:openInitClock()
|
||||
elseif kind == "say" then
|
||||
self:applyPic(step)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Gen2Save = require("src.core.gen2.Save")
|
||||
local PackGfx = require("src.ui.gen2.PackGfx")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Strings = require("src.core.Strings")
|
||||
@@ -78,6 +79,21 @@ local ASK_ITEM_MOVE = { "Where should this", "be moved to?" }
|
||||
local NO_POKEMON = { "You don't have a", "#MON!" }
|
||||
local EGG_CANT_HOLD = { "An EGG can't hold", "an item." }
|
||||
|
||||
-- _CGB_PackPals' .KrisPackPals arm, and the BATTLETYPE_TUTORIAL test above it
|
||||
-- that forces the DUDE's (../pokecrystal/engine/gfx/cgb_layouts.asm:770-786).
|
||||
local function packGfxFor(menuGfx, save, tutorial)
|
||||
local pack = menuGfx and menuGfx.pack
|
||||
if not (pack and pack.palettesFemale) then return menuGfx end
|
||||
if tutorial or not Gen2Save.isFemale(save) then return menuGfx end
|
||||
local female = {}
|
||||
for key, value in pairs(pack) do female[key] = value end
|
||||
female.palettes = pack.palettesFemale
|
||||
local out = {}
|
||||
for key, value in pairs(menuGfx) do out[key] = value end
|
||||
out.pack = female
|
||||
return out
|
||||
end
|
||||
|
||||
-- The PACK's cursor bytes. Every pocket menu restores its own cursor and
|
||||
-- scroll before ScrollingMenu and writes them back after -- `ld a,
|
||||
-- [wItemsPocketCursor] / ld [wMenuCursorPosition], a` ... `ld a, [wMenuCursorY]
|
||||
@@ -157,7 +173,8 @@ function PackMenu.new(game, opts)
|
||||
end
|
||||
self:restoreCursor()
|
||||
-- The cart's own PACK tiles, when the cache has them.
|
||||
self.gfx = PackGfx.new(game and game.data and game.data.gen2MenuGfx)
|
||||
self.gfx = PackGfx.new(packGfxFor(
|
||||
game and game.data and game.data.gen2MenuGfx, self.save, opts.tutorial))
|
||||
self:rebuild()
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
-- then a second card (PrintPartyMonPage2, the mon's other three moves and
|
||||
-- its non-HP stats) goes out behind it. There is no Game Boy Printer here
|
||||
-- -- same reason src/ui/gen2/UnownPrinter.lua takes its A press nowhere and
|
||||
-- PrintDiploma is stubbed in Specials.lua
|
||||
-- PrintDiploma's own print goes nowhere
|
||||
-- -- so only page 1 is worth transcribing: it is the portrait itself, the
|
||||
-- part a player actually watches happen, while page 2 only ever existed as
|
||||
-- ink on a strip of thermal paper nobody in this port owns. H.PhotoStudio
|
||||
|
||||
+135
-29
@@ -21,6 +21,8 @@
|
||||
-- itself at (0,0) from $46.
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local Gen2Save = require("src.core.gen2.Save")
|
||||
local Clock = require("src.core.gen2.Clock")
|
||||
local Font = require("src.render.Font")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
@@ -56,6 +58,10 @@ local CARDS = {
|
||||
-- card over. One row, so `#self.cards` stays 1 and nothing pages.
|
||||
local FLY_MAP_CARD = { id = "map", label = "FLY" }
|
||||
|
||||
-- ../pokecrystal/engine/events/specials.asm:102 OverworldTownMap -> _TownMap
|
||||
-- (:1757): the wall map and the DECO_TOWN_MAP poster, no strip and no card gate.
|
||||
local TOWN_MAP_CARD = { id = "map", label = "MAP" }
|
||||
|
||||
-- ---------------------------------------------------------------- the radio
|
||||
--
|
||||
-- engine/pokegear/radio.asm is not a text table: it is a jumptable of code.
|
||||
@@ -877,7 +883,14 @@ function Pokegear.new(game, opts)
|
||||
self.save = opts.save or (game and game.save)
|
||||
local data = game and game.data or {}
|
||||
self.landmarks = opts.landmarks or data.gen2Landmarks
|
||||
-- TownMap_GetCurrentLandmark (../pokecrystal/engine/pokegear/pokegear.asm:1783)
|
||||
-- reads the map header itself, so a caller that has no landmark to hand still
|
||||
-- gets one.
|
||||
self.currentLandmark = opts.currentLandmark
|
||||
if self.currentLandmark == nil and game and game.currentLandmark then
|
||||
local ok, id = pcall(game.currentLandmark, game)
|
||||
if ok then self.currentLandmark = id end
|
||||
end
|
||||
self.clock = opts.clock
|
||||
self.onClose = opts.onClose
|
||||
self.cards = self:visibleCards()
|
||||
@@ -930,6 +943,15 @@ function Pokegear.new(game, opts)
|
||||
self.flyIndex = (self:region() == "kanto") and #self.fly or 1
|
||||
end
|
||||
|
||||
-- _TownMap (../pokecrystal/engine/pokegear/pokegear.asm:1757): the same map,
|
||||
-- one card, no ENGINE_MAP_CARD test and no strip to page.
|
||||
self.townMap = (not self.fly) and opts.townMap and true or nil
|
||||
if self.townMap then
|
||||
self.cards = { TOWN_MAP_CARD }
|
||||
self.cardIndex = 1
|
||||
self.mode = "card"
|
||||
end
|
||||
|
||||
-- _CGB_PokegearPals writes wBGPals1 only (engine/gfx/cgb_layouts.asm:157),
|
||||
-- so the RED_WALK icon keeps the overworld's own OBJ palette.
|
||||
self.sprites = opts.sprites or data.gen2Sprites
|
||||
@@ -937,6 +959,10 @@ function Pokegear.new(game, opts)
|
||||
|
||||
local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear
|
||||
self.gfx = gfx
|
||||
-- _CGB_PokegearPals picks FemalePokegearPals off wPlayerGender
|
||||
-- (../pokecrystal/engine/gfx/cgb_layouts.asm:179-190).
|
||||
self.gearPals = gfx and ((Gen2Save.isFemale(self.save)
|
||||
and gfx.palettesFemale) or gfx.palettes) or nil
|
||||
if gfx then
|
||||
self.sheet = TileSheet.new({
|
||||
path = gfx.tiles, wide = gfx.tilesWide or 16, firstTile = 0,
|
||||
@@ -950,19 +976,25 @@ function Pokegear:styled()
|
||||
return self.sheet ~= nil and self.sheet:available()
|
||||
end
|
||||
|
||||
-- MalePokegearPals or FemalePokegearPals, whichever _CGB_PokegearPals would
|
||||
-- have copied into wBGPals1 (../pokecrystal/engine/gfx/cgb_layouts.asm:179-190).
|
||||
function Pokegear:pals()
|
||||
return self.gearPals
|
||||
end
|
||||
|
||||
-- TownMapPals: a nybble per tile id for $00..$5f, palette 0 above that.
|
||||
function Pokegear:colorsFor(tile)
|
||||
local gfx = self.gfx
|
||||
if not (gfx and gfx.palettes) then return nil end
|
||||
if tile >= 0x60 then return gfx.palettes[1] end
|
||||
return gfx.palettes[(gfx.palMap and gfx.palMap[tile + 1]) or 1]
|
||||
local pals = self:pals()
|
||||
if not pals then return nil end
|
||||
if tile >= 0x60 then return pals[1] end
|
||||
return pals[(self.gfx.palMap and self.gfx.palMap[tile + 1]) or 1]
|
||||
end
|
||||
|
||||
-- Every string on a Pokegear card is a run of font tiles laid straight into
|
||||
-- the tilemap, so it wears BG palette 0 (PokegearPals' first entry) rather
|
||||
-- than drawing as black ink over whatever was underneath.
|
||||
function Pokegear:text(str, tx, ty)
|
||||
local pals = self.gfx and self.gfx.palettes
|
||||
local pals = self:pals()
|
||||
return Chrome.printThrough(str, tx, ty, pals and pals[1])
|
||||
end
|
||||
|
||||
@@ -1079,6 +1111,7 @@ function Pokegear:update(_dt)
|
||||
-- The fly picker owns the whole screen: no strip, no card paging, and B
|
||||
-- answers -1 rather than backing out to the strip.
|
||||
if self.fly then return self:updateFlyMap(input) end
|
||||
if self.townMap then return self:updateTownMap(input) end
|
||||
if self.mode == "strip" then
|
||||
local stripCard = self:card()
|
||||
if not (stripCard and stripCard.id == "phone") then
|
||||
@@ -1160,8 +1193,9 @@ function Pokegear:region()
|
||||
and landmarks[self.currentLandmark]
|
||||
local index = current and current.index or 0
|
||||
-- LANDMARK_FAST_SHIP is $5e = 94, past every Kanto landmark and still Johto.
|
||||
if index == 94 then return "johto" end
|
||||
if index >= 46 then return "kanto" end
|
||||
if index == self:landmarkIndex("FAST_SHIP", 0x5e) then return "johto" end
|
||||
-- `cp KANTO_LANDMARK`, which is PALLET_TOWN's own index.
|
||||
if index >= self:landmarkIndex("PALLET_TOWN", 0x2e) then return "kanto" end
|
||||
return "johto"
|
||||
end
|
||||
|
||||
@@ -1605,18 +1639,31 @@ local LANDMARK_PALLET_TOWN = 0x2e
|
||||
local LANDMARK_VICTORY_ROAD = 0x57
|
||||
local LANDMARK_ROUTE_28 = 0x5d
|
||||
|
||||
-- ../pokecrystal/constants/landmark_constants.asm:34 inserts BATTLE_TOWER, so
|
||||
-- every index above it is one higher than pokegold's; the cache's own record is
|
||||
-- the authority and the numbers above are the fallback for a dataset without one.
|
||||
function Pokegear:landmarkIndex(id, fallback)
|
||||
local records = (self.landmarks or {}).landmarks
|
||||
local record = records and (records["LANDMARK_" .. id] or records[id])
|
||||
local index = record and tonumber(record.index)
|
||||
return index or fallback
|
||||
end
|
||||
|
||||
-- Returns d (last) and e (first). Kanto's pair comes from
|
||||
-- TownMap_GetKantoLandmarkLimits, which withholds everything west of Victory
|
||||
-- Road until the Hall of Fame is on the record -- before that the Kanto map
|
||||
-- only walks the seven landmarks on the road to Indigo Plateau.
|
||||
function Pokegear:cursorLimits()
|
||||
if self:region() ~= "kanto" then
|
||||
return LANDMARK_SILVER_CAVE, LANDMARK_NEW_BARK_TOWN
|
||||
-- `ld d, KANTO_LANDMARK - 1`, which is SILVER_CAVE either way round.
|
||||
return self:landmarkIndex("SILVER_CAVE", LANDMARK_SILVER_CAVE),
|
||||
self:landmarkIndex("NEW_BARK_TOWN", LANDMARK_NEW_BARK_TOWN)
|
||||
end
|
||||
local last = self:landmarkIndex("ROUTE_28", LANDMARK_ROUTE_28)
|
||||
if ((self.save or {}).flags or {}).HALL_OF_FAME then
|
||||
return LANDMARK_ROUTE_28, LANDMARK_PALLET_TOWN
|
||||
return last, self:landmarkIndex("PALLET_TOWN", LANDMARK_PALLET_TOWN)
|
||||
end
|
||||
return LANDMARK_ROUTE_28, LANDMARK_VICTORY_ROAD
|
||||
return last, self:landmarkIndex("VICTORY_ROAD", LANDMARK_VICTORY_ROAD)
|
||||
end
|
||||
|
||||
-- The cursor's landmark index. Unset, it is the player's own, which is what
|
||||
@@ -1637,32 +1684,59 @@ end
|
||||
|
||||
-- PokegearMap_ContinueMap's .DPad. Both branches share the increment or
|
||||
-- decrement that follows them, which is why the wrap writes e - 1 / d + 1
|
||||
-- rather than e / d.
|
||||
function Pokegear:moveMapCursor(input)
|
||||
-- rather than e / d. _TownMap's own .pressed_up / .pressed_down
|
||||
-- (../pokecrystal/engine/pokegear/pokegear.asm:1853-1877) are the same pair of
|
||||
-- wraps against the same d/e, so the poster steps through here too.
|
||||
function Pokegear:stepMapCursor(delta)
|
||||
local last, first = self:cursorLimits()
|
||||
local cursor = self:mapCursorIndex()
|
||||
if input:wasPressed("up") then
|
||||
if delta > 0 then
|
||||
-- `cp d / jr c, .wrap_around_up`: below the last landmark the value is
|
||||
-- left alone, at or past it the cursor is slammed to e - 1 first.
|
||||
if cursor >= last then cursor = first - 1 end
|
||||
cursor = cursor + 1
|
||||
elseif input:wasPressed("down") then
|
||||
else
|
||||
-- `cp e / jr nz, .wrap_around_down`: only the first landmark wraps.
|
||||
if cursor == first then cursor = last + 1 end
|
||||
cursor = cursor - 1
|
||||
end
|
||||
self.mapCursor = cursor
|
||||
end
|
||||
|
||||
function Pokegear:moveMapCursor(input)
|
||||
if input:wasPressed("up") then
|
||||
self:stepMapCursor(1)
|
||||
elseif input:wasPressed("down") then
|
||||
self:stepMapCursor(-1)
|
||||
elseif input:wasPressed("right") then
|
||||
-- Left and right do not move the cursor at all on this card: they page
|
||||
-- the POKeGEAR. .right takes the PHONE if it is owned and the RADIO if
|
||||
-- it is not; .left always takes the CLOCK.
|
||||
self:switchCard("phone", "radio")
|
||||
return
|
||||
elseif input:wasPressed("left") then
|
||||
self:switchCard("clock")
|
||||
return
|
||||
else
|
||||
end
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- town map
|
||||
--
|
||||
-- _TownMap's `.loop` (../pokecrystal/engine/pokegear/pokegear.asm:1831-1845)
|
||||
-- reads hJoyPressed for B and hJoyLast for UP and DOWN, and nothing else: A
|
||||
-- takes nothing, and left/right have no card to page to.
|
||||
function Pokegear:updateTownMap(input)
|
||||
if input:wasPressed("b") then
|
||||
if self.townMapClosed then return end
|
||||
self.townMapClosed = true
|
||||
local stack = self.game and self.game.stack
|
||||
if stack then stack:pop() end
|
||||
if self.onClose then self.onClose() end
|
||||
return
|
||||
end
|
||||
self.mapCursor = cursor
|
||||
if input:wasPressed("up") then
|
||||
self:stepMapCursor(1)
|
||||
elseif input:wasPressed("down") then
|
||||
self:stepMapCursor(-1)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ fly map
|
||||
@@ -1796,7 +1870,7 @@ function Pokegear:loadArrowSheet()
|
||||
-- pokegold data/sprite_anims/oam.asm .OAMData_RedWalk: STILL_CURSOR's
|
||||
-- oamset reuses RED_WALK's OAM data, so this wears PAL_OW_RED.
|
||||
palette = (self.playerIcon and self.playerIcon.objColors)
|
||||
or (gfx.palettes and gfx.palettes[1]),
|
||||
or (self:pals() and self:pals()[1]),
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -1914,7 +1988,7 @@ end
|
||||
-- painted white underneath its strings is what puts a cream bar behind every
|
||||
-- run printThrough lays down.
|
||||
function Pokegear:paperColor()
|
||||
local pals = self.gfx and self.gfx.palettes
|
||||
local pals = self:pals()
|
||||
return (pals and pals[1] and pals[1][1]) or { 255, 255, 255 }
|
||||
end
|
||||
|
||||
@@ -1929,7 +2003,7 @@ end
|
||||
-- space IS a font-page cell on colour 0, which is the contrast the day/time
|
||||
-- window and the map's KANTO label are drawn against.
|
||||
function Pokegear:groundColor()
|
||||
local pals = self.gfx and self.gfx.palettes
|
||||
local pals = self:pals()
|
||||
local pal = pals and pals[1]
|
||||
return (pal and pal[#pal]) or { 0, 0, 0 }
|
||||
end
|
||||
@@ -1989,6 +2063,20 @@ function Pokegear:drawFlyBubble()
|
||||
Chrome.cursor(18, 1)
|
||||
end
|
||||
|
||||
-- _TownMap.InitTilemap (../pokecrystal/engine/pokegear/pokegear.asm:1891-1918):
|
||||
-- with no card strip above it the rule turns down at (7,0) and runs back out
|
||||
-- along row 2, boxing the name plate into the top right corner instead.
|
||||
function Pokegear:drawTownMapRule()
|
||||
self:tile(0x06, 0, 0)
|
||||
for x = 1, 6 do self:tile(0x07, x, 0) end
|
||||
self:tile(0x17, 7, 0)
|
||||
self:tile(0x16, 7, 1)
|
||||
self:tile(0x26, 7, 2)
|
||||
-- `ld bc, NAME_LENGTH` from (8,2), so the run stops one short of the cap.
|
||||
for x = 8, 18 do self:tile(0x07, x, 2) end
|
||||
self:tile(0x17, 19, 2)
|
||||
end
|
||||
|
||||
function Pokegear:drawMap()
|
||||
-- The REGION follows the player (`cp KANTO_LANDMARK` in
|
||||
-- PokegearMap_CheckRegion); the name box follows the CURSOR, which the
|
||||
@@ -2000,11 +2088,15 @@ function Pokegear:drawMap()
|
||||
if self.fly then
|
||||
self:drawFlyBubble()
|
||||
else
|
||||
self:drawStrip()
|
||||
-- The header's own bottom rule: $07 across (1,2), with $06 and $17 as caps.
|
||||
self:tile(0x06, 0, 2)
|
||||
for x = 1, 18 do self:tile(0x07, x, 2) end
|
||||
self:tile(0x17, 19, 2)
|
||||
if self.townMap then
|
||||
self:drawTownMapRule()
|
||||
else
|
||||
self:drawStrip()
|
||||
-- The header's own bottom rule: $07 across (1,2), with $06 and $17 as caps.
|
||||
self:tile(0x06, 0, 2)
|
||||
for x = 1, 18 do self:tile(0x07, x, 2) end
|
||||
self:tile(0x17, 19, 2)
|
||||
end
|
||||
|
||||
-- PokegearMap_UpdateLandmarkName: ClearBox(8,0) 2 rows by 12 columns --
|
||||
-- with ' ', which is a font-page cell and so reads as BG palette 0's
|
||||
@@ -2047,7 +2139,12 @@ end
|
||||
function Pokegear:loadPlayerIcon()
|
||||
if self.playerIcon ~= nil then return end
|
||||
self.playerIcon = false
|
||||
local def = self.sprites and self.sprites.SPRITE_CHRIS
|
||||
-- SPRITE_ANIM_OBJ_RED_WALK or _BLUE_WALK, which is the sheet GetPlayerIcon
|
||||
-- loaded plus PAL_OW_RED or PAL_OW_BLUE
|
||||
-- (../pokecrystal/engine/pokegear/pokegear.asm:2737, :2752-2757).
|
||||
local def = self.sprites
|
||||
and self.sprites[FieldMoves.playerSprite(
|
||||
self.save and self.save.player and self.save.player.gender)]
|
||||
if not (def and def.image) then return end
|
||||
local ok, icon = pcall(SpriteRenderer.new, def, "player")
|
||||
if not (ok and icon) then return end
|
||||
@@ -2205,6 +2302,14 @@ function Pokegear:drawPlain()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.townMap then
|
||||
-- No town-map art in this cache, so the name plate the cursor walks is all
|
||||
-- there is to show (../pokecrystal/engine/pokegear/pokegear.asm:700).
|
||||
Chrome.box(7, 0, 13, 3)
|
||||
local current = self:mapLandmark()
|
||||
Chrome.print(flatName(current and current.name), 9, 1)
|
||||
return
|
||||
end
|
||||
Chrome.box(0, 0, 20, 4)
|
||||
local card = self:card()
|
||||
Chrome.print(card and card.label or "", 2, 1)
|
||||
@@ -2282,8 +2387,9 @@ function Pokegear:drawPanel()
|
||||
end
|
||||
-- Last: the arrow is an OBJ and composites over whatever the card drew.
|
||||
-- _FlyMap has no card strip and never animates it
|
||||
-- (engine/pokegear/pokegear.asm:1999).
|
||||
if not self.fly then self:drawModeArrow() end
|
||||
-- (engine/pokegear/pokegear.asm:1999); neither does _TownMap
|
||||
-- (../pokecrystal/engine/pokegear/pokegear.asm:1757).
|
||||
if not (self.fly or self.townMap) then self:drawModeArrow() end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ local GbcPalette = require("src.render.GbcPalette")
|
||||
local HpBar = require("src.battle.gen2.HpBar")
|
||||
local ItemEffects = require("src.core.gen2.ItemEffects")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local MonAnim = require("src.render.MonAnim")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local Pokerus = require("src.core.gen2.Pokerus")
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
@@ -314,6 +315,55 @@ function SummaryMenu:playCry()
|
||||
if ok and Sound and Sound.playCry then
|
||||
pcall(Sound.playCry, self.game.data, mon.species)
|
||||
end
|
||||
self:startPicAnim()
|
||||
end
|
||||
|
||||
-- StatsScreen_PlaceFrontpic loads ANIM_MON_MENU, the longer scene. A cache
|
||||
-- with no `anim` row -- every Gold and Silver one -- leaves picAnim nil.
|
||||
-- ../pokecrystal/engine/pokemon/stats_screen.asm:889-901
|
||||
function SummaryMenu:startPicAnim()
|
||||
self.picAnim = nil
|
||||
local mon = self.mon
|
||||
local def = mon and self.pokemon and self.pokemon[mon.species]
|
||||
if not def then return end
|
||||
local data = def.anim
|
||||
if mon.species == Unown.SPECIES and def.letters then
|
||||
local entry = def.letters[Unown.monLetter(mon)]
|
||||
if entry and entry.anim then data = entry.anim end
|
||||
end
|
||||
if not data then return end
|
||||
local sheet = self:picImage(data.sheet)
|
||||
if not sheet then return end
|
||||
local runner = MonAnim.new(data, "menu")
|
||||
if not runner then return end
|
||||
self.picAnim = { runner = runner, sheet = sheet, size = data.tiles * 8,
|
||||
quads = {} }
|
||||
end
|
||||
|
||||
-- AnimateFrontpic's .loop, one scene command per frame.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89
|
||||
function SummaryMenu:stepPicAnim()
|
||||
local state = self.picAnim
|
||||
if not state then return end
|
||||
state.runner:update()
|
||||
if state.runner:finished() then self.picAnim = nil end
|
||||
end
|
||||
|
||||
-- The sheet is one column of whole pictures, base picture first.
|
||||
function SummaryMenu:picAnimFrame()
|
||||
local state = self.picAnim
|
||||
if not state then return nil end
|
||||
local frame = state.runner:currentFrame()
|
||||
if frame <= 0 then return nil end
|
||||
local quad = state.quads[frame]
|
||||
if not quad then
|
||||
local w, h = state.sheet:getDimensions()
|
||||
if (frame + 1) * state.size > h then return nil end
|
||||
quad = love.graphics.newQuad(0, frame * state.size, state.size, state.size,
|
||||
w, h)
|
||||
state.quads[frame] = quad
|
||||
end
|
||||
return state.sheet, quad, state.size
|
||||
end
|
||||
|
||||
function SummaryMenu:speciesDef()
|
||||
@@ -767,6 +817,7 @@ function SummaryMenu:updateMoveDetail(input)
|
||||
end
|
||||
|
||||
function SummaryMenu:update(_dt)
|
||||
self:stepPicAnim()
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
if self.moveDetail then
|
||||
@@ -943,7 +994,7 @@ end
|
||||
|
||||
-- PrepMonFrontpic at hlcoord 0, 0: a 7x7 block with the pic padded into it and
|
||||
-- the rest of the block left at the palette's colour 0.
|
||||
function SummaryMenu:drawPicBlock(image, colors)
|
||||
function SummaryMenu:drawPicBlock(image, colors, quad, size)
|
||||
if not image then return end
|
||||
local G = love.graphics
|
||||
-- A fill behind the pic reads a palette colour directly, so it has to come
|
||||
@@ -952,10 +1003,16 @@ function SummaryMenu:drawPicBlock(image, colors)
|
||||
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, 7 * 8, 7 * 8)
|
||||
|
||||
local wide = math.floor(image:getWidth() / 8)
|
||||
local wide = math.floor((size or image:getWidth()) / 8)
|
||||
local pad = PIC_PAD[wide] or PIC_PAD[7]
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body() G.draw(image, pad[1] * 8, pad[2] * 8) end
|
||||
local function body()
|
||||
if quad then
|
||||
G.draw(image, quad, pad[1] * 8, pad[2] * 8)
|
||||
else
|
||||
G.draw(image, pad[1] * 8, pad[2] * 8)
|
||||
end
|
||||
end
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
@@ -970,6 +1027,8 @@ function SummaryMenu:drawPic()
|
||||
if not image then return end
|
||||
local colors = self.palettes and mon.species
|
||||
and Palettes.monColors(self.palettes, mon.species, mon.shiny) or nil
|
||||
local sheet, quad, size = self:picAnimFrame()
|
||||
if sheet then return self:drawPicBlock(sheet, colors, quad, size) end
|
||||
self:drawPicBlock(image, colors)
|
||||
end
|
||||
|
||||
|
||||
+160
-2
@@ -11,6 +11,7 @@ local Chrome = require("src.ui.gen2.Chrome")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Music = require("src.core.Music")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Sound = require("src.core.Sound")
|
||||
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
|
||||
|
||||
local TitleState = {}
|
||||
@@ -121,15 +122,75 @@ function TitleState.new(game, opts)
|
||||
-- How far past the 160px frame trails may fly (GB pixels); set each draw.
|
||||
self.trailMaxX = 200
|
||||
self.musicStarted = false
|
||||
|
||||
-- engine/movie/title.asm:104-127,217-273,304-338
|
||||
self.suicuneColor, self.suicuneGray = {}, {}
|
||||
if type(title.suicuneFrames) == "table" then
|
||||
for i, path in ipairs(title.suicuneFrames) do
|
||||
self.suicuneColor[i] = tryImage(path)
|
||||
end
|
||||
end
|
||||
if type(title.suicuneFramesGray) == "table" then
|
||||
for i, path in ipairs(title.suicuneFramesGray) do
|
||||
self.suicuneGray[i] = tryImage(path)
|
||||
end
|
||||
end
|
||||
self.suicuneX = tonumber(title.suicuneX) or 48
|
||||
self.suicuneY = tonumber(title.suicuneY) or 96
|
||||
-- SuicuneFrameIterator's `and %111` clock (engine/movie/title.asm:217-243).
|
||||
self.suicuneEvery = tonumber(title.suicuneEvery) or 8
|
||||
self.suicuneTick = 0
|
||||
self.suicuneFrame = 1
|
||||
self.gemColor = tryImage(title.gem)
|
||||
self.gemGray = tryImage(title.gemGray)
|
||||
self.gemX = tonumber(title.gemX) or 56
|
||||
self.gemRestY = tonumber(title.gemY) or 6
|
||||
self.gemStep = tonumber(title.gemStep) or 2
|
||||
-- TitleScreenEntrance (engine/menus/intro_menu.asm:1078-1123): hSCX walks
|
||||
-- to 0 while alternating logo lines converge and the gem descends.
|
||||
local entrance = type(title.entrance) == "table" and title.entrance or nil
|
||||
self.entrance = entrance
|
||||
self.entranceScx = entrance and (tonumber(entrance.scx) or 112) or 0
|
||||
self.entranceStep = entrance and (tonumber(entrance.step) or 4) or 4
|
||||
self.entranceLines = entrance and (tonumber(entrance.lines) or 80) or 0
|
||||
self.entranceHideBelow = entrance and tonumber(entrance.hideBelow) or nil
|
||||
self.gemY = entrance and (tonumber(title.gemFromY) or -50) or self.gemRestY
|
||||
self.entranceSfx = title.entranceSfx
|
||||
self.timeoutFrames = tonumber(title.timeoutFrames)
|
||||
self.onTimeout = opts.onTimeout
|
||||
-- The copyright window line is pal 7 (engine/movie/title.asm:40-43); its
|
||||
-- colour 0 backs the whole band.
|
||||
local pals = type(title.palettes) == "table" and title.palettes.bg or nil
|
||||
local band = type(pals) == "table" and type(pals[8]) == "table"
|
||||
and pals[8][1] or nil
|
||||
self.bandColor = (type(band) == "table" and #band >= 3)
|
||||
and { band[1] / 255, band[2] / 255, band[3] / 255, 1 } or { 0, 0, 0, 1 }
|
||||
return self
|
||||
end
|
||||
|
||||
function TitleState:enter()
|
||||
function TitleState:startMusic()
|
||||
if self.musicStarted then return end
|
||||
local data = self.game and self.game.data
|
||||
if data and data.audio and data.audio.runtime and not self.musicStarted then
|
||||
if data and data.audio and data.audio.runtime then
|
||||
Music.play(data, "Music_TitleScreen", true, { reason = "title" })
|
||||
self.musicStarted = true
|
||||
end
|
||||
end
|
||||
|
||||
function TitleState:enter()
|
||||
if self.entrance then
|
||||
-- _TitleScreen silences the channels and plays the entrance sting;
|
||||
-- MUSIC_TITLE waits for the entrance to land
|
||||
-- (engine/movie/title.asm:184,210-213; engine/menus/intro_menu.asm:1118-1119).
|
||||
Music.stop()
|
||||
local data = self.game and self.game.data
|
||||
if data and data.audio and data.audio.runtime and self.entranceSfx
|
||||
and data.audio.sfx and data.audio.sfx[self.entranceSfx] then
|
||||
Sound.play(data, self.entranceSfx)
|
||||
end
|
||||
else
|
||||
self:startMusic()
|
||||
end
|
||||
-- intro.boot.title: the last card of the GS boot cinema, up with its music
|
||||
-- started. Gen 2 only, like the rest of intro.boot.* (see
|
||||
-- src/ui/gen2/CopyrightSplash.lua for why the set is new rather than shared).
|
||||
@@ -197,15 +258,51 @@ function TitleState:stepTrails()
|
||||
self.trails = alive
|
||||
end
|
||||
|
||||
-- SuicuneFrameIterator (engine/movie/title.asm:217-249): advance once per
|
||||
-- `suicuneEvery` frames, cycling the four frame bases.
|
||||
function TitleState:advanceSuicune()
|
||||
if #self.suicuneColor == 0 then return end
|
||||
local c = self.suicuneTick
|
||||
self.suicuneTick = (c + 1) % 256
|
||||
if c % self.suicuneEvery ~= 0 then return end
|
||||
self.suicuneFrame = math.floor(c % (self.suicuneEvery * 4)
|
||||
/ self.suicuneEvery) + 1
|
||||
end
|
||||
|
||||
function TitleState:update(_dt)
|
||||
self.frameCounter = self.frameCounter + 1
|
||||
self:advanceHooh()
|
||||
self:advanceSuicune()
|
||||
if self.frameCounter % self.cloudScrollEvery == 0 then
|
||||
self.cloudScroll = (self.cloudScroll - 1) % 160
|
||||
end
|
||||
self:spawnTrail()
|
||||
self:stepTrails()
|
||||
|
||||
if self.entranceScx > 0 then
|
||||
-- TitleScreenEntrance polls no buttons and moves the gem by 2 a frame
|
||||
-- (engine/menus/intro_menu.asm:1078-1107; engine/movie/title.asm:340-362).
|
||||
self.entranceScx = math.max(0, self.entranceScx - self.entranceStep)
|
||||
if self.gemY < self.gemRestY then
|
||||
self.gemY = math.min(self.gemRestY, self.gemY + self.gemStep)
|
||||
end
|
||||
if self.entranceScx == 0 then
|
||||
self:startMusic()
|
||||
-- TitleScreenTimer only starts once the entrance scene hands over
|
||||
-- (engine/menus/intro_menu.asm:1110-1136).
|
||||
self.timeoutStart = self.frameCounter
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- TitleScreenTimer / TitleScreenMain's run-down back into the attract
|
||||
-- loop (engine/menus/intro_menu.asm:1125-1236).
|
||||
if self.timeoutFrames and self.onTimeout
|
||||
and self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then
|
||||
self.onTimeout()
|
||||
return
|
||||
end
|
||||
|
||||
local input = self.game.input
|
||||
if input and (input:wasPressed("a") or input:wasPressed("start")) then
|
||||
if self.onContinue then self.onContinue() end
|
||||
@@ -242,10 +339,71 @@ function TitleState:drawCloudSpan(x0, x1)
|
||||
end
|
||||
end
|
||||
|
||||
-- TitleScreenEntrance's interlace: even lines slide in from the left, odd
|
||||
-- from the right, converging as hSCX walks to 0
|
||||
-- (engine/menus/intro_menu.asm:1084-1103).
|
||||
function TitleState:drawEntranceScreen(screen)
|
||||
local G = love.graphics
|
||||
local scx = self.entranceScx
|
||||
if scx <= 0 then
|
||||
G.draw(screen, 0, 0)
|
||||
return
|
||||
end
|
||||
local w, h = screen:getDimensions()
|
||||
local lines = math.min(self.entranceLines, h)
|
||||
self.entranceQuad = self.entranceQuad or G.newQuad(0, 0, w, 1, w, h)
|
||||
for line = 0, lines - 1 do
|
||||
self.entranceQuad:setViewport(0, line, w, 1, w, h)
|
||||
G.draw(screen, self.entranceQuad, line % 2 == 0 and -scx or scx, line)
|
||||
end
|
||||
-- hWY holds the copyright window off screen until the entrance lands
|
||||
-- (engine/movie/title.asm:198-199; engine/menus/intro_menu.asm:1121-1122).
|
||||
local bottom = (self.entranceHideBelow or h) - lines
|
||||
if bottom > 0 then
|
||||
self.entranceQuad:setViewport(0, lines, w, bottom, w, h)
|
||||
G.draw(screen, self.entranceQuad, 0, lines)
|
||||
end
|
||||
end
|
||||
|
||||
function TitleState:drawContent()
|
||||
local G = love.graphics
|
||||
local screen, _, trail, hoohFrames = self:art()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
|
||||
local gem = self:gray() and (self.gemGray or self.gemColor) or self.gemColor
|
||||
local suicuneFrames = nil
|
||||
if #self.suicuneColor > 0 then
|
||||
suicuneFrames = (self:gray() and #self.suicuneGray > 0)
|
||||
and self.suicuneGray or self.suicuneColor
|
||||
end
|
||||
if gem or suicuneFrames then
|
||||
-- Crystal's layering: the gem is OAM_PRIO so every BG colour 1-3 pixel
|
||||
-- beats it; Suicune is BG; the window copyright covers Suicune's last
|
||||
-- row (engine/movie/title.asm:81-85,334; engine/menus/intro_menu.asm:1121-1122).
|
||||
local fill = self:gray() and SKY_GRAY or self.sky
|
||||
G.setColor(fill[1], fill[2], fill[3], 1)
|
||||
G.rectangle("fill", 0, 0, 160, 144)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
if gem then G.draw(gem, self.gemX, self.gemY) end
|
||||
if suicuneFrames then
|
||||
local frame = suicuneFrames[self.suicuneFrame] or suicuneFrames[1]
|
||||
if frame then G.draw(frame, self.suicuneX, self.suicuneY) end
|
||||
end
|
||||
if self.entranceScx <= 0 then
|
||||
-- The pal-7 window line covers the BG once hWY lands at $88
|
||||
-- (engine/movie/title.asm:40-43; engine/menus/intro_menu.asm:1121-1122).
|
||||
local bandTop = self.entranceHideBelow or 136
|
||||
local band = self.bandColor
|
||||
G.setColor(band[1], band[2], band[3], 1)
|
||||
G.rectangle("fill", 0, bandTop, 160, 144 - bandTop)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if screen then
|
||||
self:drawEntranceScreen(screen)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if screen then
|
||||
-- title_screen.png already carries the cart's © GAME FREAK line on row 17.
|
||||
G.draw(screen, 0, 0)
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Gen2Save = require("src.core.gen2.Save")
|
||||
local TileSheet = require("src.ui.gen2.TileSheet")
|
||||
|
||||
local TrainerCard = {}
|
||||
@@ -68,6 +69,19 @@ local KANTO_BADGES = {
|
||||
"EARTH",
|
||||
}
|
||||
|
||||
-- The two slots _CGB_TrainerCard swaps by gender: CHRIS and FALKNER/KrisPalette.
|
||||
-- ../pokecrystal/engine/gfx/cgb_layouts.asm:619-624, data/trainers/palettes.asm:9-12
|
||||
local PLAYER_PALETTE, BORDER_PALETTE = 1, 2
|
||||
|
||||
-- Kris's attrmap: the border takes CHRIS's palette, the portrait takes hers,
|
||||
-- the top-right corner follows the border, and Clair's face borrows hers
|
||||
-- (../pokecrystal/engine/gfx/cgb_layouts.asm:649-666, :698-712).
|
||||
local FEMALE_ZONES = {
|
||||
{ 14, 1, 5, 7, BORDER_PALETTE },
|
||||
{ 18, 1, 1, 1, PLAYER_PALETTE },
|
||||
{ 14, 14, 4, 2, BORDER_PALETTE },
|
||||
}
|
||||
|
||||
-- TrainerCard_JohtoBadgesOAM lists the badges in wJohtoBadges bit order,
|
||||
-- which is not the order they are drawn in: Mineral comes before Storm.
|
||||
local BADGE_OAM_ORDER = {
|
||||
@@ -92,10 +106,13 @@ function TrainerCard.new(game, opts)
|
||||
|
||||
local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).trainerCard
|
||||
self.gfx = gfx
|
||||
self.female = Gen2Save.isFemale(self.save) and gfx ~= nil
|
||||
and gfx.cardFemale ~= nil
|
||||
if gfx then
|
||||
-- One palette lookup per cell, flattened from the FillBoxCGB zones the
|
||||
-- way PackGfx does it. Anything outside a zone is palette 1.
|
||||
self.zone = {}
|
||||
self.zoneDefault = BORDER_PALETTE
|
||||
for _, z in ipairs(gfx.paletteZones or {}) do
|
||||
for y = z[2], z[2] + z[4] - 1 do
|
||||
for x = z[1], z[1] + z[3] - 1 do
|
||||
@@ -103,11 +120,22 @@ function TrainerCard.new(game, opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.female then
|
||||
self.zoneDefault = PLAYER_PALETTE
|
||||
for _, z in ipairs(FEMALE_ZONES) do
|
||||
for y = z[2], z[2] + z[4] - 1 do
|
||||
for x = z[1], z[1] + z[3] - 1 do
|
||||
self.zone[y * SCREEN_W + x] = z[5]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local function paletteFor(_, tx, ty)
|
||||
return self:colorsAt(tx, ty)
|
||||
end
|
||||
self.card = TileSheet.new({
|
||||
path = gfx.card, wide = gfx.cardTilesWide or 16, firstTile = 0,
|
||||
path = (self.female and gfx.cardFemale) or gfx.card,
|
||||
wide = gfx.cardTilesWide or 16, firstTile = 0,
|
||||
paletteFor = paletteFor,
|
||||
})
|
||||
self.status = TileSheet.new({
|
||||
@@ -152,7 +180,8 @@ function TrainerCard:pair(colors)
|
||||
end
|
||||
|
||||
function TrainerCard:colorsAt(tx, ty)
|
||||
local index = (self.zone and self.zone[ty * SCREEN_W + tx]) or 2
|
||||
local index = (self.zone and self.zone[ty * SCREEN_W + tx])
|
||||
or self.zoneDefault or BORDER_PALETTE
|
||||
return self:palette(index)
|
||||
end
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
-- is drawn and driven entirely on the cartridge, and only the A press
|
||||
-- (`farcall PrintUnownStamp`, engine/printer/printer.asm) walks the screen
|
||||
-- out the serial port to a Game Boy Printer. There is no printer here, the
|
||||
-- same reason PrintDiploma stays stubbed in Specials.lua, so A lands on the
|
||||
-- same reason PrintDiploma's own print goes nowhere, so A lands on the
|
||||
-- same arm a cartridge with nothing plugged into its link port takes: the
|
||||
-- stamp does not print and the screen stays up. Everything else is the
|
||||
-- cart's.
|
||||
|
||||
@@ -65,6 +65,7 @@ local PAL = {
|
||||
railGold = { 255, 203, 5 }, -- Yellow cartridge (bright)
|
||||
railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal)
|
||||
railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal)
|
||||
railCrystal = { 168, 120, 236 }, -- Crystal cartridge (translucent violet)
|
||||
}
|
||||
-- Semantic aliases kept so ported call sites read the same as before.
|
||||
PAL.cardBorder = PAL.line
|
||||
@@ -322,11 +323,13 @@ function Theme.meter(x, y, w, h, pct, c)
|
||||
end
|
||||
|
||||
-- The 4px version rail across the top of both windows: the only brand
|
||||
-- colour on screen (Red / Blue / Yellow / Gold / Silver cartridge colours).
|
||||
-- colour on screen (Red / Blue / Yellow / Gold / Silver / Crystal cartridge
|
||||
-- colours).
|
||||
function Theme.versionRail(x, y, w, h)
|
||||
if not G then return end
|
||||
local bars = {
|
||||
PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber, PAL.railSilver,
|
||||
PAL.railCrystal,
|
||||
}
|
||||
local seg = w / #bars
|
||||
for i, c in ipairs(bars) do
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
-- `action` names the World method that carries it out; everything else in the
|
||||
-- table is that action's argument.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
@@ -152,14 +153,41 @@ FieldMoves.KANTO_BADGES = {
|
||||
-- World:setEngineFlag / World:engineFlag route badge ids here so there is one
|
||||
-- store again, the same way ENGINE_BUG_CONTEST_TIMER is routed to
|
||||
-- save.bugContest rather than kept as a second copy.
|
||||
FieldMoves.BADGE_FLAG = {}
|
||||
for index, name in ipairs(FieldMoves.JOHTO_BADGES) do
|
||||
FieldMoves.BADGE_FLAG[25 + index] = { store = "badges", name = name }
|
||||
end
|
||||
for index, name in ipairs(FieldMoves.KANTO_BADGES) do
|
||||
FieldMoves.BADGE_FLAG[33 + index] = { store = "kantoBadges", name = name }
|
||||
-- Crystal declares 162 engine flags to Gold's 93 and the badge block sits one
|
||||
-- higher (constants/engine_flags.asm:39 vs pokegold's :38), so the ids come
|
||||
-- from the cache's engineFlagOrder when it has one.
|
||||
-- Crystal only; pokegold constants/engine_flags.asm:4-111 has no such row.
|
||||
FieldMoves.FEMALE_FLAG_NAME = "ENGINE_PLAYER_IS_FEMALE"
|
||||
|
||||
function FieldMoves.bindEngineFlags(order)
|
||||
local byName = {}
|
||||
if type(order) == "table" then
|
||||
-- pairs, not ipairs: a const_skip leaves a hole and ipairs would stop
|
||||
-- there, silently dropping every badge past it.
|
||||
for index, name in pairs(order) do
|
||||
if type(index) == "number" and type(name) == "string" then
|
||||
byName[name] = index - 1
|
||||
end
|
||||
end
|
||||
end
|
||||
local flags = {}
|
||||
local function place(names, store, goldBase)
|
||||
for index, name in ipairs(names) do
|
||||
local id = byName["ENGINE_" .. name .. "BADGE"] or (goldBase + index)
|
||||
flags[id] = { store = store, name = name }
|
||||
end
|
||||
end
|
||||
place(FieldMoves.JOHTO_BADGES, "badges", 25)
|
||||
place(FieldMoves.KANTO_BADGES, "kantoBadges", 33)
|
||||
FieldMoves.BADGE_FLAG = flags
|
||||
-- The flag IS wPlayerGender's bit 0, so World routes it to the gender byte
|
||||
-- (data/events/engine_flags.asm:131, constants/engine_flags.asm:121).
|
||||
FieldMoves.FEMALE_FLAG = byName[FieldMoves.FEMALE_FLAG_NAME]
|
||||
return flags
|
||||
end
|
||||
|
||||
FieldMoves.bindEngineFlags(nil)
|
||||
|
||||
function FieldMoves.hasBadge(save, badge)
|
||||
if not badge then return true end
|
||||
local owned = save and save.player and save.player.badges
|
||||
@@ -357,6 +385,39 @@ FieldMoves.STATE_SPRITE = {
|
||||
surf_pika = "SPRITE_SURFING_PIKACHU",
|
||||
}
|
||||
|
||||
-- data/sprites/player_sprites.asm:8-13 KrisStateSprites, the other half of the
|
||||
-- table GetPlayerSprite picks between (engine/overworld/overworld.asm:55-64).
|
||||
FieldMoves.STATE_SPRITE_FEMALE = {
|
||||
normal = "SPRITE_KRIS",
|
||||
bike = "SPRITE_KRIS_BIKE",
|
||||
surf = "SPRITE_SURF",
|
||||
surf_pika = "SPRITE_SURFING_PIKACHU",
|
||||
}
|
||||
|
||||
-- wPlayerGender's PLAYERGENDER_FEMALE_F, as the save spells it
|
||||
-- (constants/ram_constants.asm:176-177).
|
||||
function FieldMoves.isFemale(gender)
|
||||
return gender == "female"
|
||||
end
|
||||
|
||||
-- GetPlayerSprite's table pick and row walk
|
||||
-- (engine/overworld/overworld.asm:57-64, :67-75).
|
||||
function FieldMoves.stateSprite(state, gender)
|
||||
local table_ = FieldMoves.isFemale(gender)
|
||||
and FieldMoves.STATE_SPRITE_FEMALE or FieldMoves.STATE_SPRITE
|
||||
return table_[state] or table_[FieldMoves.PLAYER_NORMAL]
|
||||
end
|
||||
|
||||
function FieldMoves.playerSprite(gender)
|
||||
return FieldMoves.stateSprite(FieldMoves.PLAYER_NORMAL, gender)
|
||||
end
|
||||
|
||||
-- Whether the cache carries Kris at all; Gold and Silver have no
|
||||
-- KrisStateSprites to extract (pokegold data/sprites/player_sprites.asm:1-6).
|
||||
function FieldMoves.hasGenderChoice(sprites)
|
||||
return (sprites and sprites[FieldMoves.STATE_SPRITE_FEMALE.normal]) ~= nil
|
||||
end
|
||||
|
||||
function FieldMoves.isBiking(state)
|
||||
return state == FieldMoves.PLAYER_BIKE
|
||||
end
|
||||
@@ -536,10 +597,14 @@ end
|
||||
-- FlashFunction.CheckUseFlash: badge, then wTimeOfDayPalset == DARKNESS_PALSET
|
||||
-- -- so FLASH is refused in a lit cave and on a route alike, and the refusal
|
||||
-- is FieldMoveFailed's generic "Can't use that here."
|
||||
--
|
||||
-- ../pokecrystal/engine/events/overworld.asm:284-287 puts SpecialAerodactylChamber
|
||||
-- between the two, and its carry is a second way into `.useflash`.
|
||||
function FieldMoves.flashFromMenu(ctx)
|
||||
local refused = badgeGate(ctx, "FLASH")
|
||||
if refused then return refused end
|
||||
if not ctx.dark then
|
||||
local chamber = ctx.openAerodactylWall and ctx.openAerodactylWall()
|
||||
if not ctx.dark and not chamber then
|
||||
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
|
||||
end
|
||||
return { ok = true, action = "flash", text = FieldMoves.TEXT.BLINDING_FLASH }
|
||||
@@ -562,6 +627,11 @@ function FieldMoves.surfFromMenu(ctx)
|
||||
or FieldMoves.directionBlocked(ctx.playerColl, ctx.facing) then
|
||||
return { ok = false, text = FieldMoves.TEXT.CANT_SURF }
|
||||
end
|
||||
-- Crystal's added `farcall CheckFacingObject`, which pokegold's :339 tags
|
||||
-- BUG (../pokecrystal/engine/events/overworld.asm:364-365).
|
||||
if ctx.facingObject and GameVersion.fixes().surfOntoNpc then
|
||||
return { ok = false, text = FieldMoves.TEXT.CANT_SURF }
|
||||
end
|
||||
return {
|
||||
ok = true, action = "surf",
|
||||
state = FieldMoves.surfType(ctx.mon),
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:102 DisplayUnownWords, and the
|
||||
-- Unown alphabet it writes out of ../pokecrystal/constants/charmap.asm:424;
|
||||
-- :1 HoOhChamber, :13 OmanyteChamber, :54 SpecialAerodactylChamber and :81
|
||||
-- SpecialKabutoChamber, the four routines that open the chambers' walls.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local UnownWords = {}
|
||||
UnownWords.__index = UnownWords
|
||||
UnownWords.isOpaque = false
|
||||
|
||||
-- engine/tilesets/map_palettes.asm:40, ../pokecrystal/constants/tileset_constants.asm:55
|
||||
UnownWords.BANK1 = 0x80
|
||||
UnownWords.BROWN = 6
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:225 .YChar, :237 .ZChar, :249 .DashChar
|
||||
local FIXED = {
|
||||
[0x60] = { 0x5b, 0x5c, 0x4d, 0x5d },
|
||||
[0x62] = { 0x4e, 0x4f, 0x5e, 0x5f },
|
||||
[0x64] = { 0x02, 0x03, 0x03, 0x02 },
|
||||
}
|
||||
|
||||
function UnownWords.square(char)
|
||||
local fixed = FIXED[char]
|
||||
if fixed then return fixed[1], fixed[2], fixed[3], fixed[4] end
|
||||
local base = UnownWords.BANK1 + char
|
||||
return base, base + 1, base + 0x10, base + 0x11
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:122-127
|
||||
function UnownWords.origin(wall)
|
||||
return (wall.x1 or 0) + 1, (wall.y1 or 0) + 2
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:119 MenuBox, home/menu.asm:131 GetMenuBoxDims
|
||||
function UnownWords.boxRect(wall)
|
||||
local x1, y1 = wall.x1 or 0, wall.y1 or 0
|
||||
return x1, y1, (wall.x2 or x1) - x1 + 1, (wall.y2 or y1) - y1 + 1
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:182 _DisplayUnownWords_CopyWord
|
||||
function UnownWords.layout(wall)
|
||||
local tx, ty = UnownWords.origin(wall)
|
||||
local out = {}
|
||||
for index, char in ipairs(wall.chars or {}) do
|
||||
local tl, tr, bl, br = UnownWords.square(char)
|
||||
out[index] = {
|
||||
tx = tx + (index - 1) * 2, ty = ty,
|
||||
tl = tl, tr = tr, bl = bl, br = br,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ../pokecrystal/constants/event_flags.asm:486-489, the four Crystal-only
|
||||
-- EVENT_WALL_OPENED_IN_*_CHAMBER bits.
|
||||
UnownWords.WALL_OPENED = {
|
||||
HO_OH = 806,
|
||||
KABUTO = 807,
|
||||
OMANYTE = 808,
|
||||
AERODACTYL = 809,
|
||||
}
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:60, :87 -- the
|
||||
-- GetMapAttributesPointer compare the two non-special routines are gated on.
|
||||
UnownWords.CHAMBER_MAPS = {
|
||||
HO_OH = "RUINS_OF_ALPH_HO_OH_CHAMBER",
|
||||
KABUTO = "RUINS_OF_ALPH_KABUTO_CHAMBER",
|
||||
OMANYTE = "RUINS_OF_ALPH_OMANYTE_CHAMBER",
|
||||
AERODACTYL = "RUINS_OF_ALPH_AERODACTYL_CHAMBER",
|
||||
}
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:4, :22
|
||||
UnownWords.HO_OH = "HO_OH"
|
||||
UnownWords.WATER_STONE = "WATER_STONE"
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:16 EventFlagAction CHECK_FLAG
|
||||
function UnownWords.wallOpened(events, chamber)
|
||||
local flag = UnownWords.WALL_OPENED[chamber]
|
||||
if not (events and flag) then return false end
|
||||
return events:get(flag) and true or false
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:8 EventFlagAction SET_FLAG
|
||||
function UnownWords.openWall(events, chamber)
|
||||
local flag = UnownWords.WALL_OPENED[chamber]
|
||||
if not (events and flag) then return false end
|
||||
if events:get(flag) then return false end
|
||||
events:set(flag, true)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:2-5: wPartySpecies[0], which
|
||||
-- holds EGG rather than the hatchling's species while a slot is an egg.
|
||||
function UnownWords.leadIsHoOh(party)
|
||||
local lead = party and party[1]
|
||||
if not lead or lead.isEgg then return false end
|
||||
return lead.species == UnownWords.HO_OH
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:28-43: wPartyCount down to 1,
|
||||
-- MON_ITEM on each, so the LAST slot holding one is the one it stops at.
|
||||
function UnownWords.waterStoneSlot(party)
|
||||
party = party or {}
|
||||
for slot = #party, 1, -1 do
|
||||
local mon = party[slot]
|
||||
if mon and mon.item == UnownWords.WATER_STONE then return slot end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:54, whose carry FlashFunction
|
||||
-- jumps on at ../pokecrystal/engine/events/overworld.asm:285.
|
||||
function UnownWords.aerodactylChamber(events, mapId)
|
||||
if mapId ~= UnownWords.CHAMBER_MAPS.AERODACTYL then return false end
|
||||
UnownWords.openWall(events, "AERODACTYL")
|
||||
return true
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:81, off the escape rope arm of
|
||||
-- EscapeRopeOrDig (../pokecrystal/engine/events/overworld.asm:809).
|
||||
function UnownWords.kabutoChamber(events, mapId)
|
||||
if mapId ~= UnownWords.CHAMBER_MAPS.KABUTO then return false end
|
||||
UnownWords.openWall(events, "KABUTO")
|
||||
return true
|
||||
end
|
||||
|
||||
-- ../pokecrystal/constants/script_constants.asm:319 UNOWNWORDS_*
|
||||
function UnownWords.wallFor(data, scriptVar)
|
||||
local walls = data and data.gen2EventTables and data.gen2EventTables.unownWalls
|
||||
if not walls then return nil end
|
||||
return walls[(scriptVar or 0) + 1]
|
||||
end
|
||||
|
||||
-- opts: wall (an events.unownWalls row), world, onClose()
|
||||
function UnownWords.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, UnownWords)
|
||||
self.game = game
|
||||
self.wall = opts.wall
|
||||
self.world = opts.world
|
||||
self.onClose = opts.onClose
|
||||
self.done = false
|
||||
self.squares = self.wall and UnownWords.layout(self.wall) or {}
|
||||
return self
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:155 _DisplayUnownWords_FillAttr
|
||||
function UnownWords:tileset()
|
||||
local world = self.world
|
||||
local map = world and world.map
|
||||
local def = map and map.def
|
||||
local tileset = def and world.tilesets and world.tilesets[def.tileset]
|
||||
if not tileset or not tileset.image then return nil end
|
||||
if self.atlas == nil then
|
||||
local ok, image = pcall(Assets.image, tileset.image)
|
||||
self.atlas = ok and image or false
|
||||
if self.atlas then self.atlas:setFilter("nearest", "nearest") end
|
||||
local set = world.palettes
|
||||
and Palettes.bgSet(world.palettes, def, world.daytime or "DAY")
|
||||
self.colors = set and set[UnownWords.BROWN] or nil
|
||||
end
|
||||
if not self.atlas then return nil end
|
||||
return tileset, self.atlas
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:147 PlayClickSFX, :148 CloseWindow
|
||||
function UnownWords:finish()
|
||||
if self.done then return end
|
||||
self.done = true
|
||||
local game = self.game
|
||||
if game and game.data then Sound.play(game.data, "SFX_READ_TEXT_2") end
|
||||
if game and game.stack then game.stack:pop() end
|
||||
if self.onClose then self.onClose() end
|
||||
end
|
||||
|
||||
function UnownWords:update(_dt)
|
||||
if self.done then return end
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then self:finish() end
|
||||
end
|
||||
|
||||
function UnownWords:draw()
|
||||
local wall = self.wall
|
||||
if not wall then return end
|
||||
local bx, by, bw, bh = UnownWords.boxRect(wall)
|
||||
Font.drawBox(bx, by, bw, bh)
|
||||
local tileset, atlas = self:tileset()
|
||||
if not tileset then return end
|
||||
local perRow = tileset.tilesPerRow or 16
|
||||
local aw, ah = atlas:getDimensions()
|
||||
local quads = {}
|
||||
local function quadFor(tile)
|
||||
local q = quads[tile]
|
||||
if not q then
|
||||
q = love.graphics.newQuad((tile % perRow) * 8,
|
||||
math.floor(tile / perRow) * 8, 8, 8, aw, ah)
|
||||
quads[tile] = q
|
||||
end
|
||||
return q
|
||||
end
|
||||
local function body()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
for _, square in ipairs(self.squares) do
|
||||
local x, y = square.tx * 8, square.ty * 8
|
||||
love.graphics.draw(atlas, quadFor(square.tl), x, y)
|
||||
love.graphics.draw(atlas, quadFor(square.tr), x + 8, y)
|
||||
love.graphics.draw(atlas, quadFor(square.bl), x, y + 8)
|
||||
love.graphics.draw(atlas, quadFor(square.br), x + 8, y + 8)
|
||||
end
|
||||
end
|
||||
if self.colors and GbcPalette.available() then
|
||||
GbcPalette.with(self.colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
return UnownWords
|
||||
+214
-25
@@ -41,6 +41,7 @@ local HiddenItems = require("src.world.gen2.HiddenItems")
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
local Map = require("src.world.gen2.Map")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local UnownWords = require("src.world.gen2.UnownWords")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Movement = require("src.script.gen2.Movement")
|
||||
local Music = require("src.core.Music")
|
||||
@@ -116,6 +117,14 @@ local VAR = {
|
||||
XCOORD = 0x12,
|
||||
YCOORD = 0x13,
|
||||
SPECIALPHONECALL = 0x14,
|
||||
-- ../pokecrystal/constants/script_constants.asm:69-74, the six rows Gold's
|
||||
-- table stops short of; ../pokecrystal/engine/overworld/variables.asm:62-67.
|
||||
BT_WIN_STREAK = 0x15,
|
||||
KURT_APRICORNS = 0x16,
|
||||
CALLERID = 0x17,
|
||||
BLUECARDBALANCE = 0x18,
|
||||
BUENASPASSWORD = 0x19,
|
||||
KENJI_BREAK = 0x1a,
|
||||
}
|
||||
|
||||
-- constants/ram_constants.asm:293 wPlayerState. PLAYER_SKATE (2) has no row:
|
||||
@@ -374,6 +383,13 @@ local function itemByIndex(items, index)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- One WRAM byte, the width every VAR_* store is (`ld a, [de]` / `ld [de], a`).
|
||||
local function byteOf(value)
|
||||
local n = math.floor(tonumber(value) or 0)
|
||||
if n < 0 then n = 0 end
|
||||
return n % 256
|
||||
end
|
||||
|
||||
-- CountSetBits over a { key = true } flag table: VAR.DEXCAUGHT, VAR.DEXSEEN
|
||||
-- and VAR.BADGES are all "how many of these are set" reads off one.
|
||||
local function countFlags(flags)
|
||||
@@ -810,6 +826,7 @@ function World:load()
|
||||
self.text = self:dataTable("gen2Text", "data/generated/text.lua") or {}
|
||||
self.constants =
|
||||
self:dataTable("gen2Constants", "data/generated/constants.lua") or {}
|
||||
FieldMoves.bindEngineFlags(self.constants.engineFlagOrder)
|
||||
-- The side tables a script command NAMES rather than carries: the phone
|
||||
-- book, the in-game trades, the elevator's floor labels and the decoration
|
||||
-- descriptions. A cache built before the extractor reached them has no
|
||||
@@ -1256,7 +1273,7 @@ function World:load()
|
||||
end,
|
||||
|
||||
-- ---- encounters --------------------------------------------------------
|
||||
setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) end,
|
||||
setSwarm = function(group, mapNum, kind) self:setSwarm(group, mapNum, kind) end,
|
||||
rollWild = function() return self:rollWild() end,
|
||||
-- The WRAM bytes the ENGINE owns rather than the script: nil means "not
|
||||
-- mine", and the VM falls back to its own sparse store.
|
||||
@@ -1573,9 +1590,78 @@ function World:readVar(varId)
|
||||
if varId == VAR.SPECIALPHONECALL then
|
||||
return self:specialCall()
|
||||
end
|
||||
if varId >= VAR.BT_WIN_STREAK and varId <= VAR.KENJI_BREAK then
|
||||
return self:crystalVar(varId)
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/overworld/variables.asm:62-67, the six .VarActionTable
|
||||
-- rows Crystal appends past VAR_SPECIALPHONECALL.
|
||||
function World:crystalVar(varId)
|
||||
-- ../pokecrystal/ram/wram.asm:3286 wCurCaller, which this port parks on the
|
||||
-- VM (src/script/gen2/CallAsm.lua:190).
|
||||
if varId == VAR.CALLERID then
|
||||
return (self.vm and self.vm.curPhoneCaller) or 0
|
||||
end
|
||||
local save = self.game and self.game.save
|
||||
if not save then return 0 end
|
||||
-- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers.
|
||||
if varId == VAR.BT_WIN_STREAK then
|
||||
return byteOf(Gen2Save.battleTowerState(save).streak)
|
||||
end
|
||||
-- ../pokecrystal/engine/events/kurt.asm:24,45 wKurtApricornQuantity.
|
||||
if varId == VAR.KURT_APRICORNS then
|
||||
return byteOf(save.kurtApricornQuantity)
|
||||
end
|
||||
local crystal = Gen2Save.crystalState(save)
|
||||
if varId == VAR.BLUECARDBALANCE then
|
||||
return byteOf(crystal.buenaPassword.balance)
|
||||
end
|
||||
if varId == VAR.BUENASPASSWORD then
|
||||
return byteOf(crystal.buenaPassword.word)
|
||||
end
|
||||
-- ../pokecrystal/engine/overworld/time.asm:136 SampleKenjiBreakCountdown.
|
||||
if varId == VAR.KENJI_BREAK then
|
||||
return byteOf(crystal.kenjiBreak)
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- The three of them Script_writevar can reach: RETVAR_ADDR_DE rows write the
|
||||
-- variable itself, RETVAR_STRBUF2 rows write the scratch buffer and are lost
|
||||
-- (../pokecrystal/engine/overworld/variables.asm:21-25).
|
||||
function World:setCrystalVar(varId, value)
|
||||
value = byteOf(value)
|
||||
if varId == VAR.CALLERID then
|
||||
if self.vm then self.vm.curPhoneCaller = value end
|
||||
return
|
||||
end
|
||||
local save = self.game and self.game.save
|
||||
if not save then return end
|
||||
local buena = Gen2Save.crystalState(save).buenaPassword
|
||||
if varId == VAR.BLUECARDBALANCE then
|
||||
buena.balance = value
|
||||
elseif varId == VAR.BUENASPASSWORD then
|
||||
buena.word = value
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/kurt.asm:19-45 SelectApricornForKurt, whose
|
||||
-- byte is what `verbosegiveitemvar <BALL>, VAR_KURT_APRICORNS` hands over.
|
||||
function World:setKurtApricornQuantity(count)
|
||||
local save = self.game and self.game.save
|
||||
if not save then return end
|
||||
save.kurtApricornQuantity = byteOf(count)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/overworld/time.asm:136-142, the 3..6 day roll.
|
||||
function World:setKenjiBreak(days)
|
||||
local save = self.game and self.game.save
|
||||
if not save then return end
|
||||
Gen2Save.crystalState(save).kenjiBreak = byteOf(days)
|
||||
end
|
||||
|
||||
-- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm
|
||||
-- GS_VERSION).
|
||||
function World:gsVersion()
|
||||
@@ -1622,6 +1708,11 @@ function World:engineFlag(flag)
|
||||
local owned = player and player[badge.store]
|
||||
return type(owned) == "table" and owned[badge.name] == true
|
||||
end
|
||||
-- Same one-store rule for ENGINE_PLAYER_IS_FEMALE, which IS wPlayerGender
|
||||
-- (../pokecrystal/data/events/engine_flags.asm:131); Gold's FEMALE_FLAG is nil.
|
||||
if flag == FieldMoves.FEMALE_FLAG then
|
||||
return Gen2Save.isFemale(save)
|
||||
end
|
||||
-- Same one-store rule for the day care. data/events/engine_flags.asm:18-20
|
||||
-- maps the three ids onto DAYCAREMAN_HAS_EGG_F / DAYCAREMAN_HAS_MON_F /
|
||||
-- DAYCARELADY_HAS_MON_F, i.e. they ARE the bits DayCare_InitBreeding,
|
||||
@@ -1661,6 +1752,13 @@ function World:setEngineFlag(flag, value)
|
||||
save.player[badge.store][badge.name] = value and true or nil
|
||||
return
|
||||
end
|
||||
-- InitGender is the only writer on the cart, so this exists only to keep a
|
||||
-- stray setflag out of save.engineFlags (../pokecrystal/engine/menus/init_gender.asm:23-42).
|
||||
if flag == FieldMoves.FEMALE_FLAG and save then
|
||||
save.player = save.player or {}
|
||||
save.player.gender = value and "female" or "male"
|
||||
return
|
||||
end
|
||||
-- The write half of the day-care aliases. DayCareManScript_Outside's
|
||||
-- `clearflag ENGINE.DAY_CARE_MAN_HAS_EGG` (maps/Route34.asm) is the ONLY cart
|
||||
-- script that writes any of the three, and it is idempotent because
|
||||
@@ -1697,6 +1795,9 @@ function World:writeVar(varId, value)
|
||||
local state = PLAYER_STATE_BY_ID[value or 0]
|
||||
if state then self:applyPlayerState(state) end
|
||||
end
|
||||
if varId >= VAR.BT_WIN_STREAK and varId <= VAR.KENJI_BREAK then
|
||||
self:setCrystalVar(varId, value)
|
||||
end
|
||||
end
|
||||
|
||||
function World:battleType()
|
||||
@@ -1854,6 +1955,16 @@ end
|
||||
-- (maps/Route36.asm:58, and again at :70 on the DidntCatchSudowoodo arm) hands
|
||||
-- the same slot to the Route 37 twins. So the pooled objects that read
|
||||
-- through the slot have to go with it.
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1564-1575 writes
|
||||
-- the sprite byte into wMapObjects, the LIVE copy, so the map def is untouched.
|
||||
function World:setObjectSprite(objectId, spriteName)
|
||||
local npc = self:objectEntity(objectId)
|
||||
local spriteDef = spriteName and self.sprites and self.sprites[spriteName]
|
||||
if not (npc and spriteDef) then return false end
|
||||
if npc:setSpriteDef(spriteDef) then self:applySpritePalette(npc) end
|
||||
return true
|
||||
end
|
||||
|
||||
function World:setVariableSprite(slot, spriteIndex)
|
||||
if slot == nil then return end
|
||||
self.variableSprites[slot] = spriteIndex
|
||||
@@ -2318,12 +2429,10 @@ end
|
||||
-- the map pair and DAILYFLAGS1_SWARM are set by the one command. A port that
|
||||
-- stored only the map would leave the Dunsparce call live forever, because
|
||||
-- CheckSwarmFlag answers off the flag and clears the pair itself.
|
||||
function World:setSwarm(group, mapNum)
|
||||
function World:setSwarm(group, mapNum, kind)
|
||||
local save = self.game and self.game.save
|
||||
if not save then return end
|
||||
save.dailyFlags = save.dailyFlags or {}
|
||||
save.dailyFlags.swarm = true
|
||||
save.swarmMap = self:mapIdByGroupMap(group, mapNum)
|
||||
Roamers.Swarm.set(save, self:mapIdByGroupMap(group, mapNum), kind)
|
||||
end
|
||||
|
||||
-- Script_loadwildmon's other half: roll the CURRENT map's own table the way a
|
||||
@@ -3472,7 +3581,7 @@ function World:specialHooks()
|
||||
takeItem = function(index, qty) return self:takeItem(index, qty) end,
|
||||
engineFlag = function(flag) return self:engineFlag(flag) end,
|
||||
setEngineFlag = function(flag, v) self:setEngineFlag(flag, v) end,
|
||||
setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) end,
|
||||
setSwarm = function(group, mapNum, kind) self:setSwarm(group, mapNum, kind) end,
|
||||
dayCare = function(side, onDone) self:dayCare(side, onDone) end,
|
||||
givePokeMail = function(mail) return self:givePokeMail(mail) end,
|
||||
checkPokeMail = function(mail, onDone) self:checkPokeMail(mail, onDone) end,
|
||||
@@ -3504,6 +3613,14 @@ function World:specialHooks()
|
||||
magnetTrain = function(toGoldenrod, onDone)
|
||||
self:magnetTrain(toGoldenrod, onDone)
|
||||
end,
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:220-223
|
||||
startTowerBattle = function(trainer, onDone)
|
||||
return self:startBattle({ trainer = trainer, battleTower = true }, onDone)
|
||||
end,
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1552-1575
|
||||
setObjectSprite = function(objectId, spriteName)
|
||||
return self:setObjectSprite(objectId, spriteName)
|
||||
end,
|
||||
pushScreen = function(id, opts) return self:pushScreen(id, opts) end,
|
||||
monName = function(index)
|
||||
local id, def = speciesByIndex(
|
||||
@@ -3537,9 +3654,36 @@ function World:specialHooks()
|
||||
self:openScriptMenu(header, "vertical", onChoose)
|
||||
end,
|
||||
rareWildMon = function() return self:rareWildMon() end,
|
||||
-- ../pokecrystal/engine/menus/save.asm:181 AskOverwriteSaveFile and :266
|
||||
-- _SaveGameData, the two halves of Link_SaveGame (:63).
|
||||
saveFileState = function() return self:saveFileState() end,
|
||||
writeSave = function() return self:writeSave() end,
|
||||
setKurtApricornQuantity = function(n) self:setKurtApricornQuantity(n) end,
|
||||
setKenjiBreak = function(days) self:setKenjiBreak(days) end,
|
||||
}
|
||||
end
|
||||
|
||||
-- AskOverwriteSaveFile's two reads: wSaveFileExists, and
|
||||
-- CompareLoadedAndSavedPlayerID (../pokecrystal/engine/menus/save.asm:224),
|
||||
-- which is what picks AlreadyASaveFileText over AnotherSaveFileText.
|
||||
function World:saveFileState()
|
||||
local save = self.game and self.game.save
|
||||
local version = save and save.version
|
||||
if not Gen2Save.exists(version) then return false, false end
|
||||
local stored = Gen2Save.load(version)
|
||||
local mine = save and save.player and save.player.id
|
||||
local theirs = stored and stored.player and stored.player.id
|
||||
return true, (mine ~= nil and mine == theirs)
|
||||
end
|
||||
|
||||
-- _SaveGameData, through the writer the SAVE menu is handed
|
||||
-- (src/core/Game2.lua:435) so the save.write veto holds here too.
|
||||
function World:writeSave()
|
||||
local game = self.game
|
||||
if not (game and game.writeSave) then return false end
|
||||
return game:writeSave() ~= false
|
||||
end
|
||||
|
||||
-- RandomUnseenWildMon's lookup half. The routine picks one of the THREE
|
||||
-- RAREST grass slots on the map (`and %11 / jr z` rerolls 0, so it is slots 5,
|
||||
-- 6 or 7 of the seven) and drops it if that species is also one of the FOUR
|
||||
@@ -4441,6 +4585,9 @@ function World:useEscapeRope(itemId)
|
||||
local items = self.game and self.game.data and self.game.data.items
|
||||
local def = items and items[itemId or "ESCAPE_ROPE"]
|
||||
self:takeItem(def and def.index, 1)
|
||||
-- ../pokecrystal/engine/events/overworld.asm:809, between .escaperope and
|
||||
-- QueueScript.
|
||||
UnownWords.kabutoChamber(self.events, self.map and self.map.id)
|
||||
self.queuedFieldMove = {
|
||||
ok = true, action = "escaperope",
|
||||
destMap = destMapId, destWarp = destWarp,
|
||||
@@ -5186,6 +5333,9 @@ function World:fieldContext(mon)
|
||||
facingX = fx, facingY = fy,
|
||||
facingColl = map:cellCollision(fx, fy),
|
||||
playerColl = map:cellCollision(p.cellX, p.cellY),
|
||||
-- Crystal's SurfFunction.TrySurf is the only field move that asks
|
||||
-- (../pokecrystal/engine/events/overworld.asm:364).
|
||||
facingObject = self:facingObject(),
|
||||
upColl = map:cellCollision(p.cellX, p.cellY - 1),
|
||||
tileset = map.def and map.def.tileset,
|
||||
facingBlock = blockId,
|
||||
@@ -5199,6 +5349,11 @@ function World:fieldContext(mon)
|
||||
-- FlashFunction tests wTimeOfDayPalset, not the map header, so a
|
||||
-- PALETTE_DARK map that FLASH has already lit refuses a second FLASH.
|
||||
dark = Palettes.isDarkness(map.def, self:hour(), self.flashUsed),
|
||||
-- ../pokecrystal/engine/events/overworld.asm:285, called by FLASH only and
|
||||
-- only after the badge gate, because it SETS the wall-opened flag.
|
||||
openAerodactylWall = function()
|
||||
return UnownWords.aerodactylChamber(self.events, map.id)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -5328,12 +5483,26 @@ function World:refreshMapImages()
|
||||
return true
|
||||
end
|
||||
|
||||
-- wPlayerGender, the byte GetPlayerSprite and AddMapObject both branch on
|
||||
-- (engine/overworld/overworld.asm:61-64, engine/overworld/player_object.asm:32-39).
|
||||
function World:playerGender()
|
||||
local save = self.game and self.game.save
|
||||
return save and save.player and save.player.gender or nil
|
||||
end
|
||||
|
||||
-- The Chris/Kris sheet the player wears with no state on it
|
||||
-- (data/sprites/player_sprites.asm:2, :9).
|
||||
function World:playerSpriteName()
|
||||
return FieldMoves.playerSprite(self:playerGender()) or PLAYER_SPRITE
|
||||
end
|
||||
|
||||
-- UpdatePlayerSprite (data/sprites/player_sprites.asm ChrisStateSprites): the
|
||||
-- player's sprite is a pure function of wPlayerState, which is what makes
|
||||
-- getting on and off a Lapras a one-byte change rather than an animation.
|
||||
function World:applyPlayerState(state)
|
||||
self.playerState = state or FieldMoves.PLAYER_NORMAL
|
||||
local name = FieldMoves.STATE_SPRITE[self.playerState] or PLAYER_SPRITE
|
||||
local name = FieldMoves.stateSprite(self.playerState, self:playerGender())
|
||||
or PLAYER_SPRITE
|
||||
local def = self.sprites and self.sprites[name]
|
||||
if def and self.player then
|
||||
self.player:setSprite(def)
|
||||
@@ -5884,6 +6053,9 @@ function World:startBattle(opts, onDone)
|
||||
-- wBattleType, when the script armed one: the FORCESHINY / TRAP
|
||||
-- no-escape rules live in Battle:tryRun and the force-switch handler.
|
||||
battleType = opts.battleType,
|
||||
-- wInBattleTowerBattle (../pokecrystal/engine/events/battle_tower/
|
||||
-- battle_tower.asm:220-223), which turns DoBadgeTypeBoosts off.
|
||||
battleTower = opts.battleTower,
|
||||
})
|
||||
self:playBattleMusic(opts)
|
||||
local function pushBattle()
|
||||
@@ -7397,26 +7569,43 @@ function World:interact()
|
||||
return self:interactBody()
|
||||
end
|
||||
|
||||
-- CheckFacingObject (engine/overworld/npc_movement.asm:229-248): "Double the
|
||||
-- distance for counter tiles." A Pokecenter nurse and a Mart clerk stand
|
||||
-- BEHIND a COLL_COUNTER tile, so the cell the player faces is the counter
|
||||
-- itself and the object is one further on. Without this the press finds an
|
||||
-- empty wall and nothing happens -- which is to say no nurse and no clerk in
|
||||
-- the game could be talked to at all.
|
||||
--
|
||||
-- Only the OBJECT lookup is doubled, exactly as the cart does it: bg events
|
||||
-- and the tile-collision events still read the tile actually faced.
|
||||
function World:facingObjectCell()
|
||||
local p = self.player
|
||||
if not p then return nil end
|
||||
local d = Map.DELTA[p.facing] or Map.DELTA.down
|
||||
local fx, fy = p.cellX + d[1], p.cellY + d[2]
|
||||
if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then
|
||||
return p.cellX + d[1] * 2, p.cellY + d[2] * 2
|
||||
end
|
||||
return fx, fy
|
||||
end
|
||||
|
||||
-- The carry CheckFacingObject answers with: IsNPCAtCoord, and then only when
|
||||
-- that object's OBJECT_WALKING reads STANDING (npc_movement.asm:250-266).
|
||||
function World:facingObject()
|
||||
local ox, oy = self:facingObjectCell()
|
||||
if not ox then return nil end
|
||||
local npc = self:npcAt(ox, oy)
|
||||
if npc and npc.moving then return nil end
|
||||
return npc
|
||||
end
|
||||
|
||||
function World:interactBody()
|
||||
if self:busy() or not self.player or not self.vm then return false end
|
||||
local p = self.player
|
||||
if p.moving then return false end
|
||||
local d = Map.DELTA[p.facing]
|
||||
local fx, fy = p.cellX + d[1], p.cellY + d[2]
|
||||
-- CheckFacingObject (engine/overworld/npc_movement.asm:229): "Double the
|
||||
-- distance for counter tiles." A Pokecenter nurse and a Mart clerk stand
|
||||
-- BEHIND a COLL_COUNTER tile, so the cell the player faces is the counter
|
||||
-- itself and the object is one further on. Without this the press finds an
|
||||
-- empty wall and nothing happens -- which is to say no nurse and no clerk in
|
||||
-- the game could be talked to at all.
|
||||
--
|
||||
-- Only the OBJECT lookup is doubled, exactly as the cart does it: bg events
|
||||
-- and the tile-collision events below still read the tile actually faced.
|
||||
local ox, oy = fx, fy
|
||||
if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then
|
||||
ox, oy = p.cellX + d[1] * 2, p.cellY + d[2] * 2
|
||||
end
|
||||
local npc = self:npcAt(ox, oy)
|
||||
local npc = self:npcAt(self:facingObjectCell())
|
||||
-- TryObjectEvent writes hLastTalked for EVERY A-press dispatch; scripts
|
||||
-- then use LAST_TALKED (`disappear`, `applymovementlasttalked`) without any
|
||||
-- setlasttalked of their own. The port only wrote it from the explicit
|
||||
@@ -8510,13 +8699,13 @@ function World:setMap(mapId, cx, cy, facing, opts)
|
||||
-- GetWarpDestCoords / EnterMapConnection / EnterMapSpawnPoint write wXCoord
|
||||
-- and wYCoord BEFORE HandleNewMap (data/maps/setup_scripts.asm:79-106).
|
||||
local face = facing or (self.player and self.player.facing) or "down"
|
||||
local chris = self.sprites and self.sprites[PLAYER_SPRITE]
|
||||
local playerDef = self.sprites and self.sprites[self:playerSpriteName()]
|
||||
if self.player then
|
||||
self.player.cellX, self.player.cellY = cx, cy
|
||||
self.player.px, self.player.py = cx * 16, cy * 16
|
||||
self.player.facing = face
|
||||
if chris and not self.player.sprite then
|
||||
self.player:setSprite(chris)
|
||||
if playerDef and not self.player.sprite then
|
||||
self.player:setSprite(playerDef)
|
||||
end
|
||||
if not opts.seamless then
|
||||
self.player.moving = false
|
||||
@@ -8524,7 +8713,7 @@ function World:setMap(mapId, cx, cy, facing, opts)
|
||||
self.player.targetX, self.player.targetY = nil, nil
|
||||
end
|
||||
else
|
||||
self.player = Player.new(cx, cy, face, chris)
|
||||
self.player = Player.new(cx, cy, face, playerDef)
|
||||
end
|
||||
-- LoadMapObjects rebuilds OBJECT_FLAGS2 from scratch, so IN_GRASS is decided
|
||||
-- by the cell the player arrives on (engine/overworld/map_objects.asm:247).
|
||||
|
||||
Reference in New Issue
Block a user