Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 16:09:22 +02:00
1 parent 8b80d05ca2
commit c08d250cf6
39 files changed
+10727 -1524

No files matched your search

+409 -7
View File
@@ -1,12 +1,367 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { VECTORS } from "../src/game/world/types";
import { settle } from "./world-driver";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test("local maps follow a stairs transition and importing a previous position", async ({
page,
}) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
// Find a walkable route in the local fixture, then drive the actual UI.
const world = new GameWorld(
new CrystalAssets(new Uint8Array(readFileSync(romPath))),
);
const target = world.map.warps[0];
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
const seen = new Set<string>();
let path: Direction[] | undefined;
const directions = [
["up", 0, -1],
["down", 0, 1],
["left", -1, 0],
["right", 1, 0],
] as const;
for (let i = 0; i < queue.length; i++) {
const node = queue[i];
const key = `${node.x},${node.y}`;
if (seen.has(key)) continue;
seen.add(key);
if (node.x === target.x && node.y === target.y) {
path = node.path;
break;
}
for (const [direction, dx, dy] of directions) {
const x = node.x + dx,
y = node.y + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
continue;
if (
world.assets.permission(world.assets.collision(world.map, x, y)) !== 0
)
continue;
if (world.objects().some((object) => object.x === x && object.y === y))
continue;
queue.push({ x, y, path: [...node.path, direction] });
}
}
expect(path).toBeDefined();
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#tab-saves").click();
const downloaded = page.waitForEvent("download");
await page.locator("#backup").click();
const backup = await downloaded;
await page.locator("#play").click();
for (const direction of path!) {
const [dx, dy] = VECTORS[direction];
const stepX = world.x + dx,
stepY = world.y + dy;
expect(world.move(direction)).toBe(true);
settle(world);
await page.keyboard.down(
`Arrow${direction[0].toUpperCase()}${direction.slice(1)}`,
);
// Release as the step starts, before a warp and its forced exit complete.
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-x",
String(stepX),
);
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-y",
String(stepY),
);
await page.keyboard.up(
`Arrow${direction[0].toUpperCase()}${direction.slice(1)}`,
);
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-map",
world.mapId,
);
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-x",
String(world.x),
);
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-y",
String(world.y),
);
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-activity",
"idle",
);
}
await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:6");
await page.locator("#tab-map").click();
await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:6");
await expect(page.locator("#map-location")).toHaveText(
`${world.map.name} · ${world.map.location}`,
);
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles((await backup.path())!);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#tab-map").click();
await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:7");
await expect(page.locator("#world-map")).toHaveAttribute("data-x", "3");
await expect(page.locator("#world-map")).toHaveAttribute("data-y", "3");
});
test("Mom's opening scene runs with native keyboard and pointer choices", async ({
page,
}) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#play").click();
const screen = page.locator("#game-screen");
const step = async (key: string, map: string, x: number, y: number) => {
await page.keyboard.down(key);
await expect(screen).toHaveAttribute("data-map", map);
await expect(screen).toHaveAttribute("data-x", String(x));
await expect(screen).toHaveAttribute("data-y", String(y));
await page.keyboard.up(key);
};
// Bedroom (3,3) to the stairs, then down toward Mom.
await step("ArrowUp", "24:7", 3, 2);
for (const x of [4, 5, 6, 7]) await step("ArrowRight", "24:7", x, 2);
await step("ArrowUp", "24:7", 7, 1);
await step("ArrowUp", "24:7", 7, 0);
await expect(screen).toHaveAttribute("data-map", "24:6");
await expect(screen).toHaveAttribute("data-y", "1");
await expect(screen).toHaveAttribute("data-activity", "idle");
for (const y of [2, 3, 4]) await step("ArrowDown", "24:6", 9, y);
const dialogue = page.locator("#native-dialogue");
await expect(dialogue).toBeVisible();
const seen: string[] = [];
for (let guard = 0; guard < 60 && (await dialogue.isVisible()); guard++) {
const text = (await page.locator("#dialogue-text").textContent()) ?? "";
seen.push(text);
const choice = page.locator("#dialogue-choice");
if (await choice.isVisible()) {
if (await choice.locator("output").count()) {
// Weekday box: Up selects the next day, X confirms.
await expect(choice.locator("output")).toHaveText("SUNDAY");
await page.keyboard.press("ArrowUp");
await expect(choice.locator("output")).toHaveText("MONDAY");
await page.keyboard.press("KeyX");
} else {
await choice.getByRole("button", { name: "YES" }).click();
}
} else await page.keyboard.press("KeyX");
await expect
.poll(async () =>
(await dialogue.isVisible())
? await page.locator("#dialogue-text").textContent()
: "closed",
)
.not.toBe(text);
}
expect(seen).toContain("MONDAY, is it?");
expect(seen.some((text) => text.includes("received"))).toBe(true);
await expect(dialogue).toBeHidden();
// Mom walks back before the scene commits.
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(page.locator("#game-notice")).toHaveText("");
// The completed scene persists in the exported native save.
await page.locator("#tab-saves").click();
const download = page.waitForEvent("download");
await page.locator("#backup").click();
const record = JSON.parse(
readFileSync((await (await download).path())!, "utf8"),
);
const save = JSON.parse(Buffer.from(record.state, "base64").toString("utf8"));
expect(save).toMatchObject({
version: 3,
map: "24:6",
scenes: { "24:6": 1 },
phone: [1],
});
expect(save.clock.dst).toBe(true);
expect(errors).toEqual([]);
});
test("choosing a starter shows its picture, asks for a nickname and saves the party", async ({
page,
}, testInfo) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
// Start in Elm's lab after his introduction, facing Cyndaquil's ball.
const bytes = readFileSync(romPath);
const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes)));
const save = {
...world.save(),
map: "24:5",
x: 6,
y: 4,
direction: "up",
scenes: { "24:6": 1, "24:5": 1 },
};
const backup = testInfo.outputPath("lab.crystal-save.json");
writeFileSync(
backup,
JSON.stringify({
version: 1,
romHash: createHash("sha1").update(bytes).digest("hex"),
coreId: "crystal-native-world-v1",
savedAt: new Date().toISOString(),
state: Buffer.from(JSON.stringify(save)).toString("base64"),
}),
);
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(backup);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#play").click();
await page.keyboard.press("KeyX");
const picture = page.locator("#pokepic");
await expect(picture).toBeVisible();
await expect(page.locator("#pokepic-canvas")).toHaveAttribute(
"aria-label",
"CYNDAQUIL",
);
// The picture is drawn from ROM tiles, not left blank.
expect(
await page.locator("#pokepic-canvas").evaluate((element) => {
const canvas = element as HTMLCanvasElement;
const pixels = canvas.getContext("2d")!.getImageData(0, 0, 56, 56).data;
const colors = new Set<number>();
for (let i = 0; i < pixels.length; i += 4)
colors.add((pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2]);
return colors.size;
}),
).toBeGreaterThanOrEqual(3);
await page.locator("#pokepic-next").click();
await expect(picture).toBeHidden();
await expect(page.locator("#dialogue-text")).toContainText("CYNDAQUIL");
await page.getByRole("button", { name: "YES" }).click();
const nickname = page.locator("#dialogue-choice input");
for (let guard = 0; guard < 12 && !(await nickname.count()); guard++) {
const choice = page.getByRole("button", { name: "YES" });
if (await choice.count()) await choice.click();
else await page.keyboard.press("KeyX");
await page.waitForTimeout(100);
}
await expect(page.locator("#dialogue-text")).toHaveText(
"CYNDAQUIL'S\nNICKNAME?",
);
await expect(nickname).toBeFocused();
await nickname.fill("BLAZE");
await nickname.press("Enter");
const screen = page.locator("#game-screen");
for (let guard = 0; guard < 30; guard++) {
if ((await screen.getAttribute("data-activity")) === "idle") break;
if (await page.locator("#native-dialogue").isVisible())
await page.keyboard.press("KeyX");
await page.waitForTimeout(120);
}
await expect(screen).toHaveAttribute("data-activity", "idle");
await page.locator("#tab-saves").click();
const download = page.waitForEvent("download");
await page.locator("#backup").click();
const record = JSON.parse(
readFileSync((await (await download).path())!, "utf8"),
);
const saved = JSON.parse(
Buffer.from(record.state, "base64").toString("utf8"),
);
expect(saved.party).toHaveLength(1);
expect(saved.party[0]).toMatchObject({
species: 155,
nickname: "BLAZE",
level: 5,
});
expect(saved.scenes["24:5"]).toBe(5);
expect(errors).toEqual([]);
});
test("a home exit mat survives save/reload and waits for the exit direction", async ({
page,
}, testInfo) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
const bytes = readFileSync(romPath);
const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes)));
world.restore({
...world.save(),
map: "24:6",
x: 6,
y: 6,
scenes: { "24:6": 1 },
});
const backup = testInfo.outputPath("home.crystal-save.json");
writeFileSync(
backup,
JSON.stringify({
version: 1,
romHash: createHash("sha1").update(bytes).digest("hex"),
coreId: "crystal-native-world-v1",
savedAt: new Date().toISOString(),
state: Buffer.from(JSON.stringify(world.save())).toString("base64"),
}),
);
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(backup);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#play").click();
const screen = page.locator("#game-screen");
await page.keyboard.down("ArrowDown");
await expect(screen).toHaveAttribute("data-y", "7");
await page.keyboard.up("ArrowDown");
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(screen).toHaveAttribute("data-map", "24:6");
await page.locator("#tab-saves").click();
await page.locator("#save").click();
await expect(page.locator("#save-status")).toContainText("Session saved");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session");
await expect(screen).toHaveAttribute("data-map", "24:6");
await expect(screen).toHaveAttribute("data-y", "7");
await page.locator("#play").click();
// Walking back off the carpet is allowed and does not leave the house.
await page.keyboard.down("ArrowUp");
await expect(screen).toHaveAttribute("data-y", "6");
await page.keyboard.up("ArrowUp");
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(screen).toHaveAttribute("data-map", "24:6");
await page.keyboard.down("ArrowDown");
await expect(screen).toHaveAttribute("data-y", "7");
await page.keyboard.up("ArrowDown");
await expect(screen).toHaveAttribute("data-activity", "idle");
await page.keyboard.down("ArrowDown");
await page.waitForFunction(
() =>
document.querySelector("#game-screen")?.getAttribute("data-map") ===
"24:4",
);
await page.keyboard.up("ArrowDown");
await expect(screen).toHaveAttribute("data-x", "13");
await expect(screen).toHaveAttribute("data-y", "6");
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(page.locator("#game-notice")).toHaveText("");
});
test("development server does not serve ROMs or the private reference checkout", async ({
request,
}) => {
@@ -40,19 +395,30 @@ test("native player loads the actual ROM without loading an emulator and persist
"data-map",
"24:7",
);
await expect(page.locator("#minimap")).toBeVisible();
await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:7");
await page.locator("#play").click();
await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "3");
await page.keyboard.down("ArrowUp");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
await page.keyboard.up("ArrowUp");
await page.waitForTimeout(150);
await expect(page.locator("#minimap")).toHaveAttribute("data-y", "2");
// A is ignored mid-step, as in the original; wait for the step to end.
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-activity",
"idle",
);
await page.keyboard.press("KeyX");
// The radio's lines hold for `pause 45` each and advance by themselves.
await expect(page.locator("#native-dialogue")).toBeVisible();
await expect(page.locator("#dialogue-page")).toHaveText("1 / 4");
for (let i = 0; i < 4; i++) await page.locator("#dialogue-next").click();
await expect(page.locator("#native-dialogue")).toBeHidden();
await expect(page.locator("#dialogue-text")).toContainText("TALK!");
await expect(page.locator("#dialogue-next")).toBeHidden();
await expect(page.locator("#native-dialogue")).toBeHidden({
timeout: 10_000,
});
await page.locator("#play").click();
await expect(page.locator("#save-status")).toContainText("Session saved");
await page.locator("#tab-saves").click();
const exported = page.waitForEvent("download");
await page.locator("#backup").click();
const backup = await exported;
@@ -63,15 +429,30 @@ test("native player loads the actual ROM without loading an emulator and persist
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
await expect(page.locator("#minimap")).toHaveAttribute("data-y", "2");
await page.locator("#play").click();
await page.keyboard.press("KeyX");
await expect(page.locator("#dialogue-page")).toHaveText("1 / 1");
await page.locator("#dialogue-next").click();
await expect(page.locator("#dialogue-text")).toContainText("POKéMON");
await expect(page.locator("#native-dialogue")).toBeHidden({ timeout: 5_000 });
// Opening any menu pauses and releases game input, even while a key is held.
await page.keyboard.down("ArrowUp");
await page.locator("#tab-map").click();
await expect(page.locator("#power-label")).toHaveText("PAUSED");
await expect(page.locator("#world-map")).toHaveAttribute("data-y", "2");
await expect(page.locator("#map-empty")).toBeHidden();
await expect(page.locator('[data-game-button="up"]')).toHaveAttribute(
"aria-pressed",
"false",
);
await page.keyboard.up("ArrowUp");
await page.locator("#tab-game").click();
await expect(page.locator("#pause-overlay")).toBeVisible();
for (const [width, height] of [
[1440, 1000],
[1024, 900],
[390, 844],
[320, 740],
[320, 568],
[844, 390],
]) {
await page.setViewportSize({ width, height });
@@ -84,6 +465,27 @@ test("native player loads the actual ROM without loading an emulator and persist
if (width <= 390 || height <= 390) {
const box = (await page.locator(".console").boundingBox())!;
expect(box.y + box.height).toBeLessThanOrEqual(height);
const toolbar = (await page.locator(".global-bar").boundingBox())!;
expect(toolbar.y + toolbar.height).toBeLessThanOrEqual(height);
}
// Responsive native rendering draws a different area, not a stretched GB frame.
await expect
.poll(async () =>
page.locator("#game-screen").evaluate((el) => {
const canvas = el as HTMLCanvasElement;
const bounds = canvas.getBoundingClientRect();
return Math.abs(
canvas.width / canvas.height - bounds.width / bounds.height,
);
}),
)
.toBeLessThan(0.03);
if (width >= 1024) {
expect(
await page
.locator("#game-screen")
.evaluate((el) => (el as HTMLCanvasElement).width),
).toBeGreaterThan(160);
}
}
await page.setViewportSize({ width: 390, height: 844 });