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