Open Kanto: the S.S. Aqua, the Kanto gyms, the Magnet Train, Mt. Silver and Red (Closes #33)
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Failing after 25m40s

All 146 Kanto maps, Route 22, Route 28 and Silver Cave are open. The
Magnet Train, Snorlax and the Poké Flute channel, the Diploma, the Trainer
House, Bill's grandpa, Oak's Pokédex rating in Pallet and Goldenrod's
postgame move tutor are ported; big objects cover 2x2 tiles. The Magnet
Train and the Diploma are compared with the cartridge frame by frame.

The playthrough continues from the Hall of Fame through the S.S. Aqua,
the eight Kanto badges, Oak opening Mt. Silver and Red's defeat and
credits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-17 15:53:57 +02:00
1 parent f05fd8ebee
commit d3be732118
22 files changed
+1877 -31

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ const BG_TILES = 32;
* 20x18 tilemap, a row's own 32 tiles (`rowScroll`), or `scroll.fill`. */
function backgroundCell(screen: TileScreen, row: number, col: number) {
const own = screen.rowScroll?.find((entry) => entry.row === row);
if (own?.tiles) return { tile: own.tiles[col], palette: screen.attrs[row * SCREEN_WIDTH] };
if (own?.tiles) return { tile: own.tiles[col], palette: own.attrs?.[col] ?? screen.attrs[row * SCREEN_WIDTH] };
if (row < SCREEN_HEIGHT && col < SCREEN_WIDTH) {
const cell = row * SCREEN_WIDTH + col;
return { tile: screen.map[cell], palette: screen.attrs[cell] };
+165
View File
@@ -1509,6 +1509,25 @@ export const KANTO = {
"HOF_AnimatePlayerPic.PlayTime": 0x868ed,
"InitDisplayForHallOfFame.SavingRecordText": 0x4e8bd,
ProfOaksPCBoot: 0x265ee,
SnorlaxAwake: 0xc43d,
MoveTutor: 0x4925b,
"SnorlaxAwake.ProximityCoords": 0xc467,
TrainerHouse: 0xc4b9,
BillsGrandfather: 0x73f7,
MagnetTrain: 0x8cc04,
MagnetTrainBGTiles: 0x8cd82,
MagnetTrainTilemap: 0x8ceff,
"SpriteAnimOAMData.OAMData_MagnetTrainRed": 0x8dd9b,
"SpriteAnimOAMData.OAMData_MagnetTrainBlue": 0x8e213,
ChrisSpriteGFX: 0xc0000,
Diploma: 0xc49f,
PrintDiploma: 0xc4ac,
DiplomaGFX: 0x1dd805,
DiplomaPage1Tilemap: 0x1ddc4b,
PalPacket_Diploma: 0x9cb6,
"PlaceDiplomaOnScreen.Player": 0x1dd760,
"PlaceDiplomaOnScreen.Certification": 0x1dd768,
KrisSpriteGFX: 0xc7a40,
CreditsBorderGFX: 0x109c24,
CopyrightGFX: 0xe4000,
TheEndGFX: 0xcbd2e,
@@ -1969,6 +1988,152 @@ export const NATIVE_MAPS: Record<string, string> = {
"16:6": "Karen’s room",
"16:7": "Lance’s room",
"16:8": "Hall of Fame",
"3:74": "Silver Cave",
"3:75": "Silver Cave",
"3:76": "Silver Cave",
"3:77": "Silver Cave item rooms",
"3:84": "Diglett’s Cave",
"3:85": "Mt. Moon",
"3:86": "Underground Path",
"3:87": "Rock Tunnel 1F",
"3:88": "Rock Tunnel B1F",
"6:1": "Pokémon Center",
"6:3": "Route 19 Fuchsia gate",
"6:4": "Seafoam Gym",
"6:5": "Route 19",
"6:6": "Route 20",
"6:7": "Route 21",
"6:8": "Cinnabar Island",
"7:1": "Gym badge speech house",
"7:2": "Cerulean police station",
"7:3": "Trade speech house",
"7:4": "Pokémon Center",
"7:6": "Cerulean Gym",
"7:7": "Cerulean Mart",
"7:8": "Pokémon Center",
"7:10": "Power Plant",
"7:11": "Bill’s house",
"7:12": "Route 4",
"7:13": "Route 9",
"7:14": "Route 10",
"7:15": "Route 24",
"7:16": "Route 25",
"7:17": "Cerulean City",
"12:1": "Route 6",
"12:2": "Route 11",
"12:3": "Vermilion City",
"12:4": "Fishing speech house",
"12:5": "Pokémon Center",
"12:7": "Pokémon Fan Club",
"12:8": "Magnet Train speech house",
"12:9": "Vermilion Mart",
"12:10": "Diglett’s Cave speech house",
"12:11": "Vermilion Gym",
"12:12": "Route 6 Saffron gate",
"12:13": "Underground Path entrance",
"13:1": "Route 1",
"13:2": "Pallet Town",
"13:3": "Red’s house 1F",
"13:4": "Red’s house 2F",
"13:5": "Blue’s house",
"13:6": "Oak’s lab",
"14:1": "Route 3",
"14:2": "Pewter City",
"14:3": "Nidoran speech house",
"14:4": "Pewter Gym",
"14:5": "Pewter Mart",
"14:6": "Pokémon Center",
"14:8": "Snooze speech house",
"15:1": "Olivine port",
"15:2": "Vermilion port",
"15:3": "Fast Ship 1F",
"15:4": "Fast Ship cabins NNW, NNE and NE",
"15:5": "Fast Ship cabins SW, SSW and NW",
"15:6": "Fast Ship cabins SE, SSE and the captain’s",
"15:7": "Fast Ship B1F",
"15:8": "Olivine port passage",
"15:9": "Vermilion port passage",
"15:10": "Mt. Moon Square",
"15:11": "Mt. Moon gift shop",
"17:1": "Route 13",
"17:2": "Route 14",
"17:3": "Route 15",
"17:4": "Route 18",
"17:5": "Fuchsia City",
"17:6": "Fuchsia Mart",
"17:7": "Safari Zone office",
"17:8": "Fuchsia Gym",
"17:9": "Bill’s sister’s house",
"17:10": "Pokémon Center",
"17:12": "Safari Zone warden’s home",
"17:13": "Route 15 Fuchsia gate",
"18:1": "Route 8",
"18:2": "Route 12",
"18:3": "Route 10",
"18:4": "Lavender Town",
"18:5": "Pokémon Center",
"18:7": "Mr. Fuji’s house",
"18:8": "Lavender speech house",
"18:9": "Name Rater’s house",
"18:10": "Lavender Mart",
"18:11": "Soul House",
"18:12": "Lavender Radio Tower",
"18:13": "Route 8 Saffron gate",
"18:14": "Super Rod house",
"19:1": "Route 28",
"19:2": "Silver Cave outside",
"19:3": "Pokémon Center",
"19:4": "Steel Wing house",
"21:1": "Route 7",
"21:2": "Route 16",
"21:3": "Route 17",
"21:4": "Celadon City",
"21:5": "Celadon Dept. Store 1F",
"21:6": "Celadon Dept. Store 2F",
"21:7": "Celadon Dept. Store 3F",
"21:8": "Celadon Dept. Store 4F",
"21:9": "Celadon Dept. Store 5F",
"21:10": "Celadon Dept. Store 6F",
"21:11": "Celadon Dept. Store elevator",
"21:12": "Celadon Mansion 1F",
"21:13": "Celadon Mansion 2F",
"21:14": "Celadon Mansion 3F",
"21:15": "Celadon Mansion roof",
"21:16": "Celadon Mansion roof house",
"21:17": "Pokémon Center",
"21:19": "Celadon Game Corner",
"21:20": "Game Corner prize room",
"21:21": "Celadon Gym",
"21:22": "Celadon Café",
"21:23": "Fuchsia speech house",
"21:24": "Route 16 gate",
"21:25": "Route 7 Saffron gate",
"21:26": "Route 17–18 gate",
"23:1": "Route 2",
"23:2": "Route 22",
"23:3": "Viridian City",
"23:4": "Viridian Gym",
"23:5": "Nickname speech house",
"23:6": "Trainer House 1F",
"23:7": "Trainer House B1F",
"23:8": "Viridian Mart",
"23:9": "Pokémon Center",
"23:11": "Nugget house",
"23:12": "Route 2 gate",
"25:1": "Route 5",
"25:2": "Saffron City",
"25:3": "Fighting Dojo",
"25:4": "Saffron Gym",
"25:5": "Saffron Mart",
"25:6": "Pokémon Center",
"25:8": "Mr. Psychic’s house",
"25:9": "Magnet Train station",
"25:10": "Silph Co. 1F",
"25:11": "Copycat’s house 1F",
"25:12": "Copycat’s house 2F",
"25:13": "Underground Path entrance",
"25:14": "Route 5 Saffron gate",
"25:15": "Cleanse Tag house",
};
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
+77 -2
View File
@@ -1,8 +1,11 @@
import { BATTLE, JOHTO, RAM, SYMBOLS } from "../rom/profile";
import { BATTLE, JOHTO, MENU, RAM, SYMBOLS } from "../rom/profile";
import { changedHappiness } from "../battle/battle";
import { readString } from "./text";
import { NICKNAME_PATTERN } from "../party/pokemon";
import { EGG } from "../rom/pokemon";
import { EGG, NUM_TMS_HMS } from "../rom/pokemon";
import { C } from "../battle/constants";
import { SOUND } from "../audio/constants";
import { PartyEvolution } from "./evolution";
import type { Gen, SpecialContext } from "./special-context";
import {
checkCountdown,
@@ -220,3 +223,75 @@ export function* checkForLuckyNumberWinners(
yield* context.flush();
return place;
}
/** MoveTutor (engine/events/move_tutor.asm): the tutor's move for
* wScriptVar's MOVETUTOR_* (the TMHMMoves entries after the HMs that
* MT01_MOVE to MT03_MOVE name), then ChooseMonToLearnTMHM's party menu with
* ABLE or NOT ABLE until a Pokémon learns it (FALSE) or B (-1). An Egg
* plays SFX_WRONG and the menu stays; an incompatible Pokémon, one that
* knows the move or declines it returns to the menu. */
export function* moveTutor(context: SpecialContext, tutor: number): Gen<number> {
const { reader: r, data, state } = context,
party = state.party;
if (tutor < 1 || tutor > 3) throw new Error("Unknown move tutor move.");
const move = r.byte(SYMBOLS.tmhmMoves + NUM_TMS_HMS + tutor - 1),
name = data.moveName(move);
context.strings[RAM.stringBuffer1] = context.strings[RAM.stringBuffer2] = name;
const strings = MENU.PartyMenuStrings + C.PARTYMENUACTION_TEACH_TMHM * 2;
const prompt = readString(r, r.near(strings, r.bank(strings)), context.text());
let pages = context.take();
for (;;) {
const slot = yield {
kind: "menu",
pages: [...pages, prompt],
items: party.map((mon) => mon.nickname),
// PlacePartyMonTMHMCompatibility: nothing for Eggs.
details: party.map((mon) =>
mon.egg
? ""
: readString(
r,
data.canLearnTMHM(mon.species, move)
? MENU["PlacePartyMonTMHMCompatibility.string_able"]
: MENU["PlacePartyMonTMHMCompatibility.string_not_able"],
context.text(),
),
),
selected: 0,
};
pages = [];
if (typeof slot !== "number" || !Number.isInteger(slot) || slot >= party.length)
throw new Error("Invalid party choice.");
if (slot < 0) return 0xff;
const mon = party[slot];
if (mon.egg) {
context.playSFX(SOUND.SFX_WRONG);
yield* context.waitSFX();
continue;
}
// CheckCanLearnMoveTutorMove: GetNickname into wStringBuffer1.
context.strings[RAM.stringBuffer1] = mon.nickname;
context.strings[RAM.stringBuffer2] = name;
if (!data.canLearnTMHM(mon.species, move)) {
context.write(MENU.TMHMNotCompatibleText);
context.playSFX(SOUND.SFX_WRONG);
yield* context.flush();
continue;
}
if (mon.moves.includes(move)) {
context.write(MENU["KnowsMove.KnowsMoveText"]);
yield* context.flush();
continue;
}
const evolution = new PartyEvolution(state, {
reader: r,
data,
timeOfDay: context.timeOfDay,
text: context.text,
});
evolution.strings[RAM.stringBuffer1] = evolution.strings[RAM.stringBuffer2] = name;
if (!(yield* evolution.learnMove(slot, move))) continue;
changeHappiness(context, slot, C.HAPPINESS_LEARNMOVE);
return 0;
}
}
+54 -1
View File
@@ -87,6 +87,8 @@ import { unownPuzzle } from "./unown-puzzle";
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
import { slotMachine } from "./slot-machine";
import { hallOfFame, profOaksPCBoot, redCredits, type CeremonyContext } from "./league";
import { billsGrandfather, diploma, printDiploma, snorlaxAwake, trainerHouse } from "./kanto";
import { magnetTrain } from "./magnet-train";
import type { GameTime } from "../world/game-time";
import { cardFlip } from "./card-flip";
import { giveDratini, moveDeletion } from "./blackthorn";
@@ -121,6 +123,7 @@ import {
checkLuckyNumberShowFlag,
firstPokemonHappiness,
haircut,
moveTutor,
nameRater,
printTodaysLuckyNumber,
resetLuckyNumberShowFlag,
@@ -811,6 +814,12 @@ export interface ScriptHost {
initCallReceiveDelay?(): void;
/** GetWorldMapLocation for any map. */
landmarkOf?(mapId: string): number;
/** SCGB_MAPPALS's palettes for the current map with wEnvironment set to
* TOWN (MagnetTrain_Jumptable_FirstRunThrough). */
townPalettes?(): { bg: Palette[]; obj: Palette[] };
/** GetWarpDestCoords for the warp on the player's tile, for
* MAPSETUP_TRAIN's EnterMapWarp after warpcheck. */
warpHere?(): { map: string; x: number; y: number } | undefined;
/** The play time, and GAME_TIMER_COUNTING_F (halloffame clears it). */
gameTime?(): GameTime;
gameTimer?(counting: boolean): void;
@@ -1665,11 +1674,14 @@ export class ScriptRun {
break;
case 0x64: {
// Null pointers request the battle engine's default end text.
// PrintWinLossText reads the loss text only after a battle the
// player could lose (BATTLETYPE_CANLOSE); scripts that cannot
// lose may store -1 (Route 24's grunt), which is never read.
const bank = r.bank(p),
won = word(),
lost = word();
winText = won ? r.address(bank, won) : undefined;
lossText = lost ? r.address(bank, lost) : undefined;
lossText = lost && lost < 0x8000 ? r.address(bank, lost) : undefined;
break;
}
case 0x5f: {
@@ -1758,6 +1770,13 @@ export class ScriptRun {
if (!warp)
throw new UnsupportedScript(offset, opcode, " (no next warp)");
destination = warp;
} else if (method === OW.MAPSETUP_TRAIN) {
// EnterMapWarp: the warp warpcheck found on the player's tile
// (the Magnet Train's door), to the other station.
const warp = this.host.warpHere?.();
if (!warp)
throw new UnsupportedScript(offset, opcode, " (no train warp)");
destination = warp;
} else if (
method === OW.MAPSETUP_WARP ||
method === OW.MAPSETUP_TELEPORT ||
@@ -3133,6 +3152,7 @@ export class ScriptRun {
return this.host.day();
},
weekday: () => this.host.clock().weekday,
timeOfDay: () => this.host.timeOfDay(),
write,
take,
flush: function* () {
@@ -3812,6 +3832,39 @@ export class ScriptRun {
case KANTO.ProfOaksPCBoot:
yield* profOaksPCBoot(this.specialContext(pages, take));
return value;
case KANTO.SnorlaxAwake:
return snorlaxAwake(r, this.host.sound?.engine.mapMusic ?? SOUND.MUSIC_NONE, this.host.position());
case KANTO.MoveTutor:
return yield* moveTutor(this.specialContext(pages, take), value);
case KANTO.TrainerHouse:
return trainerHouse();
case KANTO.Diploma:
if (pages.length) yield { kind: "text", pages: take() };
yield* diploma(this.specialContext(pages, take));
return value;
case KANTO.PrintDiploma:
if (pages.length) yield { kind: "text", pages: take() };
yield* printDiploma(this.specialContext(pages, take));
return value;
case KANTO.MagnetTrain: {
const tileset = this.host.tileset,
palettes = this.host.townPalettes;
if (!tileset || !palettes) return undefined;
if (pages.length) yield { kind: "text", pages: take() };
yield* magnetTrain(
{
reader: r,
female: this.host.player().gender === 1,
tiles: () => tileset().graphics,
palettes: () => palettes(),
sound: this.host.sound,
},
Boolean(value),
);
return value;
}
case KANTO.BillsGrandfather:
return yield* billsGrandfather(this.specialContext(pages, take));
case JOHTO.SlotMachine:
yield* slotMachine(this.specialContext(pages, take), Boolean(value));
return value;
+95
View File
@@ -0,0 +1,95 @@
import { SOUND } from "../audio/constants";
import { fontTiles, textTiles } from "../rom/font";
import { decompress } from "../rom/lz";
import { EGG } from "../rom/pokemon";
import { KANTO, RAM, TRAINER_CARD } from "../rom/profile";
import type { RomReader } from "../rom/reader";
import { printerDisconnected } from "./printer";
import { blankScreen, romPalette, SCREEN_BUTTONS, SCREEN_WIDTH, type TileScreen } from "./screen";
import type { Gen, SpecialContext } from "./special-context";
/** SnorlaxAwake (engine/events/specials.asm): 1 when the Poké Flute
* channel is wMapMusic (the Pokégear's radio, left playing) and the player
* stands on one of .ProximityCoords beside Vermilion's Snorlax, else 0. */
export function snorlaxAwake(
r: RomReader,
mapMusic: number,
position: { x: number; y: number },
): number {
if (mapMusic !== SOUND.MUSIC_POKE_FLUTE_CHANNEL) return 0;
for (let p = KANTO["SnorlaxAwake.ProximityCoords"]; r.byte(p) !== 0xff; p += 2)
if (r.byte(p) === position.x && r.byte(p + 1) === position.y) return 1;
return 0;
}
/** TrainerHouse: sMysteryGiftTrainerHouseFlag, set only when a Mystery
* Gift partner's trainer was received, which this port does not have:
* the Trainer House fights its default trainer. */
export const trainerHouse = () => 0;
/** BillsGrandfather (engine/events/haircut.asm): the Pokémon the player
* shows from the party, as wScriptVar (EGG for an Egg), with its species
* name in wStringBuffer1 and wStringBuffer3; 0 when cancelled. */
export function* billsGrandfather(context: SpecialContext): Gen<number> {
const slot = yield* context.selectMon();
if (slot < 0) return 0;
const mon = context.state.party[slot];
const species = mon.egg ? EGG : mon.species;
const name = context.data.speciesName(species);
context.strings[RAM.stringBuffer1] = name;
context.strings[RAM.stringBuffer3] = name;
return species;
}
/** PlaceDiplomaOnScreen (engine/events/diploma.asm): DiplomaGFX from
* tile 0 under the standard font, DiplomaPage1Tilemap, "PLAYER" and the
* player's name on row 5 and the certification from (2, 8) (<NEXT> two
* rows down, '#' as POKé), in PREDEFPAL_DIPLOMA. */
export function diplomaScreen(r: RomReader, name: string, frame: number): TileScreen {
const tiles = fontTiles(r, frame);
const gfx = decompress(r, KANTO.DiplomaGFX, 0x2000);
tiles.set(gfx.subarray(0, Math.min(gfx.length, 0x80 * 16)));
// _CGB_Diploma: CopyFourPalettes puts PalPacket_Diploma's first
// predefined palette in BG palette 0, the only one the wiped
// attributes use.
const palette = romPalette(r, TRAINER_CARD.PredefPals + r.byte(KANTO.PalPacket_Diploma + 1) * 8);
const screen = blankScreen(tiles, `diploma-${frame}`, [palette], `Diploma: ${name}`);
screen.map = Array.from(r.slice(KANTO.DiplomaPage1Tilemap, SCREEN_WIDTH * 18));
const place = (p: number, x: number, y: number) => {
let column = x,
line = y;
for (let length = 0; length < 128; length++) {
const code = r.byte(p++);
if (code === 0x50) return;
if (code === 0x4e) {
line += 2;
column = x;
continue;
}
for (const tile of code === 0x54 ? [0x8f, 0x8e, 0x8a, 0xea] : [code])
screen.map[line * SCREEN_WIDTH + column++] = tile;
}
throw new Error("Unterminated diploma string.");
};
place(KANTO["PlaceDiplomaOnScreen.Player"], 2, 5);
textTiles(name).forEach((tile, i) => (screen.map[5 * SCREEN_WIDTH + 9 + i] = tile));
place(KANTO["PlaceDiplomaOnScreen.Certification"], 2, 8);
return screen;
}
/** Diploma: the diploma until A or B (WaitPressAorB_BlinkCursor, whose
* cursor shows only over a ▼ already there, which this page has not). */
export function* diploma(context: SpecialContext): Gen {
const screen = diplomaScreen(context.reader, context.player().name, context.state.options.textboxFrame);
for (;;) {
const button = yield { kind: "screen", screen };
if (typeof button !== "string" || !SCREEN_BUTTONS.includes(button)) throw new Error("Invalid diploma button.");
if (button === "a" || button === "b") return;
}
}
/** PrintDiploma: the diploma on screen, then SendScreenToPrinter, which
* finds no printer (PRINTER_ERROR_2) until B cancels. */
export function* printDiploma(context: SpecialContext): Gen {
yield* printerDisconnected(context.reader, context.state.options.textboxFrame);
}
+273
View File
@@ -0,0 +1,273 @@
import type { GameSound } from "../audio/sound";
import { SOUND } from "../audio/constants";
import { KANTO } from "../rom/profile";
import type { RomReader } from "../rom/reader";
import type { Palette } from "../world/types";
import { SCREEN_HEIGHT, SCREEN_WIDTH, type ScreenSprite, type TileScreen } from "./screen";
import type { Gen } from "./special-context";
/** PAL_BG_* (constants/tileset_constants.asm). */
const PAL_BG_GRAY = 0,
PAL_BG_GREEN = 2,
PAL_BG_YELLOW = 4;
/** OAM attribute bits. */
const OAM_PRIO = 0x80,
OAM_YFLIP = 0x40,
OAM_XFLIP = 0x20;
/** The player icon's eight tiles follow the map's 256 in this port's
* tile array (vTiles0 in the original). */
const SPRITE_BASE = 0x100;
/** MagnetTrain_Jumptable's entries. */
const enum Step {
InitPlayerSpriteAnim,
Wait1,
MoveTrain1,
Wait2,
MoveTrain2,
Wait3,
TrainArrived,
}
/** .Frameset_MagnetTrainRed/Blue: the walking icon's two OAMSETs
* (vtile $00 and $04), the last one X-flipped, each held 8 frames. */
const FRAMES: readonly { vtile: number; flags: number }[] = [
{ vtile: 0, flags: 0 },
{ vtile: 4, flags: 0 },
{ vtile: 0, flags: 0 },
{ vtile: 4, flags: OAM_XFLIP },
];
const FRAME_DURATION = 8;
/** What the Magnet Train needs from the world: the station's tileset
* graphics (the train and bushes are its tiles) and SCGB_MAPPALS's
* palettes with the environment set to TOWN for the time of day. */
export interface MagnetTrainHost {
reader: RomReader;
female: boolean;
tiles(): Uint8Array;
palettes(): { bg: Palette[]; obj: Palette[] };
sound?: GameSound;
}
/** MagnetTrain (engine/events/magnet_train.asm) frame by frame: the
* background drawn from MagnetTrainBGTiles and MagnetTrainTilemap, the
* player's walking icon as a sprite animation, the bushes' bands scrolled
* through wLYOverrides and the train's band moving to its hold position,
* waiting, and leaving. */
export class MagnetTrainMachine {
readonly direction: number;
private readonly holdPosition: number;
private readonly finalPosition: number;
private readonly playerX: number;
private index = 0;
private exit = false;
private offset: number;
private position: number;
private waitCounter: number;
private globalX = 0;
private sprite?: { frame: number; duration: number };
/** wLYOverridesBackup's bands as pushed: the bushes and the train. */
private bands: { bushes: number; train: number };
private palettesSet = false;
/** The background map: 18 rows of 32 tiles and their palettes. */
readonly bg: number[][];
readonly attrs: number[][];
oam: ScreenSprite[] = [];
constructor(
private readonly host: MagnetTrainHost,
toGoldenrod: boolean,
) {
const r = host.reader;
// MagnetTrain: forwards to Saffron, backwards to Goldenrod.
this.direction = toGoldenrod ? -1 : 1;
const initPosition = toGoldenrod ? -12 * 8 : 12 * 8;
this.holdPosition = (toGoldenrod ? -8 * 8 : 8 * 8) & 0xff;
this.finalPosition = (toGoldenrod ? 12 * 8 : -12 * 8) & 0xff;
this.playerX = (toGoldenrod ? 11 * 8 + (11 * 8 + 4) : 11 * 8 - (11 * 8 + 4)) & 0xff;
this.offset = this.position = this.waitCounter = initPosition & 0xff;
this.bands = { bushes: this.position, train: this.position };
// DrawMagnetTrain: each row repeats its MagnetTrainBGTiles pair across
// the 32 columns; rows 6-9 then take MagnetTrainTilemap's 20 columns.
this.bg = Array.from({ length: SCREEN_HEIGHT }, (_, row) => {
const e = r.byte(KANTO.MagnetTrainBGTiles + row * 2),
d = r.byte(KANTO.MagnetTrainBGTiles + row * 2 + 1);
return Array.from({ length: 32 }, (_, col) => (col & 1 ? d : e));
});
for (let row = 0; row < 4; row++)
for (let col = 0; col < SCREEN_WIDTH; col++)
this.bg[6 + row][col] = r.byte(KANTO.MagnetTrainTilemap + row * SCREEN_WIDTH + col);
// SetMagnetTrainPals.
this.attrs = Array.from({ length: SCREEN_HEIGHT }, (_, row) =>
Array<number>(32).fill(row < 4 || row >= 14 ? PAL_BG_GREEN : PAL_BG_GRAY),
);
for (let col = 7; col < 13; col++) this.attrs[8][col] = PAL_BG_YELLOW;
}
get done() {
return this.exit;
}
/** One iteration of MagnetTrain's loop: PlaySpriteAnimations, the
* jumptable, then MagnetTrain_UpdateLYOverrides (pushed for the next
* frame by PushLYOverrides). */
step() {
this.oam = this.playSpriteAnimations();
this.jumptable();
// MagnetTrain_UpdateLYOverrides: hSCX and the bushes' bands from the
// offset doubled, the train's band from its position.
this.bands = { bushes: (this.offset * 2) & 0xff, train: this.position };
this.offset = (this.offset + 2 * this.direction) & 0xff;
}
/** After the first iteration's frame, SCGB_MAPPALS (environment TOWN). */
setPalettes() {
this.palettesSet = true;
}
private jumptable() {
switch (this.index) {
case Step.InitPlayerSpriteAnim:
this.sprite = { frame: -1, duration: 0 };
this.index++;
this.waitCounter = 128;
return;
case Step.Wait1:
case Step.Wait2:
case Step.Wait3:
if (this.waitCounter) this.waitCounter--;
else this.index++;
return;
case Step.MoveTrain1:
if (this.position === this.holdPosition) {
this.index++;
this.waitCounter = 128;
return;
}
this.position = (this.position - this.direction) & 0xff;
this.globalX = (this.globalX + this.direction) & 0xff;
return;
case Step.MoveTrain2:
if (this.position === this.finalPosition) {
this.index++;
return;
}
this.position = (this.position - 2 * this.direction) & 0xff;
this.globalX = (this.globalX + 2 * this.direction) & 0xff;
return;
case Step.TrainArrived:
this.exit = true;
this.host.sound?.playSFX(SOUND.SFX_TRAIN_ARRIVED);
return;
}
}
/** GetSpriteAnimFrame and UpdateAnimFrame for the one icon: each frame
* is held for its first draw plus 8 decrements; OAMData_MagnetTrainRed
* (or Blue) placed from XCOORD = the start X and YCOORD = (8 + 2) * 8 +
* 5, plus wGlobalAnimXOffset. */
private playSpriteAnimations(): ScreenSprite[] {
const sprite = this.sprite;
if (!sprite) return [];
if (sprite.duration) sprite.duration--;
else {
sprite.frame = (sprite.frame + 1) % FRAMES.length;
sprite.duration = FRAME_DURATION;
}
const { vtile, flags } = FRAMES[sprite.frame];
const r = this.host.reader,
table = this.host.female
? KANTO["SpriteAnimOAMData.OAMData_MagnetTrainBlue"]
: KANTO["SpriteAnimOAMData.OAMData_MagnetTrainRed"];
const count = r.byte(table),
x = (this.playerX + this.globalX) & 0xff,
y = (8 + 2) * 8 + 5;
return Array.from({ length: count }, (_, i) => {
const p = table + 1 + i * 4,
dy = r.byte(p),
dx = r.byte(p + 1),
attr = r.byte(p + 3);
const flipY = flags & OAM_YFLIP ? (-8 - signed(dy)) & 0xff : dy;
const flipX = flags & OAM_XFLIP ? (-8 - signed(dx)) & 0xff : dx;
const oamAttr = ((attr ^ flags) & (OAM_PRIO | OAM_YFLIP | OAM_XFLIP)) | (attr & ~(OAM_PRIO | OAM_YFLIP | OAM_XFLIP));
return {
x: ((x + flipX) & 0xff) - 8,
y: ((y + flipY) & 0xff) - 16,
tile: SPRITE_BASE + vtile + r.byte(p + 2),
palette: oamAttr & 7,
flipX: Boolean(oamAttr & OAM_XFLIP),
flipY: Boolean(oamAttr & OAM_YFLIP),
behind: Boolean(oamAttr & OAM_PRIO),
};
});
}
/** The tiles: the station's tileset, then the player's sprite frames
* (GetPlayerIcon's standing tiles, then its walking ones 12 tiles on). */
tiles(): Uint8Array {
const r = this.host.reader,
tiles = new Uint8Array((SPRITE_BASE + 8) * 16);
tiles.set(this.host.tiles().subarray(0, SPRITE_BASE * 16));
const gfx = this.host.female ? KANTO.KrisSpriteGFX : KANTO.ChrisSpriteGFX;
tiles.set(r.slice(gfx, 4 * 16), SPRITE_BASE * 16);
tiles.set(r.slice(gfx + 12 * 16, 4 * 16), (SPRITE_BASE + 4) * 16);
return tiles;
}
/** The frame as displayed: the bands as pushed and the OAM drawn. */
screen(tiles: Uint8Array, key: string): TileScreen {
const white: Palette = [
[255, 255, 255],
[255, 255, 255],
[255, 255, 255],
[255, 255, 255],
];
const { bg, obj } = this.palettesSet ? this.host.palettes() : { bg: Array(8).fill(white), obj: Array(8).fill(white) };
const band = (row: number) => (row >= 6 && row < 12 ? this.bands.train : this.bands.bushes);
return {
tiles,
map: this.bg.flatMap((row) => row.slice(0, SCREEN_WIDTH)),
attrs: this.attrs.flatMap((row) => row.slice(0, SCREEN_WIDTH)),
palettes: bg,
sprites: this.oam,
objPalettes: obj,
tileset: key,
label: "Magnet Train",
rowScroll: this.bg.map((tilesOfRow, row) => ({ row, x: band(row), tiles: tilesOfRow, attrs: this.attrs[row] })),
};
}
/** For comparisons: the jumptable and the bands. */
debug() {
return { index: this.index, position: this.position, offset: this.offset, globalX: this.globalX, bands: this.bands };
}
}
const signed = (value: number) => (value > 127 ? value - 256 : value);
/** The MagnetTrain special: MUSIC_MAGNET_TRAIN, the ride frame by frame
* (one realtime screen a frame), and the screen white again at the end.
* wScriptVar is TRUE for the train to Goldenrod. */
export function* magnetTrain(host: MagnetTrainHost, toGoldenrod: boolean): Gen {
const machine = new MagnetTrainMachine(host, toGoldenrod);
const tiles = machine.tiles();
const key = `magnet-train-${host.female ? "kris" : "chris"}`;
host.sound?.playMusic(SOUND.MUSIC_MAGNET_TRAIN);
// MagnetTrain_Jumptable_FirstRunThrough: its frame, then SCGB_MAPPALS
// and UpdateTimePals, whose own frame shows the palettes.
machine.step();
yield { kind: "screen", screen: machine.screen(tiles, key), realtime: true };
machine.setPalettes();
yield { kind: "screen", screen: machine.screen(tiles, key), realtime: true };
while (!machine.done) {
machine.step();
yield { kind: "screen", screen: machine.screen(tiles, key), realtime: true };
}
// .done: ClearBGPalettes, ClearTilemap and the rest take 8 white frames.
const white = machine.screen(tiles, key);
const blank: Palette = [[255, 255, 255], [255, 255, 255], [255, 255, 255], [255, 255, 255]];
white.palettes = white.objPalettes = Array(8).fill(blank);
white.sprites = [];
for (let frame = 0; frame < TEARDOWN_FRAMES; frame++) yield { kind: "screen", screen: white, realtime: true };
}
/** Frames between the arrival and MagnetTrain's return, measured on the
* cartridge (tests/reference-magnet-train.spec.ts). */
export const TEARDOWN_FRAMES = 8;
+1
View File
@@ -548,6 +548,7 @@ export function radioSpecialContext(state: ScriptState, scriptHost: ScriptHost):
player: () => ({ id: scriptHost.player().id, name: scriptHost.text().player }),
today: () => scriptHost.day?.() ?? 0,
weekday: () => scriptHost.clock().weekday,
timeOfDay: () => scriptHost.timeOfDay(),
write: unused,
take: unused,
flush: unused,
+4 -3
View File
@@ -58,9 +58,10 @@ export interface TileScreen {
* `fill` (what the screen left in the rest of the 32-row BG map). */
scroll?: { x?: number; y: number; fill: number };
/** Tile rows with their own hSCX, as wLYOverrides scroll the credits'
* borders: `tiles` are the row's 32 background map tiles (in its first
* column's palette), for a row scrolled beyond the tilemap. */
rowScroll?: { row: number; x: number; tiles?: number[] }[];
* borders: `tiles` are the row's 32 background map tiles (in `attrs`'
* palettes, or its first column's), for a row scrolled beyond the
* tilemap. */
rowScroll?: { row: number; x: number; tiles?: number[]; attrs?: number[] }[];
}
/** A GBC color (RGB555, little-endian word) as RGB. */
+2
View File
@@ -25,6 +25,8 @@ export interface SpecialContext {
* source). */
today(): number;
weekday(): number;
/** wTimeOfDay: morning 0, day 1, night 2. */
timeOfDay(): number;
/** Print a text into the pending pages. */
write(address: number): void;
/** Take the pending pages (for an effect that shows them). */
+1
View File
@@ -185,6 +185,7 @@ export const OW = {
MAPSETUP_WARP: 0xf1,
MAPSETUP_TELEPORT: 0xf4,
MAPSETUP_DOOR: 0xf5,
MAPSETUP_TRAIN: 0xf9,
MAPSETUP_FLY: 0xfc,
NUM_SPAWNS: 28,
SPAWN_INDIGO: 13,
+28 -5
View File
@@ -737,8 +737,10 @@ const WONT_DELETE = 1 << 1,
MOVE_ANYWHERE = 1 << 5,
NOCLIP_OBJS = 1 << 6,
SWIMMING = 1 << 5;
/** SpriteMovementData palette flag: a boulder Strength can push. */
const STRENGTH_BOULDER = 1 << 6;
/** SpriteMovementData palette flags: a boulder Strength can push, and an
* object that covers 2x2 tiles. */
const STRENGTH_BOULDER = 1 << 6,
BIG_OBJECT = 1 << 7;
const SPRITEMOVEDATA_STRENGTH_BOULDER = 25;
const OBJECTTYPE_TRAINER = 2;
/** Map environments (map_data_constants.asm) and the special call count. */
@@ -1254,6 +1256,14 @@ export class GameWorld {
this.notice = "";
this.diagnostic = "";
}
/** IsNPCAtCoord's position test: an object's tile, or for a
* BIG_OBJECT (the big Snorlax, Lapras and dolls) the 2x2 tiles from it
* (WillObjectIntersectBigObject). */
private covers(object: VisibleObject, x: number, y: number) {
if (this.assets.spriteMovement(object.movement).paletteFlags & BIG_OBJECT)
return x - object.x >= 0 && x - object.x < 2 && y - object.y >= 0 && y - object.y < 2;
return object.x === x && object.y === y;
}
/** IsNPCAtCoord: an object blocks its tile and, mid-step, the tile it
* is leaving (OBJECT_LAST_MAP_X/Y). */
private occupied(x: number, y: number, except?: number) {
@@ -1261,7 +1271,7 @@ export class GameWorld {
if (object.id === except) return false;
const motion = this.actor(object).motion;
return (
(object.x === x && object.y === y) ||
this.covers(object, x, y) ||
(motion !== undefined && motion.fromX === x && motion.fromY === y)
);
});
@@ -2523,6 +2533,19 @@ export class GameWorld {
day: () => this.gameDay(),
gameTime: () => this.gameTime,
gameTimer: (counting) => (this.gameTimerPaused = !counting),
townPalettes: () => ({
bg: this.assets.palettes({ ...this.map, environment: OW.TOWN }, this.timeOfDay),
obj: Array.from({ length: 8 }, (_, index) => this.assets.objectPalette(index, this.timeOfDay)),
}),
warpHere: () => {
const warp = this.map.warps.find(
(candidate) => candidate.x === this.player.x && candidate.y === this.player.y,
);
const destination = warp && this.warpDestination(warp, this.liveState);
if (!destination || !this.assets.isPorted(destination.map)) return undefined;
const arrival = this.assets.map(destination.map).warps[destination.warp - 1];
return arrival && { map: destination.map, x: arrival.x, y: arrival.y };
},
tileset: () => ({
graphics: this.assets.tileset(this.map).graphics,
palettes: this.assets.palettes(this.map, this.timeOfDay),
@@ -2568,7 +2591,7 @@ export class GameWorld {
const [dx, dy] = VECTORS[this.player.direction];
const x = this.player.x + dx,
y = this.player.y + dy;
return this.objects().find((object) => object.x === x && object.y === y);
return this.objects().find((object) => this.covers(object, x, y));
}
/** CheckForHiddenItems: a BGEVENT_ITEM not found yet on the screen
* around the player, from four squares left of and above the player to
@@ -4564,7 +4587,7 @@ export class GameWorld {
y += dy;
}
const object = this.objects().find(
(object) => object.x === x && object.y === y,
(object) => this.covers(object, x, y),
);
let script: number | undefined;
// PlayTalkObject (and PlayClickSFX for tile scripts) as an event starts.