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:
1 parent
9ac111f262
commit
451464f7c6
38 files changed
+5600
-490
No files matched your search
+25
-92
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user