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

+400
View File
@@ -0,0 +1,400 @@
import { describe, expect, it } from "vitest";
import { WALK_TICKS } from "../src/game/world/world";
import { OW } from "../src/game/world/constants";
import { SYMBOLS } from "../src/game/rom/profile";
import type { Direction } from "../src/game/world/types";
import { fixture } from "./movement-fixture";
import { settle } from "./world-driver";
const pose = (world: ReturnType<typeof fixture>["world"]) =>
world.poses().find((candidate) => candidate.id === 0)!;
/** Hold a direction for `ticks` ticks and record the tick numbers on which
* the player's tile changed. */
function hold(
world: ReturnType<typeof fixture>["world"],
direction: Direction | undefined,
ticks: number,
) {
const moved: number[] = [];
for (let tick = 1; tick <= ticks; tick++) {
const at = `${world.x},${world.y}`;
world.tick(direction);
if (`${world.x},${world.y}` !== at) moved.push(tick);
}
return moved;
}
describe("turning in place (DoPlayerMovement .CheckTurning)", () => {
it("turns on the next tick and walks on the fourth tick after a turn", () => {
const { world } = fixture();
expect(world.direction).toBe("down");
world.tick("right");
// StepFunction_Turn shows the new facing a tick later.
expect([world.x, world.direction, world.busy]).toEqual([3, "down", true]);
world.tick("right");
expect(world.direction).toBe("right");
expect(pose(world).frame).toBe(1);
// ChangeDirectionScript's deactivatefacing 3 holds input until then.
expect(hold(world, "right", 2)).toEqual([]);
expect(hold(world, "right", 1)).toEqual([1]);
// Continuous walking needs no further turns.
expect(hold(world, "right", WALK_TICKS * 2)).toEqual([WALK_TICKS, WALK_TICKS * 2]);
});
it("turns without walking when the direction is only tapped", () => {
const { world } = fixture();
world.tick("left");
expect(hold(world, undefined, 8)).toEqual([]);
expect([world.x, world.y, world.direction, world.busy]).toEqual([3, 3, "left", false]);
// Facing that way now, the next press walks at once.
expect(hold(world, "left", 1)).toEqual([1]);
});
it("changes direction without turning while walking, and turns again after stopping", () => {
const { world } = fixture();
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
// Still walking: the new direction steps on the next decision.
expect(hold(world, "right", 1)).toEqual([1]);
expect(world.direction).toBe("right");
hold(world, undefined, WALK_TICKS + 1);
expect(hold(world, "up", 4)).toEqual([]);
expect(world.direction).toBe("up");
});
it("bumps into walls in place at half animation speed and must turn after a bump", () => {
const { assets, world } = fixture();
assets.tile(3, 4, 7);
const frames: number[] = [];
for (let tick = 0; tick < 16; tick++) {
world.tick("down");
frames.push(pose(world).frame);
}
expect([world.x, world.y]).toEqual([3, 3]);
// SetFacingBumpAction: OBJECT_STEP_FRAME >> 3.
expect(frames).toEqual([0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2]);
// The bump cleared wPlayerTurningDirection, so a new direction turns first.
expect(hold(world, "left", 4)).toEqual([]);
expect(hold(world, "left", 1)).toEqual([1]);
});
it("walking into a wall in a new direction bumps without facing it, then turns", () => {
const { assets, world } = fixture();
assets.tile(4, 4, 7);
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
// Mid-walk turning is not needed, but the blocked step does not turn.
world.tick("right");
expect([world.x, world.y, world.direction]).toEqual([3, 4, "down"]);
world.tick("right");
world.tick("right");
expect(world.direction).toBe("right");
});
it("does not turn toward the direction the player already faces", () => {
const { world } = fixture();
expect(hold(world, "down", 1)).toEqual([1]);
});
});
describe("forced terrain (DoPlayerMovement .CheckTile)", () => {
it.each<[number, Direction | undefined]>([
[0x40, undefined],
[0x41, "right"],
[0x42, "left"],
[0x43, "up"],
[0x44, "down"],
[0x45, undefined],
[0x50, "right"],
[0x51, "left"],
[0x52, "up"],
[0x53, "down"],
[0x54, undefined],
])("walk tile %i moves the player %s without input", (tile, direction) => {
const { assets, world } = fixture();
assets.tile(3, 3, tile);
world.tick();
if (!direction) {
expect([world.x, world.y, world.busy]).toEqual([3, 3, false]);
return;
}
const vectors = { right: [4, 3], left: [2, 3], up: [3, 2], down: [3, 4] };
expect([world.x, world.y, world.direction]).toEqual([...vectors[direction], direction]);
expect(pose(world).frame).toBe(0);
});
it("forced steps ignore held input and walls, like .continue_walk", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0x41);
assets.tile(4, 3, 7);
world.tick("left");
expect([world.x, world.y]).toEqual([4, 3]);
});
it.each<[number, Direction]>([
[0x30, "right"],
[0x31, "left"],
[0x32, "up"],
[0x33, "down"],
[0x38, "right"],
[0x3b, "down"],
])("currents and waterfalls (%i) carry a surfer %s", (tile, direction) => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
assets.tile(3, 3, tile);
world.tick();
expect(world.direction).toBe(direction);
expect(world.activity).toBe("walking");
});
});
describe("ice (STEP_ICE and .CheckForced)", () => {
it("slides at four ticks per tile until something blocks the slide", () => {
const { assets, world } = fixture();
for (let x = 3; x <= 6; x++) assets.tile(x, 3, OW.COLL_ICE);
assets.tile(7, 3, 7);
world.restore({ ...world.save(), direction: "right" });
// Stepping from floor onto ice is still a walk; here we start on ice.
const moved = hold(world, "right", 1);
expect(moved).toEqual([1]);
expect(pose(world).frame).toBe(0);
// Input no longer matters while sliding.
expect(hold(world, "up", 16)).toEqual([4, 8]);
expect([world.x, world.y]).toEqual([6, 3]);
// The wall stopped the slide with a bump, so the player turned up during
// the last hold; still on ice, the step off it is another four-tick slide.
expect(world.direction).toBe("up");
expect(hold(world, "up", 5)).toEqual([1, 5]);
});
it("does not allow button actions while sliding", () => {
const { assets, world } = fixture();
for (let x = 3; x <= 6; x++) assets.tile(x, 3, OW.COLL_ICE);
world.restore({ ...world.save(), direction: "right" });
hold(world, "right", 4);
world.interact();
expect(world.notice).toBe("");
});
it("walks normally from floor onto ice", () => {
const { assets, world } = fixture();
assets.tile(3, 4, OW.COLL_ICE);
const moved = hold(world, "down", WALK_TICKS);
expect(moved).toEqual([1]);
expect(pose(world).frame).not.toBe(undefined);
});
});
describe("bikes, surfing and side buoys", () => {
it("rides a bike at four ticks per tile", () => {
const { world } = fixture();
world.state.playerState = OW.PLAYER_BIKE;
expect(hold(world, "down", 8)).toEqual([1, 5]);
});
it("rides downhill without input and slowly uphill", () => {
const { world } = fixture();
world.state.playerState = OW.PLAYER_BIKE;
world.state.flags.add(OW.ENGINE_DOWNHILL);
world.restore({ ...world.save(), y: 1 });
world.state.playerState = OW.PLAYER_BIKE;
world.state.flags.add(OW.ENGINE_DOWNHILL);
expect(hold(world, undefined, 8)).toEqual([1, 5]);
// Still moving, so no turn: uphill against the slope is a walk.
expect(hold(world, "up", 16)).toEqual([1, 9]);
});
it("surfs over water, refuses walls and gets out onto land", () => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) assets.tile(x, y, 0x29);
assets.tile(3, 5, 0x27);
assets.tile(4, 4, 0);
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
expect(world.state.playerState).toBe(OW.PLAYER_SURF);
expect(hold(world, "down", 4)).toEqual([]);
world.tick("right");
world.tick("right");
world.tick("right");
world.tick("right");
expect(hold(world, "right", 1)).toEqual([1]);
expect(world.state.playerState).toBe(OW.PLAYER_NORMAL);
});
it("buoys block surfing like side walls", () => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) assets.tile(x, y, 0x29);
// COLL_UP_BUOY below the player blocks a downward move into it.
assets.tile(3, 4, 0xc2);
expect(hold(world, "down", 4)).toEqual([]);
assets.tile(3, 4, 0xc3);
expect(hold(world, "down", 1)).toEqual([1]);
});
it("walking cannot enter water", () => {
const { assets, world } = fixture();
assets.tile(3, 4, 0x29);
expect(hold(world, "down", 4)).toEqual([]);
});
});
describe("dynamic warps (-1)", () => {
it("remembers the entrance on arrival and returns through a -1 warp", () => {
const { assets, world } = fixture();
// 24:7's warp 1 at (3,4) leads to 24:6 warp 1, a -1 warp back.
assets.map("24:7").warps.push({ x: 3, y: 4, targetMap: "24:6", targetWarp: 1 });
assets.map("24:7").warps.push({ x: 5, y: 4, targetMap: "24:6", targetWarp: 1 });
assets.map("24:6").warps.push({ x: 2, y: 2, targetMap: "24:6", targetWarp: 0xff });
assets.tile(3, 4, 0x72);
assets.tile(5, 4, 0x72);
assets.tile(2, 3, 0x72, "24:6");
assets.map("24:6").warps.push({ x: 2, y: 3, targetMap: "24:6", targetWarp: 0xff });
world.restore({ ...world.save(), x: 5 });
expect(world.move("down")).toBe(true);
expect([world.mapId, world.x, world.y]).toEqual(["24:6", 2, 2]);
expect(world.state.backupWarp).toEqual({ map: "24:7", warp: 2 });
const saved = world.save();
expect(saved.backupWarp).toEqual({ map: "24:7", warp: 2 });
world.restore(saved);
expect(world.move("down")).toBe(true);
expect([world.mapId, world.x, world.y]).toEqual(["24:7", 5, 4]);
});
it("refuses a -1 warp without a recorded destination", () => {
const { assets, world } = fixture();
assets.map("24:7").warps.push({ x: 3, y: 4, targetMap: "24:6", targetWarp: 0xff });
assets.tile(3, 4, 0x72);
expect(world.move("down")).toBe(false);
expect(world.notice).toMatch(/not ported/);
});
it("rejects malformed backup warps in saves", () => {
const { world } = fixture();
for (const backupWarp of [{ map: "x", warp: 1 }, { map: "24:6", warp: 0 }, 5])
expect(() => world.restore({ ...world.save(), backupWarp })).toThrow("Invalid native save");
expect(() => world.restore({ ...world.save(), playerState: 3 })).toThrow("Invalid native save");
});
});
describe("Strength boulders", () => {
it("pushes a boulder one tile over sixteen ticks while the player bumps", () => {
const { assets, world } = fixture();
const type = 19; // SPRITEMOVEDATA_STRENGTH_BOULDER
const entry = SYMBOLS.spriteMovementData + type * 6;
// MovementFunction_Strength; WONT_DELETE | FIXED_FACING | SLIDING |
// MOVE_ANYWHERE; STRENGTH_BOULDER.
assets.bytes.set([0x11, 0, 1, 0x2e, 0, 0x40], entry);
assets.map("24:7").objects.push({
id: 2, x: 3, y: 4, sprite: 1, movement: type, radius: 0,
palette: 0, kind: 0, script: 0, eventFlag: 0xffff, hour1: 0xff, hour2: 0xff,
});
world.restore(world.save());
// Without Strength the boulder does not move.
hold(world, "down", 20);
expect(world.objects()[0]).toMatchObject({ x: 3, y: 4 });
world.state.flags.add(OW.ENGINE_STRENGTH_ACTIVE);
world.tick("down");
expect(world.objects()[0]).toMatchObject({ x: 3, y: 5 });
expect([world.x, world.y]).toEqual([3, 3]);
hold(world, undefined, 15);
expect(world.save().objects).toEqual([{ id: 2, x: 3, y: 5, direction: "down" }]);
// It stops at walls.
assets.tile(3, 7, 7);
hold(world, "down", 60);
expect(world.objects()[0]).toMatchObject({ x: 3, y: 6 });
});
});
describe("changeblock", () => {
const trigger = (script: number[]) => {
const { assets, world } = fixture();
assets.bytes.set(script, 0x100);
assets.map("24:7").coordinateEvents.push({ x: 3, y: 4, scene: 0xff, script: 0x100 });
return { assets, world };
};
it("changes the working block data until the map loads again", () => {
// changeblock 2, 2, $05; end
const { world } = trigger([0x7a, 2, 2, 5, 0x91]);
expect(world.move("down")).toBe(true);
settle(world);
expect(world.notice).toBe("");
expect(world.map.blocks[1 * 4 + 1]).toBe(5);
expect(world.blockRevision).toBe(1);
world.warpTo("24:7", 3, 3);
expect(world.map.blocks[1 * 4 + 1]).toBe(0);
});
it("restores the blocks when a script that changed them is refused", () => {
// changeblock 2, 2, $05; an unported command
const { world } = trigger([0x7a, 2, 2, 5, 0xff]);
world.move("down");
settle(world);
expect(world.notice).toMatch(/not been ported/);
expect(world.map.blocks[1 * 4 + 1]).toBe(0);
});
it("refuses blocks outside the map or beyond the tileset", () => {
for (const script of [[0x7a, 8, 2, 5, 0x91], [0x7a, 2, 2, 0x80, 0x91]]) {
const { world } = trigger(script);
world.move("down");
settle(world);
expect(world.notice).toMatch(/not been ported/);
}
});
});
describe("NPC movement functions", () => {
const withObject = (fn: number, action = 1, flags = 0, sprite = 1) => {
const { assets, world } = fixture();
const type = 30;
assets.bytes.set([fn, 0, action, flags, 0, 0], SYMBOLS.spriteMovementData + type * 6);
assets.map("24:7").objects.push({
id: 2, x: 5, y: 3, sprite, movement: type, radius: 0,
palette: 0, kind: 0, script: 0, eventFlag: 0xffff, hour1: 0xff, hour2: 0xff,
});
world.restore(world.save());
return { assets, world };
};
const facingOf = (world: ReturnType<typeof fixture>["world"]) =>
world.objects().find((object) => object.id === 2)!.direction;
it.each<[number, Direction[]]>([
[0x18, ["down", "left", "up", "right", "down"]],
[0x19, ["down", "right", "up", "left", "down"]],
])("SPRITEMOVEFN %i turns every sixteen ticks", (fn, expected) => {
const { world } = withObject(fn);
const seen: Direction[] = [];
for (let tick = 0; tick < 16 * 5; tick++) {
world.tick();
if (tick % 16 === 0) seen.push(facingOf(world));
}
expect(seen).toEqual(expected);
});
it("bounces between the down and up standing frames every eight ticks", () => {
const { world } = withObject(0x16, 0x0a, 0x2e);
const facings: (number | undefined)[] = [];
for (let tick = 0; tick < 17; tick++) {
world.tick();
facings.push(world.poses().find((pose) => pose.id === 2)?.facing);
}
expect(facings.slice(0, 7).every((facing) => facing === 0)).toBe(true);
expect(facings.slice(7, 15).every((facing) => facing === 4)).toBe(true);
expect(facings[15]).toBe(0);
});
it("draws big dolls with their own facing layouts", () => {
const { world } = withObject(0x15, 0x09);
world.tick();
expect(world.poses().find((pose) => pose.id === 2)?.facing).toBe(0x17);
});
it("resolves variable sprites from wVariableSprites and hides unset ones", () => {
const { world } = withObject(0x06, 1, 0, 0xf3);
expect(world.objects()).toHaveLength(0);
world.state.variables[3] = 0x2a;
world.restore(world.save());
expect(world.poses().find((pose) => pose.id === 2)?.sprite).toBe(0x2a);
});
});