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

+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(