Complete the overworld foundation
Checks / web (push) Canceled after 0s

Port forced movement (turn delays, bumps, ice, currents, pits), dynamic
return warps, elevators, Surf, Cut, Strength and cycling rules, working
map blocks with changeblock/changemapblocks, and the remaining NPC
movement types and movement commands.

Add tileset animation from the ROM's command tables, grass sprite
priority and special map palettes; room decorations with ownership, the
bedroom PC and save migration; and Key Items access to the Bicycle.

Fourteen original-engine traces compare positions, facing, actors, event
flags and scripted poses. Tile animation matches original VRAM for four
tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now
keeps the $60 sprite offset its step function leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 16:47:29 +02:00
1 parent 9ac111f262
commit 451464f7c6
38 files changed
+5600 -490

No files matched your search

+25 -92
View File
@@ -1,88 +1,8 @@
import { describe, expect, it } from "vitest";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, WALK_TICKS } from "../src/game/world/world";
import { VECTORS, type Direction, type GameMap } from "../src/game/world/types";
import { settle } from "./world-driver";
/** Synthetic maps and script bytes: these tests run without a private ROM. */
class MovementAssets extends CrystalAssets {
readonly fixtures = new Map<string, GameMap>();
readonly tiles = new Map<string, number>();
readonly bytes: Uint8Array;
constructor() {
const bytes = new Uint8Array(0xa0000);
bytes[0] = 0x50; // Empty weekday name for synthetic script hosts.
super(bytes);
this.bytes = bytes;
for (const id of ["24:7", "24:6"]) {
this.fixtures.set(id, {
id,
group: 24,
number: Number(id.split(":")[1]),
name: id,
location: id,
landmark: 1,
width: 8,
height: 8,
border: 0,
tileset: 1,
environment: 1,
paletteMode: 1,
blocks: new Uint8Array(16),
warps: [],
backgroundEvents: [],
objects: [],
connections: [],
sceneScripts: [],
callbacks: [],
coordinateEvents: [],
});
}
}
override map(id: string) {
return this.fixtures.get(id)!;
}
override isPorted(id: string) {
return this.fixtures.has(id);
}
override sceneOwner() {
return undefined;
}
override collision(map: GameMap, x: number, y: number) {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
return this.tiles.get(`${map.id}:${x},${y}`) ?? 0;
}
override permission(collision: number) {
return collision === 7 ? 15 : 0;
}
override grassCollisions() {
return [];
}
override sprite() {
return { graphics: new Uint8Array(0), palette: 0, walking: false };
}
tile(x: number, y: number, collision: number, map = "24:7") {
this.tiles.set(`${map}:${x},${y}`, collision);
}
}
function fixture() {
const assets = new MovementAssets();
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
return { assets, world };
}
function connectWarp(assets: MovementAssets, x = 3, y = 4) {
assets.map("24:7").warps.push({ x, y, targetMap: "24:6", targetWarp: 1 });
assets
.map("24:6")
.warps.push({ x: 2, y: 2, targetMap: "24:7", targetWarp: 1 });
assets.tile(2, 2, 0x70, "24:6");
}
import { MovementAssets, connectWarp, fixture } from "./movement-fixture";
describe("native ledge movement", () => {
it.each<[number, Direction]>([
@@ -105,8 +25,12 @@ describe("native ledge movement", () => {
const [dx, dy] = VECTORS[direction];
assets.tile(3, 3, tile);
assets.tile(3 + dx, 3 + dy, 7);
// Facing the ledge already, so no turn in place precedes the jump.
world.restore({ ...world.save(), direction });
world.tick(direction);
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
// StepFunction_PlayerJump: the map position reaches the tile jumped
// over first, and the landing tile halfway through.
expect([world.x, world.y]).toEqual([3 + dx, 3 + dy]);
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
x: 48 + dx * 2,
y: 48 + dy * 2,
@@ -114,12 +38,20 @@ describe("native ledge movement", () => {
shadow: dx ? 12 : 14,
});
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
for (let tick = 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick();
expect(world.busy).toBe(true);
for (let tick = 1; tick < WALK_TICKS; tick++) world.tick();
expect([world.x, world.y]).toEqual([3 + dx, 3 + dy]);
world.tick();
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
for (let tick = WALK_TICKS + 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick();
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeDefined();
world.tick();
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
// The landing's tile events run on the next tick (PLAYERSTEP_STOP).
expect([world.busy, world.tilesWalked]).toEqual([true, 0]);
world.tick();
expect(world.busy).toBe(false);
expect(world.tilesWalked).toBe(1);
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
},
);
@@ -134,17 +66,15 @@ describe("native ledge movement", () => {
expect(world.tilesWalked).toBe(2);
});
it("refuses the wrong direction, blocked or unsupported landings and map-edge jumps", () => {
it("refuses the wrong direction, blocked landings and map-edge jumps", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0xa3);
assets.tile(3, 2, 7);
expect(world.move("up")).toBe(false);
assets.tile(3, 4, 7);
for (const landing of [7, 0x30, 0x40, 0x60]) {
assets.tile(3, 5, landing);
expect(world.move("down")).toBe(false);
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 3, 0]);
}
assets.tile(3, 5, 7);
expect(world.move("down")).toBe(false);
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 3, 0]);
assets.tile(3, 6, 0xa3);
assets.tile(3, 7, 7);
world.restore({ ...world.save(), y: 6 });
@@ -262,6 +192,9 @@ describe("native warp movement", () => {
2,
"down",
]);
// WarpToNewMapScript's `end` takes the first tick in the new map.
world.tick("up");
expect([world.x, world.y, world.busy]).toEqual([2, 2, false]);
world.tick("up"); // Forced exit overrides held input.
expect(world.poses().find((pose) => pose.id === 0)?.y).toBe(34);
const save = world.save();
@@ -294,7 +227,7 @@ describe("native warp movement", () => {
assets
.map("24:7")
.connections.push({ direction: "right", targetMap: "24:6", x: 0, y: 0 });
world.restore({ ...world.save(), x: 7 });
world.restore({ ...world.save(), x: 7, direction: "right" });
world.tick("right");
const save = world.save();
expect(save).toMatchObject({ map: "24:7", x: 7, y: 3, tilesWalked: 0 });