Files
gen1recomp/src/ui/gen2/Pokegear.lua
T
bryanthaboi 6731937841 Pulled every open Gold bug (bodies, comments, screenshots/videos), matched them against the port and ../pokegold, then split them so each fixer owns a disjoint file set. Duplicates collapsed: #1164 and #1188 are the same Radio Tower stair softlock. #1150 and #1190 are the same Gold rebind freeze. Enhancements (#1161, #1130, #1112, #1100) were left out. Battle 1 src/battle/gen2/*, src/ui/gen2/BattleState.lua #1168 multi-hit always 2 #1180 DV / stats #1185 learn-move text order after a trainer fight #1152 no "Use next POKéMON?" / run after a wild faint Overworld 2 src/world/gen2/*, trade anim, hidden items #1184 Kurt blocking Slowpoke Well #1164 / #1188 Radio Tower gentleman on the stairs #1173 Rock Smash rocks walk left each visit #1172 last item name used Rock Smash #1167 Ilex Forest hidden items #1165 trade movie + ledge hop still missing (#1121 / #1126) UI 3 naming, party, mart, pokegear #1166 grey nickname screen #1162 TM/HM missing ABLE / NOT ABLE #1169 underground herb lady whites out the map #1151 Pokegear black player icon + extra A/B on the phone card Platform 4 Gold options, save slots, pack, mod tab #1150 / #1190 controls freeze #1178 no vibration row #1177 cannot move Gold touch controls #1107 SAVE with no launcher slot #1145 Gold mod tab still lists Gen 1-only mods #1192 RGXX missing tools/rom_manifest_gold.json Drivers only for the progress / data-loss ones (faint-run, multi-hit, Kurt, Radio Tower, Rock Smash shift, herb shop, save slot). No new unit tests. New comments are pokegold file.asm:line only. A verifier agent will get this same list once the four finish and check that the diffs actually fix the reports. Verifier checked the batch against pokegold. Almost everything landed. I closed the two leftovers that were still real bugs: #1185: the active mon no longer reprints GrewToLevel (engine/battle/core.asm:7044). Bench mons still print it, then the stats box, then the learn-move line. #1180: the party list recalcs stats on open, same as Summary. Gold boot now applies options.touchControls / haptics from the Gold options block, not the shared Red pad. # Verdict 1168 multi-hit FIXED 1180 DVs / stats FIXED 1185 learn-move order FIXED (was partial) 1152 run after faint FIXED 1184 Kurt FIXED 1164 / 1188 Radio Tower FIXED 1173 rocks slide FIXED 1172 Rock Smash name FIXED 1167 Ilex hidden items SKIP-OK (already in the Gold cache) 1165 trade + ledge FIXED 1166 nickname color FIXED 1162 TM ABLE / NOT ABLE FIXED 1169 herb shop white-out FIXED 1151 Pokegear FIXED 1150 / 1190 rebind freeze FIXED 1178 vibration FIXED 1177 touch layout FIXED 1107 save slot FIXED 1145 mod tab filter FIXED 1192 RGXX Gold manifest FIXED Nothing is committed. Drivers, from the repo root: POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/multihit_bug1168_test.lua love . Fury Attack should hit 2-5 times. Always twice is still broken. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/use_next_mon_bug1152_test.lua love . After the lead faints: "Use next POKéMON?" YES opens the party. NO/B tries to run. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/kurt_well_bug1184_test.lua love . Kurt at the well entrance (16, 14), not on the inner path. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/radiotower_softlock_bug1164_test.lua love . Gentleman in the 5F office (3, 6). Stairs at (12, 0) clear. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/rocksmash_shift_bug1173_test.lua love . Route 40 rocks stay put. A slide one cell left is the old bug. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/herbshop_bug1169_test.lua love . Herb shop intro over the Underground map, not a white screen. POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-bug1107 POKEPORT_DRIVER=tests/drivers/gold_save_slot_bug1107_test.lua love . Look for PASS and a saves/gold/slot*.lua path. CONTINUE should see it. Worth a hand check with no driver: nickname color, a TM for ABLE/NOT ABLE, Pokegear phone B-to-close, OPTION > CONTROLS, a ledge hop, a trade movie, and on a phone OPTION > TOUCH LAYOUT / VIBRATION. give me CLOSES #93939, in a comma separated list each saying CLOSES before each issue number that is fixed CLOSES #1107, CLOSES #1145, CLOSES #1150, CLOSES #1151, CLOSES #1152, CLOSES #1162, CLOSES #1164, CLOSES #1165, CLOSES #1166, CLOSES #1168, CLOSES #1169, CLOSES #1172, CLOSES #1173, CLOSES #1177, CLOSES #1178, CLOSES #1180, CLOSES #1184, CLOSES #1185, CLOSES #1188, CLOSES #1190, CLOSES #1192
2026-08-12 16:51:27 -04:00

2317 lines
92 KiB
Lua

-- The POKeGEAR (engine/pokegear/pokegear.asm): Gen 2's clock, town map, radio
-- and phone in one device.
--
-- Transcribed rather than laid out by eye. A Pokegear card is not a layout at
-- all -- it is a tilemap. InitPokegearTilemap fills the screen with $4f, runs
-- the card's own entry, and then Pokegear_FinishTilemap lays the card strip
-- across the top two rows. Three of the four cards are stored as RLE
-- tilemaps in the ROM (Pokegear_LoadTilemapRLE, whose comment has its own
-- format backwards: the first byte is the tile and the second the count) and
-- the MAP card is the painted region map, JohtoMap / KantoMap.
--
-- Both sheets live in one 96-tile block: Pokegear_LoadGFX puts TownMapGFX at
-- vTiles2 $00 and PokegearGFX at $30, so a tilemap byte under $30 is town map
-- art and anything above it is gear chrome. Colour is by tile id rather than
-- by rectangle -- TownMapPals walks the tilemap and reads a nybble per tile
-- out of its PalMap, with $60 and up falling back to palette 0.
--
-- Card strip (Pokegear_FinishTilemap): the two rows are cleared to $4f, then
-- each owned card's 2x2 icon is laid as n, n+1 / n+$10, n+$11 -- MAP at (2,0)
-- from $40, PHONE at (4,0) from $44, RADIO at (6,0) from $42, and the gear
-- itself at (0,0) from $46.
local Chrome = require("src.ui.gen2.Chrome")
local Clock = require("src.core.gen2.Clock")
local Font = require("src.render.Font")
local Palettes = require("src.world.gen2.Palettes")
local Phone = require("src.core.gen2.Phone")
local SpriteRenderer = require("src.render.SpriteRenderer")
local TileSheet = require("src.ui.gen2.TileSheet")
local Pokegear = {}
Pokegear.__index = Pokegear
Pokegear.isOpaque = true
local SCREEN_W, SCREEN_H = 20, 18
local BLANK_TILE = 0x4f
-- POKEGEARCARD_* order (constants/pokegear_constants.asm: CLOCK, MAP, PHONE,
-- RADIO), with the icon each one contributes to the strip. The order is load
-- bearing twice over: paging steps this list, and every joypad routine walks
-- the cards in exactly this sequence (PokegearClock_Joypad right -> MAP else
-- PHONE else RADIO, PokegearRadio_Joypad left -> PHONE else MAP else CLOCK),
-- while AnimatePokegearModeIndicatorArrow indexes its $00/$10/$20/$30 x offsets
-- by wPokegearCard. Listing RADIO before PHONE made the arrow jump column 2 ->
-- 6 -> 4.
local CARDS = {
{ id = "clock", label = "CLOCK", icon = 0x46, iconX = 0 },
{ id = "map", label = "MAP", flag = "map", icon = 0x40, iconX = 2 },
{ id = "phone", label = "PHONE", flag = "phone", icon = 0x44, iconX = 4 },
{ id = "radio", label = "RADIO", flag = "radio", icon = 0x42, iconX = 6 },
}
-- _FlyMap draws the SAME town map, but it is not the MAP card: it is its own
-- screen (LoadTownMapGFX / FlyMap / TownMapBubble), with no card strip and no
-- ENGINE_MAP_CARD gate, which is why FLY works before the Guide Gent hands the
-- card over. One row, so `#self.cards` stays 1 and nothing pages.
local FLY_MAP_CARD = { id = "map", label = "FLY" }
local DAYS = {
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
}
-- ---------------------------------------------------------------- the radio
--
-- engine/pokegear/radio.asm is not a text table: it is a jumptable of code.
-- Every station is a little state machine whose segments each print ONE line
-- and name the segment that prints the next, so the only way to get the shows
-- right is to port the shows. What follows transcribes RadioJumptable row by
-- row; the segment names are the constants/radio_constants.asm ones so a row
-- here can be read against the routine it came from.
--
-- Three pieces of RAM drive all of it (ram/wram.asm):
-- wCurRadioLine which segment runs this frame
-- wNextRadioLine which segment RADIO_SCROLL hands control to
-- wRadioTextDelay frames left before RADIO_SCROLL proceeds
-- wNumRadioLinesPrinted 0, 1 or 2 -- how much of the box is filled yet
-- and the display is the bottom text box's two lines, which
-- CopyBottomLineToTopLine scrolls up as each new line lands.
-- Channel ids. The first ten are stations (NUM_RADIO_CHANNELS); everything
-- above is an internal segment. The numbers matter in exactly one place --
-- PlayRadioShow's `cp POKE_FLUTE_RADIO` Rocket override -- so they are kept.
local RADIO_ID = {
OAKS_POKEMON_TALK = 0x00, POKEDEX_SHOW = 0x01, POKEMON_MUSIC = 0x02,
LUCKY_CHANNEL = 0x03, PLACES_AND_PEOPLE = 0x04, LETS_ALL_SING = 0x05,
ROCKET_RADIO = 0x06, POKE_FLUTE_RADIO = 0x07, UNOWN_RADIO = 0x08,
EVOLUTION_RADIO = 0x09,
}
-- data/radio/channel_music.asm, in the port's own song labels: the port names
-- songs the pokegold way (Music_*), not by the MUSIC_* constant.
local RADIO_CHANNEL_SONGS = {
OAKS_POKEMON_TALK = "Music_ProfOaksPokemonTalk", -- MUSIC_POKEMON_TALK
POKEDEX_SHOW = "Music_PokemonCenter", -- MUSIC_POKEMON_CENTER
POKEMON_MUSIC = "Music_TitleScreen", -- MUSIC_TITLE
LUCKY_CHANNEL = "Music_GameCorner", -- MUSIC_GAME_CORNER
PLACES_AND_PEOPLE = "Music_ViridianCity", -- MUSIC_VIRIDIAN_CITY
LETS_ALL_SING = "Music_Bicycle", -- MUSIC_BICYCLE
ROCKET_RADIO = "Music_RocketTheme", -- MUSIC_ROCKET_OVERTURE
POKE_FLUTE_RADIO = "Music_PokeFluteChannel",
UNOWN_RADIO = "Music_RuinsOfAlphRadio",
EVOLUTION_RADIO = "Music_LakeOfRageRocketRadio",
}
-- The station names LoadStation_* hands the tuner (the *Name labels at the
-- bottom of pokegear.asm). `#` is the four-tile POKé compression byte, so
-- spelling it out costs the same tiles; `<PKMN>` is NOT -- it is the two-tile
-- <PK><MN> ligature the font already carries, so it stays a ligature.
-- LoadStation_RocketRadio really does reuse LetsAllSingName, and
-- LoadStation_EvolutionRadio really does reuse UnownStationName.
local STATION_NAMES = {
OAKS_POKEMON_TALK = "OAK's <PK><MN> Talk",
POKEDEX_SHOW = "POKéDEX Show",
POKEMON_MUSIC = "POKéMON Music",
LUCKY_CHANNEL = "Lucky Channel",
PLACES_AND_PEOPLE = "Places & People",
LETS_ALL_SING = "Let's All Sing!",
ROCKET_RADIO = "Let's All Sing!",
POKE_FLUTE_RADIO = "POKé FLUTE",
UNOWN_RADIO = "?????",
EVOLUTION_RADIO = "?????",
}
-- OaksPKMNTalk8.Adverbs, in table order: `maskbits 16` makes every roll valid,
-- which is why there is no retry loop around it.
local OPT_ADVERBS = {
"sweet and adorably", "wiggly and slickly", "aptly named and",
"undeniably kind of", "so, so unbearably", "wow, impressively",
"almost poisonously", "ooh, so sensually", "so mischievously",
"so very topically", "sure addictively", "looks in water is",
"evolution must be", "provocatively", "so flipped out and",
"heart-meltingly",
}
-- OaksPKMNTalk9.Adjectives.
local OPT_ADJECTIVES = {
"cute.", "weird.", "pleasant.", "bold, sort of.", "frightening.",
"suave & debonair!", "powerful.", "exciting.", "now!", "inspiring.",
"friendly.", "hot, hot, hot!", "stimulating.", "guarded.", "lovely.",
"speedy.",
}
-- PeoplePlaces5.Adjectives and PeoplePlaces7.Adjectives are the same sixteen
-- rows in the same order, so one table serves both.
local PNP_ADJECTIVES = {
"is cute.", "is sort of lazy.", "is always happy.", "is quite noisy.",
"is precocious.", "is somewhat bold.", "is too picky!", "is sort of OK.",
"is just so-so.", "is actually great.", "is just my type.",
"is so cool, no?", "is inspiring!", "is kind of weird.",
"is right for me?", "is definitely odd!",
}
-- RocketRadioText1..10. The text_pause bytes inside 7-10 only stall the
-- printer, so the port carries the words either side of them as one line.
local ROCKET_LINES = {
"… …Ahem, we are", "TEAM ROCKET!", "After three years",
"of preparation, we", "have risen again", "from the ashes!",
"GIOVANNI! Can you", "hear? We did it!", "Where is our Boss?",
"Is he listening?",
}
-- data/radio/oaks_pkmn_talk_routes.asm: the fifteen maps Oak's Pokemon Talk
-- draws its wild mon from. `and %11111` then `cp 15` is a rejection roll, so
-- a byte above 14 is thrown away rather than folded.
local OPT_ROUTES = {
"ROUTE_29", "ROUTE_46", "ROUTE_30", "ROUTE_32", "ROUTE_34", "ROUTE_35",
"ROUTE_37", "ROUTE_38", "ROUTE_39", "ROUTE_42", "ROUTE_43", "ROUTE_44",
"ROUTE_45", "ROUTE_36", "ROUTE_31",
}
-- data/radio/pnp_places.asm. Two of the nine are interiors picked purely for
-- the landmark they sit in (the Cerulean police station stands in for
-- CERULEAN CITY, the beta Cinnabar centre for CINNABAR ISLAND).
local PNP_PLACES = {
"PALLET_TOWN", "ROUTE_22", "PEWTER_CITY", "CERULEAN_POLICE_STATION",
"ROUTE_12", "ROUTE_11", "ROUTE_16", "ROUTE_14",
"CINNABAR_POKECENTER_2F_BETA",
}
-- data/radio/pnp_hidden_people.asm is one list with two interior labels, and
-- the fallthrough is load bearing: PnP_HiddenPeople runs into
-- PnP_HiddenPeople_BeatE4, which runs into PnP_HiddenPeople_BeatKanto. So
-- beating the Elite Four un-hides the E4 themselves, and sweeping Kanto's
-- badges un-hides its gym leaders too.
local PNP_HIDDEN = {
"WILL", "BRUNO", "KAREN", "KOGA", "CHAMPION",
-- PnP_HiddenPeople_BeatE4
"BROCK", "MISTY", "LT_SURGE", "ERIKA", "JANINE", "SABRINA", "BLAINE",
"BLUE",
-- PnP_HiddenPeople_BeatKanto
"RIVAL1", "POKEMON_PROF", "CAL", "RIVAL2", "RED",
}
local PNP_HIDDEN_BEAT_E4 = 6 -- first index of PnP_HiddenPeople_BeatE4
local PNP_HIDDEN_BEAT_KANTO = 14 -- first index of PnP_HiddenPeople_BeatKanto
-- TextCommand_DAY's .Days table, plus its "DAY" suffix. GetWeekday counts
-- from Sunday = 0, which is NOT os.date's 1-based wday.
local RADIO_DAYS = {
[0] = "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY",
"SATURDAY",
}
-- macros/data.asm: `percent` is `* $ff / 100`, so these are the two literal
-- thresholds PeoplePlaces rolls against.
local PNP_PEOPLE_CHANCE = math.floor(49 * 255 / 100) - 1 -- 49 percent - 1
local PNP_RESTART_CHANCE = math.floor(4 * 255 / 100) -- 4 percent
-- A landmark name is stored with its town-map line break in it
-- ("BLACKTHORN\nCITY"). GetLandmarkName hands that to the radio verbatim,
-- break byte and all, which on hardware throws the rest of the sentence onto
-- the box's second line; the port spends the break as a space instead so the
-- sentence stays one radio line.
local function flatName(name)
return (tostring(name or ""):gsub("\n", " "))
end
-- An hlcoord is a column of TILES, and the port stores its strings as UTF-8,
-- so "é" is two bytes and one tile. Counting lead bytes gets the column
-- arithmetic right; it is only ever used on plain text, never on the <PK><MN>
-- ligature markers Font.split expands.
local function tileWidth(text)
local width = 0
for _ in tostring(text or ""):gmatch("[^\128-\191]") do width = width + 1 end
return width
end
-- --------------------------------------------------------- the show machine
--
-- Radio is deliberately free of love, of Game and of drawing: it is the
-- jumptable and its four bytes of RAM, stepped one frame at a time, so a test
-- can seed `rng` and assert the exact line sequence a station produces.
--
-- `data` is the cache read the shows need, gathered by Pokegear:radioData():
-- landmarks[index] = { name } GetLandmarkName
-- mapLandmark[map] = landmark index GetWorldMapLocation
-- grass[map] = { [0]=morn, [1]=day, [2]=nite } lists of species
-- species[index] = name GetPokemonName
-- dex[name] = { kind, lines } the Pokedex entry, split at <NEXT>
-- classes[index] = { name, trainer } GetTrainerClassName / GetTrainerName
-- hidden[index] = true the resolved PnP_HiddenPeople list
-- caught(name) -> boolean CheckCaughtMon
-- weekday = 0..6 GetWeekday
-- luckyNumber = wLuckyIDNumber
local Radio = {}
Radio.__index = Radio
-- PlaceRadioString and PrintRadioLine both set wRadioTextDelay to 100; the
-- Pokemon Channel jingle's last hop uses 10.
local RADIO_LINE_FRAMES = 100
local RADIO_JINGLE_FRAMES = 10
local RadioJumptable = {}
function Radio.new(opts)
opts = opts or {}
local self = setmetatable({}, Radio)
self.data = opts.data or {}
-- `call Random` yields one byte. Without a supplied source the port falls
-- back to math.random, which is fine for play and useless for a test.
self.rng = opts.rng or function() return math.random(0, 255) end
-- wCurRadioLine / wNextRadioLine / wRadioTextDelay / wNumRadioLinesPrinted.
self.cur = nil
self.next = nil
self.delay = 0
self.printed = 0
-- The bottom text box's two lines.
self.top, self.bottom = "", ""
-- Every line this station has printed, oldest first: the scroll throws the
-- top one away, and a test wants the whole sequence.
self.log = {}
-- wCurPartySpecies, wStringBuffer1/2 and wOaksPKMNTalkSegmentCounter, which
-- survive across segments because the shows read them a segment later.
self.vars = {}
-- The label RadioMusicRestartDE was last asked for, so the caller can play
-- it without the machine knowing what an audio system is.
self.music = nil
return self
end
-- `call Random`: one byte, 0..255.
function Radio:random()
return math.floor(self.rng() or 0) % 256
end
-- Half the shows sample by rejection: roll a byte, throw it away if it is out
-- of range, roll again. On hardware those loops are unbounded because
-- `call Random` never repeats itself; the port cannot promise that of an
-- injected roll source, so every sampler is capped. A sampler that runs out
-- of tries leaves its segment without printing, and the next frame retries the
-- whole segment -- which is what the cart would do with a stuck RNG anyway.
local RADIO_SAMPLE_TRIES = 512
function Radio:sample(pick)
for _ = 1, RADIO_SAMPLE_TRIES do
local value = pick(self:random())
if value ~= nil then return value end
end
return nil
end
-- LoadStation_*: every one of them parks the station id in wCurRadioLine and
-- zeroes wNumRadioLinesPrinted, which is what makes StartRadioStation fire
-- once and once only.
function Radio:tune(station)
self.cur = station
self.next = nil
self.delay = 0
self.printed = 0
self.top, self.bottom = "", ""
self.log = {}
self.vars = {}
self.music = nil
end
-- StartRadioStation: on the first frame of a station only, clear the box and
-- start RadioChannelSongs[wCurRadioLine].
function Radio:startStation()
if self.printed ~= 0 then return end
self.top, self.bottom = "", ""
self.music = RADIO_CHANNEL_SONGS[self.cur]
end
-- PrintRadioLine. The first line lands on the box's top row, the second on
-- its bottom row (`bccoord 1, 16`), and every line after that on the bottom
-- row with RADIO_SCROLL having moved the previous one up first.
function Radio:printLine(text, nextLine)
self.next = nextLine
-- wRadioText itself: the buffer keeps the last line composed into it, which
-- is what OaksPKMNTalk4's .overflow branch reprints.
self.text = text
if self.printed < 2 then
self.printed = self.printed + 1
if self.printed == 1 then self.top = text else self.bottom = text end
else
self.bottom = text
end
self.log[#self.log + 1] = text
self.cur = "RADIO_SCROLL"
self.delay = RADIO_LINE_FRAMES
end
-- NextRadioLine is PrintRadioLine with a CopyRadioTextToRAM in front of it.
-- The copy is invisible here (the port passes the composed line straight in),
-- but the two are kept apart because several segments compose the text a
-- segment early and then reach PrintRadioLine directly.
Radio.nextLine = Radio.printLine
-- PlaceRadioString: no scroll, no line counter -- it stamps a string into the
-- box where it stands and waits 100 frames. Only the Pokemon Channel jingle
-- uses it, which is why the jingle does not scroll.
function Radio:placeString(nextLine)
self.cur = nextLine
self.delay = RADIO_LINE_FRAMES
end
-- One frame of PlayRadioShow.
function Radio:step()
-- Team Rocket broadcasts on every station: a station id below
-- POKE_FLUTE_RADIO, in Johto, with the tower occupied, is overwritten. The
-- comparison is against the id, so mid-show segments (all $0a and up) never
-- trigger it -- the takeover can only happen between shows.
local id = RADIO_ID[self.cur]
if id and id < RADIO_ID.POKE_FLUTE_RADIO
and self.data.rocketsInRadioTower and self.data.inJohto then
self.cur = "ROCKET_RADIO"
end
local segment = RadioJumptable[self.cur]
if segment then segment(self) end
end
-- RadioScroll: burn the delay, then hand over to wNextRadioLine. The
-- `cp 1` skips the copy for the very first line, which is still sitting on
-- the top row and has nothing under it to scroll up.
RadioJumptable["RADIO_SCROLL"] = function(R)
if R.delay ~= 0 then
R.delay = R.delay - 1
return
end
R.cur = R.next
if R.printed ~= 1 then R.top = R.bottom end
R.bottom = ""
end
-- ----------------------------------------------------- Oak's Pokemon Talk
--
-- Five wild-mon segments (wOaksPKMNTalkSegmentCounter) and then the Pokemon
-- Channel jingle, which resets the counter and drops back into the fourth
-- segment for another five.
RadioJumptable["OAKS_POKEMON_TALK"] = function(R)
R.vars.segmentCounter = 5
R:startStation()
R:nextLine("MARY: PROF.OAK'S", "OAKS_POKEMON_TALK_2")
end
RadioJumptable["OAKS_POKEMON_TALK_2"] = function(R)
R:nextLine("POKéMON TALK!", "OAKS_POKEMON_TALK_3")
end
RadioJumptable["OAKS_POKEMON_TALK_3"] = function(R)
R:nextLine("With me, MARY!", "OAKS_POKEMON_TALK_4")
end
-- OaksPKMNTalk4: roll a route, roll a time of day, roll one of the middle
-- three grass slots, and name the mon and the route it walks.
RadioJumptable["OAKS_POKEMON_TALK_4"] = function(R)
-- .sample: `and %11111` then `cp 15`, so a byte above the table's length is
-- rerolled rather than wrapped.
local map = R:sample(function(roll)
roll = roll % 32
if roll < #OPT_ROUTES then return OPT_ROUTES[roll + 1] end
return nil
end)
if not map then return end
local slots = R.data.grass and R.data.grass[map]
if not slots then
-- .overflow: a map with no row in JohtoGrassWildMons restarts the show
-- from its intro, reprinting whatever wRadioText still holds.
return R:printLine(R.text or "", "OAKS_POKEMON_TALK")
end
-- .loop2: `maskbits NUM_DAYTIMES` then reject DARKNESS_F, so 0, 1 or 2.
local daytime = R:sample(function(roll)
roll = roll % 4
return roll ~= 3 and roll or nil
end)
-- .loop3: `maskbits NUM_GRASSMON` then reject below 2 and 5 or above, which
-- leaves the middle three of the seven slots.
local slot = R:sample(function(roll)
roll = roll % 8
return (roll >= 2 and roll < 5) and roll or nil
end)
if not (daytime and slot) then return end
local list = slots[daytime] or slots[0] or {}
local species = list[slot + 1]
R.vars.species = species
R.vars.landmark = R.data.mapLandmark and R.data.mapLandmark[map]
-- _OPT_OakText1 is "OAK: @" plus the mon name, with no punctuation: the
-- sentence is finished by the next two segments.
R:printLine("OAK: " .. tostring(species or ""), "OAKS_POKEMON_TALK_5")
end
RadioJumptable["OAKS_POKEMON_TALK_5"] = function(R)
R:nextLine("may be seen around", "OAKS_POKEMON_TALK_6")
end
RadioJumptable["OAKS_POKEMON_TALK_6"] = function(R)
-- _OPT_OakText3 is the landmark name with a full stop welded on.
local entry = R.data.landmarks and R.data.landmarks[R.vars.landmark]
R:nextLine(flatName(entry and entry.name) .. ".", "OAKS_POKEMON_TALK_7")
end
RadioJumptable["OAKS_POKEMON_TALK_7"] = function(R)
R:nextLine("MARY: " .. tostring(R.vars.species or "") .. "'s",
"OAKS_POKEMON_TALK_8")
end
RadioJumptable["OAKS_POKEMON_TALK_8"] = function(R)
local adverb = OPT_ADVERBS[R:random() % 16 + 1]
R:nextLine(adverb, "OAKS_POKEMON_TALK_9")
end
-- OaksPKMNTalk9 rolls the adjective FIRST and only then spends the segment
-- counter, so the roll happens on the fifth pass too.
RadioJumptable["OAKS_POKEMON_TALK_9"] = function(R)
local adjective = OPT_ADJECTIVES[R:random() % 16 + 1]
R.vars.segmentCounter = (R.vars.segmentCounter or 1) - 1
local nextLine = "OAKS_POKEMON_TALK_4"
if R.vars.segmentCounter == 0 then
R.vars.segmentCounter = 5
nextLine = "OAKS_POKEMON_TALK_10"
end
R:nextLine(adjective, nextLine)
end
-- The Pokemon Channel jingle. OaksPKMNTalk10 calls PrintText rather than
-- PrintRadioLine, so it bypasses the scroll entirely: the box is redrawn with
-- "POKéMON" on its top row and the three PlaceRadioString hops then stamp the
-- rest in over 100 frames each.
RadioJumptable["OAKS_POKEMON_TALK_10"] = function(R)
R.music = "Music_PokemonChannel" -- RadioMusicRestartPokemonChannel
R.top, R.bottom = "POKéMON", ""
R.log[#R.log + 1] = R.top
R.cur = "OAKS_POKEMON_TALK_11"
R.delay = RADIO_LINE_FRAMES
end
-- `hlcoord 9, 14` is the top row, column 9; the box's text starts at column 1,
-- so the stamp lands eight cells in from what is already there.
RadioJumptable["OAKS_POKEMON_TALK_11"] = function(R)
R.delay = R.delay - 1
if R.delay ~= 0 then return end
R.top = R.top .. string.rep(" ", math.max(0, 8 - tileWidth(R.top))) .. "POKéMON"
R.log[#R.log + 1] = R.top
R:placeString("OAKS_POKEMON_TALK_12")
end
RadioJumptable["OAKS_POKEMON_TALK_12"] = function(R)
R.delay = R.delay - 1
if R.delay ~= 0 then return end
R.bottom = "POKéMON Channel" -- hlcoord 1, 16
R.log[#R.log + 1] = R.bottom
R:placeString("OAKS_POKEMON_TALK_13")
end
-- `hlcoord 12, 16` with a bare "@": nothing is drawn, the hop only buys
-- another 100 frames of the jingle.
RadioJumptable["OAKS_POKEMON_TALK_13"] = function(R)
R.delay = R.delay - 1
if R.delay ~= 0 then return end
R:placeString("OAKS_POKEMON_TALK_14")
end
-- Back to the talk: the music restarts, PrintText with a bare terminator wipes
-- the box, and wNumRadioLinesPrinted goes back to zero so the next two lines
-- fill the box from the top again.
RadioJumptable["OAKS_POKEMON_TALK_14"] = function(R)
R.delay = R.delay - 1
if R.delay ~= 0 then return end
R.music = "Music_ProfOaksPokemonTalk" -- MUSIC_POKEMON_TALK
R.top, R.bottom = "", ""
R.next = "OAKS_POKEMON_TALK_4"
R.printed = 0
R.cur = "RADIO_SCROLL"
R.delay = RADIO_JINGLE_FRAMES
end
-- ------------------------------------------------------------ Pokedex Show
--
-- One caught species, then its Pokedex entry read out a line at a time: the
-- kind ("TINY BIRD"), then the six description lines, then a new species.
RadioJumptable["POKEDEX_SHOW"] = function(R)
R:startStation()
-- `cp NUM_POKEMON` rejects a byte of 251 or more, then CheckCaughtMon
-- rejects anything the player has not caught. The index is still zero-based
-- there; the `inc c` after the loop is what makes it a species number. A
-- Pokedex with nothing in it spins this loop forever on hardware, which is
-- why the port's sampler is the capped one.
local species = R:sample(function(roll)
if roll >= 251 then return nil end
local name = R.data.species and R.data.species[roll + 1]
if not name then return nil end
if R.data.caught and not R.data.caught(name) then return nil end
return name
end)
if not species then return end
R.vars.species = species
R:nextLine(species, "POKEDEX_SHOW_2")
end
-- PokedexShow2 prints from the entry's own start, which is the species kind,
-- and then steps the read pointer past the height/weight words.
RadioJumptable["POKEDEX_SHOW_2"] = function(R)
local entry = R.data.dex and R.data.dex[R.vars.species]
R:printLine(entry and entry.kind or "", "POKEDEX_SHOW_3")
end
-- PokedexShow3..8 are the same routine six times over: copy the next line of
-- the entry, print it, name the next segment. The eighth hands back to
-- POKEDEX_SHOW, which rolls a fresh species.
local POKEDEX_SHOW_SEGMENTS = {
"POKEDEX_SHOW_3", "POKEDEX_SHOW_4", "POKEDEX_SHOW_5", "POKEDEX_SHOW_6",
"POKEDEX_SHOW_7", "POKEDEX_SHOW_8",
}
for index, segment in ipairs(POKEDEX_SHOW_SEGMENTS) do
RadioJumptable[segment] = function(R)
local entry = R.data.dex and R.data.dex[R.vars.species]
local lines = entry and entry.lines or {}
R:printLine(lines[index] or "",
POKEDEX_SHOW_SEGMENTS[index + 1] or "POKEDEX_SHOW")
end
end
-- --------------------------------------- Pokemon Music / Let's All Sing
--
-- Two stations that meet: BenMonMusic is Johto's, FernMonMusic is Kanto's, and
-- LETS_ALL_SING_2 jumps straight into POKEMON_MUSIC_4 so both DJs read the
-- same three closing lines.
-- StartPokemonMusicChannel picks the song off the weekday's low bit, and
-- BenFernMusic5/6 read the same bit again for the words that go with it.
local function startPokemonMusicChannel(R)
R.top, R.bottom = "", ""
local odd = (R.data.weekday or 0) % 2 == 1
R.music = odd and "Music_PokemonLullaby" or "Music_PokemonMarch"
end
RadioJumptable["POKEMON_MUSIC"] = function(R)
startPokemonMusicChannel(R)
R:nextLine("BEN: POKéMON MUSIC", "POKEMON_MUSIC_2")
end
RadioJumptable["POKEMON_MUSIC_2"] = function(R)
R:nextLine("CHANNEL!", "POKEMON_MUSIC_3")
end
RadioJumptable["POKEMON_MUSIC_3"] = function(R)
R:nextLine("It's me, DJ BEN!", "POKEMON_MUSIC_4")
end
RadioJumptable["LETS_ALL_SING"] = function(R)
startPokemonMusicChannel(R)
R:nextLine("FERN: POKéMUSIC!", "LETS_ALL_SING_2")
end
-- FernMonMusic2 names POKEMON_MUSIC_4, not a LETS_ALL_SING segment: this is
-- the handoff, and from here Kanto's station is running Johto's code.
RadioJumptable["LETS_ALL_SING_2"] = function(R)
R:nextLine("With DJ FERN!", "POKEMON_MUSIC_4")
end
RadioJumptable["POKEMON_MUSIC_4"] = function(R)
local day = RADIO_DAYS[(R.data.weekday or 0) % 7] or ""
R:nextLine("Today's " .. day .. ",", "POKEMON_MUSIC_5")
end
RadioJumptable["POKEMON_MUSIC_5"] = function(R)
local odd = (R.data.weekday or 0) % 2 == 1
R:nextLine(odd and "so chill out to" or "so let us jam to",
"POKEMON_MUSIC_6")
end
RadioJumptable["POKEMON_MUSIC_6"] = function(R)
local odd = (R.data.weekday or 0) % 2 == 1
R:nextLine(odd and "POKéMON Lullaby!" or "POKéMON March!", "POKEMON_MUSIC_7")
end
-- BenFernMusic7 is a bare `ret`. Both music stations really do stop talking
-- here and play out, and nothing ever leaves this segment.
RadioJumptable["POKEMON_MUSIC_7"] = function() end
-- ------------------------------------------------------- Lucky Number Show
--
-- Eleven fixed lines, the week's number read out twice, and a 1-in-256 chance
-- of REED admitting he is bored before he starts over.
local LUCKY_LINES = {
LUCKY_CHANNEL = { "REED: Yeehaw! How", "LUCKY_NUMBER_SHOW_2" },
LUCKY_NUMBER_SHOW_2 = { "y'all doin' now?", "LUCKY_NUMBER_SHOW_3" },
LUCKY_NUMBER_SHOW_3 = { "Whether you're up", "LUCKY_NUMBER_SHOW_4" },
LUCKY_NUMBER_SHOW_4 = { "or way down low,", "LUCKY_NUMBER_SHOW_5" },
LUCKY_NUMBER_SHOW_5 = { "don't you miss the", "LUCKY_NUMBER_SHOW_6" },
LUCKY_NUMBER_SHOW_6 = { "LUCKY NUMBER SHOW!", "LUCKY_NUMBER_SHOW_7" },
LUCKY_NUMBER_SHOW_7 = { "This week's Lucky", "LUCKY_NUMBER_SHOW_8" },
LUCKY_NUMBER_SHOW_9 = { "I'll repeat that!", "LUCKY_NUMBER_SHOW_10" },
-- LC_Text7 and LC_Text8 again: REED reads the number out a second time.
LUCKY_NUMBER_SHOW_10 = { "This week's Lucky", "LUCKY_NUMBER_SHOW_11" },
LUCKY_NUMBER_SHOW_12 = { "Match it and go to", "LUCKY_NUMBER_SHOW_13" },
LUCKY_NUMBER_SHOW_14 = { "…Repeating myself", "LUCKY_NUMBER_SHOW_15" },
LUCKY_NUMBER_SHOW_15 = { "gets to be a drag…", "LUCKY_CHANNEL" },
}
for segment, row in pairs(LUCKY_LINES) do
RadioJumptable[segment] = function(R)
if segment == "LUCKY_CHANNEL" then R:startStation() end
R:nextLine(row[1], row[2])
end
end
-- LuckyNumberShow8 prints wLuckyIDNumber with PRINTNUM_LEADINGZEROS over five
-- digits, so a low number reads as "00042".
local function luckyNumberLine(R)
return ("Number is %05d!"):format(math.floor(R.data.luckyNumber or 0) % 100000)
end
RadioJumptable["LUCKY_NUMBER_SHOW_8"] = function(R)
R:nextLine(luckyNumberLine(R), "LUCKY_NUMBER_SHOW_9")
end
RadioJumptable["LUCKY_NUMBER_SHOW_11"] = function(R)
R:nextLine(luckyNumberLine(R), "LUCKY_NUMBER_SHOW_12")
end
-- `call Random / and a`: only a rolled zero takes the drag lines, so REED
-- complains about once every 256 times round.
RadioJumptable["LUCKY_NUMBER_SHOW_13"] = function(R)
local roll = R:random()
R:nextLine("the RADIO TOWER!",
roll ~= 0 and "LUCKY_CHANNEL" or "LUCKY_NUMBER_SHOW_14")
end
-- ------------------------------------------------------- Places and People
--
-- DJ LILY alternates between a trainer ("People") and a landmark ("Places"),
-- with a 4% chance after each of restarting the show from its own intro.
RadioJumptable["PLACES_AND_PEOPLE"] = function(R)
R:startStation()
R:nextLine("PLACES AND PEOPLE!", "PLACES_AND_PEOPLE_2")
end
RadioJumptable["PLACES_AND_PEOPLE_2"] = function(R)
R:nextLine("Brought to you by", "PLACES_AND_PEOPLE_3")
end
-- `cp 49 percent - 1` with `jr c` taking People, so the split is 123/256 to
-- People and the rest to Places.
local function peopleOrPlaces(R)
return R:random() < PNP_PEOPLE_CHANCE and "PLACES_AND_PEOPLE_4"
or "PLACES_AND_PEOPLE_6"
end
RadioJumptable["PLACES_AND_PEOPLE_3"] = function(R)
R:nextLine("me, DJ LILY!", peopleOrPlaces(R))
end
-- PeoplePlaces4: roll a trainer class, reject the ones the hidden list is
-- covering this playthrough, and name its first trainer.
RadioJumptable["PLACES_AND_PEOPLE_4"] = function(R)
local classes = R.data.classes or {}
local hidden = R.data.hidden or {}
-- `maskbits NUM_TRAINER_CLASSES` is a 128 mask, `inc a` makes it one-based,
-- and `cp NUM_TRAINER_CLASSES + 1` throws away anything past the sixty-six
-- real classes. IsInArray against PnP_HiddenPeople is a third rejection,
-- not a skip: the roll is spent and another one is taken.
local index = R:sample(function(roll)
roll = roll % 128 + 1
if roll > 66 or hidden[roll] or not classes[roll] then return nil end
return roll
end)
local class = index and classes[index]
if not class then return end
R.vars.class = class.name
R.vars.trainer = class.trainer
R.vars.classIndex = index
-- _PnP_Text4 is the class name and the trainer name with one space between.
R:nextLine(tostring(class.name or "") .. " " .. tostring(class.trainer or ""),
"PLACES_AND_PEOPLE_5")
end
-- PeoplePlaces5 rolls the adjective, then a 4% restart, then the People/Places
-- coin again. Three rolls, always in that order.
RadioJumptable["PLACES_AND_PEOPLE_5"] = function(R)
local adjective = PNP_ADJECTIVES[R:random() % 16 + 1]
local nextLine = "PLACES_AND_PEOPLE"
if R:random() >= PNP_RESTART_CHANCE then nextLine = peopleOrPlaces(R) end
R:nextLine(adjective, nextLine)
end
-- PeoplePlaces6: roll one of the nine PnP_Places maps and name its landmark.
RadioJumptable["PLACES_AND_PEOPLE_6"] = function(R)
-- `cp (PnP_Places.End - PnP_Places) / 2`: a byte past the nine rows is
-- rerolled, not folded.
local map = R:sample(function(roll)
if roll < #PNP_PLACES then return PNP_PLACES[roll + 1] end
return nil
end)
if not map then return end
local index = R.data.mapLandmark and R.data.mapLandmark[map]
local entry = R.data.landmarks and R.data.landmarks[index]
R.vars.landmark = index
R:nextLine(flatName(entry and entry.name), "PLACES_AND_PEOPLE_7")
end
-- PeoplePlaces7 is PeoplePlaces5 with the same three rolls in the same order.
RadioJumptable["PLACES_AND_PEOPLE_7"] = function(R)
local adjective = PNP_ADJECTIVES[R:random() % 16 + 1]
local nextLine = "PLACES_AND_PEOPLE"
if R:random() >= PNP_RESTART_CHANCE then nextLine = peopleOrPlaces(R) end
R:printLine(adjective, nextLine)
end
-- ------------------------------------------------------------ Rocket Radio
--
-- Ten fixed lines on a loop. Nothing rolls, nothing branches.
RadioJumptable["ROCKET_RADIO"] = function(R)
R:startStation()
R:nextLine(ROCKET_LINES[1], "ROCKET_RADIO_2")
end
for index = 2, 10 do
RadioJumptable["ROCKET_RADIO_" .. index] = function(R)
R:nextLine(ROCKET_LINES[index],
index < 10 and ("ROCKET_RADIO_" .. (index + 1)) or "ROCKET_RADIO")
end
end
-- ------------------------------------------------- the three music stations
--
-- PokeFluteRadio, UnownRadio and EvolutionRadio start their song, set
-- wNumRadioLinesPrinted to 1 so StartRadioStation never fires again, and
-- return. They print nothing at all, ever: the box stays empty.
for _, station in ipairs({ "POKE_FLUTE_RADIO", "UNOWN_RADIO",
"EVOLUTION_RADIO" }) do
RadioJumptable[station] = function(R)
R:startStation()
R.printed = 1
end
end
-- ------------------------------------------------------------- the tuner
--
-- RadioChannels (engine/pokegear/pokegear.asm) is the dial: a tuning-knob
-- value and the routine that decides whether anything is on it. The comment
-- there gives the arithmetic: frequency value = 4 x ingame frequency - 2, so
-- knob 16 is the 04.5 mark. A knob position whose test fails is not a station
-- at all -- NoRadioStation wipes the name and the box and plays nothing.
--
-- `signal(ctx)` returns the station to load, or nil for dead air. ctx carries
-- inJohto, timeOfDay, landmark (the player's, not the cursor's), expnCard and
-- rocketSignal.
local RADIO_CHANNELS = {
-- .PKMNTalkAndPokedexShow: the Pokedex Show airs in the morning and Oak's
-- Pokemon Talk the rest of the day, off the same frequency.
{ knob = 16, frequency = "04.5", signal = function(ctx)
if not ctx.inJohto then return nil end
if (ctx.timeOfDay or 0) == 0 then return "POKEDEX_SHOW" end
return "OAKS_POKEMON_TALK"
end },
{ knob = 28, frequency = "07.5", signal = function(ctx)
return ctx.inJohto and "POKEMON_MUSIC" or nil
end },
{ knob = 32, frequency = "08.5", signal = function(ctx)
return ctx.inJohto and "LUCKY_CHANNEL" or nil
end },
-- .RuinsOfAlphRadio is a one-landmark station: the static only resolves
-- standing in the Ruins of Alph themselves.
{ knob = 52, frequency = "13.5", signal = function(ctx)
return ctx.landmark == "LANDMARK_RUINS_OF_ALPH" and "UNOWN_RADIO" or nil
end },
{ knob = 64, frequency = "16.5", signal = function(ctx)
return (not ctx.inJohto) and "PLACES_AND_PEOPLE" or nil
end },
{ knob = 72, frequency = "18.5", signal = function(ctx)
return (not ctx.inJohto) and "LETS_ALL_SING" or nil
end },
-- .PokeFluteRadio also wants the EXPN card, which is the Kanto radio
-- upgrade.
{ knob = 78, frequency = "20.0", signal = function(ctx)
if ctx.inJohto or not ctx.expnCard then return nil end
return "POKE_FLUTE_RADIO"
end },
-- .EvolutionRadio only airs while Team Rocket is still in Mahogany, and only
-- within earshot of the Lake of Rage.
{ knob = 80, frequency = "20.5", signal = function(ctx)
if not ctx.rocketSignal then return nil end
local here = ctx.landmark
if here == "LANDMARK_MAHOGANY_TOWN" or here == "LANDMARK_ROUTE_43"
or here == "LANDMARK_LAKE_OF_RAGE" then
return "EVOLUTION_RADIO"
end
return nil
end },
}
-- PHONE_DISPLAY_HEIGHT.
local PHONE_ROWS = 4
-- data/text/common_3.asm. Every one of these lives in ROM bank $66, which the
-- importer does not reach yet (it follows map script pointers and nothing on a
-- map points into the phone banks), so they are transcribed here with the
-- "bank:addr" key their extracted form will have -- Pokegear:phoneText prefers
-- the extracted string and only falls back to the transcription.
local PHONE_TEXT = {
GearEllipse = { key = "66:4066", body = "……" },
GearOutOfService = { key = "66:4069",
body = "You're out of the service area." },
AskWhoCall = { key = "66:4089", body = "Whom do you want to call?" },
-- _PokegearPressButtonText, the CLOCK card's bottom-box prompt.
PressButton = { key = "66:40a4", body = "Press any button to exit." },
AskDelete = { key = "66:40bf", body = "Delete this stored phone number?" },
WrongNumber = { key = "66:40e1", body = "Huh? Sorry, wrong number!" },
Click = { key = "66:40fc", body = "Click!" },
PhoneEllipse = { key = "66:4104", body = "……" },
OutOfArea = { key = "66:4107", body = "That number is out of the area." },
JustTalkToThem = { key = "66:4128", body = "Just go talk to that person!" },
}
function Pokegear:wantsFillScale() return true end
function Pokegear:drawsWidescreen() return true end
-- opts: save, landmarks (landmarks.lua), currentLandmark, clock, menuGfx,
-- radioData (a prebuilt Radio data table, for tests), radioRng, onClose(),
-- mapDef (the maps.lua record the player is standing on, for the phone's
-- signal / same-map tests), trainers (trainers.lua, for contact names), text
-- (text.lua) and onCall(descriptor), which hands a placed call out to whoever
-- can run its script
function Pokegear.new(game, opts)
opts = opts or {}
local self = setmetatable({}, Pokegear)
self.game = game
self.save = opts.save or (game and game.save)
local data = game and game.data or {}
self.landmarks = opts.landmarks or data.gen2Landmarks
self.currentLandmark = opts.currentLandmark
self.clock = opts.clock
self.onClose = opts.onClose
self.cards = self:visibleCards()
self.cardIndex = 1
self.mode = "strip" -- strip | card
-- Which RadioChannels row the tuning knob sits on. The knob itself runs
-- 0..80 in steps of two; the port steps the row instead, because every
-- position between two stations is the same dead air.
self.station = 1
-- wPokegearPhoneCursorPosition / wPokegearPhoneScrollPosition, both of which
-- are ZERO based on the cart: the cursor runs 0..PHONE_DISPLAY_HEIGHT - 1
-- inside the visible window and the scroll runs 0..CONTACT_LIST_SIZE -
-- PHONE_DISPLAY_HEIGHT. Neither wraps.
self.phoneCursor = 0
self.phoneScroll = 0
-- wPokegearPhoneSubmenuCursor, and which submenu is open at all.
self.phoneSubmenu = nil
self.phoneSubmenuCursor = 0
self.mapDef = opts.mapDef
self.trainers = opts.trainers or data.trainers or data.gen2Trainers
-- text.lua, for the phone strings; `text` is already a method name here.
self.textData = opts.text
self.onCall = opts.onCall
-- The show that the tuned frequency resolved to, and its state machine.
-- Both are nil while the knob sits on dead air (NoRadioStation).
self.radioShow = nil
self.radio = nil
self.radioDataOverride = opts.radioData
self.radioRng = opts.radioRng
self.radioOn = false
-- TownMap_InitCursorAndPlayerIconPositions seeds both the player icon and
-- the cursor from the landmark the player is standing in; the d-pad then
-- walks the CURSOR's landmark index while the icon stays put.
self.mapCursor = nil
-- The call the phone card is showing, if any.
self.call = nil
-- _FlyMap's own state. `fly` is FieldMoves.flyPoints' answer: this region's
-- half of the Flypoints table with every row CheckIfVisitedFlypoint would
-- reject already dropped, so the cursor's skip loop is just "next row".
self.fly = opts.fly
self.onFly = opts.onFly
if self.fly and #self.fly > 0 then
self.cards = { FLY_MAP_CARD }
self.cardIndex = 1
self.mode = "card"
-- FlyMap's defaults: the Johto map opens on JOHTO_FLYPOINT (New Bark
-- Town) and the Kanto one on NUM_FLYPOINTS - 1 (Indigo Plateau), not on
-- wherever the player is standing.
self.flyIndex = (self:region() == "kanto") and #self.fly or 1
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
self.palettes = opts.palettes or data.gen2Palettes
local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear
self.gfx = gfx
if gfx then
self.sheet = TileSheet.new({
path = gfx.tiles, wide = gfx.tilesWide or 16, firstTile = 0,
paletteFor = function(tile) return self:colorsFor(tile) end,
})
end
return self
end
function Pokegear:styled()
return self.sheet ~= nil and self.sheet:available()
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]
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
return Chrome.printThrough(str, tx, ty, pals and pals[1])
end
-- wPokegearFlags' four card bits are ENGINE flags: EngineFlags rows 0-3 are
-- POKEGEAR_RADIO/MAP/PHONE/EXPN_CARD_F (pokegold data/events/engine_flags.asm,
-- constants/engine_flags.asm const order), so the scripts' `setflag` -- the
-- Radio Tower quiz's ENGINE_RADIO_CARD, the Guide Gent's ENGINE_MAP_CARD,
-- Mom's ENGINE_PHONE_CARD and the Lavender tower's ENGINE_EXPN_CARD -- lands
-- each id in save.engineFlags through World:setEngineFlag. The string-keyed
-- save.pokegearFlags overlay stays readable so a test can seed a card without
-- a world.
local CARD_ENGINE_FLAGS = { radio = 0, map = 1, phone = 2, expn = 3 }
function Pokegear:flags()
local save = self.save or {}
local flags = {}
for key, value in pairs(save.pokegearFlags or {}) do flags[key] = value end
local engine = save.engineFlags or {}
for key, id in pairs(CARD_ENGINE_FLAGS) do
if engine[id] == true then flags[key] = true end
end
return flags
end
function Pokegear:visibleCards()
local flags = self:flags()
local out = {}
for _, card in ipairs(CARDS) do
if not card.flag or flags[card.flag] then out[#out + 1] = card end
end
return out
end
function Pokegear:card()
return self.cards[self.cardIndex]
end
-- PokegearClock_Init / UpdateClock read hHours, hMinutes and GetWeekday right
-- after UpdateTime, so the CLOCK card shows the GAME clock: the RTC through the
-- save's wStartHour / wStartMinute base. The gear is pushed over a live world,
-- and that world already owns the read (World:hour, plus the POKEPORT_GOLD_HOUR
-- pin a driver sets), so prefer it and fall back to the save's own base when
-- there is no world underneath (the Pokegear opened from a test).
--
-- `weekday` comes back 1-based for the DAYS table; wCurDay counts SUNDAY 0.
function Pokegear:clockParts()
if self.clock then
return self.clock.hour or 0, self.clock.minute or 0,
self.clock.weekday or 1
end
local world = self.game and self.game.world
if world and world.hour then
return world:hour(), world:minute(), (world:weekday() % 7) + 1
end
local save = self.save
return Clock.hour(save), Clock.minute(save), Clock.weekday(save) + 1
end
-- wPhoneList itself: ten ordered slots, 0 for an empty one. The empty slots
-- are part of the display (PokegearPhone_UpdateDisplayList draws four rows
-- unconditionally, and slot 0 renders as NonTrainerCallerNames' "----------"),
-- so this does not compact them away.
function Pokegear:phoneList()
return Phone.contacts(self.save)
end
-- The contact under the cursor, or 0 for an empty slot. `.a` on the phone
-- card reads wPhoneList + scroll + cursor and returns straight back out when
-- that byte is zero.
function Pokegear:phoneSelection()
return self:phoneList()[self.phoneScroll + self.phoneCursor + 1] or 0
end
-- What the phone model needs to know about where the player is standing:
-- GetMapPhoneService reads the map header, and both the same-map test and
-- SpecialCallOnlyWhenOutside read the map record too. The Pokegear is pushed
-- from the start menu with the world still underneath it, so the record is
-- either handed in or read off that world.
function Pokegear:phoneContext()
local map = self.mapDef
if not map then
local world = self.game and self.game.world
map = world and world.map and world.map.def
end
local hour, minute = self:clockParts()
return { map = map, clock = { hour = hour, minute = minute } }
end
-- A common phone string: the extracted text when bank $66 finally arrives,
-- and the transcription from data/text/common_3.asm until then.
function Pokegear:phoneText(name)
local entry = PHONE_TEXT[name]
if not entry then return "" end
local text = self.textData
or (self.game and self.game.world and self.game.world.text)
local extracted = text and text[entry.key]
if extracted and extracted ~= "" then return extracted end
return entry.body
end
-- GetCallerClassAndName: a trainer contact is "<name>:" over the class name, a
-- non-trainer is its NonTrainerCallerNames string and nothing under it.
function Pokegear:contactRow(id)
local name, className = Phone.contactName(id, self.trainers)
return (name or "----------") .. ":", className
end
function Pokegear:update(_dt)
-- .Frameset_RedWalk (data/sprite_anims/framesets.asm:81): four 8-frame
-- beats, so the map card's player icon walks in place.
self.iconTimer = ((self.iconTimer or 0) + 1) % 32
local input = self.game and self.game.input
if not input then return end
-- 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.mode == "strip" then
local stripCard = self:card()
if not (stripCard and stripCard.id == "phone") then
if input:wasPressed("left") then
self.cardIndex = self.cardIndex > 1 and self.cardIndex - 1 or #self.cards
elseif input:wasPressed("right") then
self.cardIndex = self.cardIndex < #self.cards and self.cardIndex + 1 or 1
elseif input:wasPressed("a") then
self.mode = "card"
elseif input:wasPressed("b") then
if self.onClose then self.onClose() end
end
return
end
self.mode = "card"
end
-- Inside a card.
local card = self:card()
-- The phone card owns B while it is showing a call or a submenu: on the cart
-- those are their own jumptable states (POKEGEARSTATE_PHONE*), and B closes
-- the state, not the card.
-- The busy test comes FIRST: wasPressed consumes the press, so asking about
-- B before knowing whether the phone wants it would eat the button the
-- submenu is waiting for.
local phoneBusy = card and card.id == "phone"
and (self.call ~= nil or self.phoneSubmenu ~= nil)
-- engine/pokegear/pokegear.asm:799
if card and card.id == "phone" and not phoneBusy then
if input:wasPressed("b") then
if self.onClose then self.onClose() end
return
elseif input:wasPressed("left") then
self:switchCard("map", "clock")
return
elseif input:wasPressed("right") then
self:switchCard("radio")
return
end
end
if not phoneBusy and input:wasPressed("b") then
self.mode = "strip"
self:stopRadio()
self.call = nil
self.phoneSubmenu = nil
return
end
if card and card.id == "radio" then
self:ensureTuned()
-- AnimateTuningKnob.TuningKnob: up winds the knob towards 80 and down
-- back towards 0, and it stops dead at either end rather than wrapping.
-- The port steps RadioChannels rows, so "stops dead" is a clamp.
if input:wasPressed("up") then
if self.station < #RADIO_CHANNELS then
self.station = self.station + 1
self:tuneRadio()
end
elseif input:wasPressed("down") then
if self.station > 1 then
self.station = self.station - 1
self:tuneRadio()
end
end
self:tickRadio()
elseif card and card.id == "phone" then
self:updatePhone(input)
elseif card and card.id == "map" then
self:moveMapCursor(input)
end
end
-- --------------------------------------------------------------------- radio
--
-- .InJohto: the S.S. Aqua counts as Johto and so does anything below
-- KANTO_LANDMARK ($2e = 46). This reads the PLAYER's landmark, never the map
-- cursor's -- the two are separate bytes for exactly this reason.
function Pokegear:region()
local landmarks = self.landmarks and self.landmarks.landmarks
local current = landmarks and self.currentLandmark
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
return "johto"
end
-- The context RadioChannels' per-frequency tests read.
function Pokegear:radioContext()
local save = self.save or {}
local flags = self:flags()
local world = self.game and self.game.world
return {
inJohto = self:region() == "johto",
landmark = self.currentLandmark,
-- wTimeOfDay: MORN is 0, which is the only value that swaps Oak's
-- Pokemon Talk out for the Pokedex Show. Palettes.DAYTIME_ID is 1-based
-- for Lua's sake, so it comes back down a step here.
timeOfDay = self.timeOfDay or self:timeOfDayIndex(),
-- POKEGEAR_EXPN_CARD_F, the Kanto radio upgrade.
expnCard = flags.expn or false,
-- STATUSFLAGS_ROCKET_SIGNAL_F, set while Team Rocket holds Mahogany.
rocketSignal = (save.flags or {}).ROCKET_SIGNAL or false,
}
end
-- Every RadioChannels row, with the station its test resolves to right now
-- (nil where NoRadioStation would fire).
function Pokegear:stations()
local ctx = self:radioContext()
local out = {}
for index, row in ipairs(RADIO_CHANNELS) do
local station = row.signal(ctx)
out[index] = {
knob = row.knob, frequency = row.frequency, station = station,
name = station and STATION_NAMES[station] or nil,
}
end
return out
end
function Pokegear:currentStation()
return self:stations()[self.station]
end
-- UpdateRadioStation: the knob moved, so resolve the frequency, hand the show
-- machine the station it landed on, and let RadioChannelSongs replace the
-- map's music. Dead air is NoRadioStation: no name, no box, no song.
function Pokegear:tuneRadio()
local row = self:currentStation()
local station = row and row.station
self.radioTuned = true
self.radioShow = station
if not station then
self.radio = nil
self.radioOn = false
-- NoRadioStation: MUSIC_NONE now, and ENTER_MAP_MUSIC parked in
-- wPokegearRadioMusicPlaying so leaving the radio on dead air brings the
-- map's own theme back (ExitPokegearRadio_HandleMusic).
self.radioMusicPlaying = "enterMap"
local data = self.game and self.game.data
if data then pcall(require("src.core.Music").stop) end
return
end
self.radio = Radio.new({ data = self:radioData(), rng = self.radioRng })
self.radio:tune(station)
self.radioOn = true
self:playRadioMusic()
end
-- PokegearRadio_Init resolves the knob before the card is ever drawn, so a
-- card that arrives already selected (a driver setting cardIndex by hand, or
-- a save resumed on the radio) still knows what it is playing.
function Pokegear:ensureTuned()
if self.radioTuned then return end
self:tuneRadio()
end
-- One frame of PlayRadioShow, plus whatever song the show asked for on the
-- way through (RadioMusicRestartDE is a call, not a table lookup, so the
-- Pokemon Channel jingle really does change the music mid-show).
function Pokegear:tickRadio()
if not self.radio then return end
self.radio:step()
self:playRadioMusic()
end
-- What a started song leaves in wPokegearRadioMusicPlaying. Every station
-- start goes through RadioMusicRestartDE, which parks the SONG there (and in
-- wMapMusic); the one exception is the Pokemon Channel jingle, whose
-- RadioMusicRestartPokemonChannel parks RESTART_MAP_MUSIC instead -- closing
-- the gear mid-jingle gives the map its music back.
function Pokegear.radioPlayingValue(song)
if song == "Music_PokemonChannel" then return "restartMap" end
return song
end
function Pokegear:playRadioMusic()
local song = self.radio and self.radio.music
if not song or song == self.radioSong then return end
self.radioSong = song
self.radioMusicPlaying = Pokegear.radioPlayingValue(song)
local data = self.game and self.game.data
if not data then return end
pcall(require("src.core.Music").play, data, song)
end
-- The cache reads the shows need, gathered once per Pokegear. Everything
-- here is a lookup the cart does with a farcall (GetLandmarkName,
-- GetWorldMapLocation, GetPokemonName, GetTrainerClassName); the port hands
-- the show machine tables instead so it stays testable.
function Pokegear:radioData()
if self.radioDataOverride then return self.radioDataOverride end
if self.radioDataCache then return self.radioDataCache end
local data = (self.game and self.game.data) or {}
local save = self.save or {}
local out = { inJohto = self:region() == "johto" }
-- Landmarks by index, which is how GetLandmarkName and GetWorldMapLocation
-- both address them.
out.landmarks = {}
for _, entry in pairs((self.landmarks or {}).landmarks or {}) do
out.landmarks[entry.index or 0] = entry
end
-- GetWorldMapLocation is a map -> landmark lookup; the extracted maps carry
-- the landmark index the cart's table would have returned.
out.mapLandmark = {}
local maps = (self.game and self.game.world and self.game.world.maps)
or data.gen2Maps or {}
for id, def in pairs(maps) do out.mapLandmark[id] = def.landmark end
-- JohtoGrassWildMons, keyed by map and then by the time-of-day block Oak's
-- Pokemon Talk indexes with `AddNTimes 2 * NUM_GRASSMON`.
out.grass = {}
for id, row in pairs((data.gen2Encounters or {}).grass or {}) do
local slots = row.slots or {}
local block = {}
for index, key in ipairs({ "MORN", "DAY", "NITE" }) do
local list = {}
for slot, entry in ipairs(slots[key] or {}) do list[slot] = entry.species end
block[index - 1] = list
end
out.grass[id] = block
end
-- Species by internal index, which is what the Pokedex Show rolls.
out.species = {}
for name, def in pairs(data.pokemon or {}) do
if def.index then out.species[def.index] = name end
end
local caught = (save.pokedex or {}).caught or {}
out.caught = function(name) return caught[name] == true end
-- Pokedex entries, split the way CopyDexEntryPart1 walks them: the kind
-- name first, then one line per <NEXT>, with the page break ('@') simply
-- joining the two pages into one run of six.
out.dex = {}
for name, entry in pairs((data.gen2Pokedex or {}).entries or {}) do
local lines = {}
for _, page in ipairs({ entry.text, entry.text2 }) do
for line in (tostring(page or "") .. "<NEXT>"):gmatch("(.-)<NEXT>") do
if line ~= "" then lines[#lines + 1] = line end
end
end
out.dex[name] = { kind = entry.kind, lines = lines }
end
-- TrainerClassNames and the class's first trainer, by class index.
out.classes = {}
for _, class in pairs((data.gen2Trainers or {}).classes or {}) do
if class.index then
out.classes[class.index] = {
name = class.name,
trainer = class.trainers and class.trainers[1]
and class.trainers[1].name,
}
end
end
out.hidden = self:hiddenPeople()
out.weekday = self:radioWeekday()
-- wLuckyIDNumber, rolled by src/script/gen2/Specials.lua's
-- ResetLuckyNumberShowFlag (engine/events/lucky_number.asm's
-- LoadOrRegenerateLuckyIDNumber). A save that has never visited the Lucky
-- Number Man in Radio Tower has never rolled one, so 00000 is the honest
-- reading, not a stand-in for unfinished work.
out.luckyNumber = save.luckyNumber or 0
out.rocketsInRadioTower = (save.flags or {}).ROCKETS_IN_RADIO_TOWER or false
self.radioDataCache = out
return out
end
-- PnP_HiddenPeople, resolved to class indices against this save's progress.
-- The list is walked from one of three entry points, so the further along the
-- player is, the shorter it gets.
function Pokegear:hiddenPeople()
local save = self.save or {}
local first = 1
if (save.flags or {}).HALL_OF_FAME then
first = PNP_HIDDEN_BEAT_E4
local badges = (save.player or {}).kantoBadges or {}
local count = 0
for _, has in pairs(badges) do if has then count = count + 1 end end
if count >= 8 then first = PNP_HIDDEN_BEAT_KANTO end
end
local hidden = {}
for index = first, #PNP_HIDDEN do
local classIndex = self:trainerClassIndex(PNP_HIDDEN[index])
if classIndex then hidden[classIndex] = true end
end
return hidden
end
function Pokegear:trainerClassIndex(id)
local classes = (self.game and self.game.data and self.game.data.gen2Trainers
or {}).classes or {}
local class = classes[id]
return class and class.index or nil
end
-- wTimeOfDay, as the cart numbers it: MORN 0, DAY 1, NITE 2, DARK 3.
function Pokegear:timeOfDayIndex()
local world = self.game and self.game.world
local daytime = (world and world.daytime)
or Palettes.clockDaytime(self.clock and self.clock.hour or nil)
return (Palettes.DAYTIME_ID[daytime] or 2) - 1
end
-- GetWeekday counts from Sunday = 0; clockParts answers in the 1-based DAYS
-- numbering the clock card draws with, so the radio's day is that same read
-- shifted down rather than a second clock.
function Pokegear:radioWeekday()
local _, _, weekday = self:clockParts()
return ((weekday or 1) - 1) % 7
end
-- ExitPokegearRadio_HandleMusic (pokegold engine/pokegear/pokegear.asm): what
-- leaving the radio does to the music is decided by wPokegearRadioMusicPlaying,
-- not done unconditionally. A tuned station's song was written into wMapMusic
-- by RadioMusicRestartDE, so it KEEPS PLAYING and is the map music from then
-- on -- Music.setMapSong makes a battle's restore replay it, and only a map
-- change replaces it. ENTER_MAP_MUSIC (dead air) and RESTART_MAP_MUSIC (the
-- Pokemon Channel jingle) are the two arms that bring the map theme back.
-- Shared with src/ui/gen2/MapRadio.lua, whose PlayRadio exit runs the same
-- routine.
function Pokegear.exitRadioMusic(game, playing)
if not playing then return end
local data = game and game.data
if not data then return end
local Music = require("src.core.Music")
if playing ~= "enterMap" and playing ~= "restartMap" then
Music.setMapSong(playing)
return
end
local world = game and game.world
local song = world and world.map and world.map.def and world.map.def.music
if song then pcall(Music.play, data, song) else pcall(Music.stop) end
end
function Pokegear:stopRadio()
local playing = self.radioMusicPlaying
self.radio = nil
self.radioShow = nil
self.radioSong = nil
self.radioTuned = nil
self.radioMusicPlaying = nil
self.radioOn = false
Pokegear.exitRadioMusic(self.game, playing)
end
-- --------------------------------------------------------------------- phone
--
-- The phone card is four jumptable states on the cart, and they are separate
-- states because each one owns the buttons outright:
--
-- PHONEJOYPAD the list. PokegearPhone_GetDPad walks the cursor, A opens
-- the contact submenu.
-- (submenu) PokegearPhoneContactSubmenu, a blocking loop rather than a
-- state: CALL / DELETE / CANCEL, with DELETE withheld from a
-- contact CheckCanDeletePhoneNumber refuses (MOM and ELM).
-- MAKEPHONECALL PokegearPhone_MakePhoneCall -- two rings, then
-- MakePhoneCallFromPokegear.
-- FINISHCALL any button hangs up.
-- PokegearPhoneContactSubmenu's two string tables. The three-entry menu draws
-- its box at (9,4) and the two-entry one at (9,6), because the box origin is
-- computed from the STRING coordinate by `bccoord -1, -2, 0`; entries are one
-- <NEXT> apart, which is TWO tile rows, and the cursor column steps by the
-- same two rows.
local PHONE_SUBMENUS = {
callDeleteCancel = { x = 9, y = 4, rows = 3, textX = 11, textY = 6,
entries = { "CALL", "DELETE", "CANCEL" } },
callCancel = { x = 9, y = 6, rows = 2, textX = 11, textY = 8,
entries = { "CALL", "CANCEL" } },
}
Pokegear.PHONE_SUBMENUS = PHONE_SUBMENUS
-- PokegearPhone_GetDPad, then `.a`. Neither the cursor nor the scroll wraps:
-- the cursor stops at the top and bottom of the four visible rows and hands
-- over to the scroll, which stops at 0 and at CONTACT_LIST_SIZE - 4.
function Pokegear:updatePhone(input)
-- A placed call owns the buttons: PokegearPhone_FinishPhoneCall takes A or B
-- and hangs up.
if self.call then
if input:wasPressed("a") or input:wasPressed("b") then
self:hangUp()
end
return
end
if self.phoneSubmenu then
self:updatePhoneSubmenu(input)
return
end
if input:wasPressed("a") then
-- `ld a, [hl] / and a / ret z`: an empty slot is not a contact.
if self:phoneSelection() ~= 0 then self:openPhoneSubmenu() end
return
end
if input:wasPressed("up") then
if self.phoneCursor > 0 then
self.phoneCursor = self.phoneCursor - 1
elseif self.phoneScroll > 0 then
self.phoneScroll = self.phoneScroll - 1
end
elseif input:wasPressed("down") then
if self.phoneCursor < PHONE_ROWS - 1 then
self.phoneCursor = self.phoneCursor + 1
elseif self.phoneScroll < Phone.CONTACT_LIST_SIZE - PHONE_ROWS then
self.phoneScroll = self.phoneScroll + 1
end
end
end
-- CheckCanDeletePhoneNumber picks which of the two menus opens.
function Pokegear:openPhoneSubmenu()
local id = self:phoneSelection()
self.phoneSubmenu = Phone.canDelete(id) and "callDeleteCancel" or "callCancel"
self.phoneSubmenuCursor = 0
end
function Pokegear:updatePhoneSubmenu(input)
local menu = PHONE_SUBMENUS[self.phoneSubmenu]
if not menu then
self.phoneSubmenu = nil
return
end
-- `.d_up` refuses to move off entry 0 and `.d_down` off the last one; the
-- cart's own loop simply keeps looping, so this is a clamp, not a wrap.
if input:wasPressed("up") then
if self.phoneSubmenuCursor > 0 then
self.phoneSubmenuCursor = self.phoneSubmenuCursor - 1
end
return
end
if input:wasPressed("down") then
if self.phoneSubmenuCursor < menu.rows - 1 then
self.phoneSubmenuCursor = self.phoneSubmenuCursor + 1
end
return
end
-- `.a_b`: B always means Cancel, whatever the cursor is on.
if input:wasPressed("b") then
self.phoneSubmenu = nil
return
end
if not input:wasPressed("a") then return end
local choice = menu.entries[self.phoneSubmenuCursor + 1]
self.phoneSubmenu = nil
if choice == "CALL" then
self:callContact(self:phoneSelection())
elseif choice == "DELETE" then
-- The cart asks first (PokegearAskDeleteText + YesNoBox). There is no
-- yes/no box inside a Pokegear card in this port, so the submenu entry is
-- the confirmation; the prompt is what the deletion state shows.
Phone.deleteContactAt(self.save, self.phoneScroll + self.phoneCursor + 1)
end
end
-- PokegearPhone_MakePhoneCall. The no-signal branch never reaches
-- MakePhoneCallFromPokegear at all: it plays SFX_NO_SIGNAL and prints
-- _GearOutOfServiceText, which is a DIFFERENT string from the "that number is
-- out of the area" the engine's own out-of-area path prints.
function Pokegear:callContact(id)
if not id or id == 0 then return end
local context = self:phoneContext()
if not Phone.mapHasService(context) then
self.call = { contact = id, kind = "nosignal",
text = self:phoneText("GearOutOfService") }
return
end
-- pokegold engine/pokegear/pokegear.asm:883-889: SFX_CALL rings before the call connects.
local world = self.game and self.game.world
if world then world:playSfxNamed("Sfx_Call", 106) end
local call = Phone.call(self.save, id, context)
local name, className = Phone.contactName(id, self.trainers)
call.name, call.className = name, className
if call.kind == "outofarea" then
call.text = self:phoneText("OutOfArea")
elseif call.kind == "justtalk" then
call.text = self:phoneText("JustTalkToThem")
elseif call.wrongNumber then
call.text = self:phoneText("WrongNumber")
else
-- What the cart shows while a call connects: Phone_TextboxWithName's
-- "NAME:" and PhoneEllipseText. The handler below runs the callee script
-- over it; the ellipsis stays under its pages the way the cart's does.
call.text = (name or "") .. ": " .. self:phoneText("PhoneEllipse")
end
self.call = call
-- Hand the descriptor out. Game2:runPokegearCall is the live handler:
-- the contact's extracted SCRIPT1 runs through the overworld VM and its
-- pages ride the state stack over this card. With no handler (a bare
-- test harness) the card still shows the ring and the caller's name.
if self.onCall then self.onCall(call) end
end
-- HangUp: the click, the boops, and back to "Whom do you want to call?".
function Pokegear:hangUp()
-- pokegold engine/phone/phone.asm:517-519: HangUp_Beep plays SFX_HANG_UP.
if self.call and self.call.kind ~= "nosignal" then
local world = self.game and self.game.world
if world then world:playSfxNamed("Sfx_HangUp", 107) end
end
self.call = nil
end
-- ----------------------------------------------------------------------- map
--
-- PokegearMap_JohtoMap / PokegearMap_KantoMap. The cursor is a landmark
-- INDEX, not a position: up steps to the next landmark id and down to the
-- previous one, and the two limit registers d (the last landmark of the
-- region) and e (the first) are what it wraps between.
--
-- LANDMARK_* indices, from constants/landmark_constants.asm:
-- NEW_BARK_TOWN $01 SILVER_CAVE $2d PALLET_TOWN $2e
-- VICTORY_ROAD $57 ROUTE_28 $5d FAST_SHIP $5e
-- LANDMARK_SPECIAL ($00) and LANDMARK_FAST_SHIP ($5e) sit outside both
-- ranges, so the cursor can never land on either.
local LANDMARK_NEW_BARK_TOWN = 0x01
local LANDMARK_SILVER_CAVE = 0x2d
local LANDMARK_PALLET_TOWN = 0x2e
local LANDMARK_VICTORY_ROAD = 0x57
local LANDMARK_ROUTE_28 = 0x5d
-- 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
end
if ((self.save or {}).flags or {}).HALL_OF_FAME then
return LANDMARK_ROUTE_28, LANDMARK_PALLET_TOWN
end
return LANDMARK_ROUTE_28, LANDMARK_VICTORY_ROAD
end
-- The cursor's landmark index. Unset, it is the player's own, which is what
-- TownMap_InitCursorAndPlayerIconPositions writes into both bytes.
function Pokegear:mapCursorIndex()
-- On the fly screen the cursor IS the flypoint row: _FlyMap walks the
-- Flypoints table and reads the landmark out of it, so the name plate and
-- the arrow both follow the row rather than a free landmark index.
local flyRow = self:flyRow()
if flyRow and flyRow.index then return flyRow.index end
if self.mapCursor then return self.mapCursor end
local landmarks = self.landmarks and self.landmarks.landmarks
local current = landmarks and self.currentLandmark
and landmarks[self.currentLandmark]
local _, first = self:cursorLimits()
return current and current.index or first
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)
local last, first = self:cursorLimits()
local cursor = self:mapCursorIndex()
if input:wasPressed("up") 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
-- `cp e / jr nz, .wrap_around_down`: only the first landmark wraps.
if cursor == first then cursor = last + 1 end
cursor = cursor - 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
return
end
self.mapCursor = cursor
end
-- ------------------------------------------------------------------ fly map
--
-- _FlyMap's `.loop` (engine/pokegear/pokegear.asm:1978): A takes the flypoint
-- the cursor is on, B answers -1, and .HandleDPad walks the Flypoints table
-- with up/down, wrapping between wStartFlypoint and wEndFlypoint and skipping
-- every row CheckIfVisitedFlypoint rejects. Left and right do nothing at all
-- here -- there is no card to page to.
function Pokegear:updateFlyMap(input)
local rows = self.fly or {}
local count = #rows
if count == 0 then
if self.onClose then self.onClose() end
return
end
if input:wasPressed("up") then
self.flyIndex = ((self.flyIndex or 1) % count) + 1
elseif input:wasPressed("down") then
self.flyIndex = ((self.flyIndex or 1) - 2) % count + 1
elseif input:wasPressed("a") then
local row = rows[self.flyIndex or 1]
if row and self.onFly then self.onFly(row.spawn) end
elseif input:wasPressed("b") then
if self.onClose then self.onClose() end
end
end
-- The flypoint under the cursor.
function Pokegear:flyRow()
if not self.fly then return nil end
return self.fly[self.flyIndex or 1]
end
-- Pokegear_SwitchPage: take the first of the named cards the player owns.
function Pokegear:switchCard(...)
for _, id in ipairs({ ... }) do
for index, card in ipairs(self.cards) do
if card.id == id then
self.cardIndex = index
if id == "radio" then self:tuneRadio() end
return true
end
end
end
return false
end
-- The landmark the map card is naming and parking the cursor sprite on:
-- PokegearMap_UpdateCursorPosition reads the CURSOR's landmark, never the
-- player icon's.
function Pokegear:mapLandmark()
local index = self:mapCursorIndex()
for _, entry in pairs((self.landmarks or {}).landmarks or {}) do
if entry.index == index then return entry end
end
return nil
end
-- The landmark the player icon sits on, which the d-pad never moves.
function Pokegear:playerLandmark()
local landmarks = self.landmarks and self.landmarks.landmarks
return landmarks and self.currentLandmark and landmarks[self.currentLandmark]
or nil
end
-- ---------------------------------------------------------------- tile layer
-- A card's tilemap uses exactly two "empty" cells and they are NOT the same
-- colour, which is the whole reason the gear reads as a lit panel on black:
--
-- $4f the ground InitPokegearTilemap ByteFills SCREEN_AREA with. A solid
-- colour-3 tile, so it is BLACK (Pokegear:groundColor), and it is what
-- every cell outside a card's art stays.
-- $7f a font-page SPACE. Every pixel is colour 0, so it is the CREAM
-- plate, and it is what fills the SWITCH box, the day/time window and
-- the map's label strip. The clock card's own tilemap spells both out:
-- `30 7f 7f 7f 7f 7f 7f 31` is the SWITCH box's top row (rounded
-- corners around six spaces), and the window interior is nothing but
-- $7f between the $16 sides.
--
-- Neither is in the gear's tile sheet -- they are font-page ids -- so both are
-- painted here rather than blitted. Before this, a $7f drew nothing and the
-- black ground showed through, which took the cream out of the SWITCH box and
-- left the clock window an empty black rectangle.
local SPACE_TILE = 0x7f
function Pokegear:tile(id, tx, ty)
if id == SPACE_TILE then
local paper = self:paperColor()
local G = love.graphics
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
G.rectangle("fill", tx * 8, ty * 8, 8, 8)
G.setColor(1, 1, 1, 1)
return
end
if id == BLANK_TILE then
local ground = self:groundColor()
local G = love.graphics
G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1)
G.rectangle("fill", tx * 8, ty * 8, 8, 8)
G.setColor(1, 1, 1, 1)
return
end
if self.sheet then self.sheet:draw(id, tx, ty) end
end
function Pokegear:drawTilemap(cells)
if not cells then return end
for index = 1, SCREEN_W * SCREEN_H do
local tile = cells[index]
if tile then
self:tile(tile, (index - 1) % SCREEN_W,
math.floor((index - 1) / SCREEN_W))
end
end
end
-- Pokegear_FinishTilemap.
-- PokegearSpritesGFX, shared by the mode indicator arrow ($00) and the town
-- map cursor ($04). Loaded once and remembered as `false` when there is no
-- sheet at all, so a missing asset is not retried every frame.
function Pokegear:loadArrowSheet()
if self.arrow ~= nil then return end
self.arrow = false
local gfx = self.gfx
if gfx and gfx.sprites then
self:loadPlayerIcon()
self.arrow = TileSheet.new({
path = gfx.sprites, wide = gfx.spritesWide or 2, firstTile = 0,
-- 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]),
})
end
end
function Pokegear:drawStrip()
for x = 0, 7 do
self:tile(BLANK_TILE, x, 0)
self:tile(BLANK_TILE, x, 1)
end
for _, card in ipairs(self.cards) do
local n, x = card.icon, card.iconX
self:tile(n, x, 0)
self:tile(n + 1, x + 1, 0)
self:tile(n + 0x10, x, 1)
self:tile(n + 0x11, x + 1, 1)
end
end
-- The mode indicator arrow. Two things about it follow from its being an OBJ
-- rather than part of the tilemap, and both were wrong when it was drawn
-- inside drawStrip:
--
-- * It is ABOVE everything. A card's own art goes down after the strip, so
-- drawing the arrow with the icons put the phone list's window frame over
-- its tip and the list's plate behind its stem. On hardware an OBJ is
-- composited over the BG whatever the BG is, so this draws LAST, from
-- drawPanel, after whichever card has finished.
-- * It sits directly under the strip. The icons are rows 0 and 1, so the
-- arrow's top is row 2 -- its tip touches the selected icon's bottom edge,
-- which is the whole point of an indicator. Four pixels lower and it
-- reads as floating in the card rather than hanging off the icon.
--
-- AnimatePokegearModeIndicatorArrow slides it $10 pixels per card, which is
-- exactly one icon's width, so the x follows the selected card's own column.
--
-- Tiles $00-$03: a 16x16 up-triangle with a short stem, which is why the town
-- map cursor starts at $04 and why PokegearSpritesGFX is two tiles wide.
-- $00/$01 are its top row and $02/$03 its bottom; drawing $00 alone put the
-- triangle's top-left corner on screen and nothing else, which is the thin
-- diagonal sliver that read as a broken cursor.
function Pokegear:drawModeArrow()
local card = self:card()
local G = love.graphics
-- CENTRED on the icon, not hung off its corner. An icon is two tiles wide
-- (iconX, iconX + 1) and the arrow is two tiles wide too, so their left
-- edges are the same column: starting a tile further right put the arrow's
-- centre over the icon's right-hand edge, which reads as belonging to the
-- gap between two cards rather than to either one.
local iconX = (card and card.iconX or 0) * 8
self:loadArrowSheet()
if self.arrow and self.arrow:available() then
G.setColor(1, 1, 1, 1)
-- The sprite's first two pixel rows are all but empty (the triangle proper
-- starts on row 2 of tile $00), so the block is lifted half a tile: the
-- apex then meets the icon's bottom edge and tucks under it instead of
-- floating in the black gap below the strip.
local tx, ty = iconX / 8, 1.5
self.arrow:draw(0, tx, ty)
self.arrow:draw(1, tx + 1, ty)
self.arrow:draw(2, tx, ty + 1)
self.arrow:draw(3, tx + 1, ty + 1)
else
Chrome.cursor(math.floor(iconX / 8), 2)
end
end
-- ---------------------------------------------------------------- the cards
function Pokegear:drawClock()
local hour, minute, weekday = self:clockParts()
self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.clock)
self:drawStrip()
self:text("SWITCH", 13, 1)
Chrome.cursor(19, 1)
-- Pokegear_UpdateClock: ClearBox(3,5) 5x14, the day at (6,6) and
-- PrintHoursMins at (6,8) -- two digits, ':', two more, then AM/PM at
-- column 12.
self:text(DAYS[weekday] or "", 6, 6)
local display = hour % 12
if display == 0 then display = 12 end
self:text(Chrome.number(display, 2), 6, 8)
self:text(":", 8, 8)
self:text(Chrome.number(minute, 2, true), 9, 8)
self:text(hour < 12 and "AM" or "PM", 12, 8)
-- The bottom Textbox is part of the card (lb bc, 4, 18 at (0,12)), and
-- PokegearClock_Init prints PokegearPressButtonText straight into it
-- (engine/pokegear/pokegear.asm PokegearClock_Init), the same way
-- PokegearPhone_Init fills it with PokegearAskWhoCallText -- the box is the
-- exit prompt, not spare room. `line` starts the second row two tile rows
-- below the first (data/text/common_3.asm _PokegearPressButtonText), which is
-- why this steps 14 -> 16 rather than printing consecutive rows.
self:textbox(0, 12, 18, 4)
self:printBoxText(self:phoneText("PressButton"))
end
-- One of these bottom-box strings, laid out the way PrintText lays a two-line
-- text out: `text` on the box's first interior row and `line` two tile rows
-- under it, which is the same (1,14)/(1,16) pair the port's other Gold text
-- boxes use. The box only has room for those two rows.
function Pokegear:printBoxText(text)
local lines = Chrome.wrap(text, 18)
for i = 1, math.min(#lines, 2) do
Chrome.print(lines[i], 1, 14 + (i - 1) * 2)
end
end
-- The Pokegear's paper: BG palette 0's colour 0. _CGB_PokegearPals copies the
-- six PokegearPals entries straight into wBGPals1 (engine/gfx/cgb_layouts.asm
-- _CGB_PokegearPals), and TownMapPals hands every tile id >= $60 -- the whole
-- font page, so every blank $7f and every glyph cell -- palette 0
-- (engine/pokegear/pokegear.asm TownMapPals). That first entry is
-- `RGB 28, 31, 20` (gfx/pokegear/pokegear.pal), a pale cream, NOT white: a card
-- 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
return (pals and pals[1] and pals[1][1]) or { 255, 255, 255 }
end
-- The colour the gear's own ground reads as. InitPokegearTilemap ByteFills
-- SCREEN_AREA with $4f, and $4f is not a blank cell: it is a SOLID tile, every
-- pixel colour index 3, so the fill lands as BG palette 0's LAST colour and
-- the gear sits on black. Reading colour 0 instead put the screen on the
-- cream plate, which is why the card art appeared as a black box floating on
-- pale green rather than as a lit panel on black.
--
-- Colour 0 is still right for a plate of ' ' cells (Pokegear:drawPlate): a
-- 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 pal = pals and pals[1]
return (pal and pal[#pal]) or { 0, 0, 0 }
end
-- A run of ' ' cells over the map art. A space is a font-page cell, so it
-- reads as BG palette 0's colour 0 -- the cream plate -- rather than as
-- whatever town-map tile was underneath.
function Pokegear:drawPlate(tx, ty, tw, th)
local paper = self:paperColor()
local G = love.graphics
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
G.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
G.setColor(1, 1, 1, 1)
end
-- Textbox on this screen. Its frame is TextBoxBorder's $79-$7e and its
-- interior is ' ' ($7f), all of them font-page tiles, so the WHOLE box -- the
-- ring as much as the middle -- reads as palette 0's colour 0 here
-- (engine/pokegear/pokegear.asm TownMapPals). Chrome.textbox cannot be used
-- as-is: Font.drawBox hard-fills its rect white, which is right on every other
-- Gold screen (their BG palette 0 colour 0 IS white) and wrong on the gear, so
-- lay the gear's own paper down and draw only the frame glyphs over it. b/c in
-- the ASM are interior rows/columns, same as Chrome.textbox.
function Pokegear:textbox(tx, ty, interiorW, interiorH)
local tw, th = interiorW + 2, interiorH + 2
self:drawPlate(tx, ty, tw, th)
local B = Font.BORDER
local G = love.graphics
G.setColor(0, 0, 0, 1)
Font.drawCode(B.tl, tx * 8, ty * 8)
Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8)
Font.drawCode(B.bl, tx * 8, (ty + th - 1) * 8)
Font.drawCode(B.br, (tx + tw - 1) * 8, (ty + th - 1) * 8)
for i = 1, tw - 2 do
Font.drawCode(B.h, (tx + i) * 8, ty * 8)
Font.drawCode(B.h, (tx + i) * 8, (ty + th - 1) * 8)
end
for j = 1, th - 2 do
Font.drawCode(B.v, tx * 8, (ty + j) * 8)
Font.drawCode(B.v, (tx + tw - 1) * 8, (ty + j) * 8)
end
G.setColor(0, 0, 0, 1)
end
-- TownMapBubble: the plate the fly screen wears instead of the card strip.
-- Three rows from (1,0) to (18,2), "Where?" at (2,0), the flypoint's landmark
-- name at (2,1) and the up/down scroller at (18,1). The four rounded corners
-- come from FlyMapLabelBorderGFX, a six-tile 1bpp set loaded over vTiles2 tile
-- $30 for this screen only -- the extractor carries the town map's own $30-$33
-- instead, so the plate is drawn square rather than with the wrong art in its
-- corners.
function Pokegear:drawFlyBubble()
self:drawPlate(1, 0, 18, 3)
self:text("Where?", 2, 0)
local row = self:flyRow()
self:text(flatName(row and row.name), 2, 1)
Chrome.cursor(18, 1)
end
function Pokegear:drawMap()
-- The REGION follows the player (`cp KANTO_LANDMARK` in
-- PokegearMap_CheckRegion); the name box follows the CURSOR, which the
-- d-pad may have walked somewhere else entirely.
local region = self:region()
local current = self:mapLandmark()
self:drawTilemap(self.gfx and self.gfx.maps and self.gfx.maps[region])
local G = love.graphics
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)
-- 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
-- colour 0, the cream plate, rather than the strip's black $4f -- then
-- $34, the ▲▼ scroller, at (8,0). The name itself is placed at (9,0) by
-- TownMap_ConvertLineBreakCharacters, and the word break it rewrites is
-- <LF>, which steps one row rather than <NEXT>'s two.
G.setColor(1, 1, 1, 1)
self:drawPlate(8, 0, 12, 2)
self:tile(0x34, 8, 0)
local name = current and current.name or ""
local row = 0
for line in (tostring(name) .. "\n"):gmatch("(.-)\n") do
if row < 2 then self:text(line, 9, row) end
row = row + 1
end
end
-- Two OBJs, not one. PokegearMap_InitPlayerIcon parks RED_WALK on the
-- PLAYER's landmark and PokegearMap_InitCursor parks the POKEGEAR_ARROW
-- (sprite tile $04) on the CURSOR's, and only the second one moves. The
-- landmark macro stores x + 8 / y + 16, which is OAM space; the extractor
-- already took the offsets back off, so these coordinates are screen ones.
local player = self:playerLandmark()
if player and player.x and player.y then
if not self:drawPlayerIcon(player.x, player.y) then
G.setColor(0, 0, 0, 1)
G.rectangle("fill", player.x - 2, player.y - 2, 5, 5)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", player.x - 1, player.y - 1, 3, 3)
end
end
if current and current.x and current.y then
self:mapCursorSprite(current.x, current.y)
end
end
-- ChrisSpriteGFX, the sheet Pokegear_LoadGFX copies into vTiles0 $10 and $14
-- (engine/pokegear/pokegear.asm:135-144). `false` means no gen2 sprites.
function Pokegear:loadPlayerIcon()
if self.playerIcon ~= nil then return end
self.playerIcon = false
local def = self.sprites and self.sprites.SPRITE_CHRIS
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
local world = self.game and self.game.world
local daytime = (world and world.daytime)
or Palettes.clockDaytime(self.clock and self.clock.hour or nil)
local colors = self.palettes
and Palettes.spritePalette(self.palettes, daytime, def)
if colors then
icon:setObjPalette(colors,
("gen2:%s:%d"):format(tostring(daytime), def.paletteId or 0))
end
self.playerIcon = icon
end
function Pokegear:drawPlayerIcon(x, y)
self:loadPlayerIcon()
if not self.playerIcon then return false end
-- .Frameset_RedWalk beats (data/sprite_anims/framesets.asm:82-85): stand,
-- walk, stand, walk B_OAM_XFLIP, as FacingStepDown0-3 in facings.asm.
local beat = math.floor((self.iconTimer or 0) / 8)
-- .OAMData_RedWalk (data/sprite_anims/oam.asm:314-319) hangs its four tiles
-- at -8,-8; camY of -4 undoes the world's sprite lift.
love.graphics.setColor(1, 1, 1, 1)
self.playerIcon:draw(x - 8, y - 8, 0, -4, "down",
beat % 2, beat == 3)
return true
end
-- The cursor arrow. It is the same PokegearSpritesGFX sheet the mode
-- indicator uses, at tile $04; without the sheet the port falls back to
-- Chrome's own cursor glyph so the card is still navigable.
function Pokegear:mapCursorSprite(x, y)
self:loadArrowSheet()
local G = love.graphics
if self.arrow and self.arrow:available() then
G.setColor(1, 1, 1, 1)
-- Tiles $04-$07 are one 16x16 OBJ, and .OAMData_RedWalk (the STILL_CURSOR
-- oamset, data/sprite_anims/oam.asm:57) centres it on the landmark.
local tx, ty = (x - 8) / 8, (y - 8) / 8
self.arrow:draw(0x04, tx, ty)
self.arrow:draw(0x05, tx + 1, ty)
self.arrow:draw(0x06, tx, ty + 1)
self.arrow:draw(0x07, tx + 1, ty + 1)
return
end
Chrome.cursor(math.floor(x / 8), math.floor(y / 8))
end
function Pokegear:drawRadio()
self:ensureTuned()
self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.radio)
self:drawStrip()
local station = self:currentStation()
-- UpdateRadioStation prints the tuned channel's name at (2,9). Dead air
-- prints nothing: NoRadioStation clears the box and leaves it clear.
self:text(station and station.name or "", 2, 9)
-- The show owns the bottom text box's two lines. PrintRadioLine fills them
-- from the top the first time round and CopyBottomLineToTopLine scrolls
-- afterwards, so `top` is always the line before `bottom`.
self:textbox(0, 12, 18, 4)
local radio = self.radio
if not (station and station.station and radio) then return end
if radio.top ~= "" then Chrome.print(radio.top, 1, 14) end
if radio.bottom ~= "" then Chrome.print(radio.bottom, 1, 16) end
end
function Pokegear:drawPhone()
self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.phone)
self:drawStrip()
-- .PlacePhoneBars: the signal meter at (17,1)/(18,1)/(17,2), and the fourth
-- tile at (18,2) ONLY when GetMapPhoneService comes back zero -- the missing
-- corner is how the card says "no signal here".
self:tile(0x3c, 17, 1)
self:tile(0x3d, 18, 1)
self:tile(0x3e, 17, 2)
if Phone.mapHasService(self:phoneContext()) then
self:tile(0x3f, 18, 2)
end
self:textbox(0, 12, 18, 4)
-- A call in progress replaces the prompt with what the caller is saying;
-- otherwise the box holds PokegearAskWhoCallText the whole time.
if self.call then
Chrome.printWrapped(self.call.text or self:phoneText("GearEllipse"),
1, 14, 18, 3)
else
self:printBoxText(self:phoneText("AskWhoCall"))
end
-- PokegearPhone_UpdateDisplayList: every one of the four visible slots is
-- drawn, empty or not, from (2,4) two rows apart. GetCallerClassAndName
-- puts the name (with its trailing colon) on that row and the trainer class
-- one row down and three columns in; a non-trainer has no second line.
local list = self:phoneList()
for row = 1, PHONE_ROWS do
local id = list[row + self.phoneScroll] or 0
local ty = 4 + (row - 1) * 2
local label, className = self:contactRow(id)
self:text(label, 2, ty)
if className then self:text(className, 5, ty + 1) end
end
-- PokegearPhone_UpdateCursor draws the cursor at (1, 4 + 2 * cursor).
Chrome.cursor(1, 4 + self.phoneCursor * 2)
self:drawPhoneSubmenu()
end
-- PokegearPhoneContactSubmenu's box and its entries, laid where the ASM's
-- coordinates put them rather than by eye.
function Pokegear:drawPhoneSubmenu()
local menu = PHONE_SUBMENUS[self.phoneSubmenu or ""]
if not menu then return end
-- `ld a, [de] / sla a` -> b is twice the entry count, and Textbox's b/c are
-- interior rows/columns, so the box is (rows * 2 + 2) tall and 10 wide.
self:textbox(menu.x, menu.y, 8, menu.rows * 2)
for index, label in ipairs(menu.entries) do
local ty = menu.textY + (index - 1) * 2
self:text(label, menu.textX, ty)
end
Chrome.cursor(menu.textX - 1, menu.textY + self.phoneSubmenuCursor * 2)
end
-- ------------------------------------------------------------------ fallback
function Pokegear:drawPlain()
Chrome.clear()
if self.fly then
-- No town-map art in this cache, so the bubble's "Where?" and the rows it
-- scrolls between are the whole screen.
Chrome.box(0, 0, 20, 4)
Chrome.print("Where?", 2, 1)
Chrome.box(0, 4, 20, 14)
local rows = self.fly
local top = math.max(1, math.min((self.flyIndex or 1) - 3, #rows - 5))
for slot = 0, 5 do
local index = top + slot
local row = rows[index]
if row then
local ty = 5 + slot * 2
if index == (self.flyIndex or 1) then Chrome.cursor(1, ty) end
Chrome.print(flatName(row.name), 2, ty)
end
end
return
end
Chrome.box(0, 0, 20, 4)
local card = self:card()
Chrome.print(card and card.label or "", 2, 1)
if #self.cards > 1 then Chrome.cursor(17, 1) end
local id = card and card.id
if id == "clock" then
local hour, minute, weekday = self:clockParts()
Chrome.box(1, 5, 18, 7)
Chrome.print(DAYS[weekday] or "DAY", 3, 7)
local display = hour % 12
if display == 0 then display = 12 end
Chrome.print(("%s:%s %s"):format(
Chrome.number(display, 2), Chrome.number(minute, 2, true),
hour < 12 and "AM" or "PM"), 5, 9)
Chrome.print(Palettes.clockDaytime(hour), 5, 11)
elseif id == "radio" then
-- Without the gear sheet there is no dial art, so the frequencies go down
-- the screen as a list. A frequency whose test failed still gets a row:
-- the knob really does stop there, it just finds nothing.
Chrome.box(0, 4, 20, 14)
for i, row in ipairs(self:stations()) do
local ty = 5 + (i - 1) * 2
if ty < 17 then
if i == self.station then Chrome.cursor(1, ty) end
Chrome.print(row.frequency .. " " .. (row.name or ""), 2, ty)
end
end
elseif id == "phone" then
-- No card art, so no signal meter and no tilemap: the list and the call
-- box are the whole card, at the same coordinates the styled one uses so
-- the two read the same way.
Chrome.box(0, 3, 20, 9)
local list = self:phoneList()
for row = 1, PHONE_ROWS do
local ty = 4 + (row - 1) * 2
local label, className = self:contactRow(list[row + self.phoneScroll] or 0)
Chrome.print(label, 2, ty)
if className then Chrome.print(className, 5, ty + 1) end
end
Chrome.cursor(1, 4 + self.phoneCursor * 2)
Chrome.textbox(0, 12, 18, 4)
Chrome.printWrapped(self.call and (self.call.text or "")
or self:phoneText("AskWhoCall"), 1, 14, 18, 3)
self:drawPhoneSubmenu()
else
Chrome.box(0, 4, 20, 14)
Chrome.print("NO CARD DATA", 2, 6)
end
end
function Pokegear:drawPanel()
if not self:styled() then
self:drawPlain()
love.graphics.setColor(1, 1, 1, 1)
return
end
local G = love.graphics
-- InitPokegearTilemap ByteFills the whole SCREEN_AREA with $4f before the
-- card's tilemap goes down (engine/pokegear/pokegear.asm InitPokegearTilemap),
-- and every cell the card leaves blank is a font-page tile on palette 0, so
-- the ground under a card is the gear's paper, not white. See
-- Pokegear:paperColor.
local ground = self:groundColor()
G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1)
G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8)
local id = self:card() and self:card().id
if id == "map" then
self:drawMap()
elseif id == "radio" then
self:drawRadio()
elseif id == "phone" then
self:drawPhone()
else
self:drawClock()
end
-- Last: the arrow is an OBJ and composites over whatever the card drew.
self:drawModeArrow()
G.setColor(1, 1, 1, 1)
end
function Pokegear:draw()
self:drawPanel()
end
function Pokegear:drawWidescreen(winW, winH)
local G = love.graphics
-- The surround takes the gear's own ground, so the panel does not read as a
-- black card sitting inside a cream frame. It follows groundColor for the
-- same reason drawPanel does: $4f is what the screen is filled with.
local ground = self:groundColor()
G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 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
-- The gate World:openFlyMap reads before it pushes this screen instead of
-- falling back to its yes/no chain.
Pokegear.FLY_MAP = true
Pokegear.CARDS = CARDS
-- Exported for tests and drivers: the dial, the show machine, and the tables
-- the shows read out of.
Pokegear.RADIO_CHANNELS = RADIO_CHANNELS
Pokegear.STATION_NAMES = STATION_NAMES
Pokegear.Radio = Radio
Pokegear.OPT_ADVERBS = OPT_ADVERBS
Pokegear.OPT_ADJECTIVES = OPT_ADJECTIVES
Pokegear.PNP_ADJECTIVES = PNP_ADJECTIVES
Pokegear.ROCKET_LINES = ROCKET_LINES
Pokegear.OPT_ROUTES = OPT_ROUTES
Pokegear.PNP_PLACES = PNP_PLACES
return Pokegear