Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
This commit is contained in:
1 parent
8b80d05ca2
commit
c08d250cf6
39 files changed
+10727
-1524
No files matched your search
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user