Open Routes 35 to 37, the National Park and Ecruteak to the Fog Badge (#32)
Enable Route 35, Route 36, Route 37, the National Park and its gates, Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's room and both floors of the Burned Tower. The story runs from Floria, the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the rival in the Burned Tower and the beasts' release to Ecruteak Gym, Morty, the FOGBADGE and TM30. Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons, CheckEncounterRoamMon) with their state in the save; only Continue moves them on restore, so a failed import's rollback keeps them in place. Port the Bug-Catching Contest: registration on contest days, Park Balls, contestants, the timer, QUIT, the catch-and-swap prompt, judging from the ROM's contestant table, prizes and the saved contest state. Port the Game Corner's SlotMachine and CardFlip on a new realtime tile screen that advances two frames per tick with buffered buttons: reel strips and scrolling, bias tables, the seven streak, Golem and Chansey, payouts, the Card Flip board, cursor and navigation. Their tilemaps are checked against the ROM's own data. Also port getnum, a plain warp that continues its script on the new map, and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the Burned Tower rival) is no longer sighted once its own flag is set, as the original's read of bit 1 of wNumKeyItems gives with the usual key items. The playthrough walks through the National Park and continues to Morty; its driver chooses moves by type matchup, buys at Marts and uses Super Potions and Awakenings in trainer battles. New tests cover roaming, the contest, the Game Corner and Ecruteak's side content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0930b780fc
commit
2640fa2725
31 files changed
+4743
-91
No files matched your search
@@ -188,6 +188,8 @@ export const C = {
|
||||
ULTRA_BALL: 2,
|
||||
GREAT_BALL: 4,
|
||||
POKE_BALL: 5,
|
||||
SUPER_POTION: 0x11,
|
||||
AWAKENING: 0x0c,
|
||||
HEAVY_BALL: 0x9d,
|
||||
LEVEL_BALL: 0x9f,
|
||||
LURE_BALL: 0xa0,
|
||||
@@ -253,6 +255,7 @@ export const C = {
|
||||
START_MONEY: 3000,
|
||||
MAX_MONEY: 999999,
|
||||
MAX_COINS: 9999,
|
||||
COIN_CASE: 0x36,
|
||||
|
||||
// Status bits and masks
|
||||
SLP_MASK: 7,
|
||||
|
||||
@@ -18,6 +18,10 @@ 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 indices = new Uint8Array(width * height);
|
||||
for (let row = 0; row < SCREEN_HEIGHT; row++)
|
||||
for (let col = 0; col < SCREEN_WIDTH; col++) {
|
||||
const cell = row * SCREEN_WIDTH + col;
|
||||
@@ -31,6 +35,7 @@ export function drawTileScreen(
|
||||
col * 8,
|
||||
row * 8,
|
||||
screen.palettes[screen.attrs[cell]] ?? screen.palettes[0],
|
||||
{ indices },
|
||||
);
|
||||
}
|
||||
const blinkOff = !(frame & 0x10);
|
||||
@@ -45,7 +50,12 @@ export function drawTileScreen(
|
||||
sprite.x,
|
||||
sprite.y,
|
||||
screen.objPalettes[sprite.palette] ?? screen.objPalettes[0],
|
||||
{ transparent: true, flipX: sprite.flipX, flipY: sprite.flipY },
|
||||
{
|
||||
transparent: true,
|
||||
flipX: sprite.flipX,
|
||||
flipY: sprite.flipY,
|
||||
...(sprite.behind && { indices, behind: true }),
|
||||
},
|
||||
);
|
||||
}
|
||||
context.putImageData(image, 0, 0);
|
||||
|
||||
@@ -9,6 +9,11 @@ const TEXTBOX_FRAME_TILES = 6,
|
||||
export const FRAME_TILE = 0x79,
|
||||
SPACE_TILE = 0x7f,
|
||||
FONT_TILE = 0x80;
|
||||
/** "…" ($75): the one _LoadFontsExtra1 glyph a ported screen needs
|
||||
* (_CardFlipDarnText's "Darn…"). _LoadFontsExtra1 loads 22 raw 2bpp tiles
|
||||
* from `FontExtra + 3 tiles` into vTiles2 tile $63 ('<BOLD_D>'); $75 is
|
||||
* the 18th of those, so tile 21 of FontExtra itself. */
|
||||
export const ELLIPSIS_TILE = 0x75;
|
||||
|
||||
/** Get1bpp: both bit planes copy the 1bpp row, so set pixels are color 3. */
|
||||
function oneBpp(target: Uint8Array, tile: number, source: Uint8Array) {
|
||||
@@ -29,6 +34,8 @@ export function fontTiles(reader: RomReader, frame: number) {
|
||||
oneBpp(tiles, SPACE_TILE, reader.slice(JOHTO.TextboxSpaceGFX, 8));
|
||||
for (let i = 0; i < 128; i++)
|
||||
oneBpp(tiles, FONT_TILE + i, reader.slice(JOHTO.Font + i * 8, 8));
|
||||
// _LoadFontsExtra1's ellipsis: raw 2bpp, unlike the standard font's 1bpp.
|
||||
tiles.set(reader.slice(JOHTO.FontExtra + 21 * 16, 16), ELLIPSIS_TILE * 16);
|
||||
return tiles;
|
||||
}
|
||||
|
||||
@@ -59,6 +66,7 @@ const CODES: Record<string, number> = {
|
||||
"/": 0xf3,
|
||||
",": 0xf4,
|
||||
"♀": 0xf5,
|
||||
"…": ELLIPSIS_TILE,
|
||||
};
|
||||
/** Text as tile numbers, one character per tile. */
|
||||
export function textTiles(text: string): number[] {
|
||||
|
||||
@@ -92,6 +92,8 @@ const STAT_TRIGGERS = ["gt", "lt", "eq"] as const;
|
||||
* user's ROM on demand and cached in memory only. */
|
||||
/** UNOWN and NUM_UNOWN (constants/pokemon_constants.asm). */
|
||||
const UNOWN = 201;
|
||||
/** EGG (constants/pokemon_constants.asm). */
|
||||
export const EGG = 0xfd;
|
||||
export const NUM_UNOWN = 26;
|
||||
|
||||
export class PokemonData {
|
||||
@@ -128,9 +130,10 @@ export class PokemonData {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Fixed-length names are padded with "@"; a full-length name has none. */
|
||||
/** Fixed-length names are padded with "@"; a full-length name has none.
|
||||
* PokemonNames also names EGG, which GetPokemonName reads the same way. */
|
||||
speciesName(species: number): string {
|
||||
this.checkSpecies(species);
|
||||
if (species !== EGG) this.checkSpecies(species);
|
||||
return this.cachedName(`mon:${species}`, () => {
|
||||
const r = this.reader,
|
||||
p = SYMBOLS.pokemonNames + (species - 1) * 10;
|
||||
|
||||
@@ -188,6 +188,18 @@ export const EVENTS = {
|
||||
wallOpenedInKabutoChamber: 807,
|
||||
wallOpenedInHoOhChamber: 806,
|
||||
wallOpenedInOmanyteChamber: 808,
|
||||
/** BugCatchingContestantEventFlagTable: SelectRandomBugContestContestants
|
||||
* sets one of these for each of the five contestants a round excludes. */
|
||||
bugCatchingContestant1A: 1814,
|
||||
bugCatchingContestant2A: 1815,
|
||||
bugCatchingContestant3A: 1816,
|
||||
bugCatchingContestant4A: 1817,
|
||||
bugCatchingContestant5A: 1818,
|
||||
bugCatchingContestant6A: 1819,
|
||||
bugCatchingContestant7A: 1820,
|
||||
bugCatchingContestant8A: 1821,
|
||||
bugCatchingContestant9A: 1822,
|
||||
bugCatchingContestant10A: 1823,
|
||||
} as const;
|
||||
|
||||
/** Engine flag indices named directly by translated routines. */
|
||||
@@ -195,11 +207,14 @@ export const ENGINE_FLAGS = {
|
||||
pokegear: 4,
|
||||
pokedex: 11,
|
||||
allFruitTrees: 84,
|
||||
/** STATUSFLAGS2_BUG_CONTEST_TIMER_F is the same WRAM bit. */
|
||||
bugContestTimer: 17,
|
||||
} as const;
|
||||
|
||||
/** Overworld scripts, routines and tables, keyed by their PRET labels so
|
||||
* that `npm run check:reference` verifies each address by name. */
|
||||
export const OVERWORLD = {
|
||||
ObjectEvent: 0x26ef,
|
||||
NPCTrades: 0xfce58,
|
||||
TradeTexts: 0xfcf53,
|
||||
NPCTradeCableText: 0xfcf7b,
|
||||
@@ -207,6 +222,8 @@ export const OVERWORLD = {
|
||||
"TradedForText.done": 0xfcf92,
|
||||
BugCatchingContestBattleScript: 0x135eb,
|
||||
ContestMons: 0x97d87,
|
||||
InitRoamMons: 0x2a2a0,
|
||||
RoamMaps: 0x2a40f,
|
||||
"OverworldTownMap": 0xc2c0,
|
||||
"TownMapGFX": 0xf8ba0,
|
||||
"JohtoMap": 0x91fff,
|
||||
@@ -335,6 +352,8 @@ export const OVERWORLD = {
|
||||
PlayMapMusic: 0x3cdf,
|
||||
SurfStartStep: 0x8379,
|
||||
UpdateTimePals: 0x485,
|
||||
LoadUsedSpritesGFX: 0x14209,
|
||||
RefreshSprites: 0x14168,
|
||||
WaitTileAnimation: 0xfc2fe,
|
||||
DoneTileAnimation: 0xfc2fb,
|
||||
StandingTileFrame8: 0xfc2ff,
|
||||
@@ -1133,6 +1152,83 @@ export const JOHTO = {
|
||||
BattleTowerAction: 0x170687,
|
||||
GameboyCheck: 0xc478,
|
||||
UnlockMysteryGift: 0x1050b9,
|
||||
CheckMobileAdapterStatusSpecial: 0x10630f,
|
||||
ClearBGPalettes: 0x31f3,
|
||||
UpdateSprites: 0x1ad2,
|
||||
GiveParkBalls: 0x135db,
|
||||
SelectRandomBugContestContestants: 0x139a8,
|
||||
BugContestantPointers: 0x13783,
|
||||
ContestDropOffMons: 0x13a12,
|
||||
ContestReturnMons: 0x13a31,
|
||||
CheckPartyFullAfterContest: 0x4d9e5,
|
||||
BugContestJudging: 0xc34a,
|
||||
BugCatchingContestOverScript: 0x135f8,
|
||||
BugCatchingContestReturnToGateScript: 0x1360b,
|
||||
ContestJudging_FirstPlaceText: 0x136eb,
|
||||
ContestJudging_FirstPlaceScoreText: 0x136fd,
|
||||
ContestJudging_SecondPlaceText: 0x13702,
|
||||
ContestJudging_SecondPlaceScoreText: 0x13714,
|
||||
ContestJudging_ThirdPlaceText: 0x13719,
|
||||
ContestJudging_ThirdPlaceScoreText: 0x1372b,
|
||||
"StartMenu_Quit.StartMenuContestEndText": 0x12906,
|
||||
"StartMenu_PrintBugContestStatus.CaughtString": 0x24c4b,
|
||||
"StartMenu_PrintBugContestStatus.BallsString": 0x24c52,
|
||||
"StartMenu_PrintBugContestStatus.NoneString": 0x24c59,
|
||||
"StartMenu_PrintBugContestStatus.LevelString": 0x24c5e,
|
||||
|
||||
// Game Corner: the slot machine and Card Flip (engine/games/*, both
|
||||
// reached through StartGameCornerGame's farcall; CheckCoinsAndCoinCase's
|
||||
// refusal texts gate both games before either special even runs).
|
||||
SlotMachine: 0xc373,
|
||||
CardFlip: 0xc380,
|
||||
_NoCoinsText: 0x1bd3d7,
|
||||
_NoCoinCaseText: 0x1bd3eb,
|
||||
_SlotsBetHowManyCoinsText: 0x1c5049,
|
||||
_SlotsStartText: 0x1c505e,
|
||||
_SlotsNotEnoughCoinsText: 0x1c5066,
|
||||
_SlotsRanOutOfCoinsText: 0x1c5079,
|
||||
_SlotsPlayAgainText: 0x1c5092,
|
||||
_SlotsLinedUpText: 0x1c509f,
|
||||
_SlotsDarnText: 0x1c50bb,
|
||||
"Slots_InitBias.Normal": 0x93023,
|
||||
"Slots_InitBias.Lucky": 0x93031,
|
||||
"Slots_GetPayout.PayoutTable": 0x93145,
|
||||
Reel1Tilemap: 0x93327,
|
||||
Reel2Tilemap: 0x93339,
|
||||
Reel3Tilemap: 0x9334b,
|
||||
SlotsTilemap: 0x9335d,
|
||||
Slots1LZ: 0x9344d,
|
||||
Slots2LZ: 0x935cd,
|
||||
Slots3LZ: 0x9382d,
|
||||
SlotMachinePals: 0xb7a9,
|
||||
"SpriteAnimOAMData.OAMData_SlotsGolem": 0x8df29,
|
||||
"SpriteAnimOAMData.OAMData_SlotsChansey1": 0x8df42,
|
||||
"SpriteAnimOAMData.OAMData_SlotsChansey2": 0x8df5b,
|
||||
"SpriteAnimOAMData.OAMData_SlotsChansey3": 0x8df74,
|
||||
"SpriteAnimOAMData.OAMData_SlotsChansey4": 0x8df8d,
|
||||
"SpriteAnimOAMData.OAMData_SlotsChansey5": 0x8dfa6,
|
||||
"SpriteAnimOAMData.OAMData_1x1_Palette0": 0x8daf1,
|
||||
_CardFlipPlayWithThreeCoinsText: 0x1c5793,
|
||||
_CardFlipNotEnoughCoinsText: 0x1c57ab,
|
||||
_CardFlipChooseACardText: 0x1c57be,
|
||||
_CardFlipPlaceYourBetText: 0x1c57ce,
|
||||
_CardFlipPlayAgainText: 0x1c57df,
|
||||
_CardFlipShuffledText: 0x1c57f4,
|
||||
_CardFlipYeahText: 0x1c5813,
|
||||
_CardFlipDarnText: 0x1c581a,
|
||||
CardFlipOffButtonGFX: 0xe0cf6,
|
||||
CardFlipOnButtonGFX: 0xe0d06,
|
||||
CardFlipLZ01: 0xe0d16,
|
||||
CardFlipLZ02: 0xe0ea8,
|
||||
CardFlipLZ03: 0xe0cdb,
|
||||
CardFlipTilemap: 0xe110c,
|
||||
"CardFlip_DisplayCardFaceUp.Deck": 0xe0459,
|
||||
"CardFlip_DisplayCardFaceUp.FaceUpCardTilemap": 0xe043b,
|
||||
"PlaceCardFaceDown.FaceDownCardTilemap": 0xe03ce,
|
||||
"PlaceOAMCardBorder.SpriteData": 0xe0853,
|
||||
"CardFlip_UpdateCursorOAM.OAMData": 0xe0981,
|
||||
"CardFlip_InitAttrPals.palettes": 0xe0c93,
|
||||
FontExtra: 0xf8000,
|
||||
} as const;
|
||||
|
||||
/** Saved WRAM bytes that map scripts read and write directly (readmem,
|
||||
@@ -1148,6 +1244,10 @@ export type SavedByte = keyof typeof SAVED_WRAM;
|
||||
* PRET labels. */
|
||||
export const WRAM = {
|
||||
wTilemap: 0xc4a0,
|
||||
wBugContestWinnerName: 0xd016,
|
||||
wBugContestFirstPlaceScore: 0xd004,
|
||||
wBugContestSecondPlaceScore: 0xd008,
|
||||
wBugContestThirdPlaceScore: 0xd00c,
|
||||
} as const;
|
||||
|
||||
export const MAP_GROUP_COUNT = 26;
|
||||
@@ -1259,5 +1359,23 @@ export const NATIVE_MAPS: Record<string, string> = {
|
||||
"3:20": "Radio Tower 4F",
|
||||
"3:21": "Radio Tower 5F",
|
||||
"10:14": "Route 35 gate",
|
||||
"10:2": "Route 35",
|
||||
"10:3": "Route 36",
|
||||
"10:4": "Route 37",
|
||||
"4:1": "Tin Tower entrance",
|
||||
"4:2": "Wise Trio’s room",
|
||||
"4:3": "Pokémon Center",
|
||||
"4:4": "Lugia speech house",
|
||||
"4:5": "Dance Theater",
|
||||
"4:6": "Ecruteak Mart",
|
||||
"4:7": "Ecruteak Gym",
|
||||
"4:8": "Itemfinder house",
|
||||
"4:9": "Ecruteak City",
|
||||
"3:13": "Burned Tower 1F",
|
||||
"3:14": "Burned Tower B1F",
|
||||
"10:15": "Route 35 National Park gate",
|
||||
"10:17": "Route 36 National Park gate",
|
||||
"3:15": "National Park",
|
||||
"3:16": "National Park (Bug-Catching Contest)",
|
||||
};
|
||||
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
|
||||
+10
-1
@@ -62,6 +62,14 @@ export class NativeRuntime implements GameRuntime {
|
||||
this.publish();
|
||||
return;
|
||||
}
|
||||
// A realtime screen (the Game Corner) reads every button itself, once
|
||||
// per its own frame, rather than through the usual per-button actions.
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind === "screen" && choice.realtime) {
|
||||
world.bufferScreenButton(button);
|
||||
this.publish();
|
||||
return;
|
||||
}
|
||||
if (button === "a") world.interact();
|
||||
if (button === "b") world.advanceDialogue();
|
||||
if (
|
||||
@@ -241,11 +249,12 @@ export class NativeRuntime implements GameRuntime {
|
||||
if (!this.world) throw new Error("Load your ROM first.");
|
||||
return new TextEncoder().encode(JSON.stringify(this.world.save()));
|
||||
}
|
||||
restoreState(bytes: Uint8Array) {
|
||||
restoreState(bytes: Uint8Array, continued = true) {
|
||||
if (!this.world || bytes.length > 64_000)
|
||||
throw new Error("Invalid native save size.");
|
||||
this.world.restore(
|
||||
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
||||
continued,
|
||||
);
|
||||
this.pressed.clear();
|
||||
this.publish();
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import { JOHTO } from "../rom/profile";
|
||||
import { C } from "../battle/constants";
|
||||
import { decompress } from "../rom/lz";
|
||||
import { fontTiles, textTiles, SPACE_TILE } from "../rom/font";
|
||||
import { readText } from "./text";
|
||||
import type { Gen, SpecialContext } from "./special-context";
|
||||
import {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
blankScreen,
|
||||
fillBox,
|
||||
fillAttrs,
|
||||
romPalette,
|
||||
type ScreenSprite,
|
||||
type TileScreen,
|
||||
} from "./screen";
|
||||
import { checkCoinsAndCoinCase, signed8 } from "./slot-machine";
|
||||
|
||||
/** CARDFLIP_DECK_SIZE (constants/misc_constants.asm): 4 species x 6 levels. */
|
||||
const DECK_SIZE = 24;
|
||||
/** This port's own tile layout for the three graphics blocks. BG1/BG2 sit
|
||||
* at the same offsets the original uses so every raw tilemap byte (the
|
||||
* face-down/up card layouts and the Deck's picture anchors) can be copied
|
||||
* in unchanged; tiles $72-$78 of that same range stay free for
|
||||
* _LoadFontsExtra1's ellipsis ($75, see rom/font.ts), so CardFlipLZ03 (an
|
||||
* OBJ-bank asset in the original, vTiles0 not vTiles2) moves to its own
|
||||
* space beyond the merged standard font instead of also living there. */
|
||||
const BG1_BASE = 0x00,
|
||||
BG2_BASE = 0x3e,
|
||||
OBJ_BASE = 0x100;
|
||||
|
||||
/** wDeck (shuffled card values by position), wDiscardPile, the current
|
||||
* round's chosen card and the bet cursor (wCardFlipCursorX/Y). */
|
||||
interface CardFlipSession {
|
||||
deck: number[];
|
||||
discardPile: boolean[];
|
||||
numCardsPlayed: number;
|
||||
whichCard: 0 | 1;
|
||||
faceUpCard: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
}
|
||||
|
||||
/** CardFlip_ShuffleDeck: card values 23 down to 0 dropped into random
|
||||
* unfilled slots (Random & $1f, rejecting 24-31 and already-filled slots).
|
||||
* wDiscardPile is cleared with it by the caller. */
|
||||
export function shuffleDeck(host: { random(): number }): number[] {
|
||||
const deck = new Array<number>(DECK_SIZE).fill(0);
|
||||
for (let value = DECK_SIZE - 1; value >= 0; value--) {
|
||||
let slot: number;
|
||||
do {
|
||||
slot = host.random() & 0x1f;
|
||||
} while (slot >= DECK_SIZE || deck[slot] !== 0);
|
||||
deck[slot] = value;
|
||||
}
|
||||
return deck;
|
||||
}
|
||||
|
||||
/** ChooseCard_HandleJoypad: the board is an 8-row by 6-column grid
|
||||
* (CollapseCursorPosition: Y*6+X). Rows 0-1 (the Pokémon pair/group icons)
|
||||
* and columns 0-1 (the level pair/group numbers) snap to the nearest even
|
||||
* cell of a pair as they move, exactly as the original's own `and $e`
|
||||
* masking does (even where that leaves the cursor on an unchanged, already
|
||||
* even position), and moving past either snaps instead to the fixed
|
||||
* mon-group/number-group junction cell. */
|
||||
export function moveCardFlipCursor(
|
||||
session: { cursorX: number; cursorY: number },
|
||||
direction: string,
|
||||
) {
|
||||
const toMonGroup = () => {
|
||||
session.cursorY = 2;
|
||||
session.cursorX = 1;
|
||||
};
|
||||
const toNumberGroup = () => {
|
||||
session.cursorY = 1;
|
||||
session.cursorX = 2;
|
||||
};
|
||||
if (direction === "left") {
|
||||
if (session.cursorY === 0) {
|
||||
session.cursorX &= 0xe;
|
||||
if (session.cursorX < 3) return toMonGroup();
|
||||
session.cursorX -= 2;
|
||||
} else if (session.cursorY === 1) {
|
||||
if (session.cursorX < 3) return toMonGroup();
|
||||
session.cursorX -= 1;
|
||||
} else if (session.cursorX > 0) session.cursorX -= 1;
|
||||
return;
|
||||
}
|
||||
if (direction === "right") {
|
||||
if (session.cursorY === 0) {
|
||||
session.cursorX &= 0xe;
|
||||
if (session.cursorX >= 4) return;
|
||||
session.cursorX += 2;
|
||||
} else if (session.cursorX < 5) session.cursorX += 1;
|
||||
return;
|
||||
}
|
||||
if (direction === "up") {
|
||||
if (session.cursorX === 0) {
|
||||
session.cursorY &= 0xe;
|
||||
if (session.cursorY < 3) return toNumberGroup();
|
||||
session.cursorY -= 2;
|
||||
} else if (session.cursorX === 1) {
|
||||
if (session.cursorY < 3) return toNumberGroup();
|
||||
session.cursorY -= 1;
|
||||
} else if (session.cursorY > 0) session.cursorY -= 1;
|
||||
return;
|
||||
}
|
||||
if (direction === "down") {
|
||||
if (session.cursorX === 0) {
|
||||
session.cursorY &= 0xe;
|
||||
if (session.cursorY >= 6) return;
|
||||
session.cursorY += 2;
|
||||
} else if (session.cursorY < 7) session.cursorY += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** CardFlip_CheckWinCondition's 8x6 paytable, read off the card's species
|
||||
* (bits 0-1) and level (bits 2-4): the top two rows' outer cells are
|
||||
* always a loss (CardFlip_UpdateCursorOAM's ".Impossible" cells); the
|
||||
* species-pair/exact and level-pair/exact rows pay 6/12 and 9/18; the
|
||||
* bottom four rows' right four columns pay 72 for the exact card. */
|
||||
export function cardFlipPayout(cell: number, card: number): number {
|
||||
const row = Math.floor(cell / 6),
|
||||
col = cell % 6;
|
||||
const species = card & 3,
|
||||
level = card >> 2;
|
||||
if (row === 0) {
|
||||
if (col <= 1) return 0;
|
||||
if (col <= 3) return species < 2 ? 6 : 0; // Pikachu/Jigglypuff pair
|
||||
return species >= 2 ? 6 : 0; // Poliwag/Oddish pair
|
||||
}
|
||||
if (row === 1) {
|
||||
if (col <= 1) return 0;
|
||||
return species === col - 2 ? 12 : 0; // exact species
|
||||
}
|
||||
const pairIndex = Math.floor((row - 2) / 2),
|
||||
isPairRow = (row - 2) % 2 === 0;
|
||||
if (col === 0) return Math.floor(level / 2) === pairIndex ? 9 : 0; // level pair
|
||||
const rowLevel = pairIndex * 2 + (isPairRow ? 0 : 1);
|
||||
if (col === 1) return level === rowLevel ? 18 : 0; // exact level
|
||||
const wantCard = rowLevel * 4 + (col - 2);
|
||||
return card === wantCard ? 72 : 0; // exact card
|
||||
}
|
||||
|
||||
function cardFlipTiles(reader: RomReader, textboxFrame: number): Uint8Array {
|
||||
// fontTiles' own 256-tile buffer ends exactly at OBJ_BASE; this port's
|
||||
// own layout needs more room beyond it for CardFlipLZ03.
|
||||
const tiles = new Uint8Array((OBJ_BASE + 8) * 16);
|
||||
tiles.set(fontTiles(reader, textboxFrame));
|
||||
tiles.set(decompress(reader, JOHTO.CardFlipLZ01, 0x1000), BG1_BASE * 16);
|
||||
tiles.set(decompress(reader, JOHTO.CardFlipLZ02, 0x1000), BG2_BASE * 16);
|
||||
tiles.set(decompress(reader, JOHTO.CardFlipLZ03, 0x1000), OBJ_BASE * 16);
|
||||
// The two CopyBytes calls right after the three Decompress calls:
|
||||
// CARDFLIP_LIGHT_OFF/_ON ($ef/$f5) overwrite the standard font's own
|
||||
// (unused here) "♂"/"♀" glyphs at those same tile numbers with the
|
||||
// light-off/on bulb graphic, used by the board's own left-edge divider
|
||||
// column (CardFlipTilemap's column 0 is $ef on every row) and the two
|
||||
// bet-light indicators.
|
||||
tiles.set(reader.slice(JOHTO.CardFlipOffButtonGFX, 16), 0xef * 16);
|
||||
tiles.set(reader.slice(JOHTO.CardFlipOnButtonGFX, 16), 0xf5 * 16);
|
||||
return tiles;
|
||||
}
|
||||
/** PlaceCardFaceDown/CardFlip_DisplayCardFaceUp's 5x6 tile boxes; every
|
||||
* byte in them already indexes this port's merged tiles (see BG1_BASE/
|
||||
* BG2_BASE above), including the $7f space tile for the still-blank level
|
||||
* and picture cells on a face-down card. */
|
||||
function drawCardBox(screen: TileScreen, reader: RomReader, x: number, y: number, address: number) {
|
||||
const data = reader.slice(address, 30);
|
||||
for (let row = 0; row < 6; row++)
|
||||
for (let col = 0; col < 5; col++)
|
||||
screen.map[(y + row) * SCREEN_WIDTH + x + col] = data[row * 5 + col];
|
||||
}
|
||||
/** CardFlip_DisplayCardFaceUp: the face-up frame, then the level digit
|
||||
* (charmap "1".."6", already within the merged standard font) and the 3x3
|
||||
* picture anchor from the Deck table, by the card's species. */
|
||||
function drawFaceUpCard(screen: TileScreen, reader: RomReader, x: number, y: number, card: number) {
|
||||
drawCardBox(screen, reader, x, y, JOHTO["CardFlip_DisplayCardFaceUp.FaceUpCardTilemap"]);
|
||||
const level = card >> 2,
|
||||
species = card & 3;
|
||||
screen.map[(y + 1) * SCREEN_WIDTH + x + 3] = 0xf7 + level;
|
||||
const anchor = reader.byte(JOHTO["CardFlip_DisplayCardFaceUp.Deck"] + species * 2 + 1);
|
||||
for (let row = 0; row < 3; row++)
|
||||
for (let col = 0; col < 3; col++)
|
||||
screen.map[(y + 2 + row) * SCREEN_WIDTH + x + 1 + col] = anchor + row * 3 + col;
|
||||
}
|
||||
function drawNumber(screen: TileScreen, x: number, y: number, value: number, digits: number) {
|
||||
const text = String(Math.max(0, value)).padStart(digits, "0").slice(-digits);
|
||||
const tiles = textTiles(text);
|
||||
for (let i = 0; i < digits; i++) screen.map[y * SCREEN_WIDTH + x + i] = tiles[i];
|
||||
}
|
||||
function drawMessage(screen: TileScreen, pages: string[]) {
|
||||
// readText joins a page's own `line` continuations with "\n"; each one
|
||||
// is a separate row of this port's own message box.
|
||||
pages
|
||||
.flatMap((page) => page.split("\n"))
|
||||
.slice(0, 4)
|
||||
.forEach((line, row) => {
|
||||
const tiles = textTiles(line);
|
||||
for (let i = 0; i < tiles.length && i < SCREEN_WIDTH; i++)
|
||||
screen.map[(13 + row) * SCREEN_WIDTH + i] = tiles[i];
|
||||
});
|
||||
}
|
||||
/** CardFlip_UpdateCursorOAM.OAMData: 48 entries (CollapseCursorPosition's
|
||||
* Y*6+X order), each a dbpixel anchor (x, y) and a pointer to one of the
|
||||
* SingleTile/NumGroup/NumGroupPair/PokeGroup/PokeGroupPair/Impossible
|
||||
* style tables below it, in the same count-prefixed dbsprite format as
|
||||
* the slot machine's own sprite frames. The pointer is resolved and read
|
||||
* directly, rather than this port naming which named table each of the 48
|
||||
* cells uses. CGB draws these every tick regardless of hVBlankCounter
|
||||
* (DMG's own "and $4" blink only applies without hCGB set). */
|
||||
function cursorSprites(reader: RomReader, cursorX: number, cursorY: number): ScreenSprite[] {
|
||||
const table = JOHTO["CardFlip_UpdateCursorOAM.OAMData"];
|
||||
const cell = cursorY * 6 + cursorX;
|
||||
const entry = table + cell * 4;
|
||||
const anchorX = reader.byte(entry),
|
||||
anchorY = reader.byte(entry + 1);
|
||||
const style = reader.near(entry + 2, reader.bank(table));
|
||||
const count = reader.byte(style);
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const p = style + 1 + i * 4;
|
||||
const attr = reader.byte(p + 3);
|
||||
return {
|
||||
x: anchorX + signed8(reader.byte(p + 1)),
|
||||
y: anchorY + signed8(reader.byte(p)),
|
||||
tile: OBJ_BASE + reader.byte(p + 2),
|
||||
palette: attr & 7, // OAM_PALETTE (CGB)
|
||||
flipX: Boolean(attr & 0x20), // OAM_XFLIP
|
||||
flipY: Boolean(attr & 0x40), // OAM_YFLIP
|
||||
behind: Boolean(attr & 0x80), // OAM_PRIO
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The scene CardFlip's own tile screen shows at a given moment; used by
|
||||
* `cardFlip`'s realtime loop for every phase from ChooseACard on. */
|
||||
interface CardFlipView {
|
||||
cards: "none" | "both" | "chosen";
|
||||
bet: boolean;
|
||||
message?: string[];
|
||||
}
|
||||
/** CardFlip_InitAttrPals: WipeAttrmap (left at 0 by blankScreen), then
|
||||
* each FillBox region in its own call order; the palette each uses comes
|
||||
* straight from the same `.palettes` block copied into wBGPals1. */
|
||||
function applyCardFlipAttrs(screen: TileScreen) {
|
||||
fillAttrs(screen, 12, 1, 2, 2, 1);
|
||||
fillAttrs(screen, 14, 1, 2, 2, 2);
|
||||
fillAttrs(screen, 16, 1, 2, 2, 3);
|
||||
fillAttrs(screen, 18, 1, 2, 2, 4);
|
||||
// `lb bc, 12, 1` into CardFlip_FillBox is 12 rows of 1 column (the same
|
||||
// b=height/c=width convention as CardFlip_CopyToBox): the board's own
|
||||
// left-edge divider column (x=9, every row $ef), not a 1-tall top row.
|
||||
fillAttrs(screen, 9, 0, 1, 12, 1);
|
||||
}
|
||||
function renderCardFlip(
|
||||
context: SpecialContext,
|
||||
session: CardFlipSession,
|
||||
tiles: Uint8Array,
|
||||
view: CardFlipView,
|
||||
): TileScreen {
|
||||
const r = context.reader;
|
||||
const palettes = Array.from({ length: 9 }, (_, i) =>
|
||||
romPalette(r, JOHTO["CardFlip_InitAttrPals.palettes"] + i * 8),
|
||||
);
|
||||
const screen = blankScreen(tiles, "card-flip", palettes, "Card Flip");
|
||||
fillBox(screen, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 0x29);
|
||||
// CardFlip_InitTilemap: `lb bc, 12, 11` into CardFlip_CopyToBox, whose
|
||||
// outer loop (`dec b`) is the row count and inner loop (`dec c`) the
|
||||
// column count: 12 rows of 11 columns, not the other way round.
|
||||
const board = r.slice(JOHTO.CardFlipTilemap, 12 * 11);
|
||||
for (let row = 0; row < 12; row++)
|
||||
for (let col = 0; col < 11; col++) screen.map[row * SCREEN_WIDTH + 9 + col] = board[row * 11 + col];
|
||||
fillBox(screen, 0, 12, SCREEN_WIDTH, SCREEN_HEIGHT - 12, SPACE_TILE);
|
||||
applyCardFlipAttrs(screen);
|
||||
if (view.cards !== "none") {
|
||||
const top = session.whichCard === 0 || view.cards === "both",
|
||||
bottom = session.whichCard === 1 || view.cards === "both";
|
||||
if (top)
|
||||
if (view.cards === "chosen" && session.whichCard === 0)
|
||||
drawFaceUpCard(screen, r, 2, 0, session.faceUpCard);
|
||||
else drawCardBox(screen, r, 2, 0, JOHTO["PlaceCardFaceDown.FaceDownCardTilemap"]);
|
||||
if (bottom)
|
||||
if (view.cards === "chosen" && session.whichCard === 1)
|
||||
drawFaceUpCard(screen, r, 2, 6, session.faceUpCard);
|
||||
else drawCardBox(screen, r, 2, 6, JOHTO["PlaceCardFaceDown.FaceDownCardTilemap"]);
|
||||
}
|
||||
if (view.bet) screen.sprites = cursorSprites(r, session.cursorX, session.cursorY);
|
||||
// The coin balance is drawn last so each payout tick's increment shows
|
||||
// at once, like CardFlip_PrintCoinBalance redrawing it every loop.
|
||||
drawNumber(screen, 15, 16, context.state.coins, 4);
|
||||
if (view.message) drawMessage(screen, view.message);
|
||||
return screen;
|
||||
}
|
||||
|
||||
/** CardFlip.ChooseACard: toggle which face-down card is picked (any button
|
||||
* but A) and confirm with A. The three-blink reveal-prep and the
|
||||
* placement delay are not presented; see `renderCardFlip`'s doc comment. */
|
||||
function* chooseACard(context: SpecialContext, session: CardFlipSession, tiles: Uint8Array): Gen<void> {
|
||||
session.whichCard = 0;
|
||||
for (;;) {
|
||||
const button = yield {
|
||||
kind: "screen",
|
||||
screen: renderCardFlip(context, session, tiles, { cards: "both", bet: false }),
|
||||
realtime: true,
|
||||
};
|
||||
if (button === "a") break;
|
||||
if (button) session.whichCard = session.whichCard === 0 ? 1 : 0;
|
||||
}
|
||||
session.discardPile[session.numCardsPlayed * 2 + (1 - session.whichCard)] = true;
|
||||
}
|
||||
/** CardFlip.PlaceYourBet: move the cursor over the paytable and confirm. */
|
||||
function* placeYourBet(context: SpecialContext, session: CardFlipSession, tiles: Uint8Array): Gen<void> {
|
||||
for (;;) {
|
||||
const button = yield {
|
||||
kind: "screen",
|
||||
screen: renderCardFlip(context, session, tiles, { cards: "chosen", bet: true }),
|
||||
realtime: true,
|
||||
};
|
||||
if (button === "a") return;
|
||||
if (typeof button === "string") moveCardFlipCursor(session, button);
|
||||
}
|
||||
}
|
||||
/** CardFlip.CheckTheCard: reveal the chosen card. */
|
||||
function* checkTheCard(context: SpecialContext, session: CardFlipSession, tiles: Uint8Array): Gen<void> {
|
||||
const index = session.numCardsPlayed * 2 + session.whichCard;
|
||||
session.faceUpCard = session.deck[index];
|
||||
session.discardPile[index] = true;
|
||||
yield {
|
||||
kind: "screen",
|
||||
screen: renderCardFlip(context, session, tiles, { cards: "chosen", bet: true }),
|
||||
realtime: true,
|
||||
};
|
||||
}
|
||||
/** CardFlip.TabulateTheResult: CardFlip_CheckWinCondition's payout, paid
|
||||
* out one coin per tick like the slot machine's own payout animation
|
||||
* (Slots_CheckCoinCaseFull's cap applies the same way), then
|
||||
* WaitPressAorB_BlinkCursor. */
|
||||
function* tabulateTheResult(
|
||||
context: SpecialContext,
|
||||
session: CardFlipSession,
|
||||
tiles: Uint8Array,
|
||||
): Gen<void> {
|
||||
const cell = session.cursorY * 6 + session.cursorX;
|
||||
const payout = cardFlipPayout(cell, session.faceUpCard);
|
||||
const lines = readText(
|
||||
context.reader,
|
||||
payout > 0 ? JOHTO._CardFlipYeahText : JOHTO._CardFlipDarnText,
|
||||
context.text(),
|
||||
).split("\n\n");
|
||||
let remaining = payout;
|
||||
for (;;) {
|
||||
yield {
|
||||
kind: "screen",
|
||||
screen: renderCardFlip(context, session, tiles, {
|
||||
cards: "chosen",
|
||||
bet: true,
|
||||
message: lines,
|
||||
}),
|
||||
realtime: true,
|
||||
};
|
||||
if (remaining === 0) break;
|
||||
remaining--;
|
||||
if (context.state.coins < C.MAX_COINS) context.state.coins++;
|
||||
}
|
||||
for (;;) {
|
||||
const button = yield {
|
||||
kind: "screen",
|
||||
screen: renderCardFlip(context, session, tiles, { cards: "chosen", bet: true, message: lines }),
|
||||
realtime: true,
|
||||
};
|
||||
if (button === "a" || button === "b") break;
|
||||
}
|
||||
}
|
||||
|
||||
/** _CardFlip (engine/games/card_flip.asm), as CardFlip calls it. Each
|
||||
* round costs 3 coins: choose one of two face-down cards (the other is
|
||||
* discarded), bet on a paytable cell, reveal the card and pay out by
|
||||
* CardFlip_CheckWinCondition. The 24-card deck reshuffles after 12 rounds.
|
||||
* Sounds (SFX_KINESIS, SFX_CHOOSE_A_CARD, the payout clinks) are not
|
||||
* presented. */
|
||||
export function* cardFlip(context: SpecialContext): Gen<void> {
|
||||
if (!(yield* checkCoinsAndCoinCase(context))) return;
|
||||
const tiles = cardFlipTiles(context.reader, context.state.options.textboxFrame);
|
||||
let deck = shuffleDeck(context);
|
||||
let discardPile = new Array<boolean>(DECK_SIZE).fill(false);
|
||||
let numCardsPlayed = 0;
|
||||
let cursorX = 2,
|
||||
cursorY = 2;
|
||||
|
||||
context.write(JOHTO._CardFlipPlayWithThreeCoinsText);
|
||||
const wantsToPlay = yield { kind: "yesno", pages: context.take() };
|
||||
if (!wantsToPlay) return;
|
||||
|
||||
for (;;) {
|
||||
if (context.state.coins < 3) {
|
||||
context.write(JOHTO._CardFlipNotEnoughCoinsText);
|
||||
yield* context.flush();
|
||||
return;
|
||||
}
|
||||
context.state.coins -= 3;
|
||||
|
||||
const session: CardFlipSession = {
|
||||
deck,
|
||||
discardPile,
|
||||
numCardsPlayed,
|
||||
whichCard: 0,
|
||||
faceUpCard: 0,
|
||||
cursorX,
|
||||
cursorY,
|
||||
};
|
||||
yield* chooseACard(context, session, tiles);
|
||||
yield* placeYourBet(context, session, tiles);
|
||||
yield* checkTheCard(context, session, tiles);
|
||||
yield* tabulateTheResult(context, session, tiles);
|
||||
cursorX = session.cursorX;
|
||||
cursorY = session.cursorY;
|
||||
|
||||
context.write(JOHTO._CardFlipPlayAgainText);
|
||||
const again = yield { kind: "yesno", pages: context.take() };
|
||||
if (!again) return;
|
||||
|
||||
numCardsPlayed = session.numCardsPlayed + 1;
|
||||
if (numCardsPlayed >= 12) {
|
||||
deck = shuffleDeck(context);
|
||||
discardPile = new Array<boolean>(DECK_SIZE).fill(false);
|
||||
numCardsPlayed = 0;
|
||||
context.write(JOHTO._CardFlipShuffledText);
|
||||
yield* context.flush();
|
||||
} else {
|
||||
discardPile = session.discardPile;
|
||||
deck = session.deck;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BATTLE, JOHTO, 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 type { Gen, SpecialContext } from "./special-context";
|
||||
import {
|
||||
checkCountdown,
|
||||
@@ -59,8 +60,6 @@ export function checkFirstMonIsEgg(context: SpecialContext): number {
|
||||
name;
|
||||
return Number(Boolean(first.egg));
|
||||
}
|
||||
/** EGG (constants/pokemon_constants.asm). */
|
||||
const EGG = 0xfd;
|
||||
|
||||
/** HaircutOrGrooming (engine/events/haircut.asm): a Pokémon from the
|
||||
* party; an Egg answers 1. Otherwise one Random byte picks a row of the
|
||||
|
||||
+391
-14
@@ -18,6 +18,7 @@ import {
|
||||
POKEGEAR,
|
||||
RAM,
|
||||
SAVED_WRAM,
|
||||
WRAM,
|
||||
SPECIAL_ROUTINES,
|
||||
SYMBOLS,
|
||||
type SavedByte,
|
||||
@@ -25,7 +26,8 @@ import {
|
||||
import { PokemonData } from "../rom/pokemon";
|
||||
import { PokedexData } from "../rom/pokedex";
|
||||
import { C } from "../battle/constants";
|
||||
import type { BugContest } from "../battle/battle";
|
||||
import type { BugContest, RoamMon } from "../battle/battle";
|
||||
import { emptyRoamMons, initRoamMons } from "../world/roam-mons";
|
||||
import {
|
||||
copyBag,
|
||||
emptyBag,
|
||||
@@ -67,6 +69,8 @@ import type { FlyMapView } from "./fly-map";
|
||||
import type { TileScreen } from "./screen";
|
||||
import { unownPuzzle } from "./unown-puzzle";
|
||||
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
|
||||
import { slotMachine } from "./slot-machine";
|
||||
import { cardFlip } from "./card-flip";
|
||||
import {
|
||||
checkFirstMonIsEgg,
|
||||
checkForLuckyNumberWinners,
|
||||
@@ -182,12 +186,34 @@ export interface ScriptState {
|
||||
timers: Partial<Record<DayTimerName, DayTimer>>;
|
||||
/** sLuckyIDNumber and sLuckyNumberDay (wCurDay + 1 when drawn). */
|
||||
luckyNumber: { id: number; day: number };
|
||||
/** wRoamMon1/2/3 (Raikou, Entei, Suicune): InitRoamMons starts the first
|
||||
* two roaming; CheckEncounterRoamMon, UpdateRoamMons and JumpRoamMons
|
||||
* move or encounter them (see ../world/roam-mons). */
|
||||
roamMons: RoamMon[];
|
||||
/** wRoamMons_CurMapGroup/Number, kept from the end of the last
|
||||
* UpdateRoamMons or JumpRoamMons call: at the next call it is
|
||||
* wRoamMons_LastMapGroup/Number, the map .Update avoids repeating
|
||||
* (_BackUpMapIndices runs after every call, whether or not any roaming
|
||||
* Pokémon is active yet). Null before the first call. */
|
||||
roamMonsMap: string | null;
|
||||
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
|
||||
options: GameOptions;
|
||||
/** wParkBallsRemaining and wContestMon while the Bug-Catching Contest
|
||||
* runs (STATUSFLAGS2_BUG_CONTEST_TIMER_F). Nothing starts a contest yet,
|
||||
* so saves do not keep it. */
|
||||
* runs (ENGINE_BUG_CONTEST_TIMER/STATUSFLAGS2_BUG_CONTEST_TIMER_F). */
|
||||
bugContest?: BugContest;
|
||||
/** StartBugContestTimer's wBugContestStartTime, kept here as the real
|
||||
* deadline (the game clock's time plus BUG_CONTEST_MINUTES) rather than
|
||||
* the ROM's day/hour/min/sec struct that CheckBugContestTimer
|
||||
* decrements every check: both converge on the same remaining time for
|
||||
* any elapsed duration, since checks run on (effectively) every idle
|
||||
* tick. Set only while `bugContest` is set. */
|
||||
bugContestEnd?: number;
|
||||
/** ContestDropOffMons/ContestReturnMons: the party slots 2-6 a Bug-
|
||||
* Catching Contest hides while it runs. The ROM instead truncates
|
||||
* wPartyCount and leaves the other slots' structs untouched in WRAM;
|
||||
* here they are moved aside and spliced back, which is observably the
|
||||
* same (nothing else can read or change them meanwhile). */
|
||||
contestLeftParty?: PartyMon[];
|
||||
}
|
||||
export const emptyScriptState = (): ScriptState => ({
|
||||
decorations: emptyDecorations(),
|
||||
@@ -228,6 +254,8 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
firstUnownSeen: 0,
|
||||
timers: {},
|
||||
luckyNumber: { id: 0, day: 0 },
|
||||
roamMons: emptyRoamMons(),
|
||||
roamMonsMap: null,
|
||||
options: defaultOptions(),
|
||||
});
|
||||
export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
@@ -274,12 +302,18 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
Object.entries(state.timers).map(([name, timer]) => [name, { ...timer }]),
|
||||
),
|
||||
luckyNumber: { ...state.luckyNumber },
|
||||
roamMons: state.roamMons.map((mon) => ({ ...mon })),
|
||||
roamMonsMap: state.roamMonsMap,
|
||||
options: { ...state.options },
|
||||
...(state.bugContest && {
|
||||
bugContest: {
|
||||
parkBalls: state.bugContest.parkBalls,
|
||||
mon: structuredClone(state.bugContest.mon),
|
||||
},
|
||||
bugContestEnd: state.bugContestEnd,
|
||||
}),
|
||||
...(state.contestLeftParty && {
|
||||
contestLeftParty: structuredClone(state.contestLeftParty),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -330,6 +364,27 @@ const ROUTE_36 = "10:3";
|
||||
const CONTACT_LIST_SIZE = 10;
|
||||
/** VAR_CALLERID: wCurCaller, the phone contact a trainer script names. */
|
||||
const VAR_CALLERID = 0x17;
|
||||
/** BUG_CONTEST_BALLS, BUG_CONTEST_MINUTES and BUG_CONTEST_SECONDS
|
||||
* (constants/script_constants.asm): GiveParkBalls and StartBugContestTimer. */
|
||||
const BUG_CONTEST_BALLS = 20;
|
||||
const BUG_CONTEST_MINUTES = 20;
|
||||
/** The ten BugCatchingContestantEventFlagTable entries, in order. */
|
||||
const BUG_CONTESTANT_A_FLAGS: readonly number[] = [
|
||||
EVENTS.bugCatchingContestant1A,
|
||||
EVENTS.bugCatchingContestant2A,
|
||||
EVENTS.bugCatchingContestant3A,
|
||||
EVENTS.bugCatchingContestant4A,
|
||||
EVENTS.bugCatchingContestant5A,
|
||||
EVENTS.bugCatchingContestant6A,
|
||||
EVENTS.bugCatchingContestant7A,
|
||||
EVENTS.bugCatchingContestant8A,
|
||||
EVENTS.bugCatchingContestant9A,
|
||||
EVENTS.bugCatchingContestant10A,
|
||||
];
|
||||
/** LANDMARK_NATIONAL_PARK (constants/landmark_constants.asm), which
|
||||
* CheckPartyFullAfterContest writes over whatever landmark the gate the
|
||||
* player is standing at would otherwise give the catch. */
|
||||
const LANDMARK_NATIONAL_PARK = 0x13;
|
||||
/** PermanentNumbers: PHONECONTACT_MOM and PHONECONTACT_ELM, which share
|
||||
* PHONE_MOM's and PHONE_ELM's values. */
|
||||
const PERMANENT_NUMBERS = [1, 4];
|
||||
@@ -511,8 +566,9 @@ export type ScriptEffect =
|
||||
tree?: boolean;
|
||||
/** BATTLETYPE_FISH: a Pokémon hooked on a fishing rod. */
|
||||
fish?: boolean;
|
||||
/** The other wBattleType values scripts load: FORCESHINY, TRAP,
|
||||
* FORCEITEM, SUICUNE and CONTEST. */
|
||||
/** The other wBattleType values: FORCESHINY, TRAP, FORCEITEM,
|
||||
* SUICUNE and CONTEST (scripts load these) or ROAMING
|
||||
* (CheckEncounterRoamMon sets it before the script runs). */
|
||||
battleType?: number;
|
||||
}
|
||||
/** loademote: the emote graphics (EMOTE_*) that show_emote displays
|
||||
@@ -525,7 +581,11 @@ export type ScriptEffect =
|
||||
* MAPSETUP_DOOR (`door`) keeps the facing unless the arrival tile turns
|
||||
* the player down (CheckWarpFacingDown). newloadmap stops the script as
|
||||
* StopScript does; when more than an end follows, it resumes at `resume`
|
||||
* on the new map (ScriptEvents restarts it after EnterMap). */
|
||||
* on the new map (ScriptEvents restarts it after EnterMap). `roam`
|
||||
* reports UpdateRoamMons (MAPSETUP_DOOR, which Dig and the Escape Rope
|
||||
* use) or JumpRoamMons (MAPSETUP_TELEPORT/MAPSETUP_FLY): the shared byte
|
||||
* table in data/maps/setup_scripts.asm falls through into these from
|
||||
* those three mapsetup entries alone, never from plain MAPSETUP_WARP. */
|
||||
| {
|
||||
kind: "warp";
|
||||
map: string;
|
||||
@@ -533,6 +593,7 @@ export type ScriptEffect =
|
||||
y: number;
|
||||
facing?: Direction;
|
||||
door?: boolean;
|
||||
roam?: "update" | "jump";
|
||||
resume?: number;
|
||||
}
|
||||
/** FlyFromAnim and FlyToAnim: the Fly user's party icon flies off from
|
||||
@@ -604,8 +665,11 @@ export type ScriptEffect =
|
||||
* (warp, object, script) that boulders falling into pits trigger. */
|
||||
| { kind: "stonetable"; address: number }
|
||||
/** A full-screen minigame or special drawn in tiles, answered with the
|
||||
* button pressed (SCREEN_BUTTONS). */
|
||||
| { kind: "screen"; screen: TileScreen };
|
||||
* button pressed (SCREEN_BUTTONS). `realtime` marks a screen that keeps
|
||||
* running on its own, like the Game Corner (SlotsLoop, CardFlip's
|
||||
* MasterLoop): the world resumes it every tick (twice, as elsewhere)
|
||||
* with the latest buffered button rather than waiting for one. */
|
||||
| { kind: "screen"; screen: TileScreen; realtime?: boolean };
|
||||
|
||||
/** What the world supplies about the present moment. */
|
||||
export interface ScriptHost {
|
||||
@@ -618,6 +682,9 @@ export interface ScriptHost {
|
||||
clock(): { weekday: number; hours: number; minutes: number };
|
||||
/** The game clock's weekday before SetDayOfWeek's offset. */
|
||||
clockDay(): number;
|
||||
/** The game clock's real time (UpdateTime), in epoch milliseconds, for
|
||||
* StartBugContestTimer's deadline. */
|
||||
gameNow(): number;
|
||||
text(): TextContext;
|
||||
data: PokemonData;
|
||||
/** wPlayerID and wPlayerGender (1 for Kris). */
|
||||
@@ -807,6 +874,10 @@ export class ScriptRun {
|
||||
/** wBattleType BATTLETYPE_FISH, which FishFunction sets with the
|
||||
* hooked Pokémon (`wild`). */
|
||||
fish?: boolean;
|
||||
/** wBattleType BATTLETYPE_ROAMING, set directly by
|
||||
* CheckEncounterRoamMon before WildBattleScript ever runs (`wild`
|
||||
* names the roaming beast's own species and level). */
|
||||
roaming?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
this.state = copyScriptState(state);
|
||||
@@ -848,8 +919,15 @@ export class ScriptRun {
|
||||
? OVERWORLD["UseFlashTextScript.BlankText"]
|
||||
: // TradedForText: the fanfare after Text_NPCTraded.
|
||||
offset === OVERWORLD.TradedForText + 4
|
||||
? OVERWORLD["TradedForText.done"]
|
||||
: undefined,
|
||||
? OVERWORLD["TradedForText.done"]
|
||||
: // ContestJudging_*Text: a placing fanfare, then its score text.
|
||||
offset === JOHTO.ContestJudging_FirstPlaceText + 4
|
||||
? JOHTO.ContestJudging_FirstPlaceScoreText
|
||||
: offset === JOHTO.ContestJudging_SecondPlaceText + 4
|
||||
? JOHTO.ContestJudging_SecondPlaceScoreText
|
||||
: offset === JOHTO.ContestJudging_ThirdPlaceText + 4
|
||||
? JOHTO.ContestJudging_ThirdPlaceScoreText
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -873,7 +951,11 @@ export class ScriptRun {
|
||||
let winText = this.tempTrainer?.beatenText || undefined,
|
||||
lossText = this.tempTrainer?.lostScript || undefined;
|
||||
let canLose = false;
|
||||
let battleType: number | undefined;
|
||||
// CheckEncounterRoamMon sets wBattleType before WildBattleScript (just
|
||||
// randomwildmon/startbattle) ever runs, so no opcode here sets it.
|
||||
let battleType: number | undefined = this.options.roaming
|
||||
? C.BATTLETYPE_ROAMING
|
||||
: undefined;
|
||||
let menuHeader: number | undefined;
|
||||
const temp = (offset: number, opcode: number) => {
|
||||
if (!this.tempTrainer)
|
||||
@@ -1482,11 +1564,23 @@ export class ScriptRun {
|
||||
// carried over to the new map here.
|
||||
if (stack.length)
|
||||
throw new UnsupportedScript(offset, opcode, " (inside a call)");
|
||||
// MAPSETUP_DOOR's bytes fall through into MAPSETUP_TRAIN's
|
||||
// UpdateRoamMons; MAPSETUP_TELEPORT and MAPSETUP_FLY fall
|
||||
// through into MAPSETUP_WARP's bytes, but only after their own
|
||||
// JumpRoamMons. Plain MAPSETUP_WARP (used only by the whiteout
|
||||
// script) starts past both and calls neither.
|
||||
const roam =
|
||||
method === OW.MAPSETUP_DOOR
|
||||
? "update"
|
||||
: method === OW.MAPSETUP_TELEPORT || method === OW.MAPSETUP_FLY
|
||||
? "jump"
|
||||
: undefined;
|
||||
yield* flush();
|
||||
yield synchronous({
|
||||
kind: "warp",
|
||||
...destination,
|
||||
door: method === OW.MAPSETUP_DOOR || undefined,
|
||||
roam,
|
||||
resume:
|
||||
r.byte(p) === 0x91 || r.byte(p) === 0x93 ? undefined : p,
|
||||
});
|
||||
@@ -1513,7 +1607,10 @@ export class ScriptRun {
|
||||
case 0x3c:
|
||||
case 0xa3: {
|
||||
// warp and warpfacing: MAPSETUP_WARP with SPAWN_N_A. A zero map
|
||||
// group is the BADWARP fallback, which is not ported.
|
||||
// group is the BADWARP fallback, which is not ported. Most warps
|
||||
// end the script right after; BugContestResultsWarpScript (and
|
||||
// anything else that falls through into more commands) resumes
|
||||
// them on the new map, like newloadmap's resume.
|
||||
const facing =
|
||||
opcode === 0xa3 ? DIRECTION_ORDER[byte() & 3] : undefined;
|
||||
const group = byte(),
|
||||
@@ -1522,13 +1619,18 @@ export class ScriptRun {
|
||||
y = byte();
|
||||
if (!group)
|
||||
throw new UnsupportedScript(offset, opcode, " (bad warp)");
|
||||
const continues = r.byte(p) !== 0x91 && r.byte(p) !== 0x93;
|
||||
if (continues && stack.length)
|
||||
throw new UnsupportedScript(offset, opcode, " (inside a call)");
|
||||
yield synchronous({
|
||||
kind: "warp",
|
||||
map: `${group}:${number}`,
|
||||
x,
|
||||
y,
|
||||
facing,
|
||||
resume: continues ? p : undefined,
|
||||
});
|
||||
if (continues) return;
|
||||
break;
|
||||
}
|
||||
case 0x8c: {
|
||||
@@ -1621,6 +1723,12 @@ export class ScriptRun {
|
||||
this.strings[buffer(byte())] = String(balance);
|
||||
break;
|
||||
}
|
||||
case 0x3f:
|
||||
// getnum: wScriptVar as a left-aligned decimal string, such as
|
||||
// ContestResults_PlayerWonAPrizeText's placement number. value
|
||||
// itself is unchanged.
|
||||
this.strings[buffer(byte())] = String(value);
|
||||
break;
|
||||
case 0x42:
|
||||
case 0xa5: {
|
||||
// getcurlandmarkname and getlandmarkname (whose landmark operand
|
||||
@@ -3050,6 +3158,14 @@ export class ScriptRun {
|
||||
case 0x10:
|
||||
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount.
|
||||
return MONS_PER_BOX - currentBox(this.state.storage).length;
|
||||
case 0x11: {
|
||||
// VAR_CONTESTMINUTES: CheckBugContestTimer's remaining whole
|
||||
// minutes, from the real deadline rather than the ROM's
|
||||
// decremented wBugContestMinsRemaining/SecsRemaining pair.
|
||||
const end = this.state.bugContestEnd;
|
||||
if (end === undefined) return 0;
|
||||
return Math.floor(Math.max(0, end - this.host.gameNow()) / 60_000);
|
||||
}
|
||||
case 0x12:
|
||||
return this.host.position().x;
|
||||
case 0x13:
|
||||
@@ -3114,8 +3230,11 @@ export class ScriptRun {
|
||||
case OVERWORLD.UpdateTimePals:
|
||||
case OVERWORLD.WaitSFX:
|
||||
case OVERWORLD.FadeInFromWhite:
|
||||
// The native renderer draws the player from wPlayerState each frame;
|
||||
// music and palette fades are presentation.
|
||||
case OVERWORLD.LoadUsedSpritesGFX:
|
||||
case OVERWORLD.RefreshSprites:
|
||||
// The native renderer draws the player and objects from state each
|
||||
// frame; music, palette fades and VRAM sprite tile loading are
|
||||
// presentation.
|
||||
return value;
|
||||
case OVERWORLD.SurfStartStep:
|
||||
// slow_step in the facing direction, then step_end.
|
||||
@@ -3125,6 +3244,8 @@ export class ScriptRun {
|
||||
case SPECIAL_ROUTINES.fadeOutToBlack:
|
||||
case SPECIAL_ROUTINES.fadeInFromBlack:
|
||||
case SPECIAL_ROUTINES.reloadSpritesNoPalettes:
|
||||
case JOHTO.ClearBGPalettes:
|
||||
case JOHTO.UpdateSprites:
|
||||
// Graphics, palettes and sound only: wPartyCount/wScriptVar select
|
||||
// an animation sequence but nothing persists past it.
|
||||
return value;
|
||||
@@ -3216,6 +3337,9 @@ export class ScriptRun {
|
||||
case OVERWORLD.OverworldTownMap:
|
||||
yield { kind: "townmap" };
|
||||
return value;
|
||||
case OVERWORLD.InitRoamMons:
|
||||
this.state.roamMons = initRoamMons(this.state.roamMons);
|
||||
return value;
|
||||
case JOHTO.UnownPuzzle: {
|
||||
// FadeToMenu: the text box closes first.
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
@@ -3225,6 +3349,12 @@ export class ScriptRun {
|
||||
);
|
||||
return Number(solved);
|
||||
}
|
||||
case JOHTO.SlotMachine:
|
||||
yield* slotMachine(this.specialContext(pages, take), Boolean(value));
|
||||
return value;
|
||||
case JOHTO.CardFlip:
|
||||
yield* cardFlip(this.specialContext(pages, take));
|
||||
return value;
|
||||
case JOHTO.DisplayUnownWords: {
|
||||
const tileset = this.host.tileset;
|
||||
if (!tileset) return undefined;
|
||||
@@ -3295,6 +3425,10 @@ export class ScriptRun {
|
||||
case JOHTO.GameboyCheck:
|
||||
// The port is a Game Boy Color.
|
||||
return GBCHECK_CGB;
|
||||
case JOHTO.CheckMobileAdapterStatusSpecial:
|
||||
// Unused in this revision (CheckMobileAdapterStatus is the
|
||||
// Japanese version's routine): always clears wScriptVar.
|
||||
return 0;
|
||||
case JOHTO.UnlockMysteryGift:
|
||||
// sMysteryGiftUnlocked only adds MYSTERY GIFT to the main menu,
|
||||
// which belongs to the link features.
|
||||
@@ -3393,10 +3527,252 @@ export class ScriptRun {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case JOHTO.GiveParkBalls:
|
||||
this.state.bugContest = { parkBalls: BUG_CONTEST_BALLS, mon: null };
|
||||
// StartBugContestTimer: a real deadline rather than the ROM's
|
||||
// wBugContestStartTime day/hour/min/sec struct (see ScriptState).
|
||||
this.state.bugContestEnd =
|
||||
this.host.gameNow() + BUG_CONTEST_MINUTES * 60_000;
|
||||
return value;
|
||||
case JOHTO.SelectRandomBugContestContestants: {
|
||||
for (const flag of BUG_CONTESTANT_A_FLAGS) this.state.events.delete(flag);
|
||||
// $ff / NUM_BUG_CONTESTANTS, rejecting draws past its last
|
||||
// multiple, then SimpleDivide for a uniform index 0-9.
|
||||
const span = Math.floor(255 / BUG_CONTESTANT_A_FLAGS.length);
|
||||
let chosen = 0,
|
||||
tries = 0;
|
||||
while (chosen < 5) {
|
||||
let byte: number;
|
||||
do {
|
||||
if (++tries > 4096) throw new Error("Random draw limit exceeded.");
|
||||
byte = this.host.random();
|
||||
} while (byte >= span * BUG_CONTESTANT_A_FLAGS.length);
|
||||
const flag = BUG_CONTESTANT_A_FLAGS[Math.floor(byte / span)];
|
||||
// "If we've already set it, it doesn't count."
|
||||
if (this.state.events.has(flag)) continue;
|
||||
this.state.events.add(flag);
|
||||
chosen++;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case JOHTO.ContestDropOffMons: {
|
||||
const party = this.state.party,
|
||||
lead = party[0];
|
||||
if (!lead.hp) return 1; // .fainted
|
||||
// The ROM truncates wPartyCount and leaves the other structs
|
||||
// alone in WRAM; here they are moved aside (see ScriptState).
|
||||
this.state.contestLeftParty = party.slice(1);
|
||||
this.state.party = [lead];
|
||||
return 0;
|
||||
}
|
||||
case JOHTO.ContestReturnMons: {
|
||||
const left = this.state.contestLeftParty ?? [];
|
||||
this.state.party = [this.state.party[0], ...left];
|
||||
this.state.contestLeftParty = undefined;
|
||||
return value;
|
||||
}
|
||||
case JOHTO.CheckPartyFullAfterContest:
|
||||
return yield* this.checkPartyFullAfterContest(pages, take);
|
||||
case JOHTO.BugContestJudging:
|
||||
return yield* this.bugContestJudging();
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** CheckPartyFullAfterContest: the caught contest Pokémon (or none)
|
||||
* joins the party, or the current box first (SetBoxMonCaughtData),
|
||||
* with the landmark overridden to the National Park regardless of
|
||||
* where the player is standing when this runs (back at the gate). */
|
||||
private *checkPartyFullAfterContest(
|
||||
pages: string[],
|
||||
take: () => string[],
|
||||
): Generator<ScriptEffect, number, Input> {
|
||||
const mon = this.state.bugContest?.mon ?? null;
|
||||
// BugContestResultsScript always reaches this last, whether judging
|
||||
// ran or not: the contest itself is over (ENGINE_BUG_CONTEST_TIMER
|
||||
// was already cleared at the top of that script).
|
||||
this.state.bugContest = undefined;
|
||||
this.state.bugContestEnd = undefined;
|
||||
if (!mon) return 2; // BUGCONTEST_NO_CATCH
|
||||
mon.caughtData = caughtData(
|
||||
this.host.timeOfDay(),
|
||||
mon.level,
|
||||
this.host.player().gender,
|
||||
LANDMARK_NATIONAL_PARK,
|
||||
);
|
||||
const speciesName = this.host.data.speciesName(mon.species);
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
// GiveANickname_YesNo, then NamingScreen (NAME_MON), as the regular
|
||||
// capture flow's askNickname does; declining or a blank answer keeps
|
||||
// the species name GeneratePartyMonStats already gave it.
|
||||
this.strings[RAM.stringBuffer1] = speciesName;
|
||||
const wants = yield {
|
||||
kind: "yesno" as const,
|
||||
pages: readText(this.reader, SYMBOLS.caughtAskNicknameText, this.text())
|
||||
.split("\n\n")
|
||||
.filter(Boolean),
|
||||
};
|
||||
if (wants) {
|
||||
const r = this.reader;
|
||||
let second = SYMBOLS.nicknameStrings;
|
||||
while (r.byte(second++) !== 0x50);
|
||||
const context = this.text(),
|
||||
suffix = readString(r, SYMBOLS.nicknameStrings, context),
|
||||
title = readString(r, second, context);
|
||||
const answer = yield {
|
||||
kind: "name" as const,
|
||||
pages: [`${speciesName}${suffix}\n${title}`],
|
||||
current: speciesName,
|
||||
max: 10,
|
||||
};
|
||||
if (
|
||||
typeof answer !== "string" ||
|
||||
answer.length > 10 ||
|
||||
(answer && !NICKNAME_PATTERN.test(answer))
|
||||
)
|
||||
throw new Error("Invalid nickname.");
|
||||
mon.nickname = /^ *$/.test(answer) ? speciesName : answer;
|
||||
}
|
||||
if (this.state.party.length < PARTY_LENGTH) {
|
||||
this.state.party.push(mon);
|
||||
return 0; // BUGCONTEST_CAUGHT_MON
|
||||
}
|
||||
const box = currentBox(this.state.storage);
|
||||
if (box.length >= MONS_PER_BOX) {
|
||||
// .BoxFull falls through to this even when nothing was inserted
|
||||
// (the box was already full): the kept quirk still overwrites the
|
||||
// box's first Pokémon's caught-location byte.
|
||||
const first = box[0];
|
||||
if (first)
|
||||
first.caughtData =
|
||||
(first.caughtData & 0xff00) |
|
||||
(first.caughtData & 0x80) |
|
||||
LANDMARK_NATIONAL_PARK;
|
||||
return 1; // BUGCONTEST_BOXED_MON
|
||||
}
|
||||
const stored = toBoxMon(mon);
|
||||
restorePP(this.host.data, stored);
|
||||
box.unshift(stored);
|
||||
return 1; // BUGCONTEST_BOXED_MON
|
||||
}
|
||||
|
||||
/** ContestScore: max HP (×4), the other five stats, DV bit 1s weighted
|
||||
* 1/4/8/16 for Speed/Special/Attack/Defense, an eighth of current HP
|
||||
* and 1 for a held item, each read as a single byte (the original's
|
||||
* kept 8-bit truncation for any stat at or above 256). */
|
||||
private contestScore(mon: PartyMon): number {
|
||||
const byte = (n: number) => n & 0xff;
|
||||
const bit1 = (dv: number) => (dv >> 1) & 1;
|
||||
const attackDv = (mon.dvs >> 12) & 15,
|
||||
defenseDv = (mon.dvs >> 8) & 15,
|
||||
speedDv = (mon.dvs >> 4) & 15,
|
||||
specialDv = mon.dvs & 15;
|
||||
const statSum = mon.stats
|
||||
.slice(1)
|
||||
.reduce((sum, stat) => sum + byte(stat), 0);
|
||||
return (
|
||||
byte(mon.stats[0]) * 4 +
|
||||
statSum +
|
||||
bit1(speedDv) +
|
||||
4 * bit1(specialDv) +
|
||||
8 * bit1(attackDv) +
|
||||
16 * bit1(defenseDv) +
|
||||
(byte(mon.hp) >> 3) +
|
||||
(mon.item ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** _BugContestJudging: ComputeAIContestantScores for the round's five
|
||||
* active contestants (SelectRandomBugContestContestants left their A
|
||||
* flag clear), read from BugContestantPointers, then the player's own
|
||||
* ContestScore, through DetermineContestWinners' ">=" placement rule
|
||||
* (a tie favors whichever entry is compared last). Announces third,
|
||||
* then second, then first place and returns the player's own placement
|
||||
* (1-3), or 0 (BugContest_GetPlayersResult). StubbedTrainerRankings_-
|
||||
* BugContestScore is the cut Trainer Rankings feature's dead stub. */
|
||||
private *bugContestJudging(): Generator<ScriptEffect, number, Input> {
|
||||
const r = this.reader,
|
||||
data = this.host.data;
|
||||
interface Entry {
|
||||
player: boolean;
|
||||
classId?: number;
|
||||
id?: number;
|
||||
mon: number;
|
||||
score: number;
|
||||
}
|
||||
// ClearContestResults: the three slots start zeroed. A real contest
|
||||
// always has five active AI contestants (SelectRandomBugContestContestants)
|
||||
// plus the player, so every slot always ends up with a genuine entry;
|
||||
// this placeholder only guards a synthetic state that skipped that.
|
||||
const blank: Entry = { player: false, mon: 0, score: 0 };
|
||||
let first: Entry = blank,
|
||||
second: Entry = blank,
|
||||
third: Entry = blank;
|
||||
const place = (entry: Entry) => {
|
||||
if (entry.score >= first.score) {
|
||||
third = second;
|
||||
second = first;
|
||||
first = entry;
|
||||
} else if (entry.score >= second.score) {
|
||||
third = second;
|
||||
second = entry;
|
||||
} else if (entry.score >= third.score) {
|
||||
third = entry;
|
||||
}
|
||||
};
|
||||
const bank = r.bank(JOHTO.BugContestantPointers);
|
||||
for (let i = 0; i < BUG_CONTESTANT_A_FLAGS.length; i++) {
|
||||
if (this.state.events.has(BUG_CONTESTANT_A_FLAGS[i])) continue;
|
||||
// +1: BugContestantPointers[0] is a dummy entry ("reverts to the
|
||||
// player") that index 0 never points to.
|
||||
const record = r.near(JOHTO.BugContestantPointers + (i + 1) * 2, bank);
|
||||
const classId = r.byte(record),
|
||||
id = r.byte(record + 1);
|
||||
let pick: number,
|
||||
tries = 0;
|
||||
do {
|
||||
if (++tries > 4096) throw new Error("Random draw limit exceeded.");
|
||||
pick = this.host.random() & 3;
|
||||
} while (pick === 3);
|
||||
const slot = record + 2 + pick * 3;
|
||||
const score = (r.word(slot + 1) + (this.host.random() & 7)) & 0xffff;
|
||||
place({ player: false, classId, id, mon: r.byte(slot), score });
|
||||
}
|
||||
const contestMon = this.state.bugContest?.mon;
|
||||
place({
|
||||
player: true,
|
||||
mon: contestMon?.species ?? 0,
|
||||
score: contestMon ? this.contestScore(contestMon) : 0,
|
||||
});
|
||||
const trainers = new TrainerData(r);
|
||||
for (const [e, textAddr, scoreRam] of [
|
||||
[third, JOHTO.ContestJudging_ThirdPlaceText, WRAM.wBugContestThirdPlaceScore],
|
||||
[second, JOHTO.ContestJudging_SecondPlaceText, WRAM.wBugContestSecondPlaceScore],
|
||||
[first, JOHTO.ContestJudging_FirstPlaceText, WRAM.wBugContestFirstPlaceScore],
|
||||
] as const) {
|
||||
// LoadContestantName; a slot still at ClearContestResults' zeroed
|
||||
// placeholder (unreachable with a real contest's five AI entries)
|
||||
// gets a blank name rather than the ROM's own out-of-bounds read.
|
||||
this.strings[WRAM.wBugContestWinnerName] = e.player
|
||||
? this.text().player
|
||||
: e.classId !== undefined
|
||||
? (() => {
|
||||
const def = trainers.trainer(e.classId!, e.id!);
|
||||
return `${def.className} ${def.name}`;
|
||||
})()
|
||||
: "";
|
||||
this.strings[RAM.stringBuffer1] = e.mon ? data.speciesName(e.mon) : "";
|
||||
this.numbers[scoreRam] = e.score;
|
||||
const content = readText(r, textAddr, this.text());
|
||||
const announcePages = content ? content.split("\n\n").filter(Boolean) : [];
|
||||
if (announcePages.length) yield { kind: "text", pages: announcePages };
|
||||
}
|
||||
if (first.player) return 1;
|
||||
if (second.player) return 2;
|
||||
if (third.player) return 3;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a script that must finish without presentation, such as in tests
|
||||
@@ -3414,6 +3790,7 @@ export function runScript(
|
||||
timeOfDay: () => 1,
|
||||
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
|
||||
clockDay: () => 0,
|
||||
gameNow: () => 0,
|
||||
text: () => defaultTextContext(name),
|
||||
data: new PokemonData(reader),
|
||||
player: () => ({ id: 0, gender: 0 }),
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface ScreenSprite {
|
||||
/** Hidden while bit 4 of the frame counter is clear, as screens that
|
||||
* blink their cursor with hVBlankCounter do. */
|
||||
blink?: boolean;
|
||||
/** OAM_PRIO (B_OAM_PRIO): drawn behind any non-zero background color,
|
||||
* rather than over it, as the slot machine's reel window clips its
|
||||
* sprites against the surrounding frame tiles. */
|
||||
behind?: boolean;
|
||||
}
|
||||
|
||||
/** A full-screen Game Boy picture that a minigame or special draws in
|
||||
@@ -84,6 +88,26 @@ export function blankScreen(
|
||||
};
|
||||
}
|
||||
|
||||
/** Fill a box's background palette only (FillBoxCGB/ByteFill into
|
||||
* wAttrmap), leaving the tiles a tilemap copy already placed there alone.
|
||||
* A region partly or wholly off the visible 20x18 screen (as some of
|
||||
* _CGB_SlotMachine's own regions are) is simply clipped, like `fillBox`. */
|
||||
export function fillAttrs(
|
||||
screen: TileScreen,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
palette: number,
|
||||
) {
|
||||
for (let row = y; row < y + height; row++)
|
||||
for (let col = x; col < x + width; col++) {
|
||||
if (col < 0 || row < 0 || col >= SCREEN_WIDTH || row >= SCREEN_HEIGHT)
|
||||
continue;
|
||||
screen.attrs[row * SCREEN_WIDTH + col] = palette;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill a box of the tilemap with one tile (and optionally a palette). */
|
||||
export function fillBox(
|
||||
screen: TileScreen,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import type { PokedexData } from "../rom/pokedex";
|
||||
import { BATTLE, ENGINE_FLAGS, MENU, OVERWORLD, RAM } from "../rom/profile";
|
||||
import { BATTLE, ENGINE_FLAGS, JOHTO, MENU, OVERWORLD, RAM } from "../rom/profile";
|
||||
import { C } from "../battle/constants";
|
||||
import {
|
||||
changedHappiness,
|
||||
@@ -47,7 +47,8 @@ const STARTMENUITEM_POKEDEX = 0,
|
||||
STARTMENUITEM_SAVE = 4,
|
||||
STARTMENUITEM_OPTION = 5,
|
||||
STARTMENUITEM_EXIT = 6,
|
||||
STARTMENUITEM_POKEGEAR = 7;
|
||||
STARTMENUITEM_POKEGEAR = 7,
|
||||
STARTMENUITEM_QUIT = 8;
|
||||
/** The pack's pockets in Pack_InterpretJoypad order (*_POCKET). */
|
||||
const POCKETS = ["item", "ball", "key", "tm"] as const;
|
||||
type PocketIndex = 0 | 1 | 2 | 3;
|
||||
@@ -288,35 +289,59 @@ export class StartMenuRun {
|
||||
|
||||
// --------------------------------------------------------- start menu
|
||||
|
||||
/** StartMenu.SetUpMenuItems: no Link mode or Bug-Catching Contest yet. */
|
||||
/** StartMenu.SetUpMenuItems: no Link mode. During a Bug-Catching
|
||||
* Contest (STATUSFLAGS2_BUG_CONTEST_TIMER_F) PACK is left out and QUIT
|
||||
* (StartMenu_Quit) replaces SAVE. */
|
||||
private menuItems() {
|
||||
const items: number[] = [];
|
||||
if (this.state.flags.has(ENGINE_FLAGS.pokedex))
|
||||
items.push(STARTMENUITEM_POKEDEX);
|
||||
if (this.state.party.length) items.push(STARTMENUITEM_POKEMON);
|
||||
items.push(STARTMENUITEM_PACK);
|
||||
if (!this.state.bugContest) items.push(STARTMENUITEM_PACK);
|
||||
if (this.state.flags.has(ENGINE_FLAGS.pokegear))
|
||||
items.push(STARTMENUITEM_POKEGEAR);
|
||||
items.push(
|
||||
STARTMENUITEM_STATUS,
|
||||
STARTMENUITEM_SAVE,
|
||||
STARTMENUITEM_OPTION,
|
||||
STARTMENUITEM_EXIT,
|
||||
);
|
||||
items.push(STARTMENUITEM_STATUS);
|
||||
items.push(this.state.bugContest ? STARTMENUITEM_QUIT : STARTMENUITEM_SAVE);
|
||||
items.push(STARTMENUITEM_OPTION, STARTMENUITEM_EXIT);
|
||||
return items;
|
||||
}
|
||||
/** StartMenu.Items: (routine, string, description) pointers. */
|
||||
/** StartMenu.Items: (routine, string, description) pointers. QUIT's
|
||||
* own entry lives at the same index 8 in the ROM's table. */
|
||||
private itemString(item: number, field: 1 | 2) {
|
||||
const r = this.reader,
|
||||
entry = MENU["StartMenu.Items"] + item * 6 + field * 2;
|
||||
return this.string(r.near(entry, r.bank(entry)));
|
||||
}
|
||||
/** StartMenu_DrawBugContestStatusBox/_PrintBugContestStatus's BALLS,
|
||||
* CAUGHT and LEVEL lines, shown above the menu list throughout a
|
||||
* contest instead of as a separately positioned box. */
|
||||
private contestStatusPages(): string[] {
|
||||
const contest = this.state.bugContest;
|
||||
if (!contest) return [];
|
||||
const label = (key: keyof typeof JOHTO) => this.string(JOHTO[key]);
|
||||
const mon = contest.mon;
|
||||
const caught = mon
|
||||
? this.data.speciesName(mon.species)
|
||||
: label("StartMenu_PrintBugContestStatus.NoneString");
|
||||
const level = mon
|
||||
? `${label("StartMenu_PrintBugContestStatus.LevelString")} ${mon.level}`
|
||||
: "";
|
||||
return [
|
||||
[
|
||||
`${label("StartMenu_PrintBugContestStatus.CaughtString")} ${caught}`,
|
||||
level,
|
||||
`${label("StartMenu_PrintBugContestStatus.BallsString")} ${contest.parkBalls}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
];
|
||||
}
|
||||
|
||||
private *run(): Gen {
|
||||
for (;;) {
|
||||
const items = this.menuItems();
|
||||
const choice = yield* this.menu(
|
||||
[],
|
||||
this.contestStatusPages(),
|
||||
items.map((item) => this.itemString(item, 1)),
|
||||
// The menu account (wOptions2) describes the highlighted item.
|
||||
menuAccount(this.state.options)
|
||||
@@ -384,6 +409,18 @@ export class StartMenuRun {
|
||||
"The game saves itself as you play. Use the Savegames tab for backups.",
|
||||
);
|
||||
break;
|
||||
case STARTMENUITEM_QUIT:
|
||||
// StartMenu_Quit: retiring early ends the contest the same way
|
||||
// running out of balls or time does.
|
||||
if (
|
||||
yield* this.yesNo(
|
||||
JOHTO["StartMenu_Quit.StartMenuContestEndText"],
|
||||
)
|
||||
) {
|
||||
this.deferred = JOHTO.BugCatchingContestReturnToGateScript;
|
||||
return;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
yield* this.notice(`${this.itemString(item, 1)} ${NOT_PORTED}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import { OVERWORLD } from "../rom/profile";
|
||||
import type { RoamMon } from "../battle/battle";
|
||||
|
||||
/** RAIKOU and ENTEI (constants/pokemon_constants.asm) and the level and
|
||||
* starting maps InitRoamMons gives them: Route 42 (GROUP_ROUTE_42 2,
|
||||
* MAP_ROUTE_42 5) and Route 37 (GROUP_ROUTE_37 10, MAP_ROUTE_37 4). */
|
||||
const RAIKOU = 243,
|
||||
ENTEI = 244,
|
||||
BEAST_LEVEL = 40,
|
||||
RAIKOU_START = "2:5",
|
||||
ENTEI_START = "10:4";
|
||||
|
||||
/** NUM_ROAMMON_MAPS (data/wild/roammon_maps.asm): RoamMaps holds exactly
|
||||
* this many entries before its -1 terminator, one per listed Johto route
|
||||
* (Routes 40 and 41 are water routes and are not listed). */
|
||||
const NUM_ROAMMON_MAPS = 16;
|
||||
/** A random-draw loop that should always resolve quickly; this only
|
||||
* guards against a corrupted RoamMaps table. */
|
||||
const DRAW_LIMIT = 4096;
|
||||
|
||||
/** special InitRoamMons: Raikou and Entei (wRoamMon1/2) start at level 40
|
||||
* on their own route with HP 0, which LoadEnemyMon's `!roamer.hp` branch
|
||||
* reads as "draw fresh DVs and HP at the next encounter". Suicune
|
||||
* (wRoamMon3, index 2) is left exactly as it was: the routine never
|
||||
* writes to it, so it stays at a new game's species 0/map GROUP_N_A
|
||||
* until something else starts it roaming (nothing in the ROM does). */
|
||||
export function initRoamMons(current: readonly RoamMon[]): RoamMon[] {
|
||||
return [
|
||||
{ species: RAIKOU, level: BEAST_LEVEL, mapId: RAIKOU_START, hp: 0, dvs: 0 },
|
||||
{ species: ENTEI, level: BEAST_LEVEL, mapId: ENTEI_START, hp: 0, dvs: 0 },
|
||||
{ ...current[2] },
|
||||
];
|
||||
}
|
||||
|
||||
/** CheckEncounterRoamMon: a single Random draw both gates a roaming
|
||||
* encounter (cp 100, "25/64 chance") and, when it passes, picks which of
|
||||
* the three beasts might appear (`and %00000011`, a further 3/4 of those
|
||||
* draws split evenly over Raikou/Entei/Suicune — "1/3 chance" each in the
|
||||
* source comment). The beast only actually appears when its own current
|
||||
* map is the one the player is standing on; otherwise the draw is wasted,
|
||||
* exactly as the original leaves wTempWildMonSpecies untouched. */
|
||||
export function checkEncounterRoamMon(
|
||||
roamMons: readonly RoamMon[],
|
||||
mapId: string,
|
||||
random: () => number,
|
||||
): RoamMon | undefined {
|
||||
const draw = random();
|
||||
if (draw >= 100) return undefined;
|
||||
const pick = draw & 3;
|
||||
if (!pick) return undefined;
|
||||
const roamer = roamMons[pick - 1];
|
||||
return roamer.mapId === mapId ? roamer : undefined;
|
||||
}
|
||||
|
||||
/** RoamMaps' entries (the roam_map macro): [group][number][connection
|
||||
* count][neighbor group][neighbor number]... for each connection, ended by
|
||||
* a single 0 byte. Returns the entry's address, or undefined when `mapId`
|
||||
* is not one of the table's NUM_ROAMMON_MAPS listed routes. */
|
||||
function roamMapEntry(r: RomReader, mapId: string): number | undefined {
|
||||
const [group, number] = mapId.split(":").map(Number);
|
||||
let p = OVERWORLD.RoamMaps;
|
||||
for (let i = 0; i < NUM_ROAMMON_MAPS; i++) {
|
||||
if (r.byte(p) === group && r.byte(p + 1) === number) return p;
|
||||
p += 4 + 2 * r.byte(p + 2);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** JumpRoamMon: a uniform pick among RoamMaps' 16 entries. `maskbits
|
||||
* NUM_ROAMMON_MAPS` already restricts the draw to 0-15, so the ROM's own
|
||||
* `cp NUM_ROAMMON_MAPS` retry never fires (noted in its source comment);
|
||||
* the draw is redrawn only when it names the map given in `avoidMapId`
|
||||
* (JumpRoamMons passes the player's current map). */
|
||||
function jumpRoamMon(
|
||||
r: RomReader,
|
||||
avoidMapId: string,
|
||||
random: () => number,
|
||||
): string {
|
||||
for (let tries = 0; ; tries++) {
|
||||
if (tries > DRAW_LIMIT) throw new Error("Random draw limit exceeded.");
|
||||
const index = random() & (NUM_ROAMMON_MAPS - 1);
|
||||
let p = OVERWORLD.RoamMaps;
|
||||
for (let skip = 0; skip < index; skip++) p += 4 + 2 * r.byte(p + 2);
|
||||
const mapId = `${r.byte(p)}:${r.byte(p + 1)}`;
|
||||
if (mapId !== avoidMapId) return mapId;
|
||||
}
|
||||
}
|
||||
|
||||
/** .Update: a roaming beast's own RoamMaps entry picks a random
|
||||
* connection, redrawn whenever the index lands past the entry's
|
||||
* connection count or names `lastMapId` (wRoamMons_LastMapGroup/Number,
|
||||
* which UpdateRoamMons avoids repeating). A 1-in-32 draw jumps to any
|
||||
* listed map instead (JumpRoamMon), regardless of the current entry. A
|
||||
* beast whose map is not listed in RoamMaps at all is left in place, as
|
||||
* the original's unmatched `.Update` call (an early `ret`) does; this
|
||||
* should not happen since InitRoamMons only ever starts one on a listed
|
||||
* route. */
|
||||
function updateRoamMon(
|
||||
r: RomReader,
|
||||
mapId: string,
|
||||
lastMapId: string | null,
|
||||
playerMapId: string,
|
||||
random: () => number,
|
||||
): string {
|
||||
const entry = roamMapEntry(r, mapId);
|
||||
if (entry === undefined) return mapId;
|
||||
const count = r.byte(entry + 2),
|
||||
neighbors = entry + 3;
|
||||
for (let tries = 0; ; tries++) {
|
||||
if (tries > DRAW_LIMIT) throw new Error("Random draw limit exceeded.");
|
||||
const draw = random();
|
||||
// JumpRoamMon always reads wMapGroup/wMapNumber (the player's own
|
||||
// position) for its avoid-check, even called from here: it is not
|
||||
// parameterized by which roaming beast asked for it.
|
||||
if (!(draw & 0x1f)) return jumpRoamMon(r, playerMapId, random);
|
||||
const index = draw & 3;
|
||||
if (index >= count) continue;
|
||||
const candidate = `${r.byte(neighbors + index * 2)}:${r.byte(neighbors + index * 2 + 1)}`;
|
||||
if (candidate !== lastMapId) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/** UpdateRoamMons: every roaming beast (map not null, i.e. not
|
||||
* GROUP_N_A/MAP_N_A) moves one step along the RoamMaps graph. Mapsetup
|
||||
* MAPSETUP_CONNECTION, MAPSETUP_DOOR, MAPSETUP_FALL and MAPSETUP_TRAIN all
|
||||
* reach this through the shared byte table `data/maps/setup_scripts.asm`
|
||||
* lets them fall through into, and `BattleEnd_HandleRoamMons` calls it
|
||||
* directly after a non-roaming battle, one time in sixteen. Callers still
|
||||
* owe `_BackUpMapIndices`: the caller's map becomes `lastMapId` for the
|
||||
* next call. */
|
||||
export function updateRoamMons(
|
||||
r: RomReader,
|
||||
roamMons: readonly RoamMon[],
|
||||
lastMapId: string | null,
|
||||
playerMapId: string,
|
||||
random: () => number,
|
||||
): RoamMon[] {
|
||||
return roamMons.map((mon) =>
|
||||
mon.mapId === null
|
||||
? mon
|
||||
: { ...mon, mapId: updateRoamMon(r, mon.mapId, lastMapId, playerMapId, random) },
|
||||
);
|
||||
}
|
||||
|
||||
/** JumpRoamMons: every roaming beast (map not null) jumps to a uniformly
|
||||
* random RoamMaps entry other than the map named by `currentMapId`
|
||||
* (the player's own map at the time of the call). Mapsetup MAPSETUP_FLY
|
||||
* and MAPSETUP_TELEPORT both reach this before falling through into the
|
||||
* same MAPSETUP_WARP bytes that resolve the spawn point itself; the title
|
||||
* screen's Continue also calls it directly, once, before MAPSETUP_CONTINUE
|
||||
* even runs — the well-known trick of saving and reloading to relocate the
|
||||
* beasts. Like UpdateRoamMons, the caller still owes `_BackUpMapIndices`. */
|
||||
export function jumpRoamMons(
|
||||
r: RomReader,
|
||||
roamMons: readonly RoamMon[],
|
||||
currentMapId: string,
|
||||
random: () => number,
|
||||
): RoamMon[] {
|
||||
return roamMons.map((mon) =>
|
||||
mon.mapId === null
|
||||
? mon
|
||||
: { ...mon, mapId: jumpRoamMon(r, currentMapId, random) },
|
||||
);
|
||||
}
|
||||
|
||||
export function validRoamMon(value: unknown): value is RoamMon {
|
||||
const mon = value as RoamMon;
|
||||
return (
|
||||
Boolean(mon) &&
|
||||
typeof mon === "object" &&
|
||||
Number.isInteger(mon.species) &&
|
||||
mon.species >= 0 &&
|
||||
mon.species <= 251 &&
|
||||
Number.isInteger(mon.level) &&
|
||||
mon.level >= 0 &&
|
||||
mon.level <= 100 &&
|
||||
(mon.mapId === null ||
|
||||
(typeof mon.mapId === "string" && /^\d{1,2}:\d{1,3}$/.test(mon.mapId))) &&
|
||||
Number.isInteger(mon.hp) &&
|
||||
mon.hp >= 0 &&
|
||||
mon.hp <= 255 &&
|
||||
Number.isInteger(mon.dvs) &&
|
||||
mon.dvs >= 0 &&
|
||||
mon.dvs <= 0xffff
|
||||
);
|
||||
}
|
||||
|
||||
/** A new game's three wRoamMon structs: no species, GROUP_N_A/MAP_N_A
|
||||
* (null), as `engine/menus/intro_menu.asm`'s new-game reset leaves them
|
||||
* until InitRoamMons starts Raikou and Entei roaming. */
|
||||
export function emptyRoamMons(): RoamMon[] {
|
||||
return [
|
||||
{ species: 0, level: 0, mapId: null, hp: 0, dvs: 0 },
|
||||
{ species: 0, level: 0, mapId: null, hp: 0, dvs: 0 },
|
||||
{ species: 0, level: 0, mapId: null, hp: 0, dvs: 0 },
|
||||
];
|
||||
}
|
||||
+220
-18
@@ -9,6 +9,7 @@ import {
|
||||
ENGINE_FLAGS,
|
||||
EVENTS,
|
||||
INITIAL_POSITION,
|
||||
JOHTO,
|
||||
NUM_FRUIT_TREES,
|
||||
OVERWORLD,
|
||||
PC,
|
||||
@@ -61,6 +62,13 @@ import { IntroRun } from "../scripts/intro";
|
||||
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
|
||||
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
|
||||
import { RTC_DAYS, validDayTimer, type DayTimer } from "./day-timers";
|
||||
import {
|
||||
checkEncounterRoamMon,
|
||||
emptyRoamMons,
|
||||
jumpRoamMons,
|
||||
updateRoamMons,
|
||||
validRoamMon,
|
||||
} from "./roam-mons";
|
||||
import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex";
|
||||
import { regionAtlas, type AtlasRegion, type AtlasView } from "./atlas";
|
||||
import {
|
||||
@@ -89,6 +97,7 @@ import {
|
||||
} from "../party/storage";
|
||||
import {
|
||||
NICKNAME_PATTERN,
|
||||
PARTY_LENGTH,
|
||||
caughtData,
|
||||
validPartyMon,
|
||||
type PartyMon,
|
||||
@@ -100,6 +109,7 @@ import {
|
||||
type BattleEffect,
|
||||
type BattleInput,
|
||||
type BattleOutcome,
|
||||
type RoamMon,
|
||||
} from "../battle/battle";
|
||||
import { C } from "../battle/constants";
|
||||
import {
|
||||
@@ -188,8 +198,19 @@ export interface NativeSave {
|
||||
/** RTC day countdowns by name, and the Lucky Number show's number. */
|
||||
timers: Partial<Record<DayTimerName, DayTimer>>;
|
||||
luckyNumber: { id: number; day: number };
|
||||
/** wRoamMon1/2/3 (Raikou, Entei, Suicune). */
|
||||
roamMons: RoamMon[];
|
||||
/** wRoamMons_CurMapGroup/Number, kept from the last UpdateRoamMons or
|
||||
* JumpRoamMons call. Null before the first one (see ScriptState). */
|
||||
roamMonsMap: string | null;
|
||||
/** The option bytes, saved with the game as sOptions is. */
|
||||
options: GameOptions;
|
||||
/** wParkBallsRemaining/wContestMon and wBugContestStartTime's deadline,
|
||||
* while a Bug-Catching Contest runs; absent otherwise. */
|
||||
bugContest?: { parkBalls: number; mon: PartyMon | null };
|
||||
bugContestEnd?: number;
|
||||
/** The party slots a running contest hid (see ScriptState). */
|
||||
contestLeftParty?: PartyMon[];
|
||||
}
|
||||
|
||||
/** A random byte from the platform's secure generator. */
|
||||
@@ -339,8 +360,10 @@ export type Choice =
|
||||
| { kind: "options"; view: OptionsView; selected: 0 }
|
||||
/** The Fly map, answered with the button pressed. */
|
||||
| { kind: "flymap"; view: FlyMapView; selected: 0 }
|
||||
/** A tile screen (a minigame), answered with the button pressed. */
|
||||
| { kind: "screen"; screen: TileScreen; selected: 0 }
|
||||
/** A tile screen (a minigame), answered with the button pressed. A
|
||||
* `realtime` screen (the Game Corner) advances on its own every tick
|
||||
* instead of waiting for an answer; see `bufferScreenButton`. */
|
||||
| { kind: "screen"; screen: TileScreen; selected: 0; realtime?: boolean }
|
||||
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
|
||||
| {
|
||||
kind: "martquantity";
|
||||
@@ -730,6 +753,11 @@ export class GameWorld {
|
||||
/** The EMOTE_* whose graphics are in the emote bubble's tiles, which
|
||||
* show_emote displays. */
|
||||
private bubbleEmote = 0;
|
||||
/** Buttons pressed while a realtime screen (the Game Corner) is open,
|
||||
* consumed one per screen frame instead of answering it immediately, as
|
||||
* SlotsLoop and CardFlip's MasterLoop poll the joypad once per their own
|
||||
* video frame. */
|
||||
private screenInput: string[] = [];
|
||||
|
||||
constructor(
|
||||
readonly assets: CrystalAssets,
|
||||
@@ -1455,6 +1483,10 @@ export class GameWorld {
|
||||
if (FACE_DOWN_ON_WARP.has(destination.collision))
|
||||
this.player.direction = "down";
|
||||
this.applyMap(destination.entry);
|
||||
// WarpToNewMapScript's MAPSETUP_DOOR and FallIntoMapScript's
|
||||
// MAPSETUP_FALL both fall through into MAPSETUP_TRAIN's own
|
||||
// mapsetup UpdateRoamMons.
|
||||
this.applyRoamMons(this.state, destination.entry.id, false);
|
||||
// RefreshMapSprites: a warp leaves the player standing, with the sprite
|
||||
// CheckUpdatePlayerSprite chooses.
|
||||
this.turning = 0;
|
||||
@@ -1540,6 +1572,8 @@ export class GameWorld {
|
||||
player.x = target.x;
|
||||
player.y = target.y;
|
||||
this.applyMap(entry);
|
||||
// MapSetupScript_Connection's own mapsetup UpdateRoamMons.
|
||||
this.applyRoamMons(this.state, target.map.id, false);
|
||||
return;
|
||||
}
|
||||
// PlayerEvents: CheckTrainerEvent precedes CheckTileEvent, and a seen
|
||||
@@ -1638,7 +1672,8 @@ export class GameWorld {
|
||||
if (state.repel && chosen.level < lead.level) return;
|
||||
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
|
||||
committedPosition: previous,
|
||||
wild: chosen,
|
||||
wild: { species: chosen.species, level: chosen.level },
|
||||
roaming: Boolean(chosen.roamer),
|
||||
});
|
||||
// The step replays after a reload, so it keeps its Repel step.
|
||||
if (state.repel && this.script) this.script.committed.repel++;
|
||||
@@ -1696,13 +1731,21 @@ export class GameWorld {
|
||||
}
|
||||
/** ChooseWildEncounter: 1-100 against GrassMonProbTable or
|
||||
* WaterMonProbTable, whose entries are (percent, slot offset); surfing
|
||||
* varies the level. Undefined when no valid Pokémon is chosen. */
|
||||
* varies the level. Undefined when no valid Pokémon is chosen.
|
||||
* CheckEncounterRoamMon runs first (never on water) and, when a roaming
|
||||
* beast is standing on this map, can hijack the whole pick. */
|
||||
private chooseWildEncounter({
|
||||
water,
|
||||
grass,
|
||||
sea,
|
||||
}: ReturnType<GameWorld["encounterTable"]>) {
|
||||
}: ReturnType<GameWorld["encounterTable"]>): { species: number; level: number; roamer?: RoamMon } | undefined {
|
||||
if (!grass && !sea) return undefined;
|
||||
if (!water) {
|
||||
const roamer = checkEncounterRoamMon(this.state.roamMons, this.mapId, () =>
|
||||
this.random(),
|
||||
);
|
||||
if (roamer) return { species: roamer.species, level: roamer.level, roamer };
|
||||
}
|
||||
let roll: number;
|
||||
do roll = this.random();
|
||||
while (roll >= 100);
|
||||
@@ -1731,6 +1774,21 @@ export class GameWorld {
|
||||
return undefined;
|
||||
return chosen;
|
||||
}
|
||||
/** UpdateRoamMons (`jump` false) or JumpRoamMons (`jump` true), then
|
||||
* _BackUpMapIndices: `currentMap` becomes `roamMonsMap` for the next
|
||||
* call, regardless of whether any roaming Pokémon actually moved. */
|
||||
private applyRoamMons(
|
||||
state: ScriptState,
|
||||
currentMap: string,
|
||||
jump: boolean,
|
||||
) {
|
||||
const r = this.assets.reader,
|
||||
random = () => this.random();
|
||||
state.roamMons = jump
|
||||
? jumpRoamMons(r, state.roamMons, currentMap, random)
|
||||
: updateRoamMons(r, state.roamMons, state.roamMonsMap, currentMap, random);
|
||||
state.roamMonsMap = currentMap;
|
||||
}
|
||||
/** CanEncounterWildMon: wildoff; caves and dungeons need no grass; never
|
||||
* on ice. */
|
||||
private canEncounterWildMon(state: ScriptState) {
|
||||
@@ -2087,6 +2145,7 @@ export class GameWorld {
|
||||
timeOfDay: () => this.clockTimeOfDay,
|
||||
clock: () => this.clock(),
|
||||
clockDay: () => this.gameNow().getDay(),
|
||||
gameNow: () => this.gameNow().getTime(),
|
||||
text: () => ({
|
||||
...defaultTextContext(this.name),
|
||||
rival: this.state.rivalName,
|
||||
@@ -2195,10 +2254,13 @@ export class GameWorld {
|
||||
fieldMove,
|
||||
usedItem,
|
||||
fish,
|
||||
roaming,
|
||||
}: {
|
||||
usedItem?: number;
|
||||
/** BATTLETYPE_FISH, set by FishFunction for a hooked Pokémon. */
|
||||
fish?: boolean;
|
||||
/** BATTLETYPE_ROAMING, set by CheckEncounterRoamMon. */
|
||||
roaming?: boolean;
|
||||
fieldMove?: FieldMoveContext;
|
||||
committedPosition?: { x: number; y: number };
|
||||
scene?: boolean;
|
||||
@@ -2232,6 +2294,7 @@ export class GameWorld {
|
||||
fieldMove,
|
||||
usedItem,
|
||||
fish,
|
||||
roaming,
|
||||
},
|
||||
),
|
||||
committed,
|
||||
@@ -2251,6 +2314,8 @@ export class GameWorld {
|
||||
sighted,
|
||||
fieldMove,
|
||||
};
|
||||
// Any button buffered for an earlier realtime screen cannot still apply.
|
||||
this.screenInput = [];
|
||||
this.pump();
|
||||
}
|
||||
|
||||
@@ -2264,10 +2329,12 @@ export class GameWorld {
|
||||
}
|
||||
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
|
||||
useSelect(): boolean {
|
||||
if (
|
||||
this.dialogue?.choice?.kind === "pokegear" ||
|
||||
this.dialogue?.choice?.kind === "screen"
|
||||
) {
|
||||
const choice = this.dialogue?.choice;
|
||||
if (choice?.kind === "screen" && choice.realtime) {
|
||||
this.bufferScreenButton("select");
|
||||
return true;
|
||||
}
|
||||
if (choice?.kind === "pokegear" || choice?.kind === "screen") {
|
||||
this.choose("select");
|
||||
return true;
|
||||
}
|
||||
@@ -2280,10 +2347,14 @@ export class GameWorld {
|
||||
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
|
||||
* the player stands still. Its changes commit when it closes. */
|
||||
openStartMenu(select = false): boolean {
|
||||
const choice = this.dialogue?.choice;
|
||||
if (!select && choice?.kind === "screen" && choice.realtime) {
|
||||
this.bufferScreenButton("start");
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!select &&
|
||||
(this.dialogue?.choice?.kind === "pokegear" ||
|
||||
this.dialogue?.choice?.kind === "screen")
|
||||
(choice?.kind === "pokegear" || choice?.kind === "screen")
|
||||
) {
|
||||
this.choose("start");
|
||||
return true;
|
||||
@@ -2577,6 +2648,12 @@ export class GameWorld {
|
||||
fish: effect.fish,
|
||||
battleType: effect.battleType,
|
||||
contest: draft.bugContest,
|
||||
// GetRoamMonSpecies and the like identify a roaming battle's own
|
||||
// struct by species, exactly as the original's helpers do.
|
||||
roamer:
|
||||
effect.battleType === C.BATTLETYPE_ROAMING
|
||||
? draft.roamMons.find((mon) => mon.species === effect.wild?.species)
|
||||
: undefined,
|
||||
mapId: this.mapId,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
bag: draft.bag,
|
||||
@@ -2625,7 +2702,12 @@ export class GameWorld {
|
||||
this.dialogue = {
|
||||
pages: [""],
|
||||
index: 0,
|
||||
choice: { kind: "screen", screen: effect.screen, selected: 0 },
|
||||
choice: {
|
||||
kind: "screen",
|
||||
screen: effect.screen,
|
||||
selected: 0,
|
||||
...(effect.realtime && { realtime: true }),
|
||||
},
|
||||
};
|
||||
return true;
|
||||
case "fly":
|
||||
@@ -3125,7 +3207,7 @@ export class GameWorld {
|
||||
private finishScript() {
|
||||
const script = this.script!;
|
||||
if (script.warp) {
|
||||
const { map, x, y, facing, door } = script.warp;
|
||||
const { map, x, y, facing, door, roam } = script.warp;
|
||||
const target = this.assets.isPorted(map)
|
||||
? this.assets.map(map)
|
||||
: undefined;
|
||||
@@ -3146,6 +3228,7 @@ export class GameWorld {
|
||||
motion: undefined,
|
||||
};
|
||||
this.applyMap(entry);
|
||||
if (roam) this.applyRoamMons(this.state, map, roam === "jump");
|
||||
this.turning = 0;
|
||||
this.checkUpdatePlayerSprite(this.state);
|
||||
} else {
|
||||
@@ -3249,12 +3332,22 @@ export class GameWorld {
|
||||
if (next.done) {
|
||||
if (this.script) {
|
||||
const draft = this.script.run.state,
|
||||
{ prizeMoney = 0, momMoney = 0, payDayMoney = 0 } = next.value;
|
||||
{
|
||||
prizeMoney = 0,
|
||||
momMoney = 0,
|
||||
payDayMoney = 0,
|
||||
updateRoamMons,
|
||||
} = next.value;
|
||||
draft.money = Math.min(
|
||||
C.MAX_MONEY,
|
||||
draft.money + prizeMoney + payDayMoney,
|
||||
);
|
||||
draft.momsMoney = Math.min(C.MAX_MONEY, draft.momsMoney + momMoney);
|
||||
// CleanUpBattleRAM's callfar UpdateRoamMons, which the original
|
||||
// runs after EvolveAfterBattle below; the two touch unrelated
|
||||
// state, so committing it here first changes nothing observable.
|
||||
if (updateRoamMons)
|
||||
this.applyRoamMons(draft, this.mapId, false);
|
||||
}
|
||||
this.battle = undefined;
|
||||
this.battleRun = undefined;
|
||||
@@ -3367,6 +3460,17 @@ export class GameWorld {
|
||||
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
|
||||
this.state.dailyReset = day;
|
||||
}
|
||||
/** CheckBugContestTimer, from CheckTimeEvents while
|
||||
* STATUSFLAGS2_BUG_CONTEST_TIMER_F is set: GiveParkBalls' deadline
|
||||
* replaces the ROM's wBugContestStartTime day/hour/min/sec struct (see
|
||||
* ScriptState.bugContestEnd); past it, BugCatchingContestOverScript
|
||||
* runs in place of the player's own input this tick. */
|
||||
private checkBugContestTimer() {
|
||||
const end = this.state.bugContestEnd;
|
||||
if (end === undefined || this.gameNow(this.state.clock).getTime() < end)
|
||||
return;
|
||||
this.startScript(JOHTO.BugCatchingContestOverScript, PLAYER);
|
||||
}
|
||||
// ---------------------------------------------------------------- NPCs
|
||||
|
||||
/** HandleNPCStep: every spawned object runs its step type once per tick
|
||||
@@ -3636,6 +3740,19 @@ export class GameWorld {
|
||||
if (object.kind !== OBJECTTYPE_TRAINER || !this.spawned.has(object.id))
|
||||
continue;
|
||||
if (this.refusedSighting?.id === object.id) continue;
|
||||
// BurnedTower1F's rival is a trainer whose script is ObjectEvent, not
|
||||
// a TrainerHeader. Once its battle has set its own flag, it still
|
||||
// faces the player beside the hole, and the original's "beaten" check
|
||||
// reads ObjectEvent's bytes as flag $f251: through echo RAM, bit 1 of
|
||||
// wNumKeyItems, which is set with the two or three key items usual
|
||||
// here (BICYCLE, SQUIRTBOTTLE, COIN CASE). The port skips it as that
|
||||
// case does; what the original does otherwise has not been checked.
|
||||
if (
|
||||
object.script === OVERWORLD.ObjectEvent &&
|
||||
object.eventFlag !== 0xffff &&
|
||||
this.state.events.has(object.eventFlag)
|
||||
)
|
||||
continue;
|
||||
const actor = this.actor(object);
|
||||
// FacingPlayerDistance: same row or column, facing the player.
|
||||
let distance: number, facing: Direction;
|
||||
@@ -3733,6 +3850,13 @@ export class GameWorld {
|
||||
}
|
||||
const dialogue = this.dialogue;
|
||||
if (dialogue) {
|
||||
if (dialogue.choice?.kind === "screen" && dialogue.choice.realtime) {
|
||||
// SlotsLoop/CardFlip's MasterLoop poll the joypad and redraw once
|
||||
// per video frame; two frames run per overworld tick, as elsewhere.
|
||||
this.advanceScreenFrame();
|
||||
this.advanceScreenFrame();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
dialogue.holdTicks !== undefined &&
|
||||
dialogue.index === dialogue.pages.length - 1
|
||||
@@ -3827,7 +3951,10 @@ export class GameWorld {
|
||||
}
|
||||
this.checkScene();
|
||||
if (this.busy) return;
|
||||
this.checkDailyReset();
|
||||
// CheckTimeEvents: a running contest skips the daily path entirely.
|
||||
if (this.state.bugContest) this.checkBugContestTimer();
|
||||
else this.checkDailyReset();
|
||||
if (this.busy) return;
|
||||
// PlayerEvents precede HandleMapObjects: a step starts and moves at once.
|
||||
const stepped = this.beginStep(held);
|
||||
if (this.script || this.battle || this.dialogue) return;
|
||||
@@ -4312,7 +4439,10 @@ export class GameWorld {
|
||||
(dialogue.choice.kind === "flymap" &&
|
||||
!FLY_MAP_BUTTONS.includes(answer as never)) ||
|
||||
(dialogue.choice.kind === "screen" &&
|
||||
!SCREEN_BUTTONS.includes(answer as never))
|
||||
!SCREEN_BUTTONS.includes(answer as never) &&
|
||||
// A realtime screen's own frame-advance passes "" for a frame with
|
||||
// no button pressed, which SCREEN_BUTTONS does not list.
|
||||
!(dialogue.choice.realtime && answer === ""))
|
||||
)
|
||||
return;
|
||||
this.townMap = false;
|
||||
@@ -4321,6 +4451,20 @@ export class GameWorld {
|
||||
this.dialogue = undefined;
|
||||
this.pump(answer);
|
||||
}
|
||||
/** A button pressed while a realtime screen is open: buffered for its
|
||||
* next frame rather than answered at once. Bounded so a screen left open
|
||||
* while busy elsewhere cannot grow this without limit. */
|
||||
bufferScreenButton(button: string) {
|
||||
if (this.screenInput.length < 8) this.screenInput.push(button);
|
||||
}
|
||||
/** One video frame of a realtime screen (SlotsLoop, CardFlip's
|
||||
* MasterLoop): resume it with the next buffered button, or none. */
|
||||
private advanceScreenFrame() {
|
||||
const choice = this.dialogue?.choice;
|
||||
if (!this.script || !choice || choice.kind !== "screen" || !choice.realtime)
|
||||
return;
|
||||
this.choose(this.screenInput.shift() ?? "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- saves
|
||||
|
||||
@@ -4398,10 +4542,25 @@ export class GameWorld {
|
||||
firstUnownSeen: state.firstUnownSeen,
|
||||
timers: structuredClone(state.timers),
|
||||
luckyNumber: { ...state.luckyNumber },
|
||||
roamMons: structuredClone(state.roamMons),
|
||||
roamMonsMap: state.roamMonsMap,
|
||||
options: { ...state.options },
|
||||
...(state.bugContest && {
|
||||
bugContest: {
|
||||
parkBalls: state.bugContest.parkBalls,
|
||||
mon: structuredClone(state.bugContest.mon),
|
||||
},
|
||||
bugContestEnd: state.bugContestEnd,
|
||||
}),
|
||||
...(state.contestLeftParty && {
|
||||
contestLeftParty: structuredClone(state.contestLeftParty),
|
||||
}),
|
||||
};
|
||||
}
|
||||
restore(value: unknown) {
|
||||
/** Load a save. `continued` is Continue from the title menu, which
|
||||
* relocates the roaming beasts; a rollback to a captured state passes
|
||||
* false so the state comes back exactly. */
|
||||
restore(value: unknown, continued = true) {
|
||||
const save = migrateSave(value, () => (this.random() << 8) | this.random());
|
||||
const integer = (
|
||||
value: unknown,
|
||||
@@ -4539,7 +4698,28 @@ export class GameWorld {
|
||||
!save.luckyNumber ||
|
||||
!integer(save.luckyNumber.id, 0, 0xffff) ||
|
||||
!integer(save.luckyNumber.day, 0, RTC_DAYS) ||
|
||||
!validOptions(save.options)
|
||||
!Array.isArray(save.roamMons) ||
|
||||
save.roamMons.length !== 3 ||
|
||||
!save.roamMons.every(validRoamMon) ||
|
||||
(save.roamMonsMap !== null &&
|
||||
(typeof save.roamMonsMap !== "string" ||
|
||||
!/^\d{1,2}:\d{1,3}$/.test(save.roamMonsMap))) ||
|
||||
!validOptions(save.options) ||
|
||||
(save.bugContest === undefined) !== (save.bugContestEnd === undefined) ||
|
||||
(save.bugContest !== undefined &&
|
||||
(typeof save.bugContest !== "object" ||
|
||||
save.bugContest === null ||
|
||||
!integer(save.bugContest.parkBalls, 0, 20) ||
|
||||
(save.bugContest.mon !== null &&
|
||||
!validPartyMon(this.assets.pokemon, save.bugContest.mon)))) ||
|
||||
(save.bugContestEnd !== undefined &&
|
||||
!integer(save.bugContestEnd, 0, Number.MAX_SAFE_INTEGER)) ||
|
||||
(save.contestLeftParty !== undefined &&
|
||||
(!Array.isArray(save.contestLeftParty) ||
|
||||
save.contestLeftParty.length > PARTY_LENGTH - 1 ||
|
||||
!save.contestLeftParty.every((mon) =>
|
||||
validPartyMon(this.assets.pokemon, mon),
|
||||
)))
|
||||
) {
|
||||
throw new Error(
|
||||
"Invalid native save. Emulator snapshots cannot be imported into this port.",
|
||||
@@ -4617,10 +4797,26 @@ export class GameWorld {
|
||||
firstUnownSeen: save.firstUnownSeen,
|
||||
timers: structuredClone(save.timers),
|
||||
luckyNumber: { ...save.luckyNumber },
|
||||
roamMons: structuredClone(save.roamMons),
|
||||
roamMonsMap: save.roamMonsMap,
|
||||
options: { ...save.options },
|
||||
specialPhoneCall: save.specialPhoneCall,
|
||||
rivalName: save.rivalName,
|
||||
...(save.bugContest && {
|
||||
bugContest: {
|
||||
parkBalls: save.bugContest.parkBalls,
|
||||
mon: structuredClone(save.bugContest.mon),
|
||||
},
|
||||
bugContestEnd: save.bugContestEnd,
|
||||
}),
|
||||
...(save.contestLeftParty && {
|
||||
contestLeftParty: structuredClone(save.contestLeftParty),
|
||||
}),
|
||||
};
|
||||
// Continue's own farcall JumpRoamMons, run directly before
|
||||
// MAPSETUP_CONTINUE itself: the well-known trick of saving and
|
||||
// reloading the game to relocate the roaming beasts.
|
||||
if (continued) this.applyRoamMons(this.state, save.map, true);
|
||||
let entry;
|
||||
try {
|
||||
entry = this.prepareMap(save.map, "continue");
|
||||
@@ -4756,6 +4952,10 @@ export function migrateSave(
|
||||
timers: {},
|
||||
luckyNumber: { id: 0, day: 0 },
|
||||
}),
|
||||
...(!Object.hasOwn(save, "roamMons") && {
|
||||
roamMons: emptyRoamMons(),
|
||||
roamMonsMap: null,
|
||||
}),
|
||||
...(!Object.hasOwn(save, "intro") && { intro: false }),
|
||||
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
|
||||
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
|
||||
@@ -4815,6 +5015,8 @@ export function migrateSave(
|
||||
firstUnownSeen: 0,
|
||||
timers: {},
|
||||
luckyNumber: { id: 0, day: 0 },
|
||||
roamMons: emptyRoamMons(),
|
||||
roamMonsMap: null,
|
||||
intro: false,
|
||||
gameTime: emptyGameTime(),
|
||||
options: defaultOptions(),
|
||||
|
||||
Reference in new issue
Block a user