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
@@ -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}`);
|
||||
}
|
||||
Reference in new issue
Block a user