This commit is contained in:
1 parent
2127f5f459
commit
8b80d05ca2
56 files changed
+6910
-1
No files matched your search
@@ -0,0 +1,36 @@
|
||||
import type { Palette } from "../world/types";
|
||||
|
||||
/** Decode Game Boy planar pixels directly to RGBA; no display hardware emulation. */
|
||||
export function drawTile(
|
||||
target: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
graphics: Uint8Array,
|
||||
tile: number,
|
||||
x: number,
|
||||
y: number,
|
||||
palette: Palette,
|
||||
options: { transparent?: boolean; flipX?: boolean; flipY?: boolean } = {},
|
||||
) {
|
||||
const offset = tile * 16;
|
||||
if (offset < 0 || offset + 16 > graphics.length) return;
|
||||
for (let row = 0; row < 8; row++) {
|
||||
const sourceRow = options.flipY ? 7 - row : row;
|
||||
const lo = graphics[offset + sourceRow * 2],
|
||||
hi = graphics[offset + sourceRow * 2 + 1];
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const bit = options.flipX ? col : 7 - col;
|
||||
const index = ((lo >> bit) & 1) | (((hi >> bit) & 1) << 1);
|
||||
if (options.transparent && index === 0) continue;
|
||||
const px = x + col,
|
||||
py = y + row;
|
||||
if (px < 0 || py < 0 || px >= width || py >= height) continue;
|
||||
const destination = (py * width + px) * 4,
|
||||
color = palette[index];
|
||||
target[destination] = color[0];
|
||||
target[destination + 1] = color[1];
|
||||
target[destination + 2] = color[2];
|
||||
target[destination + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user