Port the Pokégear, phone calls and Mom's bank
Checks / web (push) Canceled after 0s

POKéGEAR in the start menu runs the clock, Town Map and phone cards with
the original joypad rules. Calls to Elm and Mom run their callee scripts,
with the out-of-area and out-of-hours cases and HangUp's corrected timing.
Mom saves a quarter of trainer prizes once asked, talking to her at home
opens BankOfMom (GET, SAVE, CHANGE and the DST checks) with its digit
amount box, and MomTriesToBuySomething buys items and dolls with her
savings. Saves gain Mom's money, her next item and its trigger balance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-01 11:38:22 +02:00
1 parent 3456aa4e11
commit 209778cabe
29 files changed
+2726 -47

No files matched your search

+140 -1
View File
@@ -5,6 +5,7 @@ import { bindTabs } from "./ui/tabs";
import { NativeRuntime } from "./game/runtime";
import { LocalMapView } from "./game/rendering/minimap";
import type { Choice, GameWorld } from "./game/world/world";
import type { PokegearCard } from "./game/scripts/pokegear";
import { drawTile } from "./game/rendering/pixels";
import { PokemonData } from "./game/rom/pokemon";
import type { Direction, Palette } from "./game/world/types";
@@ -47,6 +48,7 @@ function showWorld(world: GameWorld) {
get("game-notice").textContent = world.notice;
showBattle(world);
showStatsScreen(world);
showPokegear(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
@@ -60,6 +62,8 @@ function showWorld(world: GameWorld) {
get("native-dialogue").hidden =
!dialogue ||
pictureWait ||
// The Pokégear's map and radio cards have no textbox.
(dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) ||
Boolean(mart) ||
world.townMap ||
Boolean(world.statsScreen);
@@ -113,7 +117,11 @@ function showChoice(choice: Choice | undefined) {
return element;
};
if (!choice) container.replaceChildren();
else if (choice.kind === "yesno")
else if (choice.kind === "pokegear") {
// The Pokégear reads the controls directly.
container.replaceChildren();
container.hidden = true;
} else if (choice.kind === "yesno")
container.replaceChildren(
button("YES", choice.selected === 1, () => runtime.choose(1)),
button("NO", choice.selected === 0, () => runtime.choose(0)),
@@ -156,6 +164,51 @@ function showChoice(choice: Choice | undefined) {
);
container.firstElementChild?.setAttribute("aria-label", "Earlier");
container.children[2]?.setAttribute("aria-label", "Later");
} else if (choice.kind === "mombank") {
// Mom_ContinueMenuSetup's box: SAVED, HELD and the amount, whose digit
// under the cursor blinks. The buttons send the joypad's own presses.
const { view } = choice;
const box = document.createElement("div");
box.className = "mom-bank";
const row = (label: string, value: HTMLElement | string) => {
const line = document.createElement("p"),
name = document.createElement("span"),
amount = document.createElement("output");
name.textContent = label;
amount.append(value);
line.append(name, amount);
return line;
};
const digits = document.createElement("span");
digits.setAttribute("aria-label", `¥${view.amount}`);
digits.append(
"¥",
...[...String(view.amount).padStart(6, "0")].map((digit, index) => {
const cell = document.createElement("span");
cell.textContent = digit;
if (index === view.cursor) cell.className = "mom-bank-cursor";
return cell;
}),
);
box.append(
row(view.labels[0], `¥${view.saved}`),
row(view.labels[1], `¥${view.held}`),
row(view.labels[2], digits),
);
const press = (label: string, aria: string, name: string) => {
const element = button(label, undefined, () => runtime.choose(name));
element.setAttribute("aria-label", aria);
return element;
};
container.replaceChildren(
box,
press("◀", "Previous digit", "left"),
press("▼", "Decrease digit", "down"),
press("▲", "Increase digit", "up"),
press("▶", "Next digit", "right"),
press("OK", "OK", "a"),
press("CANCEL", "Cancel", "b"),
);
} else if (choice.kind === "name")
container.replaceChildren(
...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
@@ -807,6 +860,92 @@ function showBattle(world: GameWorld) {
);
highlightBattleChoice();
}
let pokegearKey = "";
const POKEGEAR_CARDS: Record<PokegearCard, string> = {
clock: "CLOCK",
map: "MAP",
phone: "PHONE",
radio: "RADIO",
};
/** The Pokégear's cards (engine/pokegear/pokegear.asm): the card icons,
* then the clock, the Town Map with its cursor's landmark, the phone list
* or the radio. Its textbox is the native dialogue. */
function showPokegear(world: GameWorld) {
const view = world.pokegear;
get("pokegear").hidden = !view;
if (!view) {
pokegearKey = "";
return;
}
const key = JSON.stringify(view);
if (key === pokegearKey) return;
pokegearKey = key;
const element = <K extends keyof HTMLElementTagNameMap>(
tag: K,
text = "",
className = "",
) => {
const node = document.createElement(tag);
node.textContent = text;
if (className) node.className = className;
return node;
};
get("pokegear-cards").replaceChildren(
...view.cards.map((card) => {
const icon = element("span", POKEGEAR_CARDS[card]);
icon.setAttribute("aria-current", String(card === view.card));
return icon;
}),
);
const body = get("pokegear-card");
if (view.clock) {
const clock = element("div", "", "pokegear-clock");
clock.append(element("span", view.clock.day), element("strong", view.clock.time));
body.replaceChildren(clock);
} else if (view.map) {
const map = element("div", "", "pokegear-map"),
canvas = element("canvas");
canvas.setAttribute("role", "img");
drawTownMap(canvas, world, view.map);
map.append(element("p", `▶ ${view.map.name}`), canvas);
body.replaceChildren(map);
} else if (view.phone) {
const phone = view.phone,
card = element("div", "", "pokegear-phone"),
list = element("ol");
list.setAttribute("aria-label", "Phone numbers");
list.replaceChildren(
...phone.rows.map((row, index) => {
const item = element("li", row);
item.dataset.mark =
phone.chosen === index
? "▷"
: phone.chosen === undefined && phone.cursor === index
? "▶"
: " ";
if (item.dataset.mark !== " ") item.setAttribute("aria-current", "true");
return item;
}),
);
card.append(
element("span", phone.signal ? "Signal ▮▮▮" : "Out of service", "pokegear-signal"),
list,
);
if (phone.submenu) {
const submenu = element("ol", "", "pokegear-submenu");
submenu.replaceChildren(
...phone.submenu.items.map((item, index) => {
const entry = element("li", item);
entry.dataset.mark = index === phone.submenu!.selected ? "▶" : " ";
return entry;
}),
);
card.append(submenu);
}
body.replaceChildren(card);
} else body.replaceChildren();
}
/** The stats screen's pages (engine/pokemon/stats_screen.asm), with its
* strings, type names and stat labels read from the ROM. */
function showStatsScreen(world: GameWorld) {