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,366 @@
|
||||
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");
|
||||
}
|
||||
|
||||
describe("native ledge movement", () => {
|
||||
it.each<[number, Direction]>([
|
||||
[0xa0, "right"],
|
||||
[0xa1, "left"],
|
||||
[0xa2, "up"],
|
||||
[0xa3, "down"],
|
||||
[0xa4, "right"],
|
||||
[0xa4, "down"],
|
||||
[0xa5, "down"],
|
||||
[0xa5, "left"],
|
||||
[0xa6, "up"],
|
||||
[0xa6, "right"],
|
||||
[0xa7, "up"],
|
||||
[0xa7, "left"],
|
||||
])(
|
||||
"jumps from ledge %i toward %s over two tiles in sixteen ticks",
|
||||
(tile, direction) => {
|
||||
const { assets, world } = fixture();
|
||||
const [dx, dy] = VECTORS[direction];
|
||||
assets.tile(3, 3, tile);
|
||||
assets.tile(3 + dx, 3 + dy, 7);
|
||||
world.tick(direction);
|
||||
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
|
||||
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
|
||||
x: 48 + dx * 2,
|
||||
y: 48 + dy * 2,
|
||||
lift: -6,
|
||||
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);
|
||||
world.tick();
|
||||
expect(world.busy).toBe(false);
|
||||
expect(world.tilesWalked).toBe(1);
|
||||
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("walks onto ledges and prefers a normal step when the next tile is clear", () => {
|
||||
const { assets, world } = fixture();
|
||||
assets.tile(3, 4, 0xa3);
|
||||
expect(world.move("down")).toBe(true);
|
||||
world.tick("down");
|
||||
expect([world.x, world.y]).toEqual([3, 5]);
|
||||
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
|
||||
settle(world);
|
||||
expect(world.tilesWalked).toBe(2);
|
||||
});
|
||||
|
||||
it("refuses the wrong direction, blocked or unsupported 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, 6, 0xa3);
|
||||
assets.tile(3, 7, 7);
|
||||
world.restore({ ...world.save(), y: 6 });
|
||||
expect(world.move("down")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not land on an occupied tile", () => {
|
||||
const { assets, world } = fixture();
|
||||
assets.tile(3, 3, 0xa3);
|
||||
assets.tile(3, 4, 7);
|
||||
assets.map("24:7").objects.push({
|
||||
id: 2,
|
||||
x: 3,
|
||||
y: 5,
|
||||
sprite: 1,
|
||||
movement: 6,
|
||||
palette: 0,
|
||||
kind: 0,
|
||||
script: 0,
|
||||
eventFlag: 0xffff,
|
||||
hour1: 0xff,
|
||||
hour2: 0xff,
|
||||
});
|
||||
world.restore(world.save());
|
||||
expect(world.move("down")).toBe(false);
|
||||
expect(world.y).toBe(3);
|
||||
});
|
||||
|
||||
it("replays an interrupted jump and its landing scene without skipping event writes", () => {
|
||||
const { assets, world } = fixture();
|
||||
assets.tile(3, 3, 0xa3);
|
||||
assets.tile(3, 4, 7);
|
||||
// pause 4; setevent 8; end
|
||||
assets.bytes.set([0x8b, 4, 0x33, 8, 0, 0x91], 0x100);
|
||||
assets
|
||||
.map("24:7")
|
||||
.coordinateEvents.push({ x: 3, y: 5, scene: 0xff, script: 0x100 });
|
||||
world.tick("down");
|
||||
const beforeLanding = world.save();
|
||||
const restored = new GameWorld(assets);
|
||||
restored.restore(beforeLanding);
|
||||
expect(restored.move("down")).toBe(true);
|
||||
expect(restored.activity).toBe("scene");
|
||||
expect(restored.save()).toEqual(beforeLanding);
|
||||
expect(restored.state.events.has(8)).toBe(false);
|
||||
settle(restored);
|
||||
expect(restored.state.events.has(8)).toBe(true);
|
||||
expect(restored.save()).toMatchObject({ x: 3, y: 5, tilesWalked: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("native warp movement", () => {
|
||||
it.each<[number, Direction]>([
|
||||
[0x70, "down"],
|
||||
[0x76, "left"],
|
||||
[0x78, "up"],
|
||||
[0x7e, "right"],
|
||||
])("carpet %i waits for a blocked step toward %s", (tile, direction) => {
|
||||
const { assets, world } = fixture();
|
||||
connectWarp(assets);
|
||||
assets.tile(3, 4, tile);
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(world.mapId).toBe("24:7");
|
||||
settle(world);
|
||||
expect(world.mapId).toBe("24:7");
|
||||
const [dx, dy] = VECTORS[direction];
|
||||
assets.tile(3 + dx, 4 + dy, 7);
|
||||
expect(world.move(direction)).toBe(true);
|
||||
expect(world.mapId).toBe("24:6");
|
||||
expect([world.x, world.y, world.direction, world.tilesWalked]).toEqual([
|
||||
2,
|
||||
2,
|
||||
direction,
|
||||
1,
|
||||
]);
|
||||
settle(world);
|
||||
// Arrival on a mat neither teleports inward nor warps back.
|
||||
expect([world.x, world.y]).toEqual([2, 2]);
|
||||
});
|
||||
|
||||
it("lets the player walk away from a mat and refuses the wrong exit direction", () => {
|
||||
const { assets, world } = fixture();
|
||||
connectWarp(assets);
|
||||
assets.tile(3, 4, 0x70);
|
||||
world.move("down");
|
||||
assets.tile(2, 4, 7);
|
||||
expect(world.move("left")).toBe(false);
|
||||
expect(world.move("up")).toBe(true);
|
||||
expect(world.mapId).toBe("24:7");
|
||||
expect(world.y).toBe(3);
|
||||
});
|
||||
|
||||
it("uses carpet warps on the boundary without moving outside the map", () => {
|
||||
const { assets, world } = fixture();
|
||||
connectWarp(assets, 3, 7);
|
||||
assets.tile(3, 7, 0x70);
|
||||
world.restore({ ...world.save(), y: 7 });
|
||||
world.tick("down");
|
||||
expect(world.mapId).toBe("24:6");
|
||||
expect(world.tilesWalked).toBe(0);
|
||||
expect(() => world.restore(world.save())).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([0x71, 0x79, 0x7a, 0x7b])(
|
||||
"walks down off arrival tile %i over eight ticks without input",
|
||||
(tile) => {
|
||||
const { assets, world } = fixture();
|
||||
connectWarp(assets);
|
||||
assets.tile(3, 4, 0x72);
|
||||
assets.tile(2, 2, tile, "24:6");
|
||||
world.move("down");
|
||||
expect([world.mapId, world.x, world.y, world.direction]).toEqual([
|
||||
"24:6",
|
||||
2,
|
||||
2,
|
||||
"down",
|
||||
]);
|
||||
world.tick("up"); // Forced exit overrides held input.
|
||||
expect(world.poses().find((pose) => pose.id === 0)?.y).toBe(34);
|
||||
const save = world.save();
|
||||
expect(save).toMatchObject({ x: 2, y: 2, tilesWalked: 1 });
|
||||
world.restore(save);
|
||||
settle(world);
|
||||
expect(world.save()).toMatchObject({ x: 2, y: 3, tilesWalked: 2 });
|
||||
},
|
||||
);
|
||||
|
||||
it("saves before a doorway until the walking animation and warp complete", () => {
|
||||
const { assets, world } = fixture();
|
||||
connectWarp(assets);
|
||||
assets.tile(3, 4, 0x71);
|
||||
world.tick("down");
|
||||
const save = world.save();
|
||||
expect(save).toMatchObject({ map: "24:7", x: 3, y: 3, tilesWalked: 0 });
|
||||
world.restore(save);
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(world.save()).toMatchObject({
|
||||
map: "24:6",
|
||||
x: 2,
|
||||
y: 2,
|
||||
tilesWalked: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves on the source map during a connection crossing", () => {
|
||||
const { assets, world } = fixture();
|
||||
assets
|
||||
.map("24:7")
|
||||
.connections.push({ direction: "right", targetMap: "24:6", x: 0, y: 0 });
|
||||
world.restore({ ...world.save(), x: 7 });
|
||||
world.tick("right");
|
||||
const save = world.save();
|
||||
expect(save).toMatchObject({ map: "24:7", x: 7, y: 3, tilesWalked: 0 });
|
||||
expect(() => world.restore(save)).not.toThrow();
|
||||
expect(world.move("right")).toBe(true);
|
||||
expect(world.save()).toMatchObject({
|
||||
map: "24:6",
|
||||
x: 0,
|
||||
y: 3,
|
||||
tilesWalked: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("restores early version-3 backups without money but rejects corrupt balances", () => {
|
||||
const { world } = fixture();
|
||||
const { money, ...earlySave } = world.save();
|
||||
world.state.money = 42;
|
||||
world.restore(earlySave);
|
||||
expect(world.state.money).toBe(money);
|
||||
for (const invalid of [null, -1, 1.5, 1_000_000, "3000"]) {
|
||||
expect(() => world.restore({ ...earlySave, money: invalid })).toThrow(
|
||||
"Invalid native save",
|
||||
);
|
||||
expect(world.state.money).toBe(money);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses unavailable destinations and bad callbacks before entering a doorway", () => {
|
||||
const { assets, world } = fixture();
|
||||
connectWarp(assets);
|
||||
assets.tile(3, 4, 0x71);
|
||||
const warp = world.map.warps[0];
|
||||
for (const targetWarp of [0, 255, 2]) {
|
||||
warp.targetWarp = targetWarp;
|
||||
expect(world.move("down")).toBe(false);
|
||||
expect(world.save()).toMatchObject({
|
||||
map: "24:7",
|
||||
x: 3,
|
||||
y: 3,
|
||||
tilesWalked: 0,
|
||||
});
|
||||
}
|
||||
warp.targetWarp = 1;
|
||||
warp.targetMap = "1:1";
|
||||
expect(world.move("down")).toBe(false);
|
||||
warp.targetMap = "24:6";
|
||||
assets.bytes[0x100] = 0xff;
|
||||
assets.map("24:6").callbacks.push({ kind: 5, script: 0x100 });
|
||||
expect(world.move("down")).toBe(false);
|
||||
expect(world.notice).toContain("progress is unchanged");
|
||||
expect(world.save()).toMatchObject({
|
||||
map: "24:7",
|
||||
x: 3,
|
||||
y: 3,
|
||||
tilesWalked: 0,
|
||||
});
|
||||
// A directional carpet can be entered, but its failed exit is also atomic.
|
||||
assets.tile(3, 4, 0x70);
|
||||
assets.tile(3, 5, 7);
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(world.move("down")).toBe(false);
|
||||
expect(world.save()).toMatchObject({
|
||||
map: "24:7",
|
||||
x: 3,
|
||||
y: 4,
|
||||
tilesWalked: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user