Add native Crystal exploration port and reference tooling
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 09:23:29 +02:00
1 parent 2127f5f459
commit 8b80d05ca2
56 files changed
+6910 -1

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
import { CrystalAssets } from "../rom/assets";
import { GameWorld } from "../world/world";
import type { Direction } from "../world/types";
import { drawTile } from "./pixels";
export class WorldRenderer {
readonly width = 160;
readonly height = 144;
private context: CanvasRenderingContext2D;
private image: ImageData;
constructor(
private assets: CrystalAssets,
canvas: HTMLCanvasElement,
) {
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);
}
render(
world: GameWorld,
playerX = world.x * 16,
playerY = world.y * 16,
frame = 0,
) {
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);
this.image.data.fill(255);
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.block(
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 tile = (encoded & 0x7f) + (attribute & 8 ? 0x60 : 0);
drawTile(
this.image.data,
this.width,
this.height,
tileset.graphics,
tile,
tileX * 8 - cameraX,
tileY * 8 - cameraY,
palettes[attribute & 7],
);
}
}
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)
this.actor(
actor.sprite,
Math.round(actor.x - cameraX),
Math.round(actor.y - cameraY - 4),
actor.direction,
actor.frame,
world.timeOfDay,
actor.palette,
);
this.context.putImageData(this.image, 0, 0);
}
private actor(
id: number,
x: number,
y: number,
direction: Direction,
frame: number,
time: number,
paletteIndex?: number,
) {
const sprite = this.assets.sprite(id);
if (!sprite) return;
const palette = this.assets.objectPalette(
paletteIndex ?? sprite.palette,
time,
);
for (const piece of this.assets.facing(
direction,
sprite.walking ? frame : 0,
)) {
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),
},
);
}
}
}