Files
Crystal/src/game/rendering/renderer.ts
T
Sébastien QUEROLandClaude Opus 5.5 f259163da9 Port Headbutt and Rock Smash encounters
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>
2026-10-02 12:20:13 +02:00

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),
},
);
}
}
}