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