Port MAIL in the party menu and the PC's MAIL BOX

Giving MAIL opens the mail entry: two lines of up to sixteen characters,
kept with the player's name and ID, the holder's species and the MAIL.
The party submenu's MAIL reads the letter or takes it, either to the PC's
MAILBOX (ten at most) or into the pack without its message. The player's
PC lists the MAILBOX by author to read, put in the pack or attach, and
Bill's PC keeps a Pokémon holding MAIL out of the boxes. Saves keep each
holder's MAIL and the MAILBOX.

The letter shows its message and author natively; the stationery's
graphics are not drawn yet.

Closes #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 01:10:08 +02:00
1 parent e2dc5ba520
commit 5c1ac9d726
17 files changed
+871 -54

No files matched your search

+70 -2
View File
@@ -15,6 +15,11 @@ import { PokemonData } from "./game/rom/pokemon";
import type { Direction, Palette } from "./game/world/types";
import type { Button } from "./input/types";
import { NICKNAME_PATTERN, maxPP } from "./game/party/pokemon";
import {
MAIL_LINE_LENGTH,
mailMessage,
validMailMessage,
} from "./game/party/mail";
import { GameSession } from "./app/session";
import { InputController } from "./input/controller";
import { bindControls } from "./input/bindings";
@@ -52,6 +57,7 @@ function showWorld(world: GameWorld) {
get("game-notice").textContent = world.notice;
showBattle(world);
showStatsScreen(world);
showMail(world);
showPokegear(world);
showTrainerCard(world);
showOptions(world);
@@ -76,7 +82,8 @@ function showWorld(world: GameWorld) {
dialogue.choice?.kind === "flymap" ||
Boolean(mart) ||
world.townMap ||
Boolean(world.statsScreen);
Boolean(world.statsScreen) ||
Boolean(world.mailView);
get("pokepic-next").hidden = !pictureWait;
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
get("dialogue-page").textContent =
@@ -227,7 +234,11 @@ function showChoice(choice: Choice | undefined) {
);
} else if (choice.kind === "name")
container.replaceChildren(
...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
...(choice.mail
? mailField((message) => runtime.choose(message))
: 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.
@@ -295,6 +306,58 @@ function nameField(
queueMicrotask(() => input.focus({ preventScroll: true }));
return [input, ok];
}
/** _ComposeMailMessage's entry as two lines of 16 characters and OK. A
* second line starts once the first is full, so the first is padded. */
function mailField(answer: (message: string) => void): HTMLElement[] {
const lines = [1, 2].map((line) => {
const input = document.createElement("input");
input.maxLength = MAIL_LINE_LENGTH;
input.setAttribute("aria-label", `MAIL line ${line}`);
input.autocomplete = "off";
input.spellcheck = false;
input.addEventListener("input", () => input.setCustomValidity(""));
return input;
});
const submit = () => {
const message = mailMessage(lines[0].value, lines[1].value.trimEnd());
if (!validMailMessage(message)) {
lines[0].setCustomValidity(
"Use letters, digits, spaces and the MAIL's punctuation.",
);
lines[0].reportValidity();
return;
}
answer(message);
canvas.focus({ preventScroll: true });
};
lines[0].addEventListener("keydown", (event) => {
if (event.key === "Enter") lines[1].focus({ preventScroll: true });
});
lines[1].addEventListener("keydown", (event) => {
if (event.key === "Enter") submit();
});
const ok = document.createElement("button");
ok.type = "button";
ok.textContent = "OK";
ok.addEventListener("click", () => {
if (session.state === "playing") submit();
});
queueMicrotask(() => lines[0].focus({ preventScroll: true }));
const box = document.createElement("div");
box.className = "mail-lines";
box.append(...lines);
return [box, ok];
}
/** ReadAnyMail: the message at its two lines and the author below. The
* stationery's graphics are not drawn. */
function showMail(world: GameWorld) {
const mail = world.mailView;
get("mail-view").hidden = !mail;
if (!mail) return;
get("mail-type").textContent = world.assets.pokemon.itemName(mail.type);
get("mail-message").textContent = mail.message;
get("mail-author").textContent = mail.author;
}
type MartChoice = Extract<
Choice,
{ kind: "martmenu" | "martlist" | "martquantity" | "pack" }
@@ -1525,6 +1588,11 @@ setInterval(() => {
}, 10_000);
update();
get("mail-close").onclick = () => {
if (session.state !== "playing" || !runtime.world?.mailView) return;
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
};
get("town-map-close").onclick = () => {
if (session.state !== "playing") return;
runtime.advanceDialogue();