Open the League: Victory Road, the Elite Four, the Hall of Fame and the credits (#33)
Routes 26 and 27 with their houses, Tohjo Falls, the Victory Road gate, Victory Road, Route 23, Indigo Plateau, the Elite Four's rooms, Lance's room and the Hall of Fame are open. - halloffame (game/scripts/league.ts): the music fades as SAVING RECORD shows, the win and the team (without Eggs) are recorded, each Pokémon's back picture passes and its front picture slides in for its "New Hall of Famer!" page and cry, then the player's card with the play time and Professor Oak's rating; the play time stops meanwhile. The world then continues at New Bark as Continue does after the cartridge's reset. - credits and the credits after the Hall of Fame (CreditsMachine): the ROM's script, strings, banner Pokémon and THE END, frame by frame as VBlank_Credits presents them, with the frames each command group runs over measured on the cartridge. B shortens credits seen before. - wait, appear/disappear of the player, and rival names in battle texts (PlaceEnemysName prints wRivalName alone: "???" before he is named). - The tile-screen renderer wraps hSCX/hSCY at 256 pixels and gives rows their own hSCX. - Reference: the credits match the cartridge on every frame (BG map, banner tiles, CRAM palettes, LY overrides, parser), with and without B; the ceremony's pages match for every species and the player's card for Chris and Kris. - Playthrough: five stages from Tin Tower to the Hall of Fame (HM07 from Ice Path, Tohjo Falls' waterfall, Victory Road's ladders, the Elite Four and Lance); the driver follows warps within a map and fights carefully from here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b6fdc23508
commit
f05fd8ebee
23 files changed
+1943
-107
No files matched your search
@@ -202,6 +202,8 @@ export type BattleOutcome = {
|
||||
export interface BattleTrainer {
|
||||
name: string;
|
||||
className: string;
|
||||
/** wTrainerClass, where the text needs it (the rivals' <ENEMY>). */
|
||||
classId?: number;
|
||||
/** wOTPartyMons: level, status and HP follow the Pokémon out only where
|
||||
* the original updates them (UpdateEnemyMonInParty: status changes and
|
||||
* heals, a faint or a withdrawal); damage alone leaves them stale. */
|
||||
@@ -700,8 +702,12 @@ export class Battle {
|
||||
this.battleType === C.BATTLETYPE_TUTORIAL
|
||||
? readString(this.reader, BATTLE["CatchTutorial.Dude"], base)
|
||||
: base.player,
|
||||
// PlaceEnemysName: a rival (RIVAL1, RIVAL2) is wRivalName alone,
|
||||
// other trainers the class and the name.
|
||||
trainer: this.options.trainer
|
||||
? `${this.options.trainer.className} ${this.options.trainer.name}`.trim()
|
||||
? this.options.trainer.classId === C.RIVAL1 || this.options.trainer.classId === C.RIVAL2
|
||||
? base.rival
|
||||
: `${this.options.trainer.className} ${this.options.trainer.name}`.trim()
|
||||
: base.trainer,
|
||||
strings: {
|
||||
...base.strings,
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* macros/scripts/battle_commands.asm). `npm run check:reference` compares
|
||||
* every entry with the pinned source, so a wrong value cannot ship. */
|
||||
export const C = {
|
||||
// Trainer classes (constants/trainer_constants.asm) the text treats apart.
|
||||
RIVAL1: 9,
|
||||
RIVAL2: 42,
|
||||
// Move effects (constants/move_effect_constants.asm)
|
||||
EFFECT_NORMAL_HIT: 0,
|
||||
EFFECT_LEECH_HIT: 3,
|
||||
|
||||
@@ -5,6 +5,54 @@ import {
|
||||
} from "../scripts/screen";
|
||||
import { drawTile } from "./pixels";
|
||||
|
||||
/** The background map is 32x32 tiles and wraps, as hSCX and hSCY do. */
|
||||
const BG_TILES = 32;
|
||||
|
||||
/** The background tile and palette at a background map cell: the screen's
|
||||
* 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 (row < SCREEN_HEIGHT && col < SCREEN_WIDTH) {
|
||||
const cell = row * SCREEN_WIDTH + col;
|
||||
return { tile: screen.map[cell], palette: screen.attrs[cell] };
|
||||
}
|
||||
return { tile: screen.scroll?.fill ?? -1, palette: 0 };
|
||||
}
|
||||
|
||||
/** The background's color index (0-3) and palette at every screen pixel,
|
||||
* line by line: each line reads the background map at hSCY and that
|
||||
* line's hSCX (a `rowScroll` row's own, as wLYOverrides set it), wrapping
|
||||
* at 256 pixels. A pixel of a -1 tile (an overlay's hole) is -1. */
|
||||
export function tileScreenBackground(screen: TileScreen) {
|
||||
const width = SCREEN_WIDTH * 8,
|
||||
height = SCREEN_HEIGHT * 8;
|
||||
const indices = new Int8Array(width * height).fill(-1),
|
||||
palettes = new Uint8Array(width * height);
|
||||
const scrollY = screen.scroll?.y ?? 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
const screenRow = y >> 3;
|
||||
const scrollX =
|
||||
screen.rowScroll?.find((entry) => entry.row === screenRow)?.x ??
|
||||
screen.scroll?.x ??
|
||||
0;
|
||||
const bgY = (((y + scrollY) % 256) + 256) % 256,
|
||||
row = bgY >> 3;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const bgX = (((x + scrollX) % 256) + 256) % 256;
|
||||
const { tile, palette } = backgroundCell(screen, row % BG_TILES, (bgX >> 3) % BG_TILES);
|
||||
if (tile < 0) continue;
|
||||
const offset = tile * 16 + (bgY & 7) * 2;
|
||||
if (offset < 0 || offset + 2 > screen.tiles.length) continue;
|
||||
const bit = 7 - (bgX & 7);
|
||||
indices[y * width + x] =
|
||||
((screen.tiles[offset] >> bit) & 1) | (((screen.tiles[offset + 1] >> bit) & 1) << 1);
|
||||
palettes[y * width + x] = palette;
|
||||
}
|
||||
}
|
||||
return { indices, palettes };
|
||||
}
|
||||
|
||||
/** Draw a tile screen: the background tilemap, then the sprites over it
|
||||
* (color 0 transparent). `frame` counts video frames for blinking sprites. */
|
||||
export function drawTileScreen(
|
||||
@@ -18,30 +66,17 @@ export function drawTileScreen(
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
const context = canvas.getContext("2d")!;
|
||||
const image = context.createImageData(width, height);
|
||||
// Recorded only for OAM_PRIO sprites (`behind`), which need the
|
||||
// background's own color index to clip against; a screen with none
|
||||
// pays nothing beyond the allocation.
|
||||
const background = tileScreenBackground(screen);
|
||||
// OAM_PRIO sprites (`behind`) clip against the background's own color
|
||||
// index; a hole in an overlay counts as color 0.
|
||||
const indices = new Uint8Array(width * height);
|
||||
const scrollX = screen.scroll?.x ?? 0;
|
||||
const scrollY = screen.scroll?.y ?? 0;
|
||||
for (let row = scrollY < 0 ? -1 : 0; row < SCREEN_HEIGHT + (scrollY > 0 ? 1 : 0); row++)
|
||||
for (let col = scrollX < 0 ? -1 : 0; col < SCREEN_WIDTH + (scrollX > 0 ? 1 : 0); col++) {
|
||||
const cell = row * SCREEN_WIDTH + col;
|
||||
const inside = row >= 0 && row < SCREEN_HEIGHT && col >= 0 && col < SCREEN_WIDTH;
|
||||
const tile = inside ? screen.map[cell] : screen.scroll!.fill;
|
||||
if (tile < 0) continue;
|
||||
drawTile(
|
||||
image.data,
|
||||
width,
|
||||
height,
|
||||
screen.tiles,
|
||||
tile,
|
||||
col * 8 - scrollX,
|
||||
row * 8 - scrollY,
|
||||
(inside && screen.palettes[screen.attrs[cell]]) || screen.palettes[0],
|
||||
{ indices },
|
||||
);
|
||||
}
|
||||
for (let pixel = 0; pixel < width * height; pixel++) {
|
||||
const index = background.indices[pixel];
|
||||
if (index < 0) continue;
|
||||
indices[pixel] = index;
|
||||
const color = (screen.palettes[background.palettes[pixel]] ?? screen.palettes[0])[index];
|
||||
image.data.set([color[0], color[1], color[2], 255], pixel * 4);
|
||||
}
|
||||
const blinkOff = !(frame & 0x10);
|
||||
for (const sprite of screen.sprites) {
|
||||
if (sprite.blink && blinkOff) continue;
|
||||
|
||||
@@ -1501,6 +1501,23 @@ export const JOHTO = {
|
||||
FontExtra: 0xf8000,
|
||||
} as const;
|
||||
|
||||
/** The League, Kanto and the postgame (board #33), by PRET label. */
|
||||
export const KANTO = {
|
||||
ChrisBackpic: 0x2ba1a,
|
||||
KrisBackpic: 0x88ed6,
|
||||
"AnimateHallOfFame.String_NewHallOfFamer": 0x8652c,
|
||||
"HOF_AnimatePlayerPic.PlayTime": 0x868ed,
|
||||
"InitDisplayForHallOfFame.SavingRecordText": 0x4e8bd,
|
||||
ProfOaksPCBoot: 0x265ee,
|
||||
CreditsBorderGFX: 0x109c24,
|
||||
CopyrightGFX: 0xe4000,
|
||||
TheEndGFX: 0xcbd2e,
|
||||
CreditsPichuGFX: 0x109cb4,
|
||||
CreditsScript: 0x10acb4,
|
||||
CreditsStringsPointers: 0x10ae13,
|
||||
CreditsPalettes: 0x109b6a,
|
||||
} as const;
|
||||
|
||||
/** Saved WRAM bytes that map scripts read and write directly (readmem,
|
||||
* writemem, loadmem and readvar), keyed by their PRET labels; the native
|
||||
* save keeps them by label in `memory`. */
|
||||
@@ -1936,6 +1953,22 @@ export const NATIVE_MAPS: Record<string, string> = {
|
||||
"10:17": "Route 36 National Park gate",
|
||||
"3:15": "National Park",
|
||||
"3:16": "National Park (Bug-Catching Contest)",
|
||||
"24:1": "Route 26",
|
||||
"24:2": "Route 27",
|
||||
"24:10": "Route 26 heal house",
|
||||
"24:11": "Day-of-week siblings’ house",
|
||||
"24:12": "Route 27 sandstorm house",
|
||||
"3:83": "Tohjo Falls",
|
||||
"23:13": "Victory Road gate",
|
||||
"3:91": "Victory Road",
|
||||
"16:1": "Route 23",
|
||||
"16:2": "Pokémon Center",
|
||||
"16:3": "Will’s room",
|
||||
"16:4": "Koga’s room",
|
||||
"16:5": "Bruno’s room",
|
||||
"16:6": "Karen’s room",
|
||||
"16:7": "Lance’s room",
|
||||
"16:8": "Hall of Fame",
|
||||
};
|
||||
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HallOfFameTeam } from "../party/hall-of-fame";
|
||||
import type { HallOfFameMon, HallOfFameTeam } from "../party/hall-of-fame";
|
||||
import { PokemonData } from "../rom/pokemon";
|
||||
import { JOHTO } from "../rom/profile";
|
||||
import { fontTiles, FRAME_TILE, SPACE_TILE, textTiles } from "../rom/font";
|
||||
@@ -7,63 +7,67 @@ import { blankScreen, SCREEN_BUTTONS, type TileScreen } from "./screen";
|
||||
import { defaultTextContext, readString } from "./text";
|
||||
import type { GameSound } from "../audio/sound";
|
||||
|
||||
/** _HallOfFamePC.DisplayMonAndStrings: DisplayHOFMon's page for one
|
||||
* Pokémon of a team, over _LoadFontsBattleExtra's tiles, in
|
||||
* SCGB_PLAYER_OR_MON_FRONTPIC_PALS's single palette. The picture is shown
|
||||
* at rest: HOF_AnimateFrontpic's ANIM_MON_HOF animation is not ported. */
|
||||
export function hallOfFameScreen(
|
||||
data: PokemonData,
|
||||
team: HallOfFameTeam,
|
||||
slot: number,
|
||||
frame: number,
|
||||
): TileScreen {
|
||||
const mon = team.mons[slot],
|
||||
r = data.reader;
|
||||
const tiles = fontTiles(r, frame);
|
||||
tiles.set(r.slice(JOHTO.FontBattleExtra, 25 * 16), 0x60 * 16);
|
||||
tiles.set(data.frontPic(mon.species, PokemonData.unownLetter(mon.dvs)));
|
||||
const title = readString(
|
||||
r,
|
||||
JOHTO["_HallOfFamePC.TimeFamer"],
|
||||
defaultTextContext(""),
|
||||
/** Place text on a screen as tile numbers; '"' is <PK> in a nickname. */
|
||||
export function placeText(screen: TileScreen, x: number, y: number, value: string) {
|
||||
const tiles = (value.match(/ᴾᴷ|ᴹᴺ|[\s\S]/gu) ?? []).flatMap((character) =>
|
||||
character === '"' ? [0xe1] : textTiles(character),
|
||||
);
|
||||
screen.map.splice(y * 20 + x, tiles.length, ...tiles);
|
||||
}
|
||||
/** Textbox: a frame with `width` interior columns and `height` interior
|
||||
* rows whose top-left corner is at (x, y). */
|
||||
export function placeTextbox(
|
||||
screen: TileScreen,
|
||||
x: number,
|
||||
y: number,
|
||||
height: number,
|
||||
width = 18,
|
||||
) {
|
||||
const put = (column: number, row: number, values: readonly number[]) =>
|
||||
screen.map.splice(row * 20 + column, values.length, ...values);
|
||||
put(x, y, [FRAME_TILE, ...Array(width).fill(FRAME_TILE + 1), FRAME_TILE + 2]);
|
||||
for (let row = y + 1; row <= y + height; row++) {
|
||||
put(x, row, [FRAME_TILE + 3, ...Array(width).fill(SPACE_TILE), FRAME_TILE + 3]);
|
||||
}
|
||||
put(x, y + height + 1, [
|
||||
FRAME_TILE + 4,
|
||||
...Array(width).fill(FRAME_TILE + 1),
|
||||
FRAME_TILE + 5,
|
||||
]);
|
||||
}
|
||||
/** The Hall of Fame screens' tiles: the standard font, the textbox frame
|
||||
* and _LoadFontsBattleExtra's $60-$78 (InitDisplayForHallOfFame and
|
||||
* _HallOfFamePC load the same). */
|
||||
export function hallOfFameTiles(data: PokemonData, frame: number) {
|
||||
const tiles = fontTiles(data.reader, frame);
|
||||
tiles.set(data.reader.slice(JOHTO.FontBattleExtra, 25 * 16), 0x60 * 16);
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/** DisplayHOFMon: one Pokémon's page, over the Hall of Fame tiles, in
|
||||
* SCGB_PLAYER_OR_MON_FRONTPIC_PALS's single palette, without the title
|
||||
* line its callers print at (1, 2). The picture is shown at rest:
|
||||
* HOF_AnimateFrontpic's ANIM_MON_HOF animation is not ported. */
|
||||
export function displayHOFMon(
|
||||
data: PokemonData,
|
||||
mon: HallOfFameMon,
|
||||
frame: number,
|
||||
label: string,
|
||||
): TileScreen {
|
||||
const tiles = hallOfFameTiles(data, frame);
|
||||
tiles.set(data.frontPic(mon.species, PokemonData.unownLetter(mon.dvs)));
|
||||
const screen = blankScreen(
|
||||
tiles,
|
||||
`hof-${mon.species}-${mon.dvs}-${frame}`,
|
||||
[data.palette(mon.species, PokemonData.shiny(mon.dvs))],
|
||||
`${team.win}${title.trimStart()}: ${mon.nickname}, ${data.speciesName(mon.species)}, level ${mon.level}, ID ${String(mon.otId).padStart(5, "0")}`,
|
||||
`${label}: ${mon.nickname}, ${data.speciesName(mon.species)}, level ${mon.level}, ID ${String(mon.otId).padStart(5, "0")}`,
|
||||
);
|
||||
screen.map.fill(SPACE_TILE);
|
||||
const put = (x: number, y: number, values: readonly number[]) =>
|
||||
screen.map.splice(y * 20 + x, values.length, ...values);
|
||||
const text = (x: number, y: number, value: string) =>
|
||||
put(
|
||||
x,
|
||||
y,
|
||||
(value.match(/ᴾᴷ|ᴹᴺ|[\s\S]/gu) ?? []).flatMap((character) =>
|
||||
character === '"' ? [0xe1] : textTiles(character),
|
||||
),
|
||||
);
|
||||
// Textbox with 18 interior columns and three/four interior rows.
|
||||
const box = (y: number, height: number) => {
|
||||
put(0, y, [FRAME_TILE, ...Array(18).fill(FRAME_TILE + 1), FRAME_TILE + 2]);
|
||||
for (let row = y + 1; row <= y + height; row++) {
|
||||
put(0, row, [FRAME_TILE + 3]);
|
||||
put(19, row, [FRAME_TILE + 3]);
|
||||
}
|
||||
put(0, y + height + 1, [
|
||||
FRAME_TILE + 4,
|
||||
...Array(18).fill(FRAME_TILE + 1),
|
||||
FRAME_TILE + 5,
|
||||
]);
|
||||
};
|
||||
box(0, 3);
|
||||
box(12, 4);
|
||||
// "HOF Master!" needs a count of HOF_MASTER_COUNT + 1, which the capped
|
||||
// count never reaches: the original's bug (docs/bugs_and_glitches.md)
|
||||
// leaves every team "-Time Famer".
|
||||
text(1, 2, title);
|
||||
text(2, 2, String(team.win).padStart(3, " "));
|
||||
const text = (x: number, y: number, value: string) => placeText(screen, x, y, value);
|
||||
placeTextbox(screen, 0, 0, 3);
|
||||
placeTextbox(screen, 0, 12, 4);
|
||||
for (let x = 0; x < 7; x++)
|
||||
for (let y = 0; y < 7; y++) put(x + 6, y + 5, [x * 7 + y]);
|
||||
put(1, 13, [0x74, 0xf2]); // № and <DOT>.
|
||||
@@ -83,6 +87,28 @@ export function hallOfFameScreen(
|
||||
return screen;
|
||||
}
|
||||
|
||||
/** _HallOfFamePC.DisplayMonAndStrings: DisplayHOFMon's page for one
|
||||
* Pokémon of a team with the team's "-Time Famer" title. */
|
||||
export function hallOfFameScreen(
|
||||
data: PokemonData,
|
||||
team: HallOfFameTeam,
|
||||
slot: number,
|
||||
frame: number,
|
||||
): TileScreen {
|
||||
const title = readString(
|
||||
data.reader,
|
||||
JOHTO["_HallOfFamePC.TimeFamer"],
|
||||
defaultTextContext(""),
|
||||
);
|
||||
const screen = displayHOFMon(data, team.mons[slot], frame, `${team.win}${title.trimStart()}`);
|
||||
// "HOF Master!" needs a count of HOF_MASTER_COUNT + 1, which the capped
|
||||
// count never reaches: the original's bug (docs/bugs_and_glitches.md)
|
||||
// leaves every team "-Time Famer".
|
||||
placeText(screen, 1, 2, title);
|
||||
placeText(screen, 2, 2, String(team.win).padStart(3, " "));
|
||||
return screen;
|
||||
}
|
||||
|
||||
/** _HallOfFamePC: LoadHOFTeam takes the teams newest first until an absent
|
||||
* one (or NUM_HOF_TEAMS); A shows the team's next Pokémon, and after the
|
||||
* last one the next team, Start skips to the next team and B leaves. Each
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
EMPTY_ROUTINES,
|
||||
ENGINE_FLAGS,
|
||||
JOHTO,
|
||||
KANTO,
|
||||
MENU,
|
||||
NATIVE_ROUTINES,
|
||||
NUM_FRUIT_TREES,
|
||||
@@ -85,6 +86,8 @@ import type { TileScreen } from "./screen";
|
||||
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 type { GameTime } from "../world/game-time";
|
||||
import { cardFlip } from "./card-flip";
|
||||
import { giveDratini, moveDeletion } from "./blackthorn";
|
||||
import { beastsCheck, monCheck } from "./tin-tower";
|
||||
@@ -808,6 +811,9 @@ export interface ScriptHost {
|
||||
initCallReceiveDelay?(): void;
|
||||
/** GetWorldMapLocation for any map. */
|
||||
landmarkOf?(mapId: string): number;
|
||||
/** The play time, and GAME_TIMER_COUNTING_F (halloffame clears it). */
|
||||
gameTime?(): GameTime;
|
||||
gameTimer?(counting: boolean): void;
|
||||
}
|
||||
|
||||
/** A sound a script started while its text was still to be shown: it
|
||||
@@ -1950,6 +1956,38 @@ export class ScriptRun {
|
||||
else Object.assign(memory, { wDunsparceMapGroup: group, wDunsparceMapNumber: number });
|
||||
break;
|
||||
}
|
||||
case 0xa1:
|
||||
case 0xa2: {
|
||||
// halloffame (HallOfFame, the game timer paused meanwhile) and
|
||||
// credits (RedCredits), then Script_endall. After the Hall of
|
||||
// Fame the original resets and Continue spawns at New Bark
|
||||
// (SPAWN_LANCE); after Red the overworld reloads at Mt. Silver
|
||||
// (SPAWN_RED); both by MAPSETUP_WARP, Continue's after
|
||||
// JumpRoamMons. The port has no title screen to reset to.
|
||||
yield* flush();
|
||||
const context = this.ceremonyContext(pages, take);
|
||||
if (opcode === 0xa1) {
|
||||
this.host.gameTimer?.(false);
|
||||
yield* hallOfFame(context);
|
||||
this.host.gameTimer?.(true);
|
||||
} else yield* redCredits(context);
|
||||
const entry = SYMBOLS.spawnPoints + (opcode === 0xa1 ? OW.SPAWN_NEW_BARK : OW.SPAWN_MT_SILVER) * 4;
|
||||
yield synchronous({
|
||||
kind: "warp",
|
||||
map: `${r.byte(entry)}:${r.byte(entry + 1)}`,
|
||||
x: r.byte(entry + 2),
|
||||
y: r.byte(entry + 3),
|
||||
...(opcode === 0xa1 && { roam: "jump" as const }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 0xa8: {
|
||||
// wait: DelayFrames of six frames, `n` times; the map waits.
|
||||
const ticks = byte() * 3;
|
||||
if (pages.length) yield { kind: "text", pages: take(), holdTicks: ticks };
|
||||
else yield synchronous({ kind: "delay", ticks });
|
||||
break;
|
||||
}
|
||||
case 0xa6: {
|
||||
// gettrainerclassname: GetName for TRAINER_NAME, the class's name.
|
||||
const classId = byte();
|
||||
@@ -3106,6 +3144,22 @@ export class ScriptRun {
|
||||
waitSFX: () => this.waitSFX(),
|
||||
};
|
||||
}
|
||||
/** The specials' context with what the League's ceremony and the
|
||||
* credits need. */
|
||||
private ceremonyContext(pages: string[], take: () => string[]): CeremonyContext {
|
||||
const black: Palette[number] = [0, 0, 0],
|
||||
white: Palette[number] = [255, 255, 255];
|
||||
return {
|
||||
...this.specialContext(pages, take),
|
||||
sound: this.host.sound,
|
||||
gameTime: () => {
|
||||
if (!this.host.gameTime) throw new Error("No game timer.");
|
||||
return this.host.gameTime();
|
||||
},
|
||||
female: this.host.player().gender === 1,
|
||||
mapPalette: this.host.tileset?.().palettes[0] ?? [white, white, black, black],
|
||||
};
|
||||
}
|
||||
/** PlayMonCry: it plays with the pending page, else now, and waits. */
|
||||
private *monCry(species: number): Generator<ScriptEffect, void, Input> {
|
||||
this.cue(() => this.host.sound!.playMonCry2(species));
|
||||
@@ -3755,6 +3809,9 @@ export class ScriptRun {
|
||||
);
|
||||
return Number(solved);
|
||||
}
|
||||
case KANTO.ProfOaksPCBoot:
|
||||
yield* profOaksPCBoot(this.specialContext(pages, take));
|
||||
return value;
|
||||
case JOHTO.SlotMachine:
|
||||
yield* slotMachine(this.specialContext(pages, take), Boolean(value));
|
||||
return value;
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
import type { GameSound } from "../audio/sound";
|
||||
import { SOUND } from "../audio/constants";
|
||||
import { addHallOfFameEntry, type HallOfFameMon } from "../party/hall-of-fame";
|
||||
import { textTiles } from "../rom/font";
|
||||
import { decompress } from "../rom/lz";
|
||||
import { PokemonData } from "../rom/pokemon";
|
||||
import { JOHTO, KANTO, PC, RAM } from "../rom/profile";
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { GameTime } from "../world/game-time";
|
||||
import type { Palette } from "../world/types";
|
||||
import {
|
||||
displayHOFMon,
|
||||
hallOfFameTiles,
|
||||
placeText,
|
||||
placeTextbox,
|
||||
} from "./hall-of-fame";
|
||||
import { blankScreen, rgb, SCREEN_WIDTH, type TileScreen } from "./screen";
|
||||
import type { Gen, SpecialContext } from "./special-context";
|
||||
import { defaultTextContext, readString, UnsupportedText } from "./text";
|
||||
|
||||
const SPACE = 0x7f;
|
||||
/** PlacePOKe's "POKé". */
|
||||
const POKE = [0x8f, 0x8e, 0x8a, 0xea];
|
||||
/** HALLOFFAME_COLON: HOF_AnimatePlayerPic's colon tile. */
|
||||
const HALLOFFAME_COLON = 0x63;
|
||||
/** The ▼ LoadBlinkingCursor places at (18, 17) while text waits. */
|
||||
const CURSOR = 0xee;
|
||||
|
||||
/** One state of the text box while PrintText runs: its two lines as
|
||||
* tiles, and whether the text waits for A or B there (cont, para and
|
||||
* prompt) or ends with this page shown (done, text_end). */
|
||||
export interface BoxPage {
|
||||
lines: [number[], number[]];
|
||||
wait: boolean;
|
||||
}
|
||||
|
||||
/** PrintText's text box, from the ROM's text commands and characters as
|
||||
* tiles (so a ligature such as "'s" keeps its one tile): text_start's
|
||||
* characters, `line`, `cont`, `para`, `prompt` and `done`, text_ram and
|
||||
* text_far. */
|
||||
export function textboxPages(
|
||||
r: RomReader,
|
||||
start: number,
|
||||
ram: (address: number) => string,
|
||||
player: string,
|
||||
): BoxPage[] {
|
||||
const pages: BoxPage[] = [];
|
||||
let lines: [number[], number[]] = [[], []],
|
||||
line = 0;
|
||||
const snap = (wait: boolean) =>
|
||||
pages.push({ lines: [[...lines[0]], [...lines[1]]], wait });
|
||||
/** True when the text ended (done or prompt). */
|
||||
const run = (p: number, depth: number): boolean => {
|
||||
if (depth > 4) throw new Error("Text calls exceed the recursion limit.");
|
||||
for (let count = 0; count < 4096; count++) {
|
||||
const offset = p,
|
||||
command = r.byte(p++);
|
||||
if (command === 0x50) return false;
|
||||
if (command === 0x01) {
|
||||
lines[line].push(...textTiles(ram(r.word(p))));
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (command === 0x16) {
|
||||
if (run(r.near(p, r.byte(p + 2)), depth + 1)) return true;
|
||||
p += 3;
|
||||
continue;
|
||||
}
|
||||
if (command !== 0x00) throw new UnsupportedText(offset, command);
|
||||
for (let length = 0; length < 1024; length++) {
|
||||
const at = p,
|
||||
code = r.byte(p++);
|
||||
if (code === 0x50) break;
|
||||
if (code === 0x4f) line = 1;
|
||||
else if (code === 0x55) {
|
||||
snap(true);
|
||||
lines = [lines[1], []];
|
||||
line = 1;
|
||||
} else if (code === 0x51) {
|
||||
snap(true);
|
||||
lines = [[], []];
|
||||
line = 0;
|
||||
} else if (code === 0x57) {
|
||||
snap(false);
|
||||
return true;
|
||||
} else if (code === 0x58) {
|
||||
snap(true);
|
||||
return true;
|
||||
} else if (code === 0x54) lines[line].push(...POKE);
|
||||
else if (code === 0x52) lines[line].push(...textTiles(player));
|
||||
else if (code >= 0x60) lines[line].push(code);
|
||||
else throw new UnsupportedText(at, code);
|
||||
}
|
||||
}
|
||||
throw new Error("Text command limit exceeded.");
|
||||
};
|
||||
if (!run(start, 0)) snap(false);
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** A page in the standard text box at the bottom (rows 12-17): its lines
|
||||
* at (1, 14) and (1, 16), and the ▼ while it waits. */
|
||||
export function drawBoxPage(screen: TileScreen, page: BoxPage) {
|
||||
placeTextbox(screen, 0, 12, 4);
|
||||
page.lines.forEach((tiles, row) =>
|
||||
screen.map.splice((14 + row * 2) * SCREEN_WIDTH + 1, tiles.length, ...tiles),
|
||||
);
|
||||
if (page.wait) screen.map[17 * SCREEN_WIDTH + 18] = CURSOR;
|
||||
}
|
||||
|
||||
/** OakRatings: the rating for a caught count, its sound and its text. */
|
||||
export function oakRating(r: RomReader, caught: number) {
|
||||
for (let entry = PC.OakRatings; ; entry += 5) {
|
||||
if (r.byte(entry) >= caught)
|
||||
return { sfx: r.word(entry + 1), text: r.near(entry + 3, r.bank(entry)) };
|
||||
if (entry > PC.OakRatings + 5 * 32) throw new Error("No Oak rating.");
|
||||
}
|
||||
}
|
||||
/** Rate's .UpdateRatingBuffers: the seen and caught counts it prints. */
|
||||
export function ratingCounts(context: SpecialContext) {
|
||||
const { seen, caught } = context.state.pokedex;
|
||||
return { seen: seen.size, caught: caught.size };
|
||||
}
|
||||
|
||||
/** ProfOaksPCBoot (Professor Oak's PC in his lab): the completion level,
|
||||
* the counts and the rating with its sound, each page waited for. */
|
||||
export function* profOaksPCBoot(context: SpecialContext): Gen {
|
||||
const { seen, caught } = ratingCounts(context);
|
||||
context.strings[RAM.stringBuffer3] = String(seen);
|
||||
context.strings[RAM.stringBuffer4] = String(caught);
|
||||
context.write(PC.OakPCText2);
|
||||
context.write(PC.OakPCText3);
|
||||
yield* context.flush();
|
||||
const rating = oakRating(context.reader, caught);
|
||||
context.write(rating.text);
|
||||
context.playSFX(rating.sfx);
|
||||
yield* context.flush();
|
||||
yield* context.waitSFX();
|
||||
}
|
||||
|
||||
/** What the ceremony and the credits need beyond a special's context. */
|
||||
export interface CeremonyContext extends SpecialContext {
|
||||
sound?: GameSound;
|
||||
gameTime(): GameTime;
|
||||
female: boolean;
|
||||
/** The map's BG palette 0, which SetDefaultBGPAndOBP shows again under
|
||||
* "SAVING RECORD…" (wBGPals1 still holds the map's palettes). */
|
||||
mapPalette: Palette;
|
||||
}
|
||||
|
||||
type Frame = Gen<string>;
|
||||
/** One video frame of a screen that runs on its own; the answer is the
|
||||
* button pressed meanwhile ("" for none). */
|
||||
function* frame(screen: TileScreen): Frame {
|
||||
const button = yield { kind: "screen", screen, realtime: true };
|
||||
return typeof button === "string" ? button : "";
|
||||
}
|
||||
function* frames(screen: () => TileScreen, count: number): Gen {
|
||||
for (let i = 0; i < count; i++) yield* frame(screen());
|
||||
}
|
||||
/** JoyWaitAorB, with the screen shown. */
|
||||
function* waitAorB(screen: TileScreen): Gen {
|
||||
for (;;) {
|
||||
const button = yield* frame(screen);
|
||||
if (button === "a" || button === "b") return;
|
||||
}
|
||||
}
|
||||
/** PrintText's pages over a screen (waiting at each cont, para and
|
||||
* prompt); returns the screen with the last page left shown. */
|
||||
function* printText(base: () => TileScreen, pages: BoxPage[]): Gen<TileScreen> {
|
||||
let screen = base();
|
||||
for (const page of pages) {
|
||||
screen = base();
|
||||
drawBoxPage(screen, page);
|
||||
if (page.wait) yield* waitAorB(screen);
|
||||
}
|
||||
return screen;
|
||||
}
|
||||
|
||||
/** A blank Hall of Fame screen (spaces, one palette). */
|
||||
function hofScreen(tiles: Uint8Array, key: string, palette: Palette, label: string) {
|
||||
const screen = blankScreen(tiles, key, [palette], label);
|
||||
screen.map.fill(SPACE);
|
||||
return screen;
|
||||
}
|
||||
const WHITE: Palette = [
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
];
|
||||
/** PlaceGraphic: a picture's tiles column by column from `first`. */
|
||||
function placeGraphic(screen: TileScreen, x: number, y: number, width: number, height: number, first: number) {
|
||||
for (let column = 0; column < width; column++)
|
||||
for (let row = 0; row < height; row++)
|
||||
screen.map[(y + row) * SCREEN_WIDTH + x + column] = first + column * height + row;
|
||||
}
|
||||
/** The background moved by hSCX/hSCY over a map of spaces. */
|
||||
const scrolled = (screen: TileScreen, scx: number, scy: number): TileScreen => ({
|
||||
...screen,
|
||||
scroll: { x: scx, y: scy, fill: SPACE },
|
||||
});
|
||||
/** HOF_SlideBackpic: hSCX up by 4 a frame from $90 until $70 (wrapping),
|
||||
* the back picture passing right to left over the lower screen at
|
||||
* hSCY $d0; then HOF_SlideFrontpic's front picture from hSCX `from` down
|
||||
* by 2 a frame to 0. */
|
||||
function* slides(back: TileScreen, front: TileScreen, from: number): Gen {
|
||||
yield* frame(scrolled(back, 0x90, 0xd0));
|
||||
for (let scx = 0x90; scx !== 0x70; ) {
|
||||
scx = (scx + 4) & 0xff;
|
||||
yield* frame(scrolled(back, scx, 0xd0));
|
||||
}
|
||||
yield* frame(scrolled(front, from, 0));
|
||||
for (let scx = from; scx; ) {
|
||||
scx = (scx - 2) & 0xff;
|
||||
yield* frame(scrolled(front, scx, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/** HallOfFame (engine/events/halloffame.asm), from the `halloffame`
|
||||
* command: the music fades as "SAVING RECORD…" shows, the League win is
|
||||
* counted and the party recorded (STATUSFLAGS_HALL_OF_FAME_F set), each
|
||||
* Pokémon's back picture passes and its front picture slides in as a "New
|
||||
* Hall of Famer!" with its cry, then the player with the play time and
|
||||
* Professor Oak's rating, and the credits. The game's own save is the
|
||||
* world's: the script's state commits when it ends. */
|
||||
export function* hallOfFame(context: CeremonyContext): Gen {
|
||||
const r = context.reader,
|
||||
data = context.data,
|
||||
state = context.state,
|
||||
sound = context.sound;
|
||||
// Credits: B shortens them only for a team that had entered before.
|
||||
const skippable = state.hallOfFame;
|
||||
// HallOfFame_FadeOutMusic: MUSIC_NONE at fade rate 10, the screen white,
|
||||
// InitDisplayForHallOfFame's text, then 100 frames.
|
||||
sound?.fadeOutTo(SOUND.MUSIC_NONE, 10);
|
||||
const frameOption = state.options.textboxFrame;
|
||||
const tiles = hallOfFameTiles(data, frameOption);
|
||||
const saving = hofScreen(tiles, `hof-saving-${frameOption}`, WHITE, "Saving record");
|
||||
const savingText = textboxPages(r, KANTO["InitDisplayForHallOfFame.SavingRecordText"], () => "", context.player().name);
|
||||
drawBoxPage(saving, savingText[savingText.length - 1]);
|
||||
saving.palettes = [context.mapPalette];
|
||||
yield* frames(() => saving, 100);
|
||||
state.hallOfFame = true;
|
||||
addHallOfFameEntry(state, state.party);
|
||||
const team = state.hallOfFameTeams[0];
|
||||
// AnimateHallOfFame: MUSIC_HALL_OF_FAME after one frame of MUSIC_NONE.
|
||||
sound?.playMusic(SOUND.MUSIC_NONE);
|
||||
yield* frame(saving);
|
||||
sound?.playMusic(SOUND.MUSIC_HALL_OF_FAME);
|
||||
for (const mon of team.mons) {
|
||||
yield* monEntrance(context, mon);
|
||||
// The cry and 60 frames (HOF_AnimateFrontpic's ANIM_MON_HOF animation
|
||||
// is not ported).
|
||||
const page = newHallOfFamerScreen(data, mon, frameOption);
|
||||
sound?.playMonCry2(mon.species);
|
||||
yield* frames(() => page, 60);
|
||||
}
|
||||
yield* playerPicture(context);
|
||||
// RotateThreePalettesRight to white, then 8 frames.
|
||||
yield* frames(() => hofScreen(tiles, "hof-white", WHITE, ""), 12);
|
||||
yield* credits(context, skippable);
|
||||
}
|
||||
|
||||
/** AnimateHallOfFame.DisplayNewHallOfFamer: DisplayHOFMon's page with
|
||||
* "New Hall of Famer!" at (1, 2). */
|
||||
export function newHallOfFamerScreen(data: PokemonData, mon: HallOfFameMon, frame: number) {
|
||||
const title = readString(data.reader, KANTO["AnimateHallOfFame.String_NewHallOfFamer"], defaultTextContext(""));
|
||||
const page = displayHOFMon(data, mon, frame, title);
|
||||
placeText(page, 1, 2, title);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** HOF_AnimatePlayerPic's card once the front picture is in place: the
|
||||
* player's picture at (12, 5), the boxes, the name, the ID and the play
|
||||
* time (three digits, HALLOFFAME_COLON, two digits), over the Hall of
|
||||
* Fame tiles with the colon and the player's back picture loaded. */
|
||||
export function hallOfFamePlayerCard(
|
||||
data: PokemonData,
|
||||
player: { name: string; id: number; female: boolean; time: GameTime },
|
||||
frame: number,
|
||||
): TileScreen {
|
||||
const r = data.reader,
|
||||
{ name, id, female, time } = player;
|
||||
const tiles = playerTiles(data, female, frame);
|
||||
tiles.set(data.playerPic(female));
|
||||
const screen = hofScreen(
|
||||
tiles,
|
||||
`hof-player-${female ? "kris" : "chris"}-${frame}`,
|
||||
data.trainerPalette(female ? 1 : 0),
|
||||
`${name}, ID ${String(id).padStart(5, "0")}, play time ${time.hours}:${String(time.minutes).padStart(2, "0")}`,
|
||||
);
|
||||
placeGraphic(screen, 12, 5, 7, 7, 0);
|
||||
placeTextbox(screen, 0, 2, 8, 9);
|
||||
placeTextbox(screen, 0, 12, 4);
|
||||
placeText(screen, 2, 4, name);
|
||||
screen.map.splice(6 * SCREEN_WIDTH + 1, 3, 0x73, 0x74, 0xf3);
|
||||
placeText(screen, 4, 6, String(id).padStart(5, "0"));
|
||||
placeText(screen, 1, 8, readString(r, KANTO["HOF_AnimatePlayerPic.PlayTime"], defaultTextContext(name)));
|
||||
placeText(screen, 3, 9, String(time.hours).padStart(3, " "));
|
||||
screen.map[9 * SCREEN_WIDTH + 6] = HALLOFFAME_COLON;
|
||||
placeText(screen, 7, 9, String(time.minutes).padStart(2, "0"));
|
||||
return screen;
|
||||
}
|
||||
/** The Hall of Fame tiles with HOF_AnimatePlayerPic's colon (FontExtra +
|
||||
* 13 tiles) at HALLOFFAME_COLON and the back picture from $31. */
|
||||
function playerTiles(data: PokemonData, female: boolean, frame: number) {
|
||||
const r = data.reader;
|
||||
const tiles = hallOfFameTiles(data, frame);
|
||||
tiles.set(r.slice(JOHTO.FontExtra + 13 * 16, 16), HALLOFFAME_COLON * 16);
|
||||
// GetKrisBackpic copies Kris's uncompressed picture; Chris's is LZ.
|
||||
const back = female ? r.slice(KANTO.KrisBackpic, 36 * 16) : decompress(r, KANTO.ChrisBackpic, 0x2000);
|
||||
if (back.length < 36 * 16) throw new Error("Picture data is truncated.");
|
||||
tiles.set(back.slice(0, 36 * 16), 0x31 * 16);
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/** AnimateHOFMonEntrance: the screen white for a frame, the back picture
|
||||
* (6x6 at (6, 6), tiles from $31) passing, then the front picture (7x7
|
||||
* at (6, 5)) sliding in from the left, in the Pokémon's palette. */
|
||||
function* monEntrance(context: CeremonyContext, mon: HallOfFameMon): Gen {
|
||||
const data = context.data,
|
||||
letter = PokemonData.unownLetter(mon.dvs),
|
||||
palette = data.palette(mon.species, PokemonData.shiny(mon.dvs));
|
||||
const tiles = hallOfFameTiles(data, context.state.options.textboxFrame);
|
||||
tiles.set(data.frontPic(mon.species, letter));
|
||||
tiles.set(data.backPic(mon.species, letter), 0x31 * 16);
|
||||
const key = `hof-entrance-${mon.species}-${mon.dvs}`;
|
||||
yield* frame(hofScreen(tiles, `${key}-white`, WHITE, ""));
|
||||
const back = hofScreen(tiles, `${key}-back`, palette, data.speciesName(mon.species));
|
||||
placeGraphic(back, 6, 6, 6, 6, 0x31);
|
||||
const front = hofScreen(tiles, `${key}-front`, palette, data.speciesName(mon.species));
|
||||
placeGraphic(front, 6, 5, 7, 7, 0);
|
||||
yield* slides(back, front, 0x70);
|
||||
}
|
||||
|
||||
/** HOF_AnimatePlayerPic: the player's back picture passes, the front
|
||||
* picture slides in at (12, 5), then the name, ID and play time, and
|
||||
* ProfOaksPCRating (the music stops for the rating's sound). */
|
||||
function* playerPicture(context: CeremonyContext): Gen {
|
||||
const r = context.reader,
|
||||
data = context.data,
|
||||
female = context.female,
|
||||
frameOption = context.state.options.textboxFrame,
|
||||
palette = data.trainerPalette(female ? 1 : 0);
|
||||
const tiles = playerTiles(data, female, frameOption);
|
||||
const key = `hof-player-${female ? "kris" : "chris"}`;
|
||||
yield* frame(hofScreen(tiles, `${key}-white`, WHITE, ""));
|
||||
const backScreen = hofScreen(tiles, `${key}-back`, palette, context.player().name);
|
||||
placeGraphic(backScreen, 6, 6, 6, 6, 0x31);
|
||||
const frontTiles = tiles.slice();
|
||||
frontTiles.set(data.playerPic(female));
|
||||
const front = hofScreen(frontTiles, `${key}-front`, palette, context.player().name);
|
||||
placeGraphic(front, 12, 5, 7, 7, 0);
|
||||
yield* slides(backScreen, front, 0xc0);
|
||||
const player = { ...context.player(), female, time: context.gameTime() };
|
||||
const card = () => hallOfFamePlayerCard(data, player, frameOption);
|
||||
// ProfOaksPCRating: Rate, MUSIC_NONE, the rating's sound, A or B, and
|
||||
// WaitSFX.
|
||||
const { seen, caught } = ratingCounts(context);
|
||||
const ram = (address: number) => {
|
||||
if (address === RAM.stringBuffer3) return String(seen);
|
||||
if (address === RAM.stringBuffer4) return String(caught);
|
||||
throw new Error(`Text reads RAM 0x${address.toString(16)}, which has no native value.`);
|
||||
};
|
||||
const name = context.player().name;
|
||||
let shown = yield* printText(card, textboxPages(r, PC.OakPCText3, ram, name));
|
||||
yield* waitAorB(shown);
|
||||
const rating = oakRating(r, caught);
|
||||
shown = yield* printText(card, textboxPages(r, rating.text, ram, name));
|
||||
context.sound?.playMusic(SOUND.MUSIC_NONE);
|
||||
context.sound?.playSFX(rating.sfx);
|
||||
yield* waitAorB(shown);
|
||||
while (context.sound?.sfxPlaying) yield* frame(shown);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ credits
|
||||
|
||||
const CREDITS_END = 0xff,
|
||||
CREDITS_WAIT = 0xfe,
|
||||
CREDITS_SCENE = 0xfd,
|
||||
CREDITS_CLEAR = 0xfc,
|
||||
CREDITS_MUSIC = 0xfb,
|
||||
CREDITS_WAIT2 = 0xfa,
|
||||
CREDITS_THEEND = 0xf9;
|
||||
/** constants/credits_constants.asm: the one string ParseCredits prints
|
||||
* from column 2 rather than 0. */
|
||||
const COPYRIGHT = 0x62;
|
||||
/** How many VBlanks pass while ParseCredits runs each group of commands
|
||||
* (one per wait), measured on the cartridge (tests/reference-credits.spec
|
||||
* .ts): printing a name takes about 60 of a frame's 154 lines, and how
|
||||
* long VBlank_Credits takes depends on _UpdateSound's work for the music,
|
||||
* so the spills are recorded rather than counted from cycles. They hold
|
||||
* for credits that B does not shorten. CREDITS_MUSIC's own DelayFrame is
|
||||
* not counted here. */
|
||||
const PARSE_SPILLS = [
|
||||
1, 0, 0, 1, 1, 2, 2, 1, 3, 2, 1, 0, 3, 1, 2, 2, 2, 2, 1, 1, 1, 0, 2, 1, 2, 1, 2, 1, 0, 1, 1, 1,
|
||||
1, 1, 1, 1, 2, 2, 2, 1, 2, 1, 1, 1, 0, 0,
|
||||
];
|
||||
/** Credits' jumptable (Credits_Jumptable.Jumptable), one entry a frame. */
|
||||
const enum Step {
|
||||
Parse = 0,
|
||||
PrepBGMapUpdate = 3,
|
||||
UpdateGFX = 4,
|
||||
RequestGFX = 5,
|
||||
LYOverride = 6,
|
||||
UpdateGFX2 = 10,
|
||||
RequestGFX2 = 11,
|
||||
LoopBack = 12,
|
||||
}
|
||||
|
||||
/** Credits (engine/movie/credits.asm) frame by frame, as VBlank_Credits
|
||||
* presents it: the parser waits on wCreditsTimer (one tick per 13-frame
|
||||
* jumptable cycle), prints into wTilemap, and the text reaches the screen
|
||||
* a third per VBlank; the banner's 16 tiles arrive 8 per VBlank; a
|
||||
* palette update takes its VBlank whole; the borders scroll right by 2
|
||||
* pixels a cycle through wLYOverrides (the bottom border the same frame,
|
||||
* the top one the next). */
|
||||
export class CreditsMachine {
|
||||
private index = 0;
|
||||
private group = 0;
|
||||
private exit = false;
|
||||
private pos = 0;
|
||||
private timer = 0;
|
||||
private scene = 0;
|
||||
private borderFrame = 0xff;
|
||||
private lyOverride = 0;
|
||||
private topOverride = 0;
|
||||
private bgMode = 0;
|
||||
private third = 0;
|
||||
private request: { size: number; source: number; dest: number; data: Uint8Array } = {
|
||||
size: 0,
|
||||
source: 0,
|
||||
dest: 0,
|
||||
data: new Uint8Array(),
|
||||
};
|
||||
private palUpdate = false;
|
||||
readonly tilemap: number[];
|
||||
readonly shown: number[];
|
||||
readonly vram: Uint8Array;
|
||||
private palettes: Palette[];
|
||||
private readonly frames: Uint8Array;
|
||||
private readonly blank: Uint8Array;
|
||||
/** The sound to play when the parser reaches CREDITS_MUSIC or the end. */
|
||||
onMusic?: (music: "credits" | "post") => void;
|
||||
|
||||
constructor(
|
||||
private readonly r: RomReader,
|
||||
base: Uint8Array,
|
||||
readonly skippable: boolean,
|
||||
) {
|
||||
this.vram = base.slice();
|
||||
// Credits_LoadBorderGFX's .init: wCreditsBlankFrame2bpp, 16 tiles of
|
||||
// color 2.
|
||||
this.blank = new Uint8Array(16 * 16);
|
||||
for (let i = 0; i < this.blank.length; i += 2) {
|
||||
this.blank[i] = 0;
|
||||
this.blank[i + 1] = 0xff;
|
||||
}
|
||||
this.vram.set(r.slice(KANTO.CreditsBorderGFX, 9 * 16), 0x20 * 16);
|
||||
this.vram.set(r.slice(KANTO.CopyrightGFX, 29 * 16), 0x60 * 16);
|
||||
this.vram.set(r.slice(KANTO.TheEndGFX, 16 * 16), 0x40 * 16);
|
||||
this.vram.set(this.blank, 0);
|
||||
this.frames = r.slice(KANTO.CreditsPichuGFX, 4 * 64 * 16);
|
||||
// ConstructCreditsTilemap.
|
||||
this.tilemap = Array<number>(360).fill(SPACE);
|
||||
for (let x = 0; x < SCREEN_WIDTH; x++) {
|
||||
this.tilemap[4 * SCREEN_WIDTH + x] = 0x24 + (x & 3);
|
||||
this.tilemap[17 * SCREEN_WIDTH + x] = 0x20 + (x & 3);
|
||||
}
|
||||
for (let block = 0; block < 5; block++)
|
||||
for (let row = 0; row < 4; row++)
|
||||
for (let col = 0; col < 4; col++)
|
||||
this.tilemap[row * SCREEN_WIDTH + block * 4 + col] = row * 4 + col;
|
||||
this.shown = [...this.tilemap];
|
||||
this.palettes = this.creditsPalettes();
|
||||
}
|
||||
|
||||
/** GetCreditsPalette: the scene's three palettes. */
|
||||
private creditsPalettes(): Palette[] {
|
||||
const base = KANTO.CreditsPalettes + (this.scene & 3) * 24;
|
||||
return Array.from({ length: 3 }, (_, palette) =>
|
||||
Array.from({ length: 4 }, (_, color) => rgb(this.r.word(base + palette * 8 + color * 2))),
|
||||
) as Palette[];
|
||||
}
|
||||
|
||||
/** wJumptableIndex's step, wCreditsPos, wCreditsTimer, the banner's
|
||||
* scene and frame and wCreditsLYOverride, for comparisons. */
|
||||
debug() {
|
||||
return {
|
||||
index: this.index,
|
||||
exit: this.exit,
|
||||
pos: this.pos,
|
||||
timer: this.timer,
|
||||
scene: this.scene,
|
||||
borderFrame: this.borderFrame,
|
||||
lyOverride: this.lyOverride,
|
||||
};
|
||||
}
|
||||
/** wBGPals2's first three palettes as stored words. */
|
||||
paletteWords(): number[] {
|
||||
return this.palettes.flatMap((palette) =>
|
||||
palette.map(([red, green, blue]) =>
|
||||
Math.round((red * 31) / 255) | (Math.round((green * 31) / 255) << 5) | (Math.round((blue * 31) / 255) << 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The screen as this frame draws it. */
|
||||
screen(): TileScreen {
|
||||
const attrs = Array.from({ length: 360 }, (_, i) => {
|
||||
const row = Math.floor(i / SCREEN_WIDTH);
|
||||
return row < 4 ? 0 : row === 4 || row === 17 ? 1 : 2;
|
||||
});
|
||||
const border = (first: number) => Array.from({ length: 32 }, (_, x) => first + (x & 3));
|
||||
return {
|
||||
tiles: this.vram.slice(),
|
||||
map: [...this.shown],
|
||||
attrs,
|
||||
palettes: this.palettes,
|
||||
sprites: [],
|
||||
objPalettes: this.palettes,
|
||||
tileset: `credits-${Array.from(this.vram.subarray(0, 16 * 16)).join(",")}`,
|
||||
label: this.label(),
|
||||
rowScroll: [
|
||||
{ row: 4, x: this.topOverride, tiles: border(0x24) },
|
||||
{ row: 17, x: this.lyOverride, tiles: border(0x20) },
|
||||
],
|
||||
};
|
||||
}
|
||||
/** The text shown, for the screen's description. */
|
||||
private label() {
|
||||
const rows: string[] = [];
|
||||
for (let row = 5; row < 17; row++) {
|
||||
const text = this.shown
|
||||
.slice(row * SCREEN_WIDTH, (row + 1) * SCREEN_WIDTH)
|
||||
.map((tile) => CHARS[tile] ?? " ")
|
||||
.join("")
|
||||
.trim();
|
||||
if (text) rows.push(text);
|
||||
}
|
||||
return rows.join(", ") || "Credits";
|
||||
}
|
||||
|
||||
/** The frame's logic before its VBlank: the buttons held, then one
|
||||
* jumptable entry. Returns true when A ends the credits. It yields at
|
||||
* each VBlank that passes inside the entry: CREDITS_MUSIC's DelayFrame
|
||||
* ("music", after MUSIC_NONE) and a parse running into later frames. */
|
||||
*step(held: string): Generator<"music" | "frame", boolean, void> {
|
||||
// Credits_HandleBButton: B makes the timer run faster once the first
|
||||
// 13 script bytes have passed.
|
||||
if (held === "b" && this.skippable && this.pos >= 0x0d && this.timer) this.timer--;
|
||||
// Credits_HandleAButton.
|
||||
if (held === "a" && this.exit) return true;
|
||||
switch (this.index & 0xf) {
|
||||
case Step.Parse:
|
||||
yield* this.parse();
|
||||
break;
|
||||
case Step.PrepBGMapUpdate:
|
||||
this.bgMode = 0;
|
||||
this.index++;
|
||||
break;
|
||||
case Step.UpdateGFX:
|
||||
case Step.UpdateGFX2:
|
||||
this.request = { ...this.borderGraphics(), dest: 0, size: 8 };
|
||||
this.index++;
|
||||
break;
|
||||
case Step.RequestGFX:
|
||||
case Step.RequestGFX2:
|
||||
this.request.size = 8;
|
||||
this.index++;
|
||||
break;
|
||||
case Step.LYOverride:
|
||||
this.lyOverride = (this.lyOverride - 2) & 0xff;
|
||||
this.index++;
|
||||
break;
|
||||
case Step.LoopBack:
|
||||
this.index &= 0xf0;
|
||||
break;
|
||||
default:
|
||||
this.index++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** VBlank_Credits: palettes, else a third of the tilemap and the 2bpp
|
||||
* request; the top border takes the new scroll from the next frame. */
|
||||
vblank() {
|
||||
if (this.palUpdate) {
|
||||
this.palUpdate = false;
|
||||
this.palettes = this.creditsPalettes();
|
||||
} else {
|
||||
if (this.bgMode) {
|
||||
const from = this.third * 6 * SCREEN_WIDTH;
|
||||
for (let i = from; i < from + 6 * SCREEN_WIDTH; i++) this.shown[i] = this.tilemap[i];
|
||||
this.third = (this.third + 1) % 3;
|
||||
}
|
||||
const request = this.request;
|
||||
if (request.size) {
|
||||
this.vram.set(
|
||||
request.data.subarray(request.source * 16, (request.source + request.size) * 16),
|
||||
request.dest * 16,
|
||||
);
|
||||
request.source += request.size;
|
||||
request.dest += request.size;
|
||||
request.size = 0;
|
||||
}
|
||||
}
|
||||
this.topOverride = this.lyOverride;
|
||||
}
|
||||
/** Credits_LoadBorderGFX: the next of the scene's four frames, or the
|
||||
* blank frame while the banner is cleared. */
|
||||
private borderGraphics() {
|
||||
if (this.borderFrame === 0xff) return { data: this.blank, source: 0 };
|
||||
const frame = this.borderFrame & 3;
|
||||
this.borderFrame = (frame + 1) & 3;
|
||||
return { data: this.frames, source: ((this.scene & 3) * 4 + frame) * 16 };
|
||||
}
|
||||
private get(): number {
|
||||
return this.r.byte(KANTO.CreditsScript + this.pos++);
|
||||
}
|
||||
/** ParseCredits. */
|
||||
private *parse(): Generator<"music" | "frame", void, void> {
|
||||
if (this.exit || this.timer) {
|
||||
if (!this.exit) this.timer--;
|
||||
this.index++;
|
||||
return;
|
||||
}
|
||||
this.bgMode = 0;
|
||||
this.tilemap.fill(SPACE, 5 * SCREEN_WIDTH, 17 * SCREEN_WIDTH);
|
||||
const spills = PARSE_SPILLS[this.group++] ?? 0;
|
||||
for (let count = 0; count < 256; count++) {
|
||||
const command = this.r.byte(KANTO.CreditsScript + this.pos);
|
||||
// The VBlanks a long parse runs into pass before its last command,
|
||||
// with the tilemap copy still off.
|
||||
if (command === CREDITS_END || command === CREDITS_WAIT || command === CREDITS_WAIT2)
|
||||
for (let spill = 0; spill < spills; spill++) yield "frame";
|
||||
this.pos++;
|
||||
switch (command) {
|
||||
case CREDITS_END:
|
||||
this.exit = true;
|
||||
this.onMusic?.("post");
|
||||
return;
|
||||
case CREDITS_WAIT:
|
||||
this.timer = this.get();
|
||||
this.third = 0;
|
||||
this.bgMode = 1;
|
||||
this.index++;
|
||||
return;
|
||||
case CREDITS_WAIT2:
|
||||
this.timer = this.get();
|
||||
this.index++;
|
||||
return;
|
||||
case CREDITS_SCENE:
|
||||
this.scene = this.get();
|
||||
this.borderFrame = 0;
|
||||
this.palUpdate = true;
|
||||
break;
|
||||
case CREDITS_CLEAR:
|
||||
this.borderFrame = 0xff;
|
||||
break;
|
||||
case CREDITS_MUSIC:
|
||||
// PlayMusic MUSIC_NONE, a frame, then MUSIC_CREDITS.
|
||||
yield "music";
|
||||
this.onMusic?.("credits");
|
||||
break;
|
||||
case CREDITS_THEEND:
|
||||
for (let i = 0; i < 8; i++) {
|
||||
this.tilemap[9 * SCREEN_WIDTH + 6 + i] = 0x40 + i;
|
||||
this.tilemap[10 * SCREEN_WIDTH + 6 + i] = 0x48 + i;
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
const pointer = this.r.near(
|
||||
KANTO.CreditsStringsPointers + command * 2,
|
||||
this.r.bank(KANTO.CreditsStringsPointers),
|
||||
);
|
||||
const x = command === COPYRIGHT ? 2 : 0;
|
||||
this.placeString(pointer, x, 6 + this.get() * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Credits script command limit exceeded.");
|
||||
}
|
||||
/** PlaceString: characters as tiles, '#' as POKé, <NEXT> two rows down. */
|
||||
private placeString(p: number, x: number, y: number) {
|
||||
let column = x,
|
||||
line = y;
|
||||
for (let length = 0; length < 64; length++) {
|
||||
const code = this.r.byte(p++);
|
||||
if (code === 0x50) return;
|
||||
if (code === 0x4e) {
|
||||
line += 2;
|
||||
column = x;
|
||||
continue;
|
||||
}
|
||||
const tiles = code === 0x54 ? POKE : [code];
|
||||
for (const tile of tiles) {
|
||||
if (line < 18 && column < SCREEN_WIDTH) this.tilemap[line * SCREEN_WIDTH + column] = tile;
|
||||
column++;
|
||||
}
|
||||
}
|
||||
throw new Error("Unterminated credits string.");
|
||||
}
|
||||
}
|
||||
/** Tile codes as characters, for the credits' accessible description. */
|
||||
const CHARS: Record<number, string> = (() => {
|
||||
const chars: Record<number, string> = { 0x7f: " ", 0xe3: "-", 0xe8: ".", 0xf4: ",", 0xe9: "&", 0xe6: "?", 0xea: "é", 0xe0: "'", 0x9a: "(", 0x9b: ")", 0xf3: "/" };
|
||||
for (let i = 0; i < 26; i++) {
|
||||
chars[0x80 + i] = String.fromCharCode(65 + i);
|
||||
chars[0xa0 + i] = String.fromCharCode(97 + i);
|
||||
}
|
||||
for (let i = 0; i < 10; i++) chars[0xf6 + i] = String(i);
|
||||
return chars;
|
||||
})();
|
||||
|
||||
/** RedCredits (the credits command, after the battle with Red): the
|
||||
* music fades out, the screen turns white (InitDisplayForRedCredits) for
|
||||
* eight frames, and the credits play, which B can shorten. */
|
||||
export function* redCredits(context: CeremonyContext): Gen {
|
||||
context.sound?.fadeOutTo(SOUND.MUSIC_NONE, 10);
|
||||
const tiles = hallOfFameTiles(context.data, context.state.options.textboxFrame);
|
||||
yield* frames(() => hofScreen(tiles, "credits-white", WHITE, ""), 8);
|
||||
yield* credits(context, context.state.hallOfFame);
|
||||
}
|
||||
|
||||
/** Credits, from the Hall of Fame (`skippable` when the team had entered
|
||||
* before) or RedCredits: each frame yields the screen and reads the
|
||||
* button held, until A is pressed after THE END; the screen then turns
|
||||
* white (ClearBGPalettes). B held is read as B pressed. */
|
||||
export function* credits(context: CeremonyContext, skippable: boolean): Gen {
|
||||
const tiles = hallOfFameTiles(context.data, context.state.options.textboxFrame);
|
||||
const machine = new CreditsMachine(context.reader, tiles, skippable);
|
||||
// Credits' setup (ClearBGPalettes, the graphics requests and
|
||||
// ConstructCreditsTilemap's WaitBGMap2s) keeps the screen white for
|
||||
// about 36 frames.
|
||||
yield* frames(() => hofScreen(tiles, "credits-white", WHITE, ""), 36);
|
||||
machine.onMusic = (music) => {
|
||||
const sound = context.sound;
|
||||
if (!sound) return;
|
||||
if (music === "credits") sound.playMusic(SOUND.MUSIC_CREDITS);
|
||||
else sound.fadeOutTo(SOUND.MUSIC_POST_CREDITS, 32);
|
||||
};
|
||||
let held = "";
|
||||
for (let frames = 0; frames < 200_000; frames++) {
|
||||
const logic = machine.step(held);
|
||||
let result = logic.next();
|
||||
while (!result.done) {
|
||||
// A frame shows and its VBlank runs inside the parser.
|
||||
if (result.value === "music") context.sound?.playMusic(SOUND.MUSIC_NONE);
|
||||
yield* frame(machine.screen());
|
||||
machine.vblank();
|
||||
result = logic.next();
|
||||
}
|
||||
if (result.value) {
|
||||
yield* frame(hofScreen(tiles, "credits-white", WHITE, ""));
|
||||
return;
|
||||
}
|
||||
held = yield* frame(machine.screen());
|
||||
machine.vblank();
|
||||
}
|
||||
throw new Error("The credits did not end.");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import { ENGINE_FLAGS, MENU, PC, RAM } from "../rom/profile";
|
||||
import { hallOfFamePC } from "./hall-of-fame";
|
||||
import { oakRating } from "./league";
|
||||
import { readMail } from "./mail";
|
||||
import { printerDisconnected } from "./printer";
|
||||
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
|
||||
@@ -857,10 +858,7 @@ export class PcSession {
|
||||
this.host.strings[RAM.stringBuffer3] = String(seen.size);
|
||||
this.host.strings[RAM.stringBuffer4] = String(caught.size);
|
||||
yield* this.print(PC.OakPCText3);
|
||||
// FindOakRating: the first entry whose count reaches the owned total.
|
||||
let entry = PC.OakRatings;
|
||||
while (r.byte(entry) < caught.size) entry += 5;
|
||||
yield* this.print(r.near(entry + 3, r.bank(entry)));
|
||||
yield* this.print(oakRating(r, caught.size).text);
|
||||
}
|
||||
yield* this.print(PC.OakPCText4);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ export interface TileScreen {
|
||||
* moves left by `x` and up by `y`, and rows scrolled in from beyond the tilemap show
|
||||
* `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[] }[];
|
||||
}
|
||||
|
||||
/** A GBC color (RGB555, little-endian word) as RGB. */
|
||||
|
||||
@@ -188,6 +188,8 @@ export const OW = {
|
||||
MAPSETUP_FLY: 0xfc,
|
||||
NUM_SPAWNS: 28,
|
||||
SPAWN_INDIGO: 13,
|
||||
SPAWN_NEW_BARK: 14,
|
||||
SPAWN_MT_SILVER: 26,
|
||||
JOHTO_FLYPOINT: 0,
|
||||
KANTO_FLYPOINT: 12,
|
||||
NUM_FLYPOINTS: 24,
|
||||
|
||||
+18
-2
@@ -779,6 +779,8 @@ export class GameWorld {
|
||||
/** wGameTime. It counts from the end of the introduction, through
|
||||
* scenes, menus and battles, as GameTimer does in every VBlank. */
|
||||
gameTime: GameTime = emptyGameTime();
|
||||
/** halloffame clears GAME_TIMER_COUNTING_F until the credits end. */
|
||||
private gameTimerPaused = false;
|
||||
state: ScriptState = emptyScriptState();
|
||||
dialogue: Dialogue | undefined;
|
||||
emote: { object: number; emote: number } | undefined;
|
||||
@@ -2519,6 +2521,8 @@ export class GameWorld {
|
||||
phoneService: () => this.assets.map(mapId).phoneService,
|
||||
facingObject: () => this.facingObject(),
|
||||
day: () => this.gameDay(),
|
||||
gameTime: () => this.gameTime,
|
||||
gameTimer: (counting) => (this.gameTimerPaused = !counting),
|
||||
tileset: () => ({
|
||||
graphics: this.assets.tileset(this.map).graphics,
|
||||
palettes: this.assets.palettes(this.map, this.timeOfDay),
|
||||
@@ -2905,6 +2909,12 @@ export class GameWorld {
|
||||
}
|
||||
case "appear":
|
||||
case "disappear": {
|
||||
// DeleteObjectStruct on the player's own struct: the sprite is gone
|
||||
// until the map loads again (Lance leading the champion away).
|
||||
if (effect.object === PLAYER) {
|
||||
this.player.hidden = effect.kind === "disappear";
|
||||
return false;
|
||||
}
|
||||
const object = this.map.objects.find(
|
||||
(candidate) => candidate.id === effect.object,
|
||||
);
|
||||
@@ -3014,6 +3024,7 @@ export class GameWorld {
|
||||
const trainer = towerTrainer ?? (opponent && {
|
||||
name: opponent.name,
|
||||
className: opponent.className,
|
||||
classId: effect.trainer!.classId,
|
||||
party: trainers.createParty(opponent, {
|
||||
otId: this.playerId,
|
||||
ot: this.name,
|
||||
@@ -3637,6 +3648,7 @@ export class GameWorld {
|
||||
|
||||
private finishScript() {
|
||||
const script = this.script!;
|
||||
this.gameTimerPaused = false;
|
||||
this.soundCues = [];
|
||||
this.waitingPage = undefined;
|
||||
if (script.warp) {
|
||||
@@ -3659,6 +3671,7 @@ export class GameWorld {
|
||||
y,
|
||||
direction: facing ?? (keep ? this.player.direction : "down"),
|
||||
motion: undefined,
|
||||
hidden: false,
|
||||
};
|
||||
this.applyMap(entry, door ? "door" : "warp");
|
||||
if (roam) this.applyRoamMons(this.state, map, roam === "jump");
|
||||
@@ -3721,6 +3734,7 @@ export class GameWorld {
|
||||
}
|
||||
private abortScript(error: unknown) {
|
||||
const script = this.script!;
|
||||
this.gameTimerPaused = false;
|
||||
this.soundCues = [];
|
||||
this.waitingPage = undefined;
|
||||
this.battle = undefined;
|
||||
@@ -4340,8 +4354,9 @@ export class GameWorld {
|
||||
this.sound.setStereo(((this.state.options.options >> OW.STEREO) & 1) === 1);
|
||||
this.sound.frame();
|
||||
this.sound.frame();
|
||||
// GameTimer: two frames per overworld tick, once the game has started.
|
||||
if (!this.introPending) {
|
||||
// GameTimer: two frames per overworld tick, once the game has started
|
||||
// and while GAME_TIMER_COUNTING_F is set.
|
||||
if (!this.introPending && !this.gameTimerPaused) {
|
||||
advanceGameTime(this.gameTime);
|
||||
advanceGameTime(this.gameTime);
|
||||
}
|
||||
@@ -5524,6 +5539,7 @@ export class GameWorld {
|
||||
this.avatar = save.avatar;
|
||||
this.tilesWalked = save.tilesWalked;
|
||||
this.gameTime = { ...save.gameTime };
|
||||
this.gameTimerPaused = false;
|
||||
this.playerId = save.playerId;
|
||||
this.townMap = false;
|
||||
this.statsScreen = undefined;
|
||||
|
||||
Reference in new issue
Block a user