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

+224 -37
View File
@@ -1,3 +1,5 @@
import { drawTownMap } from "./game/rendering/town-map";
import { OW } from "./game/world/constants";
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
import { bindTabs } from "./ui/tabs";
@@ -46,6 +48,10 @@ function showWorld(world: GameWorld) {
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
get("game-notice").textContent = world.notice;
showBattle(world);
showFieldMenu(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
const dialogue = world.dialogue;
showPicture(world);
// A page without text is a button wait while a picture is shown.
@@ -53,7 +59,8 @@ function showWorld(world: GameWorld) {
dialogue && !dialogue.choice && dialogue.pages[dialogue.index] === "",
);
const mart = dialogue?.choice && martChoice(dialogue.choice);
get("native-dialogue").hidden = !dialogue || pictureWait || Boolean(mart);
get("native-dialogue").hidden =
!dialogue || pictureWait || Boolean(mart) || world.townMap;
get("pokepic-next").hidden = !pictureWait;
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
get("dialogue-page").textContent =
@@ -131,6 +138,27 @@ function showChoice(choice: Choice | undefined) {
container.replaceChildren(
...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
);
else if (choice.kind === "menu") {
// A vertical menu such as the elevator's floors; B cancels.
const list = document.createElement("div");
list.className = "dialogue-menu";
list.setAttribute("role", "listbox");
list.replaceChildren(
...choice.items.map((item, index) => {
const option = button(item, choice.selected === index, () =>
runtime.choose(index),
);
option.setAttribute("role", "option");
option.setAttribute("aria-label", item);
option.setAttribute("aria-selected", String(choice.selected === index));
return option;
}),
);
container.replaceChildren(
list,
button("CANCEL", undefined, () => runtime.choose(-1)),
);
}
}
/** The naming screen as a text field and OK: an empty name keeps the
* current one. */
@@ -272,7 +300,11 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
);
menu.replaceChildren(
stepper,
button(mart.mode === "sell" ? "SELL" : "BUY", () => runtime.choose(mart.selected), { wide: true }),
button(
mart.mode === "sell" ? "SELL" : "BUY",
() => runtime.choose(mart.selected),
{ wide: true },
),
button("CANCEL", () => runtime.choose(-1), { wide: true }),
);
}
@@ -286,12 +318,28 @@ function statusName(status: number) {
if (status & 7) return "SLP";
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
}
function drawPic(target: HTMLCanvasElement, tiles: Uint8Array, size: number, palette: Palette, flip = false) {
function drawPic(
target: HTMLCanvasElement,
tiles: Uint8Array,
size: number,
palette: Palette,
flip = false,
) {
const context = target.getContext("2d")!;
const pixels = size * 8;
const image = context.createImageData(pixels, pixels);
for (let tile = 0; tile < size * size; tile++)
drawTile(image.data, pixels, pixels, tiles, tile, Math.floor(tile / size) * 8, (tile % size) * 8, palette, { flipX: flip });
drawTile(
image.data,
pixels,
pixels,
tiles,
tile,
Math.floor(tile / size) * 8,
(tile % size) * 8,
palette,
{ flipX: flip },
);
context.putImageData(image, 0, 0);
}
const battlePics = { foe: "", player: "" };
@@ -302,8 +350,67 @@ let showPack = false;
let battleCursor = 0;
let battleSelectionKey = "";
let fieldCursor = 0;
function fieldButtons() {
return [
...get("field-items").querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)",
),
];
}
function showFieldMenu(world: GameWorld) {
const panel = get("field-menu");
panel.hidden = !runtime.fieldMenuOpen;
if (panel.hidden) return;
const buttons = world.state.bag.keyItems.map((item) => {
const button = document.createElement("button");
button.textContent = world.assets.pokemon.itemName(item);
button.disabled = item !== OW.BICYCLE;
button.onclick = () => {
if (session.state !== "playing") return;
runtime.useBicycle();
canvas.focus({ preventScroll: true });
};
return button;
});
const close = document.createElement("button");
close.textContent = "CLOSE";
close.onclick = () => {
runtime.fieldMenuOpen = false;
showFieldMenu(world);
canvas.focus();
};
get("field-items").replaceChildren(...buttons, close);
fieldButtons().forEach((button, i) =>
button.classList.toggle("battle-selected", i === fieldCursor),
);
}
function handleFieldInput(button: Button): boolean {
const world = runtime.world;
if (!world || world.battle) return false;
if ((button === "start" || button === "select") && !world.busy) {
runtime.fieldMenuOpen = !runtime.fieldMenuOpen;
fieldCursor = 0;
return true;
}
if (!runtime.fieldMenuOpen) return false;
if (button === "b") runtime.fieldMenuOpen = false;
else if (button === "a") fieldButtons()[fieldCursor]?.click();
else if (["up", "down", "left", "right"].includes(button)) {
const length = fieldButtons().length;
fieldCursor =
(fieldCursor + (button === "up" || button === "left" ? -1 : 1) + length) %
length;
}
return true;
}
function battleButtons() {
return [...get("battle-menu").querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
return [
...get("battle-menu").querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)",
),
];
}
function highlightBattleChoice() {
battleButtons().forEach((button, index) => {
@@ -312,6 +419,7 @@ function highlightBattleChoice() {
}
/** Keyboard and on-screen D-pad share the same menu buttons as pointer input. */
function handleBattleInput(button: Button): boolean {
if (handleFieldInput(button)) return true;
const world = runtime.world;
if (!world?.battle) return false;
if (button === "b" && (showMoves || showPack)) {
@@ -354,7 +462,12 @@ function showBattle(world: GameWorld) {
const key = `${foe.species}:${foe.dvs}`;
if (battlePics.foe !== key) {
battlePics.foe = key;
drawPic(get("battle-foe-pic"), data.frontPic(foe.species), 7, data.palette(foe.species, PokemonData.shiny(foe.dvs)));
drawPic(
get("battle-foe-pic"),
data.frontPic(foe.species),
7,
data.palette(foe.species, PokemonData.shiny(foe.dvs)),
);
get("battle-foe-pic").setAttribute("aria-label", foe.nickname);
}
get("battle-foe-name").textContent = foe.nickname;
@@ -367,7 +480,12 @@ function showBattle(world: GameWorld) {
const key = `${own.species}:${own.dvs}`;
if (battlePics.player !== key) {
battlePics.player = key;
drawPic(get("battle-player-pic"), data.backPic(own.species), 6, data.palette(own.species, PokemonData.shiny(own.dvs)));
drawPic(
get("battle-player-pic"),
data.backPic(own.species),
6,
data.palette(own.species, PokemonData.shiny(own.dvs)),
);
get("battle-player-pic").setAttribute("aria-label", own.nickname);
}
get("battle-player-name").textContent = own.nickname;
@@ -387,11 +505,28 @@ function showBattle(world: GameWorld) {
}
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
const balls = world.liveState.bag.balls;
const key = JSON.stringify([prompt, view.selected, showMoves, showPack, own?.pp, balls, view.message?.prompt]);
const key = JSON.stringify([
prompt,
view.selected,
showMoves,
showPack,
own?.pp,
balls,
view.message?.prompt,
]);
if (key === battleMenuKey) return;
battleMenuKey = key;
const menu = get("battle-menu");
const button = (label: string, action: () => void, options: { detail?: string; disabled?: string; wide?: boolean; pressed?: boolean } = {}) => {
const button = (
label: string,
action: () => void,
options: {
detail?: string;
disabled?: string;
wide?: boolean;
pressed?: boolean;
} = {},
) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
@@ -405,7 +540,8 @@ function showBattle(world: GameWorld) {
element.title = options.disabled;
}
if (options.wide) element.classList.add("wide");
if (options.pressed !== undefined) element.setAttribute("aria-pressed", String(options.pressed));
if (options.pressed !== undefined)
element.setAttribute("aria-pressed", String(options.pressed));
element.addEventListener("click", () => {
if (session.state !== "playing") return;
action();
@@ -419,38 +555,64 @@ function showBattle(world: GameWorld) {
own.moves.forEach((move, index) => {
if (!move) return;
buttons.push(
button(data.moveName(move), () => {
showMoves = false;
runtime.battleAction({ action: "fight", move: index });
}, { detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` }),
button(
data.moveName(move),
() => {
showMoves = false;
runtime.battleAction({ action: "fight", move: index });
},
{ detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` },
),
);
});
buttons.push(button("BACK", () => {
showMoves = false;
showWorld(world);
}, { wide: true }));
buttons.push(
button(
"BACK",
() => {
showMoves = false;
showWorld(world);
},
{ wide: true },
),
);
} else if (showPack) {
balls.forEach(({ item, quantity }, slot) =>
buttons.push(
button(data.itemName(item), () => {
showPack = false;
runtime.battleAction({ action: "item", item, slot });
}, { detail: `×${quantity}` }),
button(
data.itemName(item),
() => {
showPack = false;
runtime.battleAction({ action: "item", item, slot });
},
{ detail: `×${quantity}` },
),
),
);
if (!balls.length)
buttons.push(button("No BALLS", () => {}, { disabled: "The BALL pocket is empty." }));
buttons.push(button("BACK", () => {
showPack = false;
showWorld(world);
}, { wide: true }));
buttons.push(
button("No BALLS", () => {}, {
disabled: "The BALL pocket is empty.",
}),
);
buttons.push(
button(
"BACK",
() => {
showPack = false;
showWorld(world);
},
{ wide: true },
),
);
} else {
buttons.push(
button("FIGHT", () => {
showMoves = true;
showWorld(world);
}),
button("PKMN", () => {}, { disabled: "Switching Pokémon is not ported yet." }),
button("PKMN", () => {}, {
disabled: "Switching Pokémon is not ported yet.",
}),
button("PACK", () => {
showPack = true;
showWorld(world);
@@ -460,29 +622,48 @@ function showBattle(world: GameWorld) {
}
} else if (prompt?.kind === "yesno") {
buttons.push(
button("YES", () => runtime.battleChoose(1), { pressed: view.selected === 1 }),
button("NO", () => runtime.battleChoose(0), { pressed: view.selected === 0 }),
button("YES", () => runtime.battleChoose(1), {
pressed: view.selected === 1,
}),
button("NO", () => runtime.battleChoose(0), {
pressed: view.selected === 0,
}),
);
} else if (prompt?.kind === "forget") {
prompt.moves.forEach((move, index) =>
buttons.push(button(data.moveName(move), () => runtime.battleChoose(index))),
buttons.push(
button(data.moveName(move), () => runtime.battleChoose(index)),
),
);
buttons.push(
button("CANCEL", () => runtime.battleChoose(4), { wide: true }),
);
buttons.push(button("CANCEL", () => runtime.battleChoose(4), { wide: true }));
} else if (prompt?.kind === "name") {
menu.replaceChildren(
...nameField(prompt.current, prompt.max, (name) => runtime.battleName(name)),
...nameField(prompt.current, prompt.max, (name) =>
runtime.battleName(name),
),
);
return;
} else if (prompt?.kind === "stats") {
prompt.labels.forEach((label, index) =>
buttons.push(button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " })),
buttons.push(
button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " }),
),
);
buttons.push(
button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }),
);
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
} else if (view.message?.prompt) {
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
buttons.push(
button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }),
);
}
menu.replaceChildren(...buttons);
battleCursor = Math.min(battleCursor, Math.max(0, battleButtons().length - 1));
battleCursor = Math.min(
battleCursor,
Math.max(0, battleButtons().length - 1),
);
highlightBattleChoice();
}
function setHp(bar: HTMLElement, hp: number, max: number) {
@@ -768,3 +949,9 @@ setInterval(() => {
if (ready() && !busy) void save().catch(() => {});
}, 10_000);
update();
get("town-map-close").onclick = () => {
if (session.state !== "playing") return;
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
};