Facing a Headbutt tree asks to HEADBUTT it, and HEADBUTT from the party menu skips the question. ShakeHeadbuttTree shakes the tree as a sprite over grass, then TreeMonEncounter scores the facing tile against the trainer ID and draws from the map's common or rare tree table. A Pokémon that falls fights as BATTLETYPE_TREE: "fell out of the tree!", and the time of day's AsleepTreeMons species start asleep. Smashable rocks run SmashRockScript, and ROCK SMASH from the party menu acts on the facing rock; after the rock breaks, RockMonEncounter gives the map's rock set a 40% chance of a wild battle. Closes #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
9.9 KiB
TypeScript
289 lines
9.9 KiB
TypeScript
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,
|
|
type RenderViewport,
|
|
} from "./viewport";
|
|
|
|
/** Video frames before LoadOverworldTilemapAndAttrmapPals puts the
|
|
* Headbutt tree back: HideHeadbuttTree's WaitBGMap and the 32 shaking
|
|
* frames. */
|
|
const HEADBUTT_HIDDEN_FRAMES = 36;
|
|
|
|
export class WorldRenderer {
|
|
width: number = BASE_VIEWPORT.width;
|
|
height: number = BASE_VIEWPORT.height;
|
|
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,
|
|
) {
|
|
this.canvas = canvas;
|
|
const viewport = this.viewportFromCanvas();
|
|
this.width = viewport.width;
|
|
this.height = viewport.height;
|
|
canvas.width = this.width;
|
|
canvas.height = this.height;
|
|
const context = canvas.getContext("2d", { alpha: false });
|
|
if (!context) throw new Error("Canvas rendering is unavailable.");
|
|
this.context = context;
|
|
this.image = context.createImageData(this.width, this.height);
|
|
}
|
|
resize(cssWidth: number, cssHeight: number) {
|
|
// ResizeObserver reports zero-sized boxes while an ancestor is hidden.
|
|
// Retaining the last usable viewport avoids destroying the current frame.
|
|
if (
|
|
!Number.isFinite(cssWidth) ||
|
|
!Number.isFinite(cssHeight) ||
|
|
cssWidth <= 0 ||
|
|
cssHeight <= 0
|
|
)
|
|
return false;
|
|
const viewport = renderViewportForBox(cssWidth, cssHeight);
|
|
if (viewport.width === this.width && viewport.height === this.height)
|
|
return false;
|
|
this.width = viewport.width;
|
|
this.height = viewport.height;
|
|
this.canvas.width = this.width;
|
|
this.canvas.height = this.height;
|
|
this.image = this.context.createImageData(this.width, this.height);
|
|
return true;
|
|
}
|
|
private viewportFromCanvas(): RenderViewport {
|
|
try {
|
|
const box = this.canvas.getBoundingClientRect();
|
|
return renderViewportForBox(box.width, box.height);
|
|
} catch {
|
|
return { ...BASE_VIEWPORT, pixelScale: 3 };
|
|
}
|
|
}
|
|
render(world: GameWorld) {
|
|
const map = world.map,
|
|
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,
|
|
y: world.y * 16,
|
|
};
|
|
// 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)) + world.screenShake;
|
|
this.image.data.fill(255);
|
|
// HideHeadbuttTree puts tile $05 (grass) in the tree's four tilemap
|
|
// places, keeping their attributes, until the shaking ends.
|
|
const tree = world.headbuttTree;
|
|
const hidden = tree && tree.frames < HEADBUTT_HIDDEN_FRAMES ? tree : undefined;
|
|
for (
|
|
let tileY = Math.floor(cameraY / 8);
|
|
tileY < Math.ceil((cameraY + this.height) / 8);
|
|
tileY++
|
|
) {
|
|
for (
|
|
let tileX = Math.floor(cameraX / 8);
|
|
tileX < Math.ceil((cameraX + this.width) / 8);
|
|
tileX++
|
|
) {
|
|
const block = this.assets.visibleBlock(
|
|
map,
|
|
Math.floor(tileX / 2),
|
|
Math.floor(tileY / 2),
|
|
);
|
|
const localX = ((tileX % 4) + 4) % 4,
|
|
localY = ((tileY % 4) + 4) % 4;
|
|
const encoded = tileset.metatiles[block * 16 + localY * 4 + localX];
|
|
const packed = tileset.paletteMap[encoded >> 1],
|
|
attribute = encoded & 1 ? packed >> 4 : packed & 15;
|
|
const covered =
|
|
hidden &&
|
|
tileX >> 1 === hidden.x &&
|
|
tileY >> 1 === hidden.y;
|
|
const tile =
|
|
(covered ? 0x05 : encoded & 0x7f) + (attribute & 8 ? 0x60 : 0);
|
|
drawTile(
|
|
this.image.data,
|
|
this.width,
|
|
this.height,
|
|
animator.graphics,
|
|
tile,
|
|
tileX * 8 - cameraX,
|
|
tileY * 8 - cameraY,
|
|
palettes[attribute & 7],
|
|
{ indices: this.indices },
|
|
);
|
|
}
|
|
}
|
|
const time = world.timeOfDay;
|
|
// Temporary effect objects use PAL_OW_EMOTE.
|
|
const effectPalette = this.assets.objectPalette(5, time);
|
|
for (const pose of poses) {
|
|
if (pose.shadow === undefined) continue;
|
|
const shadow = this.assets.emote(8);
|
|
for (const [x, flipX] of [
|
|
[0, false],
|
|
[8, true],
|
|
] as const)
|
|
drawTile(
|
|
this.image.data,
|
|
this.width,
|
|
this.height,
|
|
shadow,
|
|
0,
|
|
pose.x - cameraX + x,
|
|
pose.y - cameraY - 4 + pose.shadow,
|
|
effectPalette,
|
|
{ transparent: true, flipX },
|
|
);
|
|
}
|
|
for (const pose of [...poses].sort((a, b) => a.y - b.y))
|
|
this.actor(
|
|
pose.sprite,
|
|
pose.x - cameraX,
|
|
pose.y - cameraY - 4 + (pose.lift ?? 0),
|
|
pose.direction,
|
|
pose.frame,
|
|
time,
|
|
pose.palette,
|
|
pose.facing,
|
|
pose.inGrass,
|
|
);
|
|
const emote =
|
|
world.emote && poses.find((pose) => pose.id === world.emote!.object);
|
|
if (emote) {
|
|
// FacingEmote: a 2x2 bubble 16 pixels above the object.
|
|
const graphics = this.assets.emote(world.emote!.emote);
|
|
for (const piece of this.assets.facingTiles(0x14))
|
|
drawTile(
|
|
this.image.data,
|
|
this.width,
|
|
this.height,
|
|
graphics,
|
|
piece.tile - 0xf8,
|
|
emote.x - cameraX + piece.x,
|
|
emote.y - cameraY - 4 - 16 + piece.y,
|
|
effectPalette,
|
|
{ transparent: true },
|
|
);
|
|
}
|
|
const flying = world.flyingIcon;
|
|
if (flying) {
|
|
// SPRITE_ANIM_OBJ_RED_WALK with the icon's tiles: each frame shows
|
|
// for nine video frames, the second one mirrored on every other pass.
|
|
const graphics = this.assets.monIcon(flying.species),
|
|
phase = Math.floor(flying.frames / 9) % 4,
|
|
flip = phase === 3,
|
|
palette = this.assets.objectPalette(0, time);
|
|
for (let tile = 0; tile < 4; tile++) {
|
|
const column = tile & 1;
|
|
drawTile(
|
|
this.image.data,
|
|
this.width,
|
|
this.height,
|
|
graphics,
|
|
(phase & 1) * 4 + tile,
|
|
player.x - cameraX + flying.x + (flip ? 8 - column * 8 : column * 8),
|
|
player.y - cameraY - 4 + flying.y + (tile >> 1) * 8,
|
|
palette,
|
|
{ transparent: true, flipX: flip },
|
|
);
|
|
}
|
|
}
|
|
if (tree) {
|
|
// SPRITE_ANIM_OBJ_HEADBUTT over the tree's square, in PAL_OW_TREE:
|
|
// the two frames alternate every three animation steps, the second
|
|
// mirrored on every other pass. The first step lasts through
|
|
// HideHeadbuttTree's WaitBGMap, the last until the sprite is cleared.
|
|
const graphics = this.assets.headbuttTree(),
|
|
step = Math.min(32, Math.max(0, tree.frames - 4)),
|
|
phase = Math.floor(step / 3) % 4,
|
|
flip = phase === 3,
|
|
palette = this.assets.objectPalette(6, time);
|
|
for (let tile = 0; tile < 4; tile++) {
|
|
const column = tile & 1;
|
|
drawTile(
|
|
this.image.data,
|
|
this.width,
|
|
this.height,
|
|
graphics,
|
|
(phase & 1) * 4 + tile,
|
|
tree.x * 16 - cameraX + (flip ? 8 - column * 8 : column * 8),
|
|
tree.y * 16 - cameraY + (tile >> 1) * 8,
|
|
palette,
|
|
{ transparent: true, flipX: flip },
|
|
);
|
|
}
|
|
}
|
|
this.context.putImageData(this.image, 0, 0);
|
|
}
|
|
private actor(
|
|
id: number,
|
|
x: number,
|
|
y: number,
|
|
direction: Direction,
|
|
frame: number,
|
|
time: number,
|
|
paletteIndex?: number,
|
|
facing?: number,
|
|
inGrass = false,
|
|
) {
|
|
const sprite = this.assets.sprite(id);
|
|
if (!sprite) return;
|
|
const palette = this.assets.objectPalette(
|
|
paletteIndex ?? sprite.palette,
|
|
time,
|
|
);
|
|
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,
|
|
this.width,
|
|
this.height,
|
|
sprite.graphics,
|
|
tile,
|
|
x + piece.x,
|
|
y + piece.y,
|
|
palette,
|
|
{
|
|
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),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|