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