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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user