Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 16:09:22 +02:00
1 parent 8b80d05ca2
commit c08d250cf6
39 files changed
+10727 -1524

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import type { CrystalAssets } from "../rom/assets";
import type { GameWorld } from "../world/world";
import { drawTile } from "./pixels";
/** A local-area atlas decoded in memory from the same tiles as the world. */
export class LocalMapView {
private terrain = document.createElement("canvas");
private assets?: CrystalAssets;
private mapKey = "";
constructor(
private mini: HTMLCanvasElement,
private full: HTMLCanvasElement,
) {}
update(world: GameWorld) {
const map = world.map;
const key = `${map.id}:${world.timeOfDay}`;
if (this.assets !== world.assets || this.mapKey !== key) {
this.buildTerrain(world);
this.assets = world.assets;
this.mapKey = key;
}
this.draw(this.mini, world, 160, 128);
this.draw(
this.full,
world,
this.terrain.width + 16,
this.terrain.height + 16,
);
}
private buildTerrain(world: GameWorld) {
const { map, assets } = world;
// The supported maps are small; reject unbounded buffers if profiles grow.
const width = map.width * 16;
const height = map.height * 16;
if (width < 1 || height < 1 || width > 2048 || height > 2048)
throw new Error("This area is too large for the local map.");
this.terrain.width = width;
this.terrain.height = height;
const context = this.terrain.getContext("2d", { alpha: false });
if (!context) throw new Error("Map rendering is unavailable.");
const image = context.createImageData(width, height);
const tileset = assets.tileset(map);
const palettes = assets.palettes(map, world.timeOfDay);
for (let y = 0; y < height / 8; y++) {
for (let x = 0; x < width / 8; x++) {
const block = assets.block(map, Math.floor(x / 2), Math.floor(y / 2));
const encoded = tileset.metatiles[block * 16 + (y % 4) * 4 + (x % 4)];
const packed = tileset.paletteMap[encoded >> 1];
const attribute = encoded & 1 ? packed >> 4 : packed & 15;
drawTile(
image.data,
width,
height,
tileset.graphics,
(encoded & 0x7f) + (attribute & 8 ? 0x60 : 0),
x * 8,
y * 8,
palettes[attribute & 7],
);
}
}
context.putImageData(image, 0, 0);
}
private draw(
canvas: HTMLCanvasElement,
world: GameWorld,
width: number,
height: number,
) {
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const context = canvas.getContext("2d", { alpha: false });
if (!context) throw new Error("Map rendering is unavailable.");
context.imageSmoothingEnabled = false;
context.fillStyle = "#f8f4df";
context.fillRect(0, 0, width, height);
const scale = Math.min(
(width - 16) / this.terrain.width,
(height - 16) / this.terrain.height,
);
const mapWidth = this.terrain.width * scale;
const mapHeight = this.terrain.height * scale;
const left = (width - mapWidth) / 2;
const top = (height - mapHeight) / 2;
context.drawImage(this.terrain, left, top, mapWidth, mapHeight);
const x = Math.round(left + (world.x * 16 + 8) * scale);
const y = Math.round(top + (world.y * 16 + 8) * scale);
// An outlined marker remains readable over both bright and dark terrain.
context.fillStyle = "#fffdf4";
context.fillRect(x - 4, y - 4, 9, 9);
context.fillStyle = "#24283d";
context.fillRect(x - 3, y - 3, 7, 7);
context.fillStyle = "#d33b49";
context.fillRect(x - 2, y - 2, 5, 5);
canvas.dataset.map = world.mapId;
canvas.dataset.x = String(world.x);
canvas.dataset.y = String(world.y);
canvas.setAttribute("role", "img");
canvas.setAttribute(
"aria-label",
`${world.map.name}. You are at ${world.x}, ${world.y}, shown in red.`,
);
}
}
+100 -40
View File
@@ -2,16 +2,26 @@ import { CrystalAssets } from "../rom/assets";
import { GameWorld } from "../world/world";
import type { Direction } from "../world/types";
import { drawTile } from "./pixels";
import {
BASE_VIEWPORT,
renderViewportForBox,
type RenderViewport,
} from "./viewport";
export class WorldRenderer {
readonly width = 160;
readonly height = 144;
width: number = BASE_VIEWPORT.width;
height: number = BASE_VIEWPORT.height;
private context: CanvasRenderingContext2D;
private image: ImageData;
private canvas: HTMLCanvasElement;
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 });
@@ -19,19 +29,48 @@ export class WorldRenderer {
this.context = context;
this.image = context.createImageData(this.width, this.height);
}
render(
world: GameWorld,
playerX = world.x * 16,
playerY = world.y * 16,
frame = 0,
) {
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),
palettes = this.assets.palettes(map, world.timeOfDay);
// Keep the camera player-centered as the original engine does. Out-of-map
// pixels use the map's own border block, including small room padding.
const cameraX = Math.round(playerX - 72),
cameraY = Math.round(playerY - 64);
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.
const cameraX = Math.round(player.x - (this.width / 2 - 8)),
cameraY = Math.round(player.y - (this.height / 2 - 8));
this.image.data.fill(255);
for (
let tileY = Math.floor(cameraY / 8);
@@ -43,7 +82,7 @@ export class WorldRenderer {
tileX < Math.ceil((cameraX + this.width) / 8);
tileX++
) {
const block = this.assets.block(
const block = this.assets.visibleBlock(
map,
Math.floor(tileX / 2),
Math.floor(tileY / 2),
@@ -66,35 +105,56 @@ export class WorldRenderer {
);
}
}
const actors = world
.objects()
.map((object) => ({
x: object.x * 16,
y: object.y * 16,
sprite: object.sprite,
direction: world.objectDirection(object),
palette: object.palette ? object.palette - 1 : undefined,
frame: 0,
}));
actors.push({
x: playerX,
y: playerY,
sprite: world.avatar === "kris" ? 0x60 : 1,
direction: world.direction,
palette: undefined,
frame,
});
actors.sort((a, b) => a.y - b.y);
for (const actor of actors)
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(
actor.sprite,
Math.round(actor.x - cameraX),
Math.round(actor.y - cameraY - 4),
actor.direction,
actor.frame,
world.timeOfDay,
actor.palette,
pose.sprite,
pose.x - cameraX,
pose.y - cameraY - 4 + (pose.lift ?? 0),
pose.direction,
pose.frame,
time,
pose.palette,
);
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 },
);
}
this.context.putImageData(this.image, 0, 0);
}
private actor(
+36
View File
@@ -0,0 +1,36 @@
export const BASE_VIEWPORT = { width: 160, height: 144 } as const;
export const MAX_VIEWPORT = { width: 480, height: 432 } as const;
export type RenderViewport = {
width: number;
height: number;
pixelScale: number;
};
const finitePositive = (value: number) => Number.isFinite(value) && value > 0;
/**
* Selects a bounded low-resolution drawing buffer for a measured CSS box.
* The same integer divisor is used on both axes so world pixels retain their
* proportions. Detached and test canvases keep the original Game Boy view.
*/
export function renderViewportForBox(
cssWidth: number,
cssHeight: number,
): RenderViewport {
if (!finitePositive(cssWidth) || !finitePositive(cssHeight)) {
return { ...BASE_VIEWPORT, pixelScale: 3 };
}
const nominalScale = cssWidth < 600 ? 2 : 3;
const pixelScale = Math.max(
nominalScale,
Math.ceil(cssWidth / MAX_VIEWPORT.width),
Math.ceil(cssHeight / MAX_VIEWPORT.height),
);
return {
width: Math.max(1, Math.round(cssWidth / pixelScale)),
height: Math.max(1, Math.round(cssHeight / pixelScale)),
pixelScale,
};
}