Port forced movement (turn delays, bumps, ice, currents, pits), dynamic return warps, elevators, Surf, Cut, Strength and cycling rules, working map blocks with changeblock/changemapblocks, and the remaining NPC movement types and movement commands. Add tileset animation from the ROM's command tables, grass sprite priority and special map palettes; room decorations with ownership, the bedroom PC and save migration; and Key Items access to the Bicycle. Fourteen original-engine traces compare positions, facing, actors, event flags and scripted poses. Tile animation matches original VRAM for four tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now keeps the $60 sprite offset its step function leaves behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ac111f262
commit
451464f7c6
38 files changed
+5600
-490
No files matched your search
@@ -262,6 +262,24 @@ export class BinjgbEmulator implements Emulator {
|
||||
this.canvas!.putImageData(this.frame!, 0, 0);
|
||||
}
|
||||
|
||||
/** Development-only: run a bounded number of CPU cycles, to align frame
|
||||
* sampling with a scanline when comparing against native routines. */
|
||||
advanceCycles(cycles: number) {
|
||||
if (
|
||||
!this.handle ||
|
||||
this.raf ||
|
||||
!Number.isInteger(cycles) ||
|
||||
cycles < 1 ||
|
||||
cycles > 70224
|
||||
)
|
||||
throw new Error("Pause the reference before bounded cycle stepping.");
|
||||
const module = this.module!;
|
||||
const target = module._emulator_get_ticks_f64(this.handle) + cycles;
|
||||
for (let events = 0; events < 100; events++)
|
||||
if (module._emulator_run_until_f64(this.handle, target) & 4) return;
|
||||
throw new Error("Reference cycle budget exceeded.");
|
||||
}
|
||||
|
||||
private fileData(
|
||||
kind: "state" | "ext_ram",
|
||||
action: (pointer: number, bytes: Uint8Array) => void,
|
||||
|
||||
@@ -15,7 +15,7 @@ export class LocalMapView {
|
||||
|
||||
update(world: GameWorld) {
|
||||
const map = world.map;
|
||||
const key = `${map.id}:${world.timeOfDay}`;
|
||||
const key = `${map.id}:${world.timeOfDay}:${world.blockRevision}`;
|
||||
if (this.assets !== world.assets || this.mapKey !== key) {
|
||||
this.buildTerrain(world);
|
||||
this.assets = world.assets;
|
||||
|
||||
@@ -10,7 +10,15 @@ export function drawTile(
|
||||
x: number,
|
||||
y: number,
|
||||
palette: Palette,
|
||||
options: { transparent?: boolean; flipX?: boolean; flipY?: boolean } = {},
|
||||
options: {
|
||||
transparent?: boolean;
|
||||
flipX?: boolean;
|
||||
flipY?: boolean;
|
||||
/** Background color indices per pixel: a background tile records them;
|
||||
* an OAM_PRIO sprite (`behind`) only draws over color 0. */
|
||||
indices?: Uint8Array;
|
||||
behind?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const offset = tile * 16;
|
||||
if (offset < 0 || offset + 16 > graphics.length) return;
|
||||
@@ -25,6 +33,11 @@ export function drawTile(
|
||||
const px = x + col,
|
||||
py = y + row;
|
||||
if (px < 0 || py < 0 || px >= width || py >= height) continue;
|
||||
if (options.indices) {
|
||||
if (options.behind) {
|
||||
if (options.indices[py * width + px]) continue;
|
||||
} else if (!options.transparent) options.indices[py * width + px] = index;
|
||||
}
|
||||
const destination = (py * width + px) * 4,
|
||||
color = palette[index];
|
||||
target[destination] = color[0];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { OW } from "../world/constants";
|
||||
import { CrystalAssets } from "../rom/assets";
|
||||
import { GameWorld } from "../world/world";
|
||||
import type { Direction } from "../world/types";
|
||||
import { drawTile } from "./pixels";
|
||||
import { TilesetAnimator } from "./tileset-animation";
|
||||
import {
|
||||
BASE_VIEWPORT,
|
||||
renderViewportForBox,
|
||||
@@ -14,6 +16,11 @@ export class WorldRenderer {
|
||||
private context: CanvasRenderingContext2D;
|
||||
private image: ImageData;
|
||||
private canvas: HTMLCanvasElement;
|
||||
/** Background color indices of the current frame, for OAM priority. */
|
||||
private indices = new Uint8Array(0);
|
||||
/** The current tileset's animation, advanced two video frames per tick. */
|
||||
private animator?: TilesetAnimator;
|
||||
private animated = { key: "", ticks: 0 };
|
||||
constructor(
|
||||
private assets: CrystalAssets,
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -59,8 +66,19 @@ export class WorldRenderer {
|
||||
}
|
||||
render(world: GameWorld) {
|
||||
const map = world.map,
|
||||
tileset = this.assets.tileset(map),
|
||||
palettes = this.assets.palettes(map, world.timeOfDay);
|
||||
tileset = this.assets.tileset(map);
|
||||
const key = `${map.tileset}:${map.group}`;
|
||||
if (this.animated.key !== key) {
|
||||
this.animator = new TilesetAnimator(this.assets, map, tileset.graphics);
|
||||
this.animated = { key, ticks: world.ticks };
|
||||
}
|
||||
const animator = this.animator!;
|
||||
// _AnimateTileset runs in VBlank: two video frames per overworld tick.
|
||||
animator.advance(Math.min(240, (world.ticks - this.animated.ticks) * 2), world.timeOfDay === 3, world.liveState.flags.has(OW.ENGINE_FOREST_IS_RESTLESS));
|
||||
this.animated.ticks = world.ticks;
|
||||
const palettes = animator.palettes(this.assets.palettes(map, world.timeOfDay));
|
||||
if (this.indices.length !== this.width * this.height)
|
||||
this.indices = new Uint8Array(this.width * this.height);
|
||||
const poses = world.poses();
|
||||
const player = poses.find((pose) => pose.id === 0) ?? {
|
||||
x: world.x * 16,
|
||||
@@ -69,8 +87,9 @@ export class WorldRenderer {
|
||||
// Keep the camera player-centered as the original engine does. Beyond the
|
||||
// edges, connected maps are drawn like the original connection strips;
|
||||
// elsewhere the map's own border block fills the view.
|
||||
// An earthquake shakes the view vertically (StepFunction_ScreenShake).
|
||||
const cameraX = Math.round(player.x - (this.width / 2 - 8)),
|
||||
cameraY = Math.round(player.y - (this.height / 2 - 8));
|
||||
cameraY = Math.round(player.y - (this.height / 2 - 8)) + world.screenShake;
|
||||
this.image.data.fill(255);
|
||||
for (
|
||||
let tileY = Math.floor(cameraY / 8);
|
||||
@@ -97,11 +116,12 @@ export class WorldRenderer {
|
||||
this.image.data,
|
||||
this.width,
|
||||
this.height,
|
||||
tileset.graphics,
|
||||
animator.graphics,
|
||||
tile,
|
||||
tileX * 8 - cameraX,
|
||||
tileY * 8 - cameraY,
|
||||
palettes[attribute & 7],
|
||||
{ indices: this.indices },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -136,6 +156,8 @@ export class WorldRenderer {
|
||||
pose.frame,
|
||||
time,
|
||||
pose.palette,
|
||||
pose.facing,
|
||||
pose.inGrass,
|
||||
);
|
||||
const emote =
|
||||
world.emote && poses.find((pose) => pose.id === world.emote!.object);
|
||||
@@ -165,6 +187,8 @@ export class WorldRenderer {
|
||||
frame: number,
|
||||
time: number,
|
||||
paletteIndex?: number,
|
||||
facing?: number,
|
||||
inGrass = false,
|
||||
) {
|
||||
const sprite = this.assets.sprite(id);
|
||||
if (!sprite) return;
|
||||
@@ -172,10 +196,11 @@ export class WorldRenderer {
|
||||
paletteIndex ?? sprite.palette,
|
||||
time,
|
||||
);
|
||||
for (const piece of this.assets.facing(
|
||||
direction,
|
||||
sprite.walking ? frame : 0,
|
||||
)) {
|
||||
const pieces =
|
||||
facing !== undefined
|
||||
? this.assets.facingTiles(facing)
|
||||
: this.assets.facing(direction, sprite.walking ? frame : 0);
|
||||
for (const piece of pieces) {
|
||||
const tile = piece.tile >= 0x80 ? piece.tile - 0x80 + 12 : piece.tile;
|
||||
drawTile(
|
||||
this.image.data,
|
||||
@@ -190,6 +215,10 @@ export class WorldRenderer {
|
||||
transparent: true,
|
||||
flipX: Boolean(piece.attributes & 0x20),
|
||||
flipY: Boolean(piece.attributes & 0x40),
|
||||
// RELATIVE_ATTRIBUTES pieces (the lower half) take OAM_PRIO from
|
||||
// IN_GRASS_F and hide behind the grass's colored pixels.
|
||||
indices: this.indices,
|
||||
behind: inGrass && Boolean(piece.attributes & 0x02),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { CrystalAssets } from "../rom/assets";
|
||||
import { OVERWORLD, SYMBOLS } from "../rom/profile";
|
||||
import type { GameMap, Palette } from "../world/types";
|
||||
|
||||
/** vTiles2 in VRAM bank 0: animations write tileset tiles from here. */
|
||||
const VTILES2 = 0x9000;
|
||||
/** wTileAnimBuffer, the scratch tile that scrolling animations use. */
|
||||
const TILE_ANIM_BUFFER = 0xcf41;
|
||||
/** Tile animation routines by ROM address (engine/tilesets/tileset_anims.asm). */
|
||||
const R = OVERWORLD;
|
||||
|
||||
/** _AnimateTileset (engine/tilesets/tileset_anims.asm): the tileset's own
|
||||
* list of (parameter, routine) pairs from data/tileset_anims.asm, run one
|
||||
* entry per video frame. Routines are selected by their ROM address and
|
||||
* translated natively; tiles are written to a private copy of the tileset
|
||||
* graphics, and the palette animations to color 0 of two palettes. */
|
||||
export class TilesetAnimator {
|
||||
readonly graphics: Uint8Array;
|
||||
private readonly script: number;
|
||||
/** hTileAnimFrame, wTileAnimationTimer and hVBlankCounter. */
|
||||
private frame = 0;
|
||||
private timer = 0;
|
||||
private vblank = 0;
|
||||
private buffer = new Uint8Array(16);
|
||||
/** Which of the water palette's colors 0-2 replaces color 0, and the
|
||||
* dark cave entrance's yellow color 0 or 1. */
|
||||
private water = 0;
|
||||
private cave?: number;
|
||||
|
||||
constructor(
|
||||
private assets: CrystalAssets,
|
||||
map: GameMap,
|
||||
graphics: Uint8Array,
|
||||
) {
|
||||
const r = assets.reader;
|
||||
this.graphics = new Uint8Array(graphics);
|
||||
const tileset = SYMBOLS.tilesets + map.tileset * 15;
|
||||
this.script = r.near(tileset + 9, r.bank(R.WaitTileAnimation));
|
||||
}
|
||||
|
||||
/** Run `frames` video frames; `dark` is wTimeOfDayPalset's darkness. */
|
||||
advance(frames: number, dark: boolean, restless = false) {
|
||||
for (let count = 0; count < frames; count++) {
|
||||
this.vblank = (this.vblank + 1) & 0xff;
|
||||
this.step(dark, restless);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the palette animations to the map's palettes. */
|
||||
palettes(palettes: Palette[]): Palette[] {
|
||||
const result = [...palettes];
|
||||
// AnimateWaterPalette: PAL_BG_WATER color 0 cycles through colors 0-2.
|
||||
if (this.water) result[3] = [palettes[3][this.water], ...palettes[3].slice(1)];
|
||||
if (this.cave !== undefined)
|
||||
result[4] = [palettes[4][this.cave], ...palettes[4].slice(1)];
|
||||
return result;
|
||||
}
|
||||
|
||||
private step(dark: boolean, restless: boolean) {
|
||||
const r = this.assets.reader,
|
||||
entry = this.script + this.frame * 4;
|
||||
this.frame = (this.frame + 1) & 0xff;
|
||||
const parameter = r.word(entry),
|
||||
routine = r.address(r.bank(R.WaitTileAnimation), r.word(entry + 2));
|
||||
const bank = r.bank(R.WaitTileAnimation);
|
||||
const at = (address: number) => r.address(bank, address);
|
||||
switch (routine) {
|
||||
case R.WaitTileAnimation:
|
||||
return;
|
||||
case R.DoneTileAnimation:
|
||||
this.frame = 0;
|
||||
return;
|
||||
case R.StandingTileFrame8:
|
||||
this.timer = (this.timer + 1) & 7;
|
||||
return;
|
||||
case R.StandingTileFrame:
|
||||
this.timer = (this.timer + 1) & 0xff;
|
||||
return;
|
||||
case R.ScrollTileRightLeft:
|
||||
this.timer = (this.timer + 1) & 7;
|
||||
return this.scroll(parameter, this.timer & 4 ? "left" : "right");
|
||||
case R.ScrollTileUpDown:
|
||||
this.timer = (this.timer + 1) & 7;
|
||||
return this.scroll(parameter, this.timer & 4 ? "down" : "up");
|
||||
case R.ScrollTileLeft:
|
||||
return this.scroll(parameter, "left");
|
||||
case R.ScrollTileRight:
|
||||
return this.scroll(parameter, "right");
|
||||
case R.ScrollTileUp:
|
||||
return this.scroll(parameter, "up");
|
||||
case R.ScrollTileDown:
|
||||
return this.scroll(parameter, "down");
|
||||
case R.ReadTileToAnimBuffer:
|
||||
this.buffer.set(this.memory(parameter));
|
||||
return;
|
||||
case R.WriteTileFromAnimBuffer:
|
||||
this.memory(parameter).set(this.buffer);
|
||||
return;
|
||||
case R.AnimateWaterTile:
|
||||
// A cycle of 4 frames, updating every other tick.
|
||||
return this.write(parameter, R["AnimateWaterTile.WaterTileFrames"] + (this.timer & 6) * 8);
|
||||
case R.AnimateFountainTile: {
|
||||
const table = R["AnimateFountainTile.FountainTileFramePointers"];
|
||||
return this.write(parameter, at(r.word(table + (this.timer & 7) * 2)));
|
||||
}
|
||||
case R.AnimateFlowerTile:
|
||||
// CGB flower frames follow the DMG ones in .FlowerTileFrames.
|
||||
return this.write(VTILES2 + 0x03 * 16, R["AnimateFlowerTile.FlowerTileFrames"] + ((this.timer & 2) + 1) * 16);
|
||||
case R.ForestTreeLeftAnimation:
|
||||
case R.ForestTreeLeftAnimation2:
|
||||
case R.ForestTreeRightAnimation:
|
||||
case R.ForestTreeRightAnimation2: {
|
||||
const right = routine === R.ForestTreeRightAnimation || routine === R.ForestTreeRightAnimation2;
|
||||
const alternate = routine === R.ForestTreeLeftAnimation2 || routine === R.ForestTreeRightAnimation2;
|
||||
// GetForestTreeFrame returns the odd frame for all timer values >= 7.
|
||||
const frame = restless ? ((this.timer >= 7 || (this.timer & 1) ? 1 : 0) ^ Number(alternate)) : 0;
|
||||
return this.write(VTILES2 + (right ? 0x0f : 0x0c) * 16,
|
||||
(right ? R.ForestTreeRightFrames : R.ForestTreeLeftFrames) + frame * 16);
|
||||
}
|
||||
case R.AnimateLavaBubbleTile1:
|
||||
return this.write(VTILES2 + 0x5b * 16, R.LavaBubbleTileFrames + ((((this.timer & 6) >> 1) + 2) & 3) * 16);
|
||||
case R.AnimateLavaBubbleTile2:
|
||||
return this.write(VTILES2 + 0x38 * 16, R.LavaBubbleTileFrames + (this.timer & 6) * 8);
|
||||
case R.AnimateTowerPillarTile: {
|
||||
const pointer = at(parameter);
|
||||
const offset = r.byte(R["AnimateTowerPillarTile.TowerPillarTileFrameOffsets"] + (this.timer & 7));
|
||||
return this.write(r.word(pointer), at(r.word(pointer + 2)) + offset);
|
||||
}
|
||||
case R.AnimateWhirlpoolTile: {
|
||||
const pointer = at(parameter);
|
||||
return this.write(r.word(pointer), at(r.word(pointer + 2)) + (this.timer & 3) * 16);
|
||||
}
|
||||
case R.AnimateWaterPalette:
|
||||
// Only on even ticks: colors 0, 1, 2, 1.
|
||||
if (this.timer & 1) return;
|
||||
this.water = (this.timer & 6) === 0 ? 0 : (this.timer & 6) === 4 ? 2 : 1;
|
||||
return;
|
||||
case R.FlickeringCaveEntrancePalette:
|
||||
// Only in a dark cave: yellow color 0 alternates with color 1.
|
||||
this.cave = dark ? (this.vblank & 2 ? 1 : 0) : undefined;
|
||||
return;
|
||||
default:
|
||||
throw new Error("Tileset animation routine is not ported.");
|
||||
}
|
||||
}
|
||||
|
||||
/** A tile in vTiles2 (bank 0), or wTileAnimBuffer. */
|
||||
private memory(address: number): Uint8Array {
|
||||
if (address === TILE_ANIM_BUFFER) return this.buffer;
|
||||
const tile = (address - VTILES2) >> 4;
|
||||
if (address < VTILES2 || (address & 15) || tile >= 0x80)
|
||||
throw new Error("Tileset animation writes outside the tiles.");
|
||||
return this.graphics.subarray(tile * 16, tile * 16 + 16);
|
||||
}
|
||||
private write(destination: number, source: number) {
|
||||
this.memory(destination).set(this.assets.reader.slice(source, 16));
|
||||
}
|
||||
/** ScrollTile*: bytes rotate for left/right; rows rotate for up/down. */
|
||||
private scroll(address: number, direction: "left" | "right" | "up" | "down") {
|
||||
const tile = this.memory(address);
|
||||
if (direction === "left" || direction === "right")
|
||||
for (let i = 0; i < 16; i++)
|
||||
tile[i] =
|
||||
direction === "left"
|
||||
? ((tile[i] << 1) | (tile[i] >> 7)) & 0xff
|
||||
: ((tile[i] >> 1) | (tile[i] << 7)) & 0xff;
|
||||
else {
|
||||
const rows = Array.from({ length: 8 }, (_, row) => tile.slice(row * 2, row * 2 + 2));
|
||||
for (let row = 0; row < 8; row++)
|
||||
tile.set(rows[direction === "up" ? (row + 1) % 8 : (row + 7) % 8], row * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { GameWorld } from "../world/world";
|
||||
import { OVERWORLD, SYMBOLS } from "../rom/profile";
|
||||
import { decompress } from "../rom/lz";
|
||||
import { drawTile } from "./pixels";
|
||||
|
||||
/** The ROM's Town Map, used by wall maps and the bedroom poster. */
|
||||
export function drawTownMap(canvas: HTMLCanvasElement, world: GameWorld) {
|
||||
const { assets } = world,
|
||||
r = assets.reader;
|
||||
const landmark = world.map.landmark;
|
||||
const map = landmark >= 47 ? OVERWORLD.KantoMap : OVERWORLD.JohtoMap;
|
||||
const graphics = decompress(r, OVERWORLD.TownMapGFX, 0x1000);
|
||||
const palette =
|
||||
world.avatar === "kris"
|
||||
? OVERWORLD.FemalePokegearPals
|
||||
: OVERWORLD.MalePokegearPals;
|
||||
canvas.width = 160;
|
||||
canvas.height = 144;
|
||||
const context = canvas.getContext("2d")!;
|
||||
const image = context.createImageData(160, 144);
|
||||
image.data.fill(255);
|
||||
for (let i = 0; i < 360; i++) {
|
||||
const tile = r.byte(map + i);
|
||||
const packed =
|
||||
tile < 0x60 ? r.byte(OVERWORLD["TownMapPals.PalMap"] + (tile >> 1)) : 0;
|
||||
const color = (tile & 1 ? packed >> 4 : packed) & 7;
|
||||
drawTile(
|
||||
image.data,
|
||||
160,
|
||||
144,
|
||||
graphics,
|
||||
tile,
|
||||
(i % 20) * 8,
|
||||
Math.floor(i / 20) * 8,
|
||||
assets.palette(palette + color * 8),
|
||||
);
|
||||
}
|
||||
context.putImageData(image, 0, 0);
|
||||
// Landmark coordinates are the same ones TownMapPlayerIcon uses.
|
||||
const p = SYMBOLS.landmarks + landmark * 4;
|
||||
context.fillStyle = "#b32628";
|
||||
context.fillRect(r.byte(p) - 10, r.byte(p + 1) - 18, 4, 4);
|
||||
canvas.setAttribute("aria-label", `Town Map: ${world.map.location}`);
|
||||
}
|
||||
+87
-15
@@ -6,11 +6,13 @@ import {
|
||||
NATIVE_MAPS,
|
||||
NUM_ENGINE_FLAGS,
|
||||
NUM_SPRITEMOVEDATA,
|
||||
OVERWORLD,
|
||||
RAM,
|
||||
SYMBOLS,
|
||||
} from "./profile";
|
||||
import { defaultTextContext, readString } from "../scripts/text";
|
||||
import { PokemonData } from "./pokemon";
|
||||
import { OW } from "../world/constants";
|
||||
import type {
|
||||
Direction,
|
||||
GameMap,
|
||||
@@ -308,6 +310,35 @@ export class CrystalAssets {
|
||||
];
|
||||
});
|
||||
}
|
||||
/** LoadSpecialMapPalette: tilesets with their own eight palettes, which
|
||||
* do not follow the time of day. */
|
||||
private specialPalettes(map: GameMap): Palette[] | undefined {
|
||||
const eight = (address: number) =>
|
||||
Array.from({ length: 8 }, (_, i) => this.palette(address + i * 8));
|
||||
switch (map.tileset) {
|
||||
case OW.TILESET_POKECOM_CENTER:
|
||||
return eight(OVERWORLD.PokeComPalette);
|
||||
case OW.TILESET_BATTLE_TOWER_INSIDE:
|
||||
return eight(OVERWORLD.BattleTowerInsidePalette);
|
||||
case OW.TILESET_ICE_PATH:
|
||||
// The Hall of Fame shares the tileset but is INDOOR.
|
||||
return (map.environment & 7) === OW.INDOOR ? undefined : eight(OVERWORLD.IcePathPalette);
|
||||
case OW.TILESET_HOUSE:
|
||||
return eight(SYMBOLS.housePalettes);
|
||||
case OW.TILESET_RADIO_TOWER:
|
||||
return eight(OVERWORLD.RadioTowerPalette);
|
||||
case OW.TILESET_MANSION: {
|
||||
// LoadMansionPalette: MansionPalette1, then yellow from
|
||||
// MansionPalette2, water from palette 6 and roof from palette 8.
|
||||
const palettes = eight(OVERWORLD.MansionPalette1);
|
||||
palettes[4] = this.palette(OVERWORLD.MansionPalette2);
|
||||
palettes[3] = this.palette(OVERWORLD.MansionPalette1 + 6 * 8);
|
||||
palettes[6] = this.palette(OVERWORLD.MansionPalette1 + 8 * 8);
|
||||
return palettes;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
palettes(map: GameMap, time: number): Palette[] {
|
||||
const r = this.reader;
|
||||
const group =
|
||||
@@ -316,13 +347,11 @@ export class CrystalAssets {
|
||||
r.bank(SYMBOLS.environmentColors),
|
||||
) +
|
||||
time * 8;
|
||||
const palettes = Array.from({ length: 8 }, (_, i) =>
|
||||
this.palette(
|
||||
map.tileset === 5
|
||||
? SYMBOLS.housePalettes + i * 8
|
||||
: SYMBOLS.backgroundPalettes + r.byte(group + i) * 8,
|
||||
),
|
||||
);
|
||||
const palettes =
|
||||
this.specialPalettes(map) ??
|
||||
Array.from({ length: 8 }, (_, i) =>
|
||||
this.palette(SYMBOLS.backgroundPalettes + r.byte(group + i) * 8),
|
||||
);
|
||||
if (map.environment === 1 || map.environment === 2) {
|
||||
const roof = SYMBOLS.roofPalettes + map.group * 8 + (time >= 2 ? 4 : 0);
|
||||
const colors = this.palette(roof);
|
||||
@@ -344,13 +373,15 @@ export class CrystalAssets {
|
||||
}
|
||||
throw new Error("Grass collision list is unterminated.");
|
||||
}
|
||||
/** JohtoGrassWildMons for a map: encounter rates and seven slots per
|
||||
* time of day (data/wild/johto_grass.asm). */
|
||||
/** JohtoGrassWildMons or KantoGrassWildMons for a map: encounter rates
|
||||
* and seven slots per time of day (data/wild/johto_grass.asm). */
|
||||
grassWildMons(
|
||||
id: string,
|
||||
kanto = false,
|
||||
): { rates: number[]; slots: { level: number; species: number }[][] } | undefined {
|
||||
const r = this.reader;
|
||||
for (let p = SYMBOLS.johtoGrassWildMons, i = 0; i < 256; i++, p += 47) {
|
||||
const table = kanto ? OVERWORLD.KantoGrassWildMons : SYMBOLS.johtoGrassWildMons;
|
||||
for (let p = table, i = 0; i < 256; i++, p += 47) {
|
||||
const group = r.byte(p);
|
||||
if (group === 0xff) return undefined;
|
||||
if (`${group}:${r.byte(p + 1)}` !== id) continue;
|
||||
@@ -365,6 +396,28 @@ export class CrystalAssets {
|
||||
}
|
||||
throw new Error("Wild encounter data is unterminated.");
|
||||
}
|
||||
/** JohtoWaterWildMons or KantoWaterWildMons: one rate and three slots
|
||||
* (data/wild/johto_water.asm). */
|
||||
waterWildMons(
|
||||
id: string,
|
||||
kanto = false,
|
||||
): { rate: number; slots: { level: number; species: number }[] } | undefined {
|
||||
const r = this.reader;
|
||||
const table = kanto ? OVERWORLD.KantoWaterWildMons : OVERWORLD.JohtoWaterWildMons;
|
||||
for (let p = table, i = 0; i < 256; i++, p += 9) {
|
||||
const group = r.byte(p);
|
||||
if (group === 0xff) return undefined;
|
||||
if (`${group}:${r.byte(p + 1)}` !== id) continue;
|
||||
return {
|
||||
rate: r.byte(p + 2),
|
||||
slots: Array.from({ length: 3 }, (_, slot) => ({
|
||||
level: r.byte(p + 3 + slot * 2),
|
||||
species: r.byte(p + 4 + slot * 2),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error("Wild encounter data is unterminated.");
|
||||
}
|
||||
/** Engine flags kept in the four bytes CheckDailyResetTimer clears
|
||||
* (wDailyFlags1 through wUnusedDailyFlag), from the ROM's EngineFlags. */
|
||||
dailyEngineFlags(): number[] {
|
||||
@@ -381,13 +434,20 @@ export class CrystalAssets {
|
||||
* function, flags1 (FIXED_FACING, NOCLIP_*, ...) and palette flags. */
|
||||
spriteMovement(type: number): {
|
||||
fn: number;
|
||||
/** OBJECT_ACTION_* the object starts with. */
|
||||
action: number;
|
||||
flags: number;
|
||||
paletteFlags: number;
|
||||
} {
|
||||
if (type >= NUM_SPRITEMOVEDATA) throw new Error("Unknown movement type.");
|
||||
const r = this.reader,
|
||||
p = SYMBOLS.spriteMovementData + type * 6;
|
||||
return { fn: r.byte(p), flags: r.byte(p + 3), paletteFlags: r.byte(p + 5) };
|
||||
return {
|
||||
fn: r.byte(p),
|
||||
action: r.byte(p + 2),
|
||||
flags: r.byte(p + 3),
|
||||
paletteFlags: r.byte(p + 5),
|
||||
};
|
||||
}
|
||||
/** Emote bubble graphics (data/sprites/emotes.asm). */
|
||||
emote(index: number): Uint8Array {
|
||||
@@ -399,13 +459,25 @@ export class CrystalAssets {
|
||||
r.byte(entry + 2),
|
||||
);
|
||||
}
|
||||
/** GetSprite: overworld sprites, and Pokémon drawn from their party
|
||||
* menu icons (GetMonSprite). Variable sprites are resolved by the world
|
||||
* from wVariableSprites first; day-care Pokémon are not ported. */
|
||||
sprite(id: number): Sprite | undefined {
|
||||
// Variable decorations and Pokémon icons need separate decoders.
|
||||
if (id < 1 || id >= 0xa0) return undefined;
|
||||
const cached = this.sprites.get(id);
|
||||
if (cached) return cached;
|
||||
const r = this.reader,
|
||||
p = SYMBOLS.sprites + (id - 1) * 6;
|
||||
const r = this.reader;
|
||||
if (id >= OW.SPRITE_POKEMON && id < OW.SPRITE_POKEMON + OW.NUM_POKEMON_SPRITES) {
|
||||
// LoadOverworldMonIcon: eight tiles, two frames in the down and up
|
||||
// standing slots, with palette 0.
|
||||
const species = r.byte(OVERWORLD.SpriteMons + id - OW.SPRITE_POKEMON);
|
||||
const icon = r.byte(OVERWORLD.MonMenuIcons + species - 1);
|
||||
const pointer = r.near(OVERWORLD.IconPointers + icon * 2, r.bank(OVERWORLD.IconPointers));
|
||||
const sprite = { graphics: r.slice(pointer, 8 * 16), palette: 0, walking: true };
|
||||
this.sprites.set(id, sprite);
|
||||
return sprite;
|
||||
}
|
||||
if (id < 1 || id > OW.NUM_OVERWORLD_SPRITES) return undefined;
|
||||
const p = SYMBOLS.sprites + (id - 1) * 6;
|
||||
const length = r.byte(p + 2),
|
||||
walking = r.byte(p + 4) === 1;
|
||||
const sprite = {
|
||||
|
||||
@@ -155,6 +155,8 @@ export const RAM = {
|
||||
hiddenItemID: 0xd040,
|
||||
runningTrainerBattleScript: 0xd04d,
|
||||
callerContact: 0xd03f,
|
||||
surfingPlayerState: 0xd1eb,
|
||||
strengthSpecies: 0xd1ef,
|
||||
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
|
||||
moneyTemp: 0xffc3,
|
||||
} as const;
|
||||
@@ -174,6 +176,111 @@ export const ENGINE_FLAGS = {
|
||||
allFruitTrees: 84,
|
||||
} 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 = {
|
||||
"OverworldTownMap": 0xc2c0,
|
||||
"TownMapGFX": 0xf8ba0,
|
||||
"JohtoMap": 0x91fff,
|
||||
"KantoMap": 0x92168,
|
||||
"TownMapPals.PalMap": 0x91f4b,
|
||||
"MalePokegearPals": 0xb729,
|
||||
"FemalePokegearPals": 0xb759,
|
||||
|
||||
"DecorationAttributes": 0x26a4f,
|
||||
"DecorationNames": 0x26b8d,
|
||||
"_PlayerDecorationMenu.category_pointers": 0x267aa,
|
||||
"PlayersHousePC": 0xc2e7,
|
||||
"PutAwayTheDecoText": 0x26ed1,
|
||||
"NothingToPutAwayText": 0x26ed6,
|
||||
"SetUpTheDecoText": 0x26edb,
|
||||
"PutAwayAndSetUpText": 0x26ee0,
|
||||
"AlreadySetUpText": 0x26ee5,
|
||||
"WhichSidePutOnText": 0x26e41,
|
||||
"WhichSidePutAwayText": 0x26e6b,
|
||||
"DecorationDesc_PosterPointers": 0x26f84,
|
||||
"DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript": 0x26fd5,
|
||||
"DecorationDesc_GiantOrnament.BigDollScript": 0x26fe3,
|
||||
|
||||
Script_ForcedMovement: 0x1253d,
|
||||
FallIntoMapScript: 0x96c38,
|
||||
"_Sine.sinetable_u10": 0x850b,
|
||||
AskFloorElevatorText: 0x1350d,
|
||||
Elevator_CurrentFloorText: 0x13537,
|
||||
ElevatorFloorNames: 0x13583,
|
||||
JohtoWaterWildMons: 0x2b11d,
|
||||
KantoGrassWildMons: 0x2b274,
|
||||
KantoWaterWildMons: 0x2b7f7,
|
||||
WaterMonProbTable: 0x2a1d9,
|
||||
SpriteMons: 0x14495,
|
||||
MonMenuIcons: 0x8eac4,
|
||||
IconPointers: 0x8ebbf,
|
||||
AskSurfScript: 0xca2c,
|
||||
"UsedSurfScript.stubbed_fn": 0xc9a2,
|
||||
AskCutScript: 0xd1a9,
|
||||
"AskCutScript.CheckMap": 0xd1ba,
|
||||
CantCutScript: 0xd1cd,
|
||||
CutDownTreeOrGrass: 0xc810,
|
||||
GetPartyNickname: 0xc706,
|
||||
CutTreeBlockPointers: 0xc862,
|
||||
WhirlpoolBlockPointers: 0xc8a4,
|
||||
TryStrengthOW: 0xcd78,
|
||||
SetStrengthFlag: 0xcd12,
|
||||
Script_AskWaterfall: 0xcb86,
|
||||
Script_CantDoWaterfall: 0xcb7e,
|
||||
"Script_UsedWaterfall.CheckContinueWaterfall": 0xcb38,
|
||||
Script_AskWhirlpoolOW: 0xce6e,
|
||||
Script_MightyWhirlpool: 0xce66,
|
||||
DisappearWhirlpool: 0xce1d,
|
||||
ChrisStateSprites: 0x141b7,
|
||||
KrisStateSprites: 0x141c0,
|
||||
Script_GetOnBike: 0xd13e,
|
||||
Script_GetOffBike: 0xd158,
|
||||
Script_CantGetOffBike: 0xd171,
|
||||
UpdatePlayerSprite: 0xe4a,
|
||||
PlayMapMusic: 0x3cdf,
|
||||
SurfStartStep: 0x8379,
|
||||
UpdateTimePals: 0x485,
|
||||
WaitTileAnimation: 0xfc2fe,
|
||||
DoneTileAnimation: 0xfc2fb,
|
||||
StandingTileFrame8: 0xfc2ff,
|
||||
StandingTileFrame: 0xfc673,
|
||||
AnimateWaterTile: 0xfc402,
|
||||
"AnimateWaterTile.WaterTileFrames": 0xfc41c,
|
||||
AnimateWaterPalette: 0xfc6d7,
|
||||
AnimateFlowerTile: 0xfc56d,
|
||||
"AnimateFlowerTile.FlowerTileFrames": 0xfc58c,
|
||||
AnimateFountainTile: 0xfc387,
|
||||
"AnimateFountainTile.FountainTileFramePointers": 0xfc3a2,
|
||||
ForestTreeLeftAnimation: 0xfc45c,
|
||||
ForestTreeRightAnimation: 0xfc4c4,
|
||||
ForestTreeLeftAnimation2: 0xfc4f2,
|
||||
ForestTreeRightAnimation2: 0xfc51c,
|
||||
ForestTreeLeftFrames: 0xfc484,
|
||||
ForestTreeRightFrames: 0xfc4a4,
|
||||
AnimateLavaBubbleTile1: 0xfc5cc,
|
||||
AnimateLavaBubbleTile2: 0xfc5eb,
|
||||
LavaBubbleTileFrames: 0xfc605,
|
||||
AnimateTowerPillarTile: 0xfc645,
|
||||
"AnimateTowerPillarTile.TowerPillarTileFrameOffsets": 0xfc66b,
|
||||
AnimateWhirlpoolTile: 0xfc678,
|
||||
ReadTileToAnimBuffer: 0xfc6a2,
|
||||
WriteTileFromAnimBuffer: 0xfc696,
|
||||
ScrollTileRightLeft: 0xfc309,
|
||||
ScrollTileUpDown: 0xfc318,
|
||||
ScrollTileLeft: 0xfc327,
|
||||
ScrollTileRight: 0xfc33b,
|
||||
ScrollTileUp: 0xfc34f,
|
||||
ScrollTileDown: 0xfc36a,
|
||||
FlickeringCaveEntrancePalette: 0xfc71e,
|
||||
PokeComPalette: 0x49501,
|
||||
BattleTowerInsidePalette: 0x49550,
|
||||
IcePathPalette: 0x4959f,
|
||||
RadioTowerPalette: 0x4963d,
|
||||
MansionPalette1: 0x4967d,
|
||||
MansionPalette2: 0x496fe,
|
||||
} as const;
|
||||
|
||||
/** Battle engine tables and texts, keyed by their PRET labels so that
|
||||
* \`npm run check:reference\` can verify each address by name. */
|
||||
export const BATTLE = {
|
||||
|
||||
+15
-4
@@ -9,6 +9,7 @@ import { WorldRenderer } from "./rendering/renderer";
|
||||
export class NativeRuntime implements GameRuntime {
|
||||
readonly id = "crystal-native-world-v1";
|
||||
world?: GameWorld;
|
||||
fieldMenuOpen = false;
|
||||
private renderer?: WorldRenderer;
|
||||
private pressed = new Set<Button>();
|
||||
private raf = 0;
|
||||
@@ -26,6 +27,7 @@ export class NativeRuntime implements GameRuntime {
|
||||
) {}
|
||||
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
|
||||
this.dispose();
|
||||
this.fieldMenuOpen = false;
|
||||
const assets = new CrystalAssets(bytes);
|
||||
const world = new GameWorld(assets);
|
||||
const player = this.player();
|
||||
@@ -57,20 +59,28 @@ export class NativeRuntime implements GameRuntime {
|
||||
if (this.pressed.has(button) || !this.world) return;
|
||||
this.pressed.add(button);
|
||||
const world = this.world;
|
||||
if (world.battle && this.battleInput?.(button)) {
|
||||
if (this.battleInput?.(button)) {
|
||||
this.publish();
|
||||
return;
|
||||
}
|
||||
if (button === "a") world.interact();
|
||||
if (button === "b") world.advanceDialogue();
|
||||
if (["up", "down", "left", "right"].includes(button) &&
|
||||
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno"))
|
||||
if (
|
||||
["up", "down", "left", "right"].includes(button) &&
|
||||
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno")
|
||||
)
|
||||
world.moveCursor(button as Direction);
|
||||
if ((button === "start" || button === "select") && !world.busy)
|
||||
world.notice =
|
||||
"Use the Savegames tab to save, export or import. Party, pack and Pokégear menus are not ported yet.";
|
||||
this.publish();
|
||||
}
|
||||
useBicycle() {
|
||||
this.fieldMenuOpen = false;
|
||||
if (this.world && !this.world.useBicycle())
|
||||
this.world.notice = "You can’t use the BICYCLE here.";
|
||||
this.publish();
|
||||
}
|
||||
/** Answer a choice from a pointer or keyboard control outside the canvas. */
|
||||
choose(answer: number | string) {
|
||||
this.world?.choose(answer);
|
||||
@@ -108,7 +118,7 @@ export class NativeRuntime implements GameRuntime {
|
||||
let ticks = 0;
|
||||
while (this.pending >= 1 / TICKS_PER_SECOND && ticks < 6) {
|
||||
this.pending -= 1 / TICKS_PER_SECOND;
|
||||
world.tick(held);
|
||||
if (!this.fieldMenuOpen) world.tick(held);
|
||||
ticks++;
|
||||
}
|
||||
if (ticks === 6) this.pending = 0;
|
||||
@@ -225,6 +235,7 @@ export class NativeRuntime implements GameRuntime {
|
||||
restoreState(bytes: Uint8Array) {
|
||||
if (!this.world || bytes.length > 64_000)
|
||||
throw new Error("Invalid native save size.");
|
||||
this.fieldMenuOpen = false;
|
||||
this.world.restore(
|
||||
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
||||
);
|
||||
|
||||
+615
-42
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
DecorationData,
|
||||
emptyDecorations,
|
||||
type Decorations,
|
||||
} from "../world/decorations";
|
||||
import { RomReader } from "../rom/reader";
|
||||
import {
|
||||
EMPTY_ROUTINES,
|
||||
@@ -5,6 +10,7 @@ import {
|
||||
NATIVE_ROUTINES,
|
||||
NUM_FRUIT_TREES,
|
||||
EVENTS,
|
||||
OVERWORLD,
|
||||
RAM,
|
||||
SPECIAL_ROUTINES,
|
||||
SYMBOLS,
|
||||
@@ -28,6 +34,7 @@ import {
|
||||
} from "../party/pokemon";
|
||||
import { TrainerData, type TrainerHeader } from "../rom/trainers";
|
||||
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
||||
import { OW } from "../world/constants";
|
||||
import { readMovement, type MovementStep } from "./movement";
|
||||
import {
|
||||
defaultTextContext,
|
||||
@@ -42,6 +49,7 @@ export interface ClockSettings {
|
||||
dst: boolean;
|
||||
}
|
||||
export interface ScriptState {
|
||||
decorations: Decorations;
|
||||
events: Set<number>;
|
||||
flags: Set<number>;
|
||||
variables: Record<number, number>;
|
||||
@@ -66,8 +74,18 @@ export interface ScriptState {
|
||||
/** wSpecialPhoneCallID: nonzero while a saved story call is pending. */
|
||||
specialPhoneCall: number;
|
||||
rivalName: string;
|
||||
/** wPlayerState (VAR_MOVEMENT): walking, biking, skating or surfing. */
|
||||
playerState: number;
|
||||
/** wBackupWarpNumber/wBackupMapGroup/wBackupMapNumber: where a warp
|
||||
* numbered -1 leads, recorded on arrival or by warpmod and elevators. */
|
||||
backupWarp: { map: string; warp: number } | null;
|
||||
/** wStatusFlags bits without engine flag numbers: STATUSFLAGS_FLASH_F
|
||||
* (a dark cave lit) and STATUSFLAGS_NO_WILD_ENCOUNTERS_F (wildoff). */
|
||||
flash: boolean;
|
||||
noWildEncounters: boolean;
|
||||
}
|
||||
export const emptyScriptState = (): ScriptState => ({
|
||||
decorations: emptyDecorations(),
|
||||
events: new Set(),
|
||||
flags: new Set(),
|
||||
variables: {},
|
||||
@@ -83,8 +101,13 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
lastSpawnMap: null,
|
||||
specialPhoneCall: 0,
|
||||
rivalName: "???",
|
||||
playerState: 0,
|
||||
backupWarp: null,
|
||||
flash: false,
|
||||
noWildEncounters: false,
|
||||
});
|
||||
export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
decorations: { ...state.decorations },
|
||||
events: new Set(state.events),
|
||||
flags: new Set(state.flags),
|
||||
variables: { ...state.variables },
|
||||
@@ -103,6 +126,10 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
lastSpawnMap: state.lastSpawnMap,
|
||||
specialPhoneCall: state.specialPhoneCall,
|
||||
rivalName: state.rivalName,
|
||||
playerState: state.playerState,
|
||||
backupWarp: state.backupWarp && { ...state.backupWarp },
|
||||
flash: state.flash,
|
||||
noWildEncounters: state.noWildEncounters,
|
||||
});
|
||||
|
||||
/** Object operands keep the script's numbering: 0 is the player, 2+ are map
|
||||
@@ -121,6 +148,7 @@ export interface MartEntry {
|
||||
owned?: number;
|
||||
}
|
||||
export type ScriptEffect =
|
||||
| { kind: "townmap" }
|
||||
/** Pages the player acknowledges. With holdTicks the last page instead
|
||||
* stays for a fixed time, as `writetext` followed by `pause` does. */
|
||||
| { kind: "text"; pages: string[]; holdTicks?: number }
|
||||
@@ -144,7 +172,12 @@ export type ScriptEffect =
|
||||
| { kind: "martmenu"; prompt: string; selected: number }
|
||||
/** The mart's item list (buy: full stock; sell: the bag's pockets);
|
||||
* answered with the chosen entry's index, or a negative number to cancel. */
|
||||
| { kind: "martlist"; mode: "buy" | "sell"; entries: MartEntry[]; selected: number }
|
||||
| {
|
||||
kind: "martlist";
|
||||
mode: "buy" | "sell";
|
||||
entries: MartEntry[];
|
||||
selected: number;
|
||||
}
|
||||
/** The 1-99 quantity stepper; answered with the chosen amount, or a
|
||||
* number below 1 to cancel back to the item list. */
|
||||
| {
|
||||
@@ -161,13 +194,31 @@ export type ScriptEffect =
|
||||
| {
|
||||
kind: "battle";
|
||||
wild?: { species: number; level: number };
|
||||
trainer?: { classId: number; id: number; winText?: number; lossText?: number };
|
||||
trainer?: {
|
||||
classId: number;
|
||||
id: number;
|
||||
winText?: number;
|
||||
lossText?: number;
|
||||
};
|
||||
canLose?: boolean;
|
||||
/** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */
|
||||
tutorial?: boolean;
|
||||
}
|
||||
/** newloadmap: the world enters this map after the script ends. */
|
||||
| { kind: "warp"; map: string; x: number; y: number };
|
||||
/** newloadmap and warp: the world enters this map after the script
|
||||
* ends, facing `facing` (warpfacing) or down (SpawnInFacingDown). */
|
||||
| { kind: "warp"; map: string; x: number; y: number; facing?: Direction }
|
||||
/** A vertical menu (the elevator's floors); answered with the chosen
|
||||
* item's index, or -1 when cancelled with B. */
|
||||
| { kind: "menu"; pages: string[]; items: string[]; selected: number }
|
||||
/** warpcheck: the player's tile events run again after the script. */
|
||||
| { kind: "warpcheck" }
|
||||
/** changeblock: the block covering map tile (x, y) becomes `block`. */
|
||||
| { kind: "changeblock"; x: number; y: number; block: number }
|
||||
/** changemapblocks: replace the map's block data from the ROM. */
|
||||
| { kind: "mapblocks"; address: number }
|
||||
/** writecmdqueue with CMDQUEUE_STONETABLE: the map's stone table
|
||||
* (warp, object, script) that boulders falling into pits trigger. */
|
||||
| { kind: "stonetable"; address: number };
|
||||
|
||||
/** What the world supplies about the present moment. */
|
||||
export interface ScriptHost {
|
||||
@@ -192,6 +243,9 @@ export interface ScriptHost {
|
||||
position(): { x: number; y: number };
|
||||
/** OBJECT_MAP_X/Y of a visible object (script numbering), if any. */
|
||||
objectPosition(id: number): { x: number; y: number } | undefined;
|
||||
/** wPlayerTileCollision and wEnvironment. */
|
||||
playerCollision(): number;
|
||||
environment(): number;
|
||||
}
|
||||
|
||||
export class UnsupportedScript extends Error {
|
||||
@@ -240,7 +294,16 @@ export function trainerWalkBuffer(
|
||||
v = DOWN;
|
||||
}
|
||||
// Swapped only when the y distance is the smaller one.
|
||||
const path: [number, number][] = y < x ? [[v, y], [h, x]] : [[h, x], [v, y]];
|
||||
const path: [number, number][] =
|
||||
y < x
|
||||
? [
|
||||
[v, y],
|
||||
[h, x],
|
||||
]
|
||||
: [
|
||||
[h, x],
|
||||
[v, y],
|
||||
];
|
||||
for (const [direction, count] of path)
|
||||
for (let i = 0; i < count; i++) buffer.push(STEP | direction);
|
||||
buffer.pop(); // DecrementMovementBufferCount
|
||||
@@ -274,8 +337,11 @@ export class ScriptRun {
|
||||
/** wRunningTrainerBattleScript: zeroed by LoadTrainer_continue, set to -1
|
||||
* after the battle so the trainer's after-script ends at once. */
|
||||
private runningTrainerBattleScript = 0;
|
||||
/** wMovementBuffer, filled by TrainerWalkToPlayer. */
|
||||
/** wMovementBuffer, filled by TrainerWalkToPlayer and SurfStartStep. */
|
||||
private movementBuffer?: number[];
|
||||
/** wCurPartyMon as CheckPartyMove leaves it, and wStrengthSpecies. */
|
||||
private curPartyMon = 0;
|
||||
private strengthSpecies = 0;
|
||||
private iterator: Generator<ScriptEffect, void, Input>;
|
||||
private instructions = 0;
|
||||
|
||||
@@ -299,10 +365,30 @@ export class ScriptRun {
|
||||
/** wCallerContact's second script, copied by CheckSpecialPhoneCall
|
||||
* from the queued SpecialPhoneCallList entry. */
|
||||
callerScript?: number;
|
||||
/** A field move's context from TrySurfOW, TryCutOW and similar: the
|
||||
* party member CheckPartyMove found (wCurPartyMon), GetSurfType's
|
||||
* wSurfingPlayerState, and the block Cut or Whirlpool replaces
|
||||
* (wCutWhirlpoolOverworldBlockAddr and its replacement). */
|
||||
fieldMove?: {
|
||||
mon: number;
|
||||
surfState?: number;
|
||||
block?: { x: number; y: number; block: number };
|
||||
};
|
||||
/** An item used from the pack: UseItem names it in wStringBuffer1 and
|
||||
* CopyName1 copies it to wStringBuffer2. */
|
||||
usedItem?: number;
|
||||
} = {},
|
||||
) {
|
||||
this.state = copyScriptState(state);
|
||||
this.lastTalked = host.lastTalked;
|
||||
this.curPartyMon = options.fieldMove?.mon ?? 0;
|
||||
// TrySurfOW and similar name the Pokémon before calling their script.
|
||||
if (options.fieldMove) this.partyNickname();
|
||||
if (options.usedItem !== undefined) {
|
||||
const name = host.data.itemName(options.usedItem);
|
||||
this.strings[RAM.stringBuffer1] = name;
|
||||
this.strings[RAM.stringBuffer2] = name;
|
||||
}
|
||||
if (options.trainer)
|
||||
this.tempTrainer = new TrainerData(reader).header(options.trainer.header);
|
||||
this.iterator = this.execute(start);
|
||||
@@ -384,7 +470,15 @@ export class ScriptRun {
|
||||
const synchronous = (effect: ScriptEffect) => {
|
||||
if (
|
||||
this.options.callback &&
|
||||
!["turn", "appear", "disappear", "moveobject"].includes(effect.kind)
|
||||
![
|
||||
"turn",
|
||||
"appear",
|
||||
"disappear",
|
||||
"moveobject",
|
||||
"changeblock",
|
||||
"mapblocks",
|
||||
"stonetable",
|
||||
].includes(effect.kind)
|
||||
)
|
||||
throw new Error("Map callbacks cannot present scenes.");
|
||||
return effect;
|
||||
@@ -394,7 +488,11 @@ export class ScriptRun {
|
||||
// spawn map, or SPAWN_HOME when unset or no longer a listed spawn point.
|
||||
const findSpawn = (mapId: string | null) => {
|
||||
if (mapId)
|
||||
for (let entry = SYMBOLS.spawnPoints, index = 0; r.byte(entry) !== 0xff; entry += 4, index++)
|
||||
for (
|
||||
let entry = SYMBOLS.spawnPoints, index = 0;
|
||||
r.byte(entry) !== 0xff;
|
||||
entry += 4, index++
|
||||
)
|
||||
if (`${r.byte(entry)}:${r.byte(entry + 1)}` === mapId) return index;
|
||||
return 0;
|
||||
};
|
||||
@@ -421,7 +519,10 @@ export class ScriptRun {
|
||||
// memcall: only Script_ReceivePhoneCall's caller script is known.
|
||||
const address = word();
|
||||
const script = this.options.callerScript;
|
||||
if (address !== RAM.callerContact + PHONE_CONTACT_SCRIPT2_BANK || script === undefined)
|
||||
if (
|
||||
address !== RAM.callerContact + PHONE_CONTACT_SCRIPT2_BANK ||
|
||||
script === undefined
|
||||
)
|
||||
throw new UnsupportedScript(offset, opcode);
|
||||
stack.push(p);
|
||||
p = script;
|
||||
@@ -526,21 +627,26 @@ export class ScriptRun {
|
||||
else if (routine === NATIVE_ROUTINES.setMemEvent) {
|
||||
// SetMemEvent: the hidden item's wHiddenItemEvent flag.
|
||||
const hidden = this.options.hiddenItem;
|
||||
if (!hidden) throw new UnsupportedScript(offset, opcode, " (no hidden item)");
|
||||
if (!hidden)
|
||||
throw new UnsupportedScript(offset, opcode, " (no hidden item)");
|
||||
this.state.events.add(hidden.event);
|
||||
} else if (routine === NATIVE_ROUTINES.trainerWalkToPlayer) {
|
||||
const distance = this.options.trainer?.distance,
|
||||
from = this.host.objectPosition(this.lastTalked);
|
||||
if (distance === undefined || !from || this.lastTalked === PLAYER)
|
||||
throw new UnsupportedScript(offset, opcode, " (no seen trainer)");
|
||||
this.movementBuffer = trainerWalkBuffer(from, this.host.position(), distance);
|
||||
}
|
||||
else if (routine === NATIVE_ROUTINES.tryReceiveItem) {
|
||||
this.movementBuffer = trainerWalkBuffer(
|
||||
from,
|
||||
this.host.position(),
|
||||
distance,
|
||||
);
|
||||
} else if (routine === NATIVE_ROUTINES.tryReceiveItem) {
|
||||
// FindItemInBallScript.TryReceiveItem: give the ball's item and
|
||||
// name it for FoundItemText (wStringBuffer3), matching GetItemName
|
||||
// + CopyName2. wScriptVar (value) reports success for iffalse.
|
||||
const ball = this.options.itemBall;
|
||||
if (!ball) throw new UnsupportedScript(offset, opcode, " (no item ball)");
|
||||
if (!ball)
|
||||
throw new UnsupportedScript(offset, opcode, " (no item ball)");
|
||||
const name = data.itemName(ball.item);
|
||||
this.strings[RAM.stringBuffer1] = name;
|
||||
this.strings[RAM.stringBuffer3] = name;
|
||||
@@ -560,6 +666,41 @@ export class ScriptRun {
|
||||
// The ringing tones and caller name box are not presented yet.
|
||||
// InitCallReceiveDelay restarts the random-call timers, and
|
||||
// random calls are not ported.
|
||||
} else if (routine === OVERWORLD.GetPartyNickname)
|
||||
this.partyNickname();
|
||||
else if (routine === OVERWORLD["AskCutScript.CheckMap"])
|
||||
// CheckMapForSomethingToCut, evaluated by the world beforehand.
|
||||
value = Number(this.options.fieldMove?.block !== undefined);
|
||||
else if (
|
||||
routine === OVERWORLD.CutDownTreeOrGrass ||
|
||||
routine === OVERWORLD.DisappearWhirlpool
|
||||
) {
|
||||
const block = this.options.fieldMove?.block;
|
||||
if (!block)
|
||||
throw new UnsupportedScript(offset, opcode, " (nothing to cut)");
|
||||
yield synchronous({ kind: "changeblock", ...block });
|
||||
} else if (routine === OVERWORLD.TryStrengthOW) {
|
||||
// 0 asks to use Strength, 1 lacks the move or badge, 2 is active.
|
||||
const mon = this.partyMove(OW.STRENGTH);
|
||||
value =
|
||||
mon < 0 || !this.state.flags.has(OW.ENGINE_PLAINBADGE)
|
||||
? 1
|
||||
: this.state.flags.has(OW.ENGINE_STRENGTH_ACTIVE)
|
||||
? 2
|
||||
: 0;
|
||||
if (mon >= 0) this.curPartyMon = mon;
|
||||
} else if (routine === OVERWORLD.SetStrengthFlag) {
|
||||
this.state.flags.add(OW.ENGINE_STRENGTH_ACTIVE);
|
||||
this.strengthSpecies =
|
||||
this.state.party[this.curPartyMon]?.species ?? 0;
|
||||
this.partyNickname();
|
||||
} else if (
|
||||
routine === OVERWORLD["Script_UsedWaterfall.CheckContinueWaterfall"]
|
||||
) {
|
||||
const tile = this.host.playerCollision();
|
||||
value = Number(!(tile === 0x33 || tile === 0x3b)); // CheckWaterfallTile
|
||||
} else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) {
|
||||
// StubbedTrainerRankings_Surf: the cut Trainer Rankings feature.
|
||||
} else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
|
||||
throw new UnsupportedScript(offset, opcode);
|
||||
break;
|
||||
@@ -570,6 +711,13 @@ export class ScriptRun {
|
||||
if (address === RAM.curFruit) value = this.fruit;
|
||||
else if (address === RAM.hiddenItemID && this.options.hiddenItem)
|
||||
value = this.options.hiddenItem.item;
|
||||
else if (
|
||||
address === RAM.surfingPlayerState &&
|
||||
this.options.fieldMove?.surfState !== undefined
|
||||
)
|
||||
value = this.options.fieldMove.surfState;
|
||||
else if (address === RAM.strengthSpecies)
|
||||
value = this.strengthSpecies;
|
||||
else throw new UnsupportedScript(offset, opcode);
|
||||
break;
|
||||
}
|
||||
@@ -586,19 +734,42 @@ export class ScriptRun {
|
||||
case 0x9b: {
|
||||
// Script_fruittree jumps to FruitTreeScript for FRUITTREE_* id.
|
||||
const tree = byte();
|
||||
if (tree < 1 || tree > NUM_FRUIT_TREES) throw new Error("Invalid fruit tree.");
|
||||
if (tree < 1 || tree > NUM_FRUIT_TREES)
|
||||
throw new Error("Invalid fruit tree.");
|
||||
this.fruitTree = tree;
|
||||
p = SYMBOLS.fruitTreeScript;
|
||||
break;
|
||||
}
|
||||
case 0x1e: {
|
||||
// Only VAR_BATTLETYPE's normal and CANLOSE modes are translated.
|
||||
const variable = byte(), setting = byte();
|
||||
// loadvar: VAR_BATTLETYPE's normal and CANLOSE modes, and
|
||||
// VAR_MOVEMENT (wPlayerState).
|
||||
const variable = byte(),
|
||||
setting = byte();
|
||||
if (variable === 8) {
|
||||
this.setPlayerState(setting, offset, opcode);
|
||||
break;
|
||||
}
|
||||
if (variable !== 3 || (setting !== 0 && setting !== 1))
|
||||
throw new UnsupportedScript(offset, opcode, " (battle type or variable)");
|
||||
throw new UnsupportedScript(
|
||||
offset,
|
||||
opcode,
|
||||
" (battle type or variable)",
|
||||
);
|
||||
canLose = setting === 1;
|
||||
break;
|
||||
}
|
||||
case 0x1d: {
|
||||
// writevar: only VAR_MOVEMENT is writable natively.
|
||||
const variable = byte();
|
||||
if (variable !== 8)
|
||||
throw new UnsupportedScript(
|
||||
offset,
|
||||
opcode,
|
||||
` (variable ${variable})`,
|
||||
);
|
||||
this.setPlayerState(value, offset, opcode);
|
||||
break;
|
||||
}
|
||||
case 0x5b:
|
||||
trainer = undefined;
|
||||
wild = this.options.wild;
|
||||
@@ -621,7 +792,9 @@ export class ScriptRun {
|
||||
case 0x62: {
|
||||
// trainertext: seen, beaten or loss text from wTempTrainer.
|
||||
const header = temp(offset, opcode),
|
||||
text = [header.seenText, header.beatenText, header.lostScript][byte()];
|
||||
text = [header.seenText, header.beatenText, header.lostScript][
|
||||
byte()
|
||||
];
|
||||
if (!text) throw new UnsupportedScript(offset, opcode, " (text)");
|
||||
write(text);
|
||||
break;
|
||||
@@ -640,7 +813,8 @@ export class ScriptRun {
|
||||
}
|
||||
case 0x65: {
|
||||
const after = temp(offset, opcode).afterScript;
|
||||
if (!after) throw new UnsupportedScript(offset, opcode, " (no script)");
|
||||
if (!after)
|
||||
throw new UnsupportedScript(offset, opcode, " (no script)");
|
||||
p = after;
|
||||
break;
|
||||
}
|
||||
@@ -660,19 +834,25 @@ export class ScriptRun {
|
||||
break;
|
||||
case 0x64: {
|
||||
// Null pointers request the battle engine's default end text.
|
||||
const bank = r.bank(p), won = word(), lost = word();
|
||||
const bank = r.bank(p),
|
||||
won = word(),
|
||||
lost = word();
|
||||
winText = won ? r.address(bank, won) : undefined;
|
||||
lossText = lost ? r.address(bank, lost) : undefined;
|
||||
break;
|
||||
}
|
||||
case 0x5f: {
|
||||
if (!wild && !trainer) throw new UnsupportedScript(offset, opcode, " (no opponent)");
|
||||
if (!wild && !trainer)
|
||||
throw new UnsupportedScript(offset, opcode, " (no opponent)");
|
||||
yield* flush();
|
||||
const result = yield synchronous({
|
||||
kind: "battle", wild,
|
||||
trainer: trainer && { ...trainer, winText, lossText }, canLose,
|
||||
kind: "battle",
|
||||
wild,
|
||||
trainer: trainer && { ...trainer, winText, lossText },
|
||||
canLose,
|
||||
});
|
||||
if (typeof result !== "number") throw new Error("Invalid battle result.");
|
||||
if (typeof result !== "number")
|
||||
throw new Error("Invalid battle result.");
|
||||
battleResult = result;
|
||||
value = result & 0x3f; // Script_startbattle also updates wScriptVar.
|
||||
break;
|
||||
@@ -719,6 +899,91 @@ export class ScriptRun {
|
||||
// blackoutmod writes wLastSpawnMapGroup/wLastSpawnMapNumber.
|
||||
this.state.lastSpawnMap = `${byte()}:${byte()}`;
|
||||
break;
|
||||
case 0x37:
|
||||
case 0x38:
|
||||
// wildon/wildoff: STATUSFLAGS_NO_WILD_ENCOUNTERS_F.
|
||||
this.state.noWildEncounters = opcode === 0x38;
|
||||
break;
|
||||
case 0x3a: {
|
||||
// warpmod: wBackupWarpNumber and wBackupMapGroup/Number.
|
||||
const warp = byte();
|
||||
this.state.backupWarp = { warp, map: `${byte()}:${byte()}` };
|
||||
break;
|
||||
}
|
||||
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.
|
||||
const facing =
|
||||
opcode === 0xa3 ? DIRECTION_ORDER[byte() & 3] : undefined;
|
||||
const group = byte(),
|
||||
number = byte(),
|
||||
x = byte(),
|
||||
y = byte();
|
||||
if (!group)
|
||||
throw new UnsupportedScript(offset, opcode, " (bad warp)");
|
||||
yield synchronous({
|
||||
kind: "warp",
|
||||
map: `${group}:${number}`,
|
||||
x,
|
||||
y,
|
||||
facing,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 0x8c: {
|
||||
// deactivatefacing: SCRIPT_WAIT for the given number of ticks.
|
||||
const ticks = byte();
|
||||
yield* flush();
|
||||
yield synchronous({ kind: "pause", ticks: ticks || 1 });
|
||||
break;
|
||||
}
|
||||
case 0x8e:
|
||||
yield synchronous({ kind: "warpcheck" });
|
||||
break;
|
||||
case 0x8f: {
|
||||
// stopandsjump: the script resumes at the target on the next tick.
|
||||
const target = near();
|
||||
yield* flush();
|
||||
yield synchronous({ kind: "pause", ticks: 1 });
|
||||
p = target;
|
||||
break;
|
||||
}
|
||||
case 0x95: {
|
||||
// elevator: Elevator (engine/events/elevator.asm).
|
||||
value = 0;
|
||||
const floors = this.elevatorFloors(near());
|
||||
const origin = floors.findIndex(
|
||||
(floor) => floor.map === this.state.backupWarp?.map,
|
||||
);
|
||||
if (origin < 0) break;
|
||||
write(OVERWORLD.AskFloorElevatorText);
|
||||
const now = readString(
|
||||
r,
|
||||
OVERWORLD.Elevator_CurrentFloorText,
|
||||
this.text(),
|
||||
);
|
||||
const answer = yield {
|
||||
kind: "menu",
|
||||
pages: [...take(), `${now} ${floors[origin].name}`],
|
||||
items: floors.map((floor) => floor.name),
|
||||
selected: 0,
|
||||
};
|
||||
if (
|
||||
typeof answer !== "number" ||
|
||||
!Number.isInteger(answer) ||
|
||||
answer >= floors.length
|
||||
)
|
||||
throw new Error("Invalid elevator floor.");
|
||||
if (answer < 0 || answer === origin) break;
|
||||
// Elevator_GoToFloor copies the floor's warp and map to the backup.
|
||||
this.state.backupWarp = {
|
||||
warp: floors[answer].warp,
|
||||
map: floors[answer].map,
|
||||
};
|
||||
value = 1;
|
||||
break;
|
||||
}
|
||||
case 0x9c:
|
||||
this.state.specialPhoneCall = word();
|
||||
break;
|
||||
@@ -901,6 +1166,32 @@ export class ScriptRun {
|
||||
case 0x36:
|
||||
this.state.flags.add(word());
|
||||
break;
|
||||
case 0x9a: {
|
||||
const which = byte(),
|
||||
data = new DecorationData(r, this.host.data);
|
||||
if (which === 0) {
|
||||
const id = this.state.decorations.poster;
|
||||
let table = OVERWORLD.DecorationDesc_PosterPointers;
|
||||
while (r.byte(table) !== 0xff && r.byte(table) !== id) table += 3;
|
||||
if (r.byte(table) === 0xff) return;
|
||||
p = r.near(table + 1, r.bank(table));
|
||||
} else if (which === 3)
|
||||
p = OVERWORLD["DecorationDesc_GiantOrnament.BigDollScript"];
|
||||
else {
|
||||
const slot = ({ 1: "left", 2: "right", 4: "console" } as const)[
|
||||
which as 1 | 2 | 4
|
||||
];
|
||||
if (!slot) throw new UnsupportedScript(offset, opcode);
|
||||
this.strings[RAM.stringBuffer3] = data.name(
|
||||
this.state.decorations[slot],
|
||||
);
|
||||
p =
|
||||
OVERWORLD[
|
||||
"DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript"
|
||||
];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 0x44: {
|
||||
const source = near(),
|
||||
buffer = byte();
|
||||
@@ -960,7 +1251,8 @@ export class ScriptRun {
|
||||
if (r.word(p) === RAM.movementBuffer) {
|
||||
word();
|
||||
const buffer = this.movementBuffer;
|
||||
if (!buffer) throw new UnsupportedScript(offset, opcode, " (movement buffer)");
|
||||
if (!buffer)
|
||||
throw new UnsupportedScript(offset, opcode, " (movement buffer)");
|
||||
steps = readMovement({ byte: (index) => buffer[index] }, 0);
|
||||
} else steps = readMovement(r, near());
|
||||
yield* flush();
|
||||
@@ -1015,7 +1307,11 @@ export class ScriptRun {
|
||||
position = this.host.objectPosition(target);
|
||||
if (target === PLAYER || !position)
|
||||
throw new UnsupportedScript(offset, opcode, " (object)");
|
||||
yield synchronous({ kind: "moveobject", object: target, ...position });
|
||||
yield synchronous({
|
||||
kind: "moveobject",
|
||||
object: target,
|
||||
...position,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 0x75: {
|
||||
@@ -1038,6 +1334,45 @@ export class ScriptRun {
|
||||
yield synchronous({ kind: "turn", object: target, direction });
|
||||
break;
|
||||
}
|
||||
case 0x79:
|
||||
yield synchronous({ kind: "mapblocks", address: far() });
|
||||
break;
|
||||
case 0x7a: {
|
||||
const x = byte(),
|
||||
y = byte();
|
||||
yield synchronous({ kind: "changeblock", x, y, block: byte() });
|
||||
break;
|
||||
}
|
||||
case 0x7c:
|
||||
break; // refreshmap redraws the screen; native draws every tick.
|
||||
case 0x7d: {
|
||||
// writecmdqueue: the cmdqueue macro's type and data pointer. Only
|
||||
// CMDQUEUE_STONETABLE is used by the game's maps.
|
||||
const entry = near(),
|
||||
type = r.byte(entry);
|
||||
if (type !== 2)
|
||||
throw new UnsupportedScript(offset, opcode, " (queue type)");
|
||||
yield synchronous({
|
||||
kind: "stonetable",
|
||||
address: r.near(entry + 1, r.bank(entry)),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 0x78: {
|
||||
// earthquake: applymovement PLAYER with EarthquakeMovement, its
|
||||
// step_shake set to the operand and step_sleep to its low 6 bits.
|
||||
const displacement = byte();
|
||||
yield* flush();
|
||||
yield synchronous({
|
||||
kind: "move",
|
||||
object: PLAYER,
|
||||
steps: [
|
||||
{ kind: "shake", displacement },
|
||||
{ kind: "sleep", ticks: displacement & 0x3f || 256 },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Audio commands are consumed; native sound sequencing is pending.
|
||||
case 0x7f:
|
||||
case 0x84:
|
||||
@@ -1159,7 +1494,12 @@ export class ScriptRun {
|
||||
name: data.itemName(item),
|
||||
price: data.itemPrice(item),
|
||||
}));
|
||||
const selection = yield { kind: "martlist", mode: "buy", entries, selected: 0 };
|
||||
const selection = yield {
|
||||
kind: "martlist",
|
||||
mode: "buy",
|
||||
entries,
|
||||
selected: 0,
|
||||
};
|
||||
const item = typeof selection === "number" ? stock[selection] : undefined;
|
||||
if (item === undefined) return;
|
||||
const price = data.itemPrice(item);
|
||||
@@ -1219,7 +1559,12 @@ export class ScriptRun {
|
||||
price: data.itemPrice(slot.item),
|
||||
owned: slot.quantity,
|
||||
}));
|
||||
const selection = yield { kind: "martlist", mode: "sell", entries, selected: 0 };
|
||||
const selection = yield {
|
||||
kind: "martlist",
|
||||
mode: "sell",
|
||||
entries,
|
||||
selected: 0,
|
||||
};
|
||||
const slot = typeof selection === "number" ? slots[selection] : undefined;
|
||||
if (!slot) return;
|
||||
if (data.itemUnsellable(slot.item)) {
|
||||
@@ -1252,6 +1597,152 @@ export class ScriptRun {
|
||||
}
|
||||
}
|
||||
|
||||
/** _PlayerDecorationMenu: owned categories, replacement and ornament sides. */
|
||||
private *decorationMenu(): Generator<
|
||||
ScriptEffect,
|
||||
boolean,
|
||||
number | string | undefined
|
||||
> {
|
||||
const data = new DecorationData(this.reader, this.host.data);
|
||||
const categories = [
|
||||
{ slot: "bed", action: 1, index: 0 },
|
||||
{ slot: "carpet", action: 3, index: 1 },
|
||||
{ slot: "plant", action: 5, index: 2 },
|
||||
{ slot: "poster", action: 7, index: 3 },
|
||||
{ slot: "console", action: 9, index: 4 },
|
||||
{ slot: "left", action: 13, index: 5 },
|
||||
{ slot: "bigDoll", action: 11, index: 6 },
|
||||
] as const;
|
||||
let changed = false;
|
||||
for (;;) {
|
||||
const owned = categories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
ids: data.owned(this.state.events, category.action),
|
||||
}))
|
||||
.filter((category) => category.ids.length);
|
||||
const names = owned.map((category) => {
|
||||
const p =
|
||||
OVERWORLD["_PlayerDecorationMenu.category_pointers"] +
|
||||
category.index * 4 +
|
||||
2;
|
||||
return readString(
|
||||
this.reader,
|
||||
this.reader.near(p, this.reader.bank(p)),
|
||||
this.text(),
|
||||
);
|
||||
});
|
||||
const choice = yield {
|
||||
kind: "menu",
|
||||
pages: ["DECORATION"],
|
||||
items: [...names, "EXIT"],
|
||||
selected: 0,
|
||||
};
|
||||
if (typeof choice !== "number" || choice < 0 || choice >= owned.length)
|
||||
return changed;
|
||||
const category = owned[choice];
|
||||
const picked = yield {
|
||||
kind: "menu",
|
||||
pages: [names[choice]],
|
||||
items: [
|
||||
...category.ids.map((id) => data.name(id)),
|
||||
data.name(1),
|
||||
data.name(0),
|
||||
],
|
||||
selected: 0,
|
||||
};
|
||||
if (
|
||||
typeof picked !== "number" ||
|
||||
picked < 0 ||
|
||||
picked > category.ids.length
|
||||
)
|
||||
continue;
|
||||
const id = category.ids[picked] ?? 0;
|
||||
let slot: keyof Decorations = category.slot;
|
||||
if (slot === "left") {
|
||||
const side = yield {
|
||||
kind: "menu",
|
||||
pages: [
|
||||
readText(
|
||||
this.reader,
|
||||
id
|
||||
? OVERWORLD.WhichSidePutOnText
|
||||
: OVERWORLD.WhichSidePutAwayText,
|
||||
this.text(),
|
||||
),
|
||||
],
|
||||
items: ["RIGHT SIDE", "LEFT SIDE", "CANCEL"],
|
||||
selected: 0,
|
||||
};
|
||||
if (side !== 0 && side !== 1) continue;
|
||||
slot = side === 0 ? "right" : "left";
|
||||
}
|
||||
const old = this.state.decorations[slot];
|
||||
let text: number;
|
||||
if (id === old)
|
||||
text = id ? OVERWORLD.AlreadySetUpText : OVERWORLD.NothingToPutAwayText;
|
||||
else {
|
||||
this.state.decorations[slot] = id;
|
||||
if (id && (slot === "left" || slot === "right")) {
|
||||
const other = slot === "left" ? "right" : "left";
|
||||
if (this.state.decorations[other] === id)
|
||||
this.state.decorations[other] = 0;
|
||||
}
|
||||
changed = true;
|
||||
this.strings[RAM.stringBuffer3] = data.name(old || id);
|
||||
this.strings[RAM.stringBuffer4] = data.name(id);
|
||||
text = !id
|
||||
? OVERWORLD.PutAwayTheDecoText
|
||||
: old
|
||||
? OVERWORLD.PutAwayAndSetUpText
|
||||
: OVERWORLD.SetUpTheDecoText;
|
||||
}
|
||||
yield { kind: "text", pages: [readText(this.reader, text, this.text())] };
|
||||
}
|
||||
}
|
||||
|
||||
/** VAR_MOVEMENT: only the states GetPlayerSprite knows. */
|
||||
private setPlayerState(state: number, offset: number, opcode: number) {
|
||||
if (![0, 1, 2, 4, 8].includes(state))
|
||||
throw new UnsupportedScript(offset, opcode, " (player state)");
|
||||
this.state.playerState = state;
|
||||
}
|
||||
/** CheckPartyMove: the first party member (not an Egg) knowing `move`. */
|
||||
private partyMove(move: number): number {
|
||||
return this.state.party.findIndex(
|
||||
(mon) => mon.species !== OW.EGG && mon.moves.includes(move as never),
|
||||
);
|
||||
}
|
||||
/** GetPartyNickname: wCurPartyMon's nickname into wStringBuffer1-3. */
|
||||
private partyNickname() {
|
||||
const name = this.state.party[this.curPartyMon]?.nickname ?? "";
|
||||
this.strings[RAM.stringBuffer1] = name;
|
||||
this.strings[RAM.stringBuffer2] = name;
|
||||
this.strings[RAM.stringBuffer3] = name;
|
||||
}
|
||||
|
||||
/** An elevator's floors (elevfloor: floor, warp, map), from the count
|
||||
* that precedes them; names come from ElevatorFloorNames. */
|
||||
private elevatorFloors(data: number) {
|
||||
const r = this.reader,
|
||||
count = r.byte(data);
|
||||
if (!count || count > 15) throw new Error("Invalid elevator data.");
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const entry = data + 1 + index * 4,
|
||||
floor = r.byte(entry);
|
||||
const names = OVERWORLD.ElevatorFloorNames;
|
||||
return {
|
||||
name: readString(
|
||||
r,
|
||||
r.near(names + floor * 2, r.bank(names)),
|
||||
this.text(),
|
||||
),
|
||||
warp: r.byte(entry + 1),
|
||||
map: `${r.byte(entry + 2)}:${r.byte(entry + 3)}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** readvar: the script variables that native state can answer. */
|
||||
private readVar(id: number, offset: number): number {
|
||||
const clock = this.host.clock();
|
||||
@@ -1261,6 +1752,21 @@ export class ScriptRun {
|
||||
return this.state.party.length;
|
||||
case 0x04:
|
||||
return this.host.timeOfDay();
|
||||
case 0x07: {
|
||||
// VAR_BADGES: Johto and Kanto badges together.
|
||||
let badges = 0;
|
||||
for (
|
||||
let flag = OW.ENGINE_ZEPHYRBADGE;
|
||||
flag < OW.ENGINE_ZEPHYRBADGE + 16;
|
||||
flag++
|
||||
)
|
||||
if (this.state.flags.has(flag)) badges++;
|
||||
return badges;
|
||||
}
|
||||
case 0x08:
|
||||
return this.state.playerState;
|
||||
case 0x0f:
|
||||
return this.host.environment();
|
||||
case 0x05:
|
||||
return this.state.pokedex.caught.size & 0xff;
|
||||
case 0x06:
|
||||
@@ -1308,11 +1814,18 @@ export class ScriptRun {
|
||||
const fallback = readString(r, SYMBOLS.rivalDefaultName, this.text());
|
||||
const name = yield {
|
||||
kind: "name",
|
||||
pages: [...take(), readString(r, SYMBOLS.rivalNameString, this.text())],
|
||||
pages: [
|
||||
...take(),
|
||||
readString(r, SYMBOLS.rivalNameString, this.text()),
|
||||
],
|
||||
current: fallback,
|
||||
max: 7,
|
||||
};
|
||||
if (typeof name !== "string" || name.length > 7 || (name && !NICKNAME_PATTERN.test(name)))
|
||||
if (
|
||||
typeof name !== "string" ||
|
||||
name.length > 7 ||
|
||||
(name && !NICKNAME_PATTERN.test(name))
|
||||
)
|
||||
throw new Error("Invalid rival name.");
|
||||
// InitName: a name of zero or more spaces takes the default.
|
||||
this.state.rivalName = /^ *$/.test(name) ? fallback : name;
|
||||
@@ -1327,6 +1840,16 @@ export class ScriptRun {
|
||||
// StubbedTrainerRankings_Healings begins with `ret`: the Trainer
|
||||
// Rankings feature (Pokémon News) was cut, leaving a dead stub.
|
||||
return value;
|
||||
case OVERWORLD.UpdatePlayerSprite:
|
||||
case OVERWORLD.PlayMapMusic:
|
||||
case OVERWORLD.UpdateTimePals:
|
||||
// The native renderer draws the player from wPlayerState each frame;
|
||||
// music and palette fades are presentation.
|
||||
return value;
|
||||
case OVERWORLD.SurfStartStep:
|
||||
// slow_step in the facing direction, then step_end.
|
||||
this.movementBuffer = [0x08 | this.host.facing(), 0x47];
|
||||
return value;
|
||||
case SPECIAL_ROUTINES.healMachineAnim:
|
||||
case SPECIAL_ROUTINES.fadeOutToBlack:
|
||||
case SPECIAL_ROUTINES.fadeInFromBlack:
|
||||
@@ -1345,7 +1868,9 @@ export class ScriptRun {
|
||||
base = this.host.data.move(move).pp;
|
||||
// ComputeMaxPP clamps each PP Up's bonus to 7, so a 40 PP move
|
||||
// maxes at 61 instead of overflowing the packed byte at 64.
|
||||
return (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups);
|
||||
return (
|
||||
(ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups)
|
||||
);
|
||||
}) as PartyMon["pp"];
|
||||
}
|
||||
return value;
|
||||
@@ -1409,22 +1934,68 @@ export class ScriptRun {
|
||||
pages.push(`${time}${text.startsWith(",") ? "" : " "}${text}`);
|
||||
return value;
|
||||
}
|
||||
case SPECIAL_ROUTINES.toggleDecorationsVisibility:
|
||||
// No decorations are placed until the native decoration system exists,
|
||||
// so every decoration object is hidden, as with empty wDeco* values.
|
||||
for (const event of [
|
||||
case OVERWORLD.OverworldTownMap:
|
||||
yield { kind: "townmap" };
|
||||
return value;
|
||||
case OVERWORLD.PlayersHousePC: {
|
||||
take();
|
||||
const changed = yield* this.decorationMenu();
|
||||
if (changed)
|
||||
yield {
|
||||
kind: "warp",
|
||||
map: this.host.mapId,
|
||||
...this.host.position(),
|
||||
facing: DIRECTION_ORDER[this.host.facing()],
|
||||
};
|
||||
return 0;
|
||||
}
|
||||
case SPECIAL_ROUTINES.toggleDecorationsVisibility: {
|
||||
const data = new DecorationData(r, this.host.data);
|
||||
const slots = ["console", "left", "right", "bigDoll"] as const;
|
||||
const events = [
|
||||
EVENTS.playersHouseConsole,
|
||||
EVENTS.playersHouseDoll1,
|
||||
EVENTS.playersHouseDoll2,
|
||||
EVENTS.playersHouseBigDoll,
|
||||
])
|
||||
this.state.events.add(event);
|
||||
];
|
||||
slots.forEach((slot, i) => {
|
||||
const id = this.state.decorations[slot];
|
||||
if (id) {
|
||||
this.state.events.delete(events[i]);
|
||||
this.state.variables[i] = data.attribute(id).sprite;
|
||||
} else this.state.events.add(events[i]);
|
||||
});
|
||||
return value;
|
||||
case SPECIAL_ROUTINES.toggleMaptileDecorations:
|
||||
// Empty decoration slots leave the room blocks unchanged and clear
|
||||
// the poster's conditional event.
|
||||
this.state.events.delete(EVENTS.playersRoomPoster);
|
||||
}
|
||||
case SPECIAL_ROUTINES.toggleMaptileDecorations: {
|
||||
const data = new DecorationData(r, this.host.data),
|
||||
deco = this.state.decorations;
|
||||
for (const [slot, x, y] of [
|
||||
["bed", 0, 4],
|
||||
["plant", 7, 4],
|
||||
["poster", 6, 0],
|
||||
] as const)
|
||||
if (deco[slot])
|
||||
yield {
|
||||
kind: "changeblock",
|
||||
x,
|
||||
y,
|
||||
block: data.attribute(deco[slot]).sprite,
|
||||
};
|
||||
if (deco.poster) this.state.events.add(EVENTS.playersRoomPoster);
|
||||
else this.state.events.delete(EVENTS.playersRoomPoster);
|
||||
if (deco.carpet) {
|
||||
const block = data.attribute(deco.carpet).sprite;
|
||||
for (const [x, y, delta] of [
|
||||
[0, 0, 0],
|
||||
[0, 2, 1],
|
||||
[2, 2, 2],
|
||||
[4, 2, 1],
|
||||
])
|
||||
yield { kind: "changeblock", x, y, block: block + delta };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -1454,6 +2025,8 @@ export function runScript(
|
||||
facing: () => 0,
|
||||
position: () => ({ x: 0, y: 0 }),
|
||||
objectPosition: () => undefined,
|
||||
playerCollision: () => 0,
|
||||
environment: () => 0,
|
||||
});
|
||||
const pages: string[] = [];
|
||||
for (let effect = run.resume(); effect; effect = run.resume()) {
|
||||
|
||||
@@ -1,18 +1,64 @@
|
||||
import { RomReader } from "../rom/reader";
|
||||
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
||||
|
||||
/** Poses a movement command holds for a number of ticks (OBJECT_ACTION and
|
||||
* the step functions that drive it in engine/overworld/map_objects.asm). */
|
||||
export type MovementPose =
|
||||
/** step_dig: SetFacingCounterclockwiseSpin in place. */
|
||||
| "spin"
|
||||
/** return_dig: StepFunction_DigTo alternates spinning and flickering. */
|
||||
| "spin-flicker"
|
||||
/** rock_smash: StepFunction_RockSmash flickers the object. */
|
||||
| "flicker"
|
||||
/** tree_shake: OBJECT_ACTION_WEIRD_TREE. */
|
||||
| "tree"
|
||||
/** fish_got_bite: StepFunction_GotBite jiggles the rod. */
|
||||
| "fish-bite"
|
||||
/** fish_cast_rod: OBJECT_ACTION_FISHING for one tick. */
|
||||
| "fish-cast"
|
||||
/** skyfall: invisible for 15 ticks, then falls from above in 16. */
|
||||
| "skyfall"
|
||||
/** skyfall_top: 16 ticks in the air (SetFacingSkyfall). */
|
||||
| "skyfall-top"
|
||||
/** teleport_from: spins for 16 ticks, then spins while rising for 16. */
|
||||
| "teleport-from"
|
||||
/** teleport_to: waits 16, descends spinning 16, then spins 16. */
|
||||
| "teleport-to";
|
||||
|
||||
/** One command from the game's own movement data (macros/scripts/movement.asm).
|
||||
* Durations are overworld ticks: the original loop advances objects once per
|
||||
* two video frames. Commands without a duration take effect immediately. */
|
||||
export type MovementStep =
|
||||
| { kind: "turn"; direction: Direction; ticks: 1 }
|
||||
| { kind: "step"; direction: Direction; ticks: number; animate: boolean }
|
||||
/** turn_step: StepFunction_Turn faces the new direction after one tick
|
||||
* and reads the next command on the fourth. */
|
||||
| { kind: "turn-step"; direction: Direction; ticks: 3 }
|
||||
/** Steps, slides (`animate` false) and the spinning turn_away, turn_in and
|
||||
* turn_waterfall steps (`spin`). */
|
||||
| { kind: "step"; direction: Direction; ticks: number; animate: boolean; spin?: boolean }
|
||||
/** Two tiles in two phases of `ticks` each, with a shadow and arc. */
|
||||
| { kind: "jump"; direction: Direction; ticks: number }
|
||||
| { kind: "sleep"; ticks: number }
|
||||
/** A pose held in place for `ticks`. */
|
||||
| { kind: "pose"; pose: MovementPose; ticks: number }
|
||||
/** step_bump: one tick walking in place. */
|
||||
| { kind: "bump"; ticks: 1 }
|
||||
| { kind: "fix-facing"; fixed: boolean }
|
||||
/** set_sliding/remove_sliding: SLIDING_F shows no walking frames. */
|
||||
| { kind: "sliding"; sliding: boolean }
|
||||
| { kind: "visibility"; hidden: boolean }
|
||||
| { kind: "emote"; shown: boolean };
|
||||
| { kind: "emote"; shown: boolean }
|
||||
/** step_shake: ShakeScreen with the given displacement byte. */
|
||||
| { kind: "shake"; displacement: number }
|
||||
/** step_wait_end: the script continues at once; the object sleeps, then
|
||||
* returns to its own movement. */
|
||||
| { kind: "wait-end"; ticks: number }
|
||||
/** remove_object: DeleteMapObject; the script continues. */
|
||||
| { kind: "remove" }
|
||||
/** step_stop: the object stands for good; the script continues. */
|
||||
| { kind: "stop" }
|
||||
/** step_loop: start the movement data over, forever. */
|
||||
| { kind: "loop" };
|
||||
|
||||
export class UnsupportedMovement extends Error {
|
||||
constructor(
|
||||
@@ -27,9 +73,10 @@ export class UnsupportedMovement extends Error {
|
||||
|
||||
const STEP_TICKS = [16, 8, 4] as const; // slow, walk, big (bike speed)
|
||||
|
||||
/** Decode movement data until step_end. Unknown commands abort the script
|
||||
* rather than approximating a jump, slide or special effect. The source is
|
||||
* usually the ROM; wMovementBuffer data built natively decodes the same way. */
|
||||
/** Decode movement data until step_end or another command that ends it.
|
||||
* Unknown commands abort the script rather than approximating an effect.
|
||||
* The source is usually the ROM; wMovementBuffer data built natively
|
||||
* decodes the same way. */
|
||||
export function readMovement(
|
||||
reader: Pick<RomReader, "byte">,
|
||||
start: number,
|
||||
@@ -41,14 +88,16 @@ export function readMovement(
|
||||
command = reader.byte(p++);
|
||||
const direction = DIRECTION_ORDER[command & 3];
|
||||
if (command < 0x04) steps.push({ kind: "turn", direction, ticks: 1 });
|
||||
else if (command >= 0x08 && command < 0x14)
|
||||
else if (command < 0x08)
|
||||
steps.push({ kind: "turn-step", direction, ticks: 3 });
|
||||
else if (command < 0x14)
|
||||
steps.push({
|
||||
kind: "step",
|
||||
direction,
|
||||
ticks: STEP_TICKS[(command - 0x08) >> 2],
|
||||
animate: true,
|
||||
});
|
||||
else if (command >= 0x14 && command < 0x20)
|
||||
else if (command < 0x20)
|
||||
// Slides move without the walking animation (e.g. being pushed).
|
||||
steps.push({
|
||||
kind: "step",
|
||||
@@ -56,12 +105,23 @@ export function readMovement(
|
||||
ticks: STEP_TICKS[(command - 0x14) >> 2],
|
||||
animate: false,
|
||||
});
|
||||
else if (command >= 0x2c && command < 0x38)
|
||||
else if (command < 0x2c)
|
||||
// turn_away, turn_in, turn_waterfall: TurningStep spins while moving.
|
||||
steps.push({
|
||||
kind: "step",
|
||||
direction,
|
||||
ticks: STEP_TICKS[(command - 0x20) >> 2],
|
||||
animate: true,
|
||||
spin: true,
|
||||
});
|
||||
else if (command < 0x38)
|
||||
steps.push({
|
||||
kind: "jump",
|
||||
direction,
|
||||
ticks: STEP_TICKS[(command - 0x2c) >> 2],
|
||||
});
|
||||
else if (command === 0x38 || command === 0x39)
|
||||
steps.push({ kind: "sliding", sliding: command === 0x39 });
|
||||
else if (command === 0x3a || command === 0x3b)
|
||||
steps.push({ kind: "fix-facing", fixed: command === 0x3b });
|
||||
else if (command === 0x3c || command === 0x3d)
|
||||
@@ -69,10 +129,45 @@ export function readMovement(
|
||||
else if (command >= 0x3e && command <= 0x45)
|
||||
steps.push({ kind: "sleep", ticks: command - 0x3d });
|
||||
else if (command === 0x46)
|
||||
steps.push({ kind: "sleep", ticks: reader.byte(p++) });
|
||||
steps.push({ kind: "sleep", ticks: reader.byte(p++) || 256 });
|
||||
else if (command === 0x47) return steps;
|
||||
else if (command === 0x48) {
|
||||
steps.push({ kind: "wait-end", ticks: reader.byte(p++) || 256 });
|
||||
return steps;
|
||||
} else if (command === 0x49) {
|
||||
steps.push({ kind: "remove" });
|
||||
return steps;
|
||||
} else if (command === 0x4a) {
|
||||
steps.push({ kind: "loop" });
|
||||
return steps;
|
||||
} else if (command === 0x4b) {
|
||||
steps.push({ kind: "stop" });
|
||||
return steps;
|
||||
} else if (command === 0x4c)
|
||||
steps.push({ kind: "pose", pose: "teleport-from", ticks: 32 });
|
||||
else if (command === 0x4d)
|
||||
steps.push({ kind: "pose", pose: "teleport-to", ticks: 49 });
|
||||
else if (command === 0x4e)
|
||||
steps.push({ kind: "pose", pose: "skyfall", ticks: 31 });
|
||||
else if (command === 0x4f)
|
||||
steps.push({ kind: "pose", pose: "spin", ticks: reader.byte(p++) || 256 });
|
||||
else if (command === 0x50) steps.push({ kind: "bump", ticks: 1 });
|
||||
else if (command === 0x51)
|
||||
steps.push({ kind: "pose", pose: "fish-bite", ticks: 8 });
|
||||
else if (command === 0x52)
|
||||
steps.push({ kind: "pose", pose: "fish-cast", ticks: 1 });
|
||||
else if (command === 0x53 || command === 0x54)
|
||||
steps.push({ kind: "emote", shown: command === 0x54 });
|
||||
else if (command === 0x55)
|
||||
steps.push({ kind: "shake", displacement: reader.byte(p++) });
|
||||
else if (command === 0x56)
|
||||
steps.push({ kind: "pose", pose: "tree", ticks: 24 });
|
||||
else if (command === 0x57)
|
||||
steps.push({ kind: "pose", pose: "flicker", ticks: reader.byte(p++) || 256 });
|
||||
else if (command === 0x58)
|
||||
steps.push({ kind: "pose", pose: "spin-flicker", ticks: reader.byte(p++) || 256 });
|
||||
else if (command === 0x59)
|
||||
steps.push({ kind: "pose", pose: "skyfall-top", ticks: 16 });
|
||||
else throw new UnsupportedMovement(offset, command);
|
||||
}
|
||||
throw new Error("Movement data is unterminated.");
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/** Overworld constants keyed by their PRET names, so that
|
||||
* `npm run check:reference` verifies every value against the pinned
|
||||
* disassembly (constants/*.asm). */
|
||||
export const OW = {
|
||||
// Collision types (constants/collision_constants.asm).
|
||||
COLL_CUT_TREE: 0x12,
|
||||
COLL_CUT_TREE_1A: 0x1a,
|
||||
COLL_TALL_GRASS_10: 0x10,
|
||||
COLL_TALL_GRASS: 0x18,
|
||||
COLL_LONG_GRASS: 0x14,
|
||||
COLL_LONG_GRASS_1C: 0x1c,
|
||||
COLL_HEADBUTT_TREE: 0x15,
|
||||
COLL_HEADBUTT_TREE_1D: 0x1d,
|
||||
COLL_ICE: 0x23,
|
||||
COLL_ICE_2B: 0x2b,
|
||||
COLL_WHIRLPOOL: 0x24,
|
||||
COLL_WHIRLPOOL_2C: 0x2c,
|
||||
COLL_WATERFALL: 0x33,
|
||||
COLL_CURRENT_DOWN: 0x3b,
|
||||
COLL_PIT: 0x60,
|
||||
COLL_PIT_68: 0x68,
|
||||
COLL_WARP_CARPET_DOWN: 0x70,
|
||||
COLL_DOOR: 0x71,
|
||||
COLL_WARP_CARPET_LEFT: 0x76,
|
||||
COLL_WARP_CARPET_UP: 0x78,
|
||||
COLL_DOOR_79: 0x79,
|
||||
COLL_STAIRCASE: 0x7a,
|
||||
COLL_CAVE: 0x7b,
|
||||
COLL_WARP_CARPET_RIGHT: 0x7e,
|
||||
COLL_COUNTER: 0x90,
|
||||
HI_NYBBLE_CURRENT: 0x30,
|
||||
HI_NYBBLE_WALK: 0x40,
|
||||
HI_NYBBLE_WALK_ALT: 0x50,
|
||||
HI_NYBBLE_WARPS: 0x70,
|
||||
HI_NYBBLE_LEDGES: 0xa0,
|
||||
HI_NYBBLE_SIDE_WALLS: 0xb0,
|
||||
LAND_TILE: 0x00,
|
||||
WATER_TILE: 0x01,
|
||||
// wPlayerState (constants/ram_constants.asm).
|
||||
PLAYER_NORMAL: 0,
|
||||
PLAYER_BIKE: 1,
|
||||
PLAYER_SKATE: 2,
|
||||
PLAYER_SURF: 4,
|
||||
PLAYER_SURF_PIKA: 8,
|
||||
// Engine flags backed by wBikeFlags (constants/engine_flags.asm).
|
||||
ENGINE_FOREST_IS_RESTLESS: 100,
|
||||
ENGINE_STRENGTH_ACTIVE: 24,
|
||||
ENGINE_ALWAYS_ON_BIKE: 25,
|
||||
ENGINE_DOWNHILL: 26,
|
||||
ENGINE_ZEPHYRBADGE: 27,
|
||||
ENGINE_HIVEBADGE: 28,
|
||||
ENGINE_PLAINBADGE: 29,
|
||||
ENGINE_FOGBADGE: 30,
|
||||
ENGINE_GLACIERBADGE: 33,
|
||||
ENGINE_RISINGBADGE: 34,
|
||||
// Map environments (constants/map_data_constants.asm).
|
||||
TOWN: 1,
|
||||
ROUTE: 2,
|
||||
INDOOR: 3,
|
||||
CAVE: 4,
|
||||
ENVIRONMENT_5: 5,
|
||||
GATE: 6,
|
||||
DUNGEON: 7,
|
||||
// Tilesets (constants/tileset_constants.asm).
|
||||
NUM_TILESETS: 36,
|
||||
TILESET_JOHTO: 1,
|
||||
TILESET_JOHTO_MODERN: 2,
|
||||
TILESET_KANTO: 3,
|
||||
TILESET_HOUSE: 5,
|
||||
TILESET_POKECENTER: 7,
|
||||
TILESET_PORT: 9,
|
||||
TILESET_FOREST: 31,
|
||||
TILESET_PARK: 25,
|
||||
TILESET_POKECOM_CENTER: 21,
|
||||
TILESET_BATTLE_TOWER_INSIDE: 22,
|
||||
TILESET_RADIO_TOWER: 27,
|
||||
TILESET_MANSION: 13,
|
||||
TILESET_ICE_PATH: 29,
|
||||
TILESET_ELITE_FOUR_ROOM: 15,
|
||||
TILESET_TOWER: 23,
|
||||
TILESET_CAVE: 24,
|
||||
TILESET_DARK_CAVE: 30,
|
||||
// Map palettes (constants/map_data_constants.asm).
|
||||
PALETTE_DARK: 4,
|
||||
// Regions (constants/landmark_constants.asm).
|
||||
LANDMARK_SPECIAL: 0,
|
||||
LANDMARK_FAST_SHIP: 95,
|
||||
KANTO_LANDMARK: 47,
|
||||
COLL_WATER: 0x29,
|
||||
// Sprite ranges (constants/sprite_constants.asm).
|
||||
NUM_OVERWORLD_SPRITES: 102,
|
||||
SPRITE_POKEMON: 0x80,
|
||||
NUM_POKEMON_SPRITES: 35,
|
||||
SPRITE_VARS: 0xf0,
|
||||
// Moves used as field moves (constants/move_constants.asm).
|
||||
CUT: 0x0f,
|
||||
SURF: 0x39,
|
||||
STRENGTH: 0x46,
|
||||
WHIRLPOOL: 250,
|
||||
WATERFALL: 127,
|
||||
HEADBUTT: 29,
|
||||
BICYCLE: 7,
|
||||
PIKACHU: 25,
|
||||
EGG: 0xfd,
|
||||
} as const;
|
||||
|
||||
/** Directions in the original's order (DOWN, UP, LEFT, RIGHT). */
|
||||
export const DOWN = 0,
|
||||
UP = 1,
|
||||
LEFT = 2,
|
||||
RIGHT = 3;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { RomReader } from "../rom/reader";
|
||||
import { OVERWORLD } from "../rom/profile";
|
||||
import { PokemonData } from "../rom/pokemon";
|
||||
import { defaultTextContext, readString } from "../scripts/text";
|
||||
|
||||
export const DECORATION_SLOTS = [
|
||||
"bed",
|
||||
"carpet",
|
||||
"plant",
|
||||
"poster",
|
||||
"console",
|
||||
"bigDoll",
|
||||
"left",
|
||||
"right",
|
||||
] as const;
|
||||
export type DecorationSlot = (typeof DECORATION_SLOTS)[number];
|
||||
export type Decorations = Record<DecorationSlot, number>;
|
||||
export const emptyDecorations = (): Decorations => ({
|
||||
bed: 0,
|
||||
carpet: 0,
|
||||
plant: 0,
|
||||
poster: 0,
|
||||
console: 0,
|
||||
bigDoll: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
});
|
||||
export const initialDecorations = (): Decorations => ({
|
||||
...emptyDecorations(),
|
||||
bed: 2,
|
||||
poster: 16,
|
||||
});
|
||||
|
||||
/** DecorationAttributes and GetDecoName, read from the user's ROM. */
|
||||
export class DecorationData {
|
||||
constructor(
|
||||
private reader: RomReader,
|
||||
private pokemon: PokemonData,
|
||||
) {}
|
||||
attribute(id: number) {
|
||||
if (!Number.isInteger(id) || id < 0 || id > 52)
|
||||
throw new Error("Invalid decoration.");
|
||||
const p = OVERWORLD.DecorationAttributes + id * 6,
|
||||
r = this.reader;
|
||||
return {
|
||||
type: r.byte(p),
|
||||
name: r.byte(p + 1),
|
||||
action: r.byte(p + 2),
|
||||
event: r.word(p + 3),
|
||||
sprite: r.byte(p + 5),
|
||||
};
|
||||
}
|
||||
private word(index: number) {
|
||||
let p = OVERWORLD.DecorationNames;
|
||||
for (let i = 0; i < index; i++) {
|
||||
while (this.reader.byte(p++) !== 0x50) {}
|
||||
}
|
||||
return readString(this.reader, p, defaultTextContext("PLAYER"));
|
||||
}
|
||||
name(id: number) {
|
||||
const a = this.attribute(id);
|
||||
if (a.type === 1) return this.word(a.name);
|
||||
if (a.type === 2 || a.type === 3)
|
||||
return this.word(a.name) + this.word(a.type === 2 ? 13 : 14);
|
||||
const mon = this.pokemon.speciesName(a.name);
|
||||
return a.type === 6
|
||||
? this.word(17) + mon
|
||||
: mon + this.word(a.type === 4 ? 15 : 16);
|
||||
}
|
||||
owned(events: Set<number>, action: number) {
|
||||
return Array.from({ length: 52 }, (_, i) => i + 1).filter((id) => {
|
||||
const a = this.attribute(id);
|
||||
return a.action === action && events.has(a.event);
|
||||
});
|
||||
}
|
||||
valid(value: unknown, events: Set<number>): value is Decorations {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).length !== 8
|
||||
)
|
||||
return false;
|
||||
const slots = value as Decorations;
|
||||
return (
|
||||
DECORATION_SLOTS.every((slot, i) => {
|
||||
const id = slots[slot];
|
||||
return (
|
||||
Number.isInteger(id) &&
|
||||
id >= 0 &&
|
||||
id <= 52 &&
|
||||
(id === 0 ||
|
||||
(this.attribute(id).action === (i < 6 ? i * 2 + 1 : 13) &&
|
||||
events.has(this.attribute(id).event)))
|
||||
);
|
||||
}) &&
|
||||
(!slots.left || slots.left !== slots.right)
|
||||
);
|
||||
}
|
||||
}
|
||||
+1546
-251
File diff suppressed because it is too large.
Load diff
+224
-37
@@ -1,3 +1,5 @@
|
||||
import { drawTownMap } from "./game/rendering/town-map";
|
||||
import { OW } from "./game/world/constants";
|
||||
import "./ui/styles.css";
|
||||
import { renderShell } from "./ui/shell";
|
||||
import { bindTabs } from "./ui/tabs";
|
||||
@@ -46,6 +48,10 @@ function showWorld(world: GameWorld) {
|
||||
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
|
||||
get("game-notice").textContent = world.notice;
|
||||
showBattle(world);
|
||||
showFieldMenu(world);
|
||||
get("town-map-view").hidden = !world.townMap;
|
||||
if (world.townMap)
|
||||
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
|
||||
const dialogue = world.dialogue;
|
||||
showPicture(world);
|
||||
// A page without text is a button wait while a picture is shown.
|
||||
@@ -53,7 +59,8 @@ function showWorld(world: GameWorld) {
|
||||
dialogue && !dialogue.choice && dialogue.pages[dialogue.index] === "",
|
||||
);
|
||||
const mart = dialogue?.choice && martChoice(dialogue.choice);
|
||||
get("native-dialogue").hidden = !dialogue || pictureWait || Boolean(mart);
|
||||
get("native-dialogue").hidden =
|
||||
!dialogue || pictureWait || Boolean(mart) || world.townMap;
|
||||
get("pokepic-next").hidden = !pictureWait;
|
||||
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
|
||||
get("dialogue-page").textContent =
|
||||
@@ -131,6 +138,27 @@ function showChoice(choice: Choice | undefined) {
|
||||
container.replaceChildren(
|
||||
...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
|
||||
);
|
||||
else if (choice.kind === "menu") {
|
||||
// A vertical menu such as the elevator's floors; B cancels.
|
||||
const list = document.createElement("div");
|
||||
list.className = "dialogue-menu";
|
||||
list.setAttribute("role", "listbox");
|
||||
list.replaceChildren(
|
||||
...choice.items.map((item, index) => {
|
||||
const option = button(item, choice.selected === index, () =>
|
||||
runtime.choose(index),
|
||||
);
|
||||
option.setAttribute("role", "option");
|
||||
option.setAttribute("aria-label", item);
|
||||
option.setAttribute("aria-selected", String(choice.selected === index));
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
container.replaceChildren(
|
||||
list,
|
||||
button("CANCEL", undefined, () => runtime.choose(-1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
/** The naming screen as a text field and OK: an empty name keeps the
|
||||
* current one. */
|
||||
@@ -272,7 +300,11 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
|
||||
);
|
||||
menu.replaceChildren(
|
||||
stepper,
|
||||
button(mart.mode === "sell" ? "SELL" : "BUY", () => runtime.choose(mart.selected), { wide: true }),
|
||||
button(
|
||||
mart.mode === "sell" ? "SELL" : "BUY",
|
||||
() => runtime.choose(mart.selected),
|
||||
{ wide: true },
|
||||
),
|
||||
button("CANCEL", () => runtime.choose(-1), { wide: true }),
|
||||
);
|
||||
}
|
||||
@@ -286,12 +318,28 @@ function statusName(status: number) {
|
||||
if (status & 7) return "SLP";
|
||||
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
|
||||
}
|
||||
function drawPic(target: HTMLCanvasElement, tiles: Uint8Array, size: number, palette: Palette, flip = false) {
|
||||
function drawPic(
|
||||
target: HTMLCanvasElement,
|
||||
tiles: Uint8Array,
|
||||
size: number,
|
||||
palette: Palette,
|
||||
flip = false,
|
||||
) {
|
||||
const context = target.getContext("2d")!;
|
||||
const pixels = size * 8;
|
||||
const image = context.createImageData(pixels, pixels);
|
||||
for (let tile = 0; tile < size * size; tile++)
|
||||
drawTile(image.data, pixels, pixels, tiles, tile, Math.floor(tile / size) * 8, (tile % size) * 8, palette, { flipX: flip });
|
||||
drawTile(
|
||||
image.data,
|
||||
pixels,
|
||||
pixels,
|
||||
tiles,
|
||||
tile,
|
||||
Math.floor(tile / size) * 8,
|
||||
(tile % size) * 8,
|
||||
palette,
|
||||
{ flipX: flip },
|
||||
);
|
||||
context.putImageData(image, 0, 0);
|
||||
}
|
||||
const battlePics = { foe: "", player: "" };
|
||||
@@ -302,8 +350,67 @@ let showPack = false;
|
||||
let battleCursor = 0;
|
||||
let battleSelectionKey = "";
|
||||
|
||||
let fieldCursor = 0;
|
||||
function fieldButtons() {
|
||||
return [
|
||||
...get("field-items").querySelectorAll<HTMLButtonElement>(
|
||||
"button:not(:disabled)",
|
||||
),
|
||||
];
|
||||
}
|
||||
function showFieldMenu(world: GameWorld) {
|
||||
const panel = get("field-menu");
|
||||
panel.hidden = !runtime.fieldMenuOpen;
|
||||
if (panel.hidden) return;
|
||||
const buttons = world.state.bag.keyItems.map((item) => {
|
||||
const button = document.createElement("button");
|
||||
button.textContent = world.assets.pokemon.itemName(item);
|
||||
button.disabled = item !== OW.BICYCLE;
|
||||
button.onclick = () => {
|
||||
if (session.state !== "playing") return;
|
||||
runtime.useBicycle();
|
||||
canvas.focus({ preventScroll: true });
|
||||
};
|
||||
return button;
|
||||
});
|
||||
const close = document.createElement("button");
|
||||
close.textContent = "CLOSE";
|
||||
close.onclick = () => {
|
||||
runtime.fieldMenuOpen = false;
|
||||
showFieldMenu(world);
|
||||
canvas.focus();
|
||||
};
|
||||
get("field-items").replaceChildren(...buttons, close);
|
||||
fieldButtons().forEach((button, i) =>
|
||||
button.classList.toggle("battle-selected", i === fieldCursor),
|
||||
);
|
||||
}
|
||||
function handleFieldInput(button: Button): boolean {
|
||||
const world = runtime.world;
|
||||
if (!world || world.battle) return false;
|
||||
if ((button === "start" || button === "select") && !world.busy) {
|
||||
runtime.fieldMenuOpen = !runtime.fieldMenuOpen;
|
||||
fieldCursor = 0;
|
||||
return true;
|
||||
}
|
||||
if (!runtime.fieldMenuOpen) return false;
|
||||
if (button === "b") runtime.fieldMenuOpen = false;
|
||||
else if (button === "a") fieldButtons()[fieldCursor]?.click();
|
||||
else if (["up", "down", "left", "right"].includes(button)) {
|
||||
const length = fieldButtons().length;
|
||||
fieldCursor =
|
||||
(fieldCursor + (button === "up" || button === "left" ? -1 : 1) + length) %
|
||||
length;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function battleButtons() {
|
||||
return [...get("battle-menu").querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
|
||||
return [
|
||||
...get("battle-menu").querySelectorAll<HTMLButtonElement>(
|
||||
"button:not(:disabled)",
|
||||
),
|
||||
];
|
||||
}
|
||||
function highlightBattleChoice() {
|
||||
battleButtons().forEach((button, index) => {
|
||||
@@ -312,6 +419,7 @@ function highlightBattleChoice() {
|
||||
}
|
||||
/** Keyboard and on-screen D-pad share the same menu buttons as pointer input. */
|
||||
function handleBattleInput(button: Button): boolean {
|
||||
if (handleFieldInput(button)) return true;
|
||||
const world = runtime.world;
|
||||
if (!world?.battle) return false;
|
||||
if (button === "b" && (showMoves || showPack)) {
|
||||
@@ -354,7 +462,12 @@ function showBattle(world: GameWorld) {
|
||||
const key = `${foe.species}:${foe.dvs}`;
|
||||
if (battlePics.foe !== key) {
|
||||
battlePics.foe = key;
|
||||
drawPic(get("battle-foe-pic"), data.frontPic(foe.species), 7, data.palette(foe.species, PokemonData.shiny(foe.dvs)));
|
||||
drawPic(
|
||||
get("battle-foe-pic"),
|
||||
data.frontPic(foe.species),
|
||||
7,
|
||||
data.palette(foe.species, PokemonData.shiny(foe.dvs)),
|
||||
);
|
||||
get("battle-foe-pic").setAttribute("aria-label", foe.nickname);
|
||||
}
|
||||
get("battle-foe-name").textContent = foe.nickname;
|
||||
@@ -367,7 +480,12 @@ function showBattle(world: GameWorld) {
|
||||
const key = `${own.species}:${own.dvs}`;
|
||||
if (battlePics.player !== key) {
|
||||
battlePics.player = key;
|
||||
drawPic(get("battle-player-pic"), data.backPic(own.species), 6, data.palette(own.species, PokemonData.shiny(own.dvs)));
|
||||
drawPic(
|
||||
get("battle-player-pic"),
|
||||
data.backPic(own.species),
|
||||
6,
|
||||
data.palette(own.species, PokemonData.shiny(own.dvs)),
|
||||
);
|
||||
get("battle-player-pic").setAttribute("aria-label", own.nickname);
|
||||
}
|
||||
get("battle-player-name").textContent = own.nickname;
|
||||
@@ -387,11 +505,28 @@ function showBattle(world: GameWorld) {
|
||||
}
|
||||
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
|
||||
const balls = world.liveState.bag.balls;
|
||||
const key = JSON.stringify([prompt, view.selected, showMoves, showPack, own?.pp, balls, view.message?.prompt]);
|
||||
const key = JSON.stringify([
|
||||
prompt,
|
||||
view.selected,
|
||||
showMoves,
|
||||
showPack,
|
||||
own?.pp,
|
||||
balls,
|
||||
view.message?.prompt,
|
||||
]);
|
||||
if (key === battleMenuKey) return;
|
||||
battleMenuKey = key;
|
||||
const menu = get("battle-menu");
|
||||
const button = (label: string, action: () => void, options: { detail?: string; disabled?: string; wide?: boolean; pressed?: boolean } = {}) => {
|
||||
const button = (
|
||||
label: string,
|
||||
action: () => void,
|
||||
options: {
|
||||
detail?: string;
|
||||
disabled?: string;
|
||||
wide?: boolean;
|
||||
pressed?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
element.textContent = label;
|
||||
@@ -405,7 +540,8 @@ function showBattle(world: GameWorld) {
|
||||
element.title = options.disabled;
|
||||
}
|
||||
if (options.wide) element.classList.add("wide");
|
||||
if (options.pressed !== undefined) element.setAttribute("aria-pressed", String(options.pressed));
|
||||
if (options.pressed !== undefined)
|
||||
element.setAttribute("aria-pressed", String(options.pressed));
|
||||
element.addEventListener("click", () => {
|
||||
if (session.state !== "playing") return;
|
||||
action();
|
||||
@@ -419,38 +555,64 @@ function showBattle(world: GameWorld) {
|
||||
own.moves.forEach((move, index) => {
|
||||
if (!move) return;
|
||||
buttons.push(
|
||||
button(data.moveName(move), () => {
|
||||
showMoves = false;
|
||||
runtime.battleAction({ action: "fight", move: index });
|
||||
}, { detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` }),
|
||||
button(
|
||||
data.moveName(move),
|
||||
() => {
|
||||
showMoves = false;
|
||||
runtime.battleAction({ action: "fight", move: index });
|
||||
},
|
||||
{ detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` },
|
||||
),
|
||||
);
|
||||
});
|
||||
buttons.push(button("BACK", () => {
|
||||
showMoves = false;
|
||||
showWorld(world);
|
||||
}, { wide: true }));
|
||||
buttons.push(
|
||||
button(
|
||||
"BACK",
|
||||
() => {
|
||||
showMoves = false;
|
||||
showWorld(world);
|
||||
},
|
||||
{ wide: true },
|
||||
),
|
||||
);
|
||||
} else if (showPack) {
|
||||
balls.forEach(({ item, quantity }, slot) =>
|
||||
buttons.push(
|
||||
button(data.itemName(item), () => {
|
||||
showPack = false;
|
||||
runtime.battleAction({ action: "item", item, slot });
|
||||
}, { detail: `×${quantity}` }),
|
||||
button(
|
||||
data.itemName(item),
|
||||
() => {
|
||||
showPack = false;
|
||||
runtime.battleAction({ action: "item", item, slot });
|
||||
},
|
||||
{ detail: `×${quantity}` },
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!balls.length)
|
||||
buttons.push(button("No BALLS", () => {}, { disabled: "The BALL pocket is empty." }));
|
||||
buttons.push(button("BACK", () => {
|
||||
showPack = false;
|
||||
showWorld(world);
|
||||
}, { wide: true }));
|
||||
buttons.push(
|
||||
button("No BALLS", () => {}, {
|
||||
disabled: "The BALL pocket is empty.",
|
||||
}),
|
||||
);
|
||||
buttons.push(
|
||||
button(
|
||||
"BACK",
|
||||
() => {
|
||||
showPack = false;
|
||||
showWorld(world);
|
||||
},
|
||||
{ wide: true },
|
||||
),
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
button("FIGHT", () => {
|
||||
showMoves = true;
|
||||
showWorld(world);
|
||||
}),
|
||||
button("PKMN", () => {}, { disabled: "Switching Pokémon is not ported yet." }),
|
||||
button("PKMN", () => {}, {
|
||||
disabled: "Switching Pokémon is not ported yet.",
|
||||
}),
|
||||
button("PACK", () => {
|
||||
showPack = true;
|
||||
showWorld(world);
|
||||
@@ -460,29 +622,48 @@ function showBattle(world: GameWorld) {
|
||||
}
|
||||
} else if (prompt?.kind === "yesno") {
|
||||
buttons.push(
|
||||
button("YES", () => runtime.battleChoose(1), { pressed: view.selected === 1 }),
|
||||
button("NO", () => runtime.battleChoose(0), { pressed: view.selected === 0 }),
|
||||
button("YES", () => runtime.battleChoose(1), {
|
||||
pressed: view.selected === 1,
|
||||
}),
|
||||
button("NO", () => runtime.battleChoose(0), {
|
||||
pressed: view.selected === 0,
|
||||
}),
|
||||
);
|
||||
} else if (prompt?.kind === "forget") {
|
||||
prompt.moves.forEach((move, index) =>
|
||||
buttons.push(button(data.moveName(move), () => runtime.battleChoose(index))),
|
||||
buttons.push(
|
||||
button(data.moveName(move), () => runtime.battleChoose(index)),
|
||||
),
|
||||
);
|
||||
buttons.push(
|
||||
button("CANCEL", () => runtime.battleChoose(4), { wide: true }),
|
||||
);
|
||||
buttons.push(button("CANCEL", () => runtime.battleChoose(4), { wide: true }));
|
||||
} else if (prompt?.kind === "name") {
|
||||
menu.replaceChildren(
|
||||
...nameField(prompt.current, prompt.max, (name) => runtime.battleName(name)),
|
||||
...nameField(prompt.current, prompt.max, (name) =>
|
||||
runtime.battleName(name),
|
||||
),
|
||||
);
|
||||
return;
|
||||
} else if (prompt?.kind === "stats") {
|
||||
prompt.labels.forEach((label, index) =>
|
||||
buttons.push(button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " })),
|
||||
buttons.push(
|
||||
button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " }),
|
||||
),
|
||||
);
|
||||
buttons.push(
|
||||
button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }),
|
||||
);
|
||||
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
|
||||
} else if (view.message?.prompt) {
|
||||
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
|
||||
buttons.push(
|
||||
button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }),
|
||||
);
|
||||
}
|
||||
menu.replaceChildren(...buttons);
|
||||
battleCursor = Math.min(battleCursor, Math.max(0, battleButtons().length - 1));
|
||||
battleCursor = Math.min(
|
||||
battleCursor,
|
||||
Math.max(0, battleButtons().length - 1),
|
||||
);
|
||||
highlightBattleChoice();
|
||||
}
|
||||
function setHp(bar: HTMLElement, hp: number, max: number) {
|
||||
@@ -768,3 +949,9 @@ setInterval(() => {
|
||||
if (ready() && !busy) void save().catch(() => {});
|
||||
}, 10_000);
|
||||
update();
|
||||
|
||||
get("town-map-close").onclick = () => {
|
||||
if (session.state !== "playing") return;
|
||||
runtime.advanceDialogue();
|
||||
canvas.focus({ preventScroll: true });
|
||||
};
|
||||
@@ -35,6 +35,8 @@ export function renderShell(root: HTMLElement) {
|
||||
<div id="battle-player" class="battle-player"><canvas id="battle-player-pic" width="48" height="48" role="img"></canvas><div class="battle-hud"><div><strong id="battle-player-name"></strong><span id="battle-player-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-player-hp"></i></span></div><div class="battle-numbers"><span id="battle-player-status" class="battle-status"></span><span id="battle-player-hp-text"></span></div></div></div>
|
||||
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
|
||||
</div>
|
||||
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
|
||||
<div id="field-menu" class="field-menu" hidden aria-label="Pack"><p>KEY ITEMS</p><div id="field-items"></div></div>
|
||||
<div id="mart" class="mart" hidden aria-label="Poké Mart">
|
||||
<p id="mart-money" class="mart-money"></p>
|
||||
<div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-menu" class="mart-menu"></div></div>
|
||||
|
||||
@@ -341,6 +341,13 @@ dialog h2 {
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.dialogue-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 9.5em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dialogue-choice input {
|
||||
flex: 1 1 12ch;
|
||||
min-width: 0;
|
||||
@@ -1024,3 +1031,47 @@ dialog dd {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
.field-menu {
|
||||
position: absolute;
|
||||
inset: 1rem 1rem auto auto;
|
||||
z-index: 6;
|
||||
max-height: calc(100% - 2rem);
|
||||
overflow: auto;
|
||||
min-width: 10rem;
|
||||
padding: 1rem;
|
||||
border: 2px solid #27332a;
|
||||
border-radius: 0.4rem;
|
||||
background: #f8f4df;
|
||||
color: #27332a;
|
||||
}
|
||||
.field-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.field-menu p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
#field-items {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.town-map-view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 7;
|
||||
background: #f8f4df;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.town-map-view[hidden] {
|
||||
display: none;
|
||||
}
|
||||
#town-map-canvas {
|
||||
width: auto;
|
||||
height: calc(100% - 3rem);
|
||||
max-width: 100%;
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
}
|
||||
Reference in new issue
Block a user