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

+64
View File
@@ -0,0 +1,64 @@
import type { PokemonData } from "../rom/pokemon";
/** A mailmsg struct (macros/ram.asm): the message, the author's name and
* trainer ID, the species that held the MAIL when it was written, and the
* MAIL item. The author's two "nationality" bytes are whatever followed
* the player's name and are not kept. */
export interface Mail {
/** Up to MAIL_LINE_LENGTH characters, then a second line after "\n"
* only once the first is full (the naming screen's <NEXT>). */
message: string;
author: string;
authorId: number;
species: number;
type: number;
}
/** MAIL_LINE_LENGTH and MAILBOX_CAPACITY (constants/item_data_constants.asm). */
export const MAIL_LINE_LENGTH = 16;
export const MAILBOX_CAPACITY = 10;
/** The mail entry's characters (data/text/mail_input_chars.asm) that have
* a single character of their own; <PK>, <MN>, <PO>, <KE> and the 'd to 'v
* ligatures are left out. */
const MAIL_CHARACTERS = /^[A-Za-z0-9 ,?!.\-/'&()“”[\]:;é♂♀¥…×]*$/u;
/** A message the mail entry could have written. */
export function validMailMessage(message: unknown): message is string {
if (typeof message !== "string") return false;
const lines = message.split("\n");
return (
lines.length <= 2 &&
lines.every(
(line) =>
[...line].length <= MAIL_LINE_LENGTH && MAIL_CHARACTERS.test(line),
) &&
(lines.length === 1 || [...lines[0]].length === MAIL_LINE_LENGTH)
);
}
/** Two typed lines as the mail entry stores them: the first is padded to
* a full line when a second follows. */
export function mailMessage(first: string, second: string): string {
return second
? `${first.padEnd(MAIL_LINE_LENGTH, " ")}\n${second}`
: first;
}
export function validMail(data: PokemonData, value: unknown): value is Mail {
const mail = value as Mail;
const integer = (value: unknown, min: number, max: number) =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
return (
Boolean(mail) &&
typeof mail === "object" &&
validMailMessage(mail.message) &&
typeof mail.author === "string" &&
/^[A-Za-z0-9 -]{1,7}$/.test(mail.author) &&
integer(mail.authorId, 0, 0xffff) &&
integer(mail.species, 1, 251) &&
integer(mail.type, 1, 255) &&
data.itemIsMail(mail.type)
);
}
export const copyMail = (mail: Mail): Mail => ({ ...mail });
+3
View File
@@ -1,4 +1,5 @@
import type { PokemonData } from "../rom/pokemon";
import type { Mail } from "./mail";
/** A party Pokémon with the fields of the original party_struct. */
export interface PartyMon {
@@ -24,6 +25,8 @@ export interface PartyMon {
hp: number;
/** Max HP, Attack, Defense, Speed, Sp. Atk, Sp. Def. */
stats: [number, number, number, number, number, number];
/** sPartyMail's entry while the Pokémon holds MAIL. */
mail?: Mail;
}
export const PARTY_LENGTH = 6;
+8
View File
@@ -3,6 +3,7 @@ import { decompress } from "./lz";
import {
BATTLE,
INTRO,
MENU,
NUM_ITEMS,
NUM_POKEMON,
NUM_TRAINER_CLASSES,
@@ -161,6 +162,13 @@ export class PokemonData {
this.checkItem(item);
return this.reader.word(SYMBOLS.itemAttributes + (item - 1) * 7);
}
/** ItemIsMail: whether MailItems lists the item. */
itemIsMail(item: number): boolean {
const r = this.reader;
for (let p = MENU.MailItems; r.byte(p) !== 0xff; p++)
if (r.byte(p) === item) return true;
return false;
}
/** ItemAttributes permissions, bit 7 (CANT_TOSS_F): mostly key items and
* TMs/HMs, checked by _CheckTossableItem before a mart buys one back. */
itemUnsellable(item: number): boolean {
+16
View File
@@ -443,6 +443,15 @@ export const PC = {
"TossItemFromPC.ItemsDiscardedText": 0x12a4f,
"TossItemFromPC.ItemsTooImportantText": 0x12a5b,
"_PlayerMailBoxMenu.EmptyMailboxText": 0x447b4,
"MailboxPC.SubMenuData": 0x4496c,
"MailboxPC.MailClearedPutAwayText": 0x448ac,
"MailboxPC.MailPackFullText": 0x448b1,
"MailboxPC.MailMessageLostText": 0x448b6,
"MailboxPC.MailAlreadyHoldingItemText": 0x4493c,
"MailboxPC.MailEggText": 0x44941,
"MailboxPC.MailMovedFromBoxText": 0x44946,
PCString_RemoveMail: 0xe3544,
"ScrollingMenu_UpdateDisplay.CancelString": 0x2485f,
"_BillsPC.PCGottaHavePokemonText": 0xe417,
"_BillsPC.PCWhatText": 0xe43a,
"_BillsPC.strings": 0xe47f,
@@ -509,6 +518,13 @@ export const MENU = {
PokemonTookItemText: 0x12cc8,
PokemonAskSwapItemText: 0x12ccd,
ItemCantHeldText: 0x12cd2,
"MonMailAction.MenuData": 0x12dd1,
"MonMailAction.MailLoseMessageText": 0x12de2,
"MonMailAction.MailDetachedText": 0x12de7,
"MonMailAction.MailNoSpaceText": 0x12dec,
"MonMailAction.MailAskSendToPCText": 0x12df1,
"MonMailAction.MailboxFullText": 0x12df6,
"MonMailAction.MailSentToPCText": 0x12dfb,
"GiveItem.AnEggCantHoldAnItemText": 0x1048d,
"MenuHeader_UsableKeyItem.MenuData": 0x10251,
"MenuHeader_UsableItem.MenuData": 0x1027c,
+20 -2
View File
@@ -45,6 +45,7 @@ import {
maxPP,
type PartyMon,
} from "../party/pokemon";
import { copyMail, type Mail } from "../party/mail";
import { TrainerData, type TrainerHeader } from "../rom/trainers";
import { DIRECTION_ORDER, type Direction } from "../world/types";
import { OW } from "../world/constants";
@@ -129,6 +130,8 @@ export interface ScriptState {
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
* and wRegisteredItem: the item Select uses; zeros when none. */
registeredItem: { which: number; item: number };
/** sMailboxes: MAIL sent to the player's PC, oldest first. */
mailbox: Mail[];
/** wRepelEffect: the steps a Repel has left; 0 when none is in effect. */
repel: number;
/** wCoins: the Coin Case's coins. */
@@ -166,6 +169,7 @@ export const emptyScriptState = (): ScriptState => ({
flash: false,
noWildEncounters: false,
registeredItem: { which: 0, item: 0 },
mailbox: [],
repel: 0,
coins: 0,
blueCardBalance: 0,
@@ -202,6 +206,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
flash: state.flash,
noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem },
mailbox: state.mailbox.map(copyMail),
repel: state.repel,
coins: state.coins,
blueCardBalance: state.blueCardBalance,
@@ -275,8 +280,18 @@ export type ScriptEffect =
| { kind: "portrait"; portrait?: Portrait }
/** InitClock's hour or minute stepper; answered with the chosen index. */
| { kind: "clock"; pages: string[]; labels: string[]; selected: number }
/** The naming screen; answered with the name, or "" to keep `current`. */
| { kind: "name"; pages: string[]; current: string; max: number }
/** The naming screen; answered with the name, or "" to keep `current`.
* With `mail`, _ComposeMailMessage's entry: two lines of `max`
* characters as a mail message (validMailMessage), "" for none. */
| {
kind: "name";
pages: string[];
current: string;
max: number;
mail?: boolean;
}
/** ReadAnyMail: a letter, closed with a button. */
| { kind: "mail"; mail: Mail }
/** pokemart's BUY/SELL/QUIT box; answered 0/1/2. */
| { kind: "martmenu"; prompt: string; selected: number }
/** The mart's item list (buy: full stock; sell: the bag's pockets);
@@ -1438,6 +1453,9 @@ export class ScriptRun {
gift = byte();
if (gift)
throw new UnsupportedScript(offset, opcode, " (gift with OT)");
// A MAIL item needs its message (GivePokeMail).
if (item && data.itemIsMail(item))
throw new UnsupportedScript(offset, opcode, " (MAIL)");
if (this.state.party.length >= PARTY_LENGTH)
throw new UnsupportedScript(
offset,
+80 -3
View File
@@ -1,6 +1,7 @@
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { ENGINE_FLAGS, MENU, PC, RAM } from "../rom/profile";
import { copyMail } from "../party/mail";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import {
BOX_NAME_PATTERN,
@@ -215,8 +216,7 @@ export class PcSession {
yield* this.tossItems();
break;
case PLAYERSPCITEM_MAIL_BOX:
// _PlayerMailBoxMenu: no MAIL can be stored yet.
yield* this.print(PC["_PlayerMailBoxMenu.EmptyMailboxText"]);
yield* this.mailbox();
break;
case PLAYERSPCITEM_DECORATION:
if (yield* this.host.decorationMenu()) return true;
@@ -228,6 +228,80 @@ export class PcSession {
}
}
// ----------------------------------------------------------- mailbox
/** _PlayerMailBoxMenu and MailboxPC: the MAIL listed by author, each with
* READ MAIL, PUT IN PACK, ATTACH MAIL or CANCEL, until B or CANCEL. */
private *mailbox(): Gen {
if (!this.state.mailbox.length)
return yield* this.print(PC["_PlayerMailBoxMenu.EmptyMailboxText"]);
const actions = this.strings(PC["MailboxPC.SubMenuData"] + 2, 4);
let cursor = 0;
for (;;) {
const mailbox = this.state.mailbox;
const index = yield* this.menu(
[],
[
...mailbox.map((mail) => mail.author),
this.string(PC["ScrollingMenu_UpdateDisplay.CancelString"]),
],
undefined,
Math.min(cursor, mailbox.length),
);
if (index < 0 || index >= mailbox.length) return;
cursor = index;
const action = yield* this.menu([], actions);
if (action === 0) yield { kind: "mail", mail: copyMail(mailbox[index]) };
else if (action === 1) yield* this.putMailInPack(index);
else if (action === 2) yield* this.attachMail(index);
}
}
/** MailboxPC.PutInPack: the MAIL item goes to the pack without its
* message (DeleteMailFromPC). */
private *putMailInPack(index: number): Gen {
const ok = yield {
kind: "yesno",
pages: this.pages(PC["MailboxPC.MailMessageLostText"]),
};
if (!ok) return;
const mailbox = this.state.mailbox;
if (!receiveItem(this.host.data, this.state.bag, mailbox[index].type, 1))
return yield* this.print(PC["MailboxPC.MailPackFullText"]);
mailbox.splice(index, 1);
yield* this.print(PC["MailboxPC.MailClearedPutAwayText"]);
}
/** MailboxPC.AttachMail: the party menu until a Pokémon that is not an
* Egg and holds nothing is chosen (MoveMailFromPCToParty), or B. */
private *attachMail(index: number): Gen {
const r = this.host.reader,
party = this.state.party;
// PlacePartyMenuText with wPartyMenuActionText 0: "Choose a POKéMON."
const prompt = this.string(
r.near(MENU.PartyMenuStrings, r.bank(MENU.PartyMenuStrings)),
);
for (;;) {
const slot = yield* this.menu(
[prompt],
party.map((mon) => mon.nickname),
this.monDetails(party),
);
if (slot < 0) return;
const mon = party[slot];
if (mon.species === EGG) {
yield* this.print(PC["MailboxPC.MailEggText"]);
continue;
}
if (mon.item) {
yield* this.print(PC["MailboxPC.MailAlreadyHoldingItemText"]);
continue;
}
const [mail] = this.state.mailbox.splice(index, 1);
mon.item = mail.type;
mon.mail = mail;
return yield* this.print(PC["MailboxPC.MailMovedFromBoxText"]);
}
}
// ------------------------------------------------------ item storage
private itemEntries(slots: ItemSlot[]): MartEntry[] {
@@ -554,13 +628,16 @@ export class PcSession {
}
/** BillsPC_CheckMail_PreventBlackout for a party Pokémon: the last one,
* or the last that has not fainted, stays. No MAIL exists yet. */
* or the last that has not fainted, stays, and one holding MAIL
* (wBillsPC_MonHasMail) must have it removed first. */
private *partyMonMayLeave(index: number): Gen<boolean> {
const party = this.state.party;
let text: number | undefined;
if (party.length < 2) text = PC.PCString_ItsYourLastPKMN;
else if (party.every((mon, i) => i === index || !mon.hp))
text = PC.PCString_NoMoreUsablePKMN;
else if (this.host.data.itemIsMail(party[index].item))
text = PC.PCString_RemoveMail;
if (text === undefined) return true;
yield* this.placeString(this.string(text));
return false;
+68 -10
View File
@@ -25,6 +25,12 @@ import { OptionsMenu, menuAccount } from "./options";
import { flyMap } from "./fly-map";
import { OW } from "../world/constants";
import { DecorationData } from "../world/decorations";
import {
MAIL_LINE_LENGTH,
MAILBOX_CAPACITY,
validMailMessage,
type Mail,
} from "../party/mail";
import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined;
@@ -271,10 +277,7 @@ export class StartMenuRun {
this.strings[RAM.monOrItemNameBuffer] = this.state.party[slot].nickname;
}
private isMail(item: number) {
const r = this.reader;
for (let p = MENU.MailItems; r.byte(p) !== 0xff; p++)
if (r.byte(p) === item) return true;
return false;
return this.data.itemIsMail(item);
}
// --------------------------------------------------------- start menu
@@ -523,7 +526,7 @@ export class StartMenuRun {
case MONMENUITEM_CANCEL:
break;
case MONMENUITEM_MAIL:
yield* this.notice(`MAIL ${NOT_PORTED}`);
yield* this.monMailAction(slot);
break;
default:
return yield* this.fieldMove(slot, item);
@@ -649,16 +652,12 @@ export class StartMenuRun {
item: number,
): Gen {
const mon = this.state.party[slot];
// ComposeMailMessage follows a given MAIL; it is refused before
// anything changes.
if (this.isMail(item)) return yield* this.notice(`Writing MAIL ${NOT_PORTED}`);
this.nameItem(item);
const held = mon.item;
if (!held) {
this.giveItemToPokemon(pocket, index, item);
yield* this.print(MENU.PokemonHoldItemText);
mon.item = item;
return;
return yield* this.givePartyItem(slot, item);
}
if (this.isMail(held)) return yield* this.print(MENU.PokemonRemoveMailText);
this.strings[RAM.stringBuffer1] = this.data.itemName(held);
@@ -669,7 +668,66 @@ export class StartMenuRun {
return yield* this.print(MENU.ItemStorageFullText);
}
yield* this.print(MENU.PokemonSwapItemText);
yield* this.givePartyItem(slot, item);
}
/** GivePartyItem: the Pokémon holds the item, and MAIL gets its message
* (ComposeMailMessage). */
private *givePartyItem(slot: number, item: number): Gen {
const mon = this.state.party[slot];
mon.item = item;
if (!this.isMail(item)) return;
// _ComposeMailMessage's entry, then the author (wPlayerName), wPlayerID,
// wCurPartySpecies and the MAIL (wCurItem).
// The mail entry has no title above its two lines.
const message = yield {
kind: "name",
pages: [""],
current: "",
max: MAIL_LINE_LENGTH,
mail: true,
};
if (!validMailMessage(message)) throw new Error("Invalid MAIL message.");
mon.mail = {
message,
author: this.text().player,
authorId: this.host.scriptHost().player().id,
species: mon.species,
type: item,
};
}
/** MonMailAction: READ, TAKE or QUIT for a Pokémon holding MAIL. TAKE
* sends the MAIL to the PC (SendMailToPC), or puts the item in the pack
* once its message may be lost. */
private *monMailAction(slot: number): Gen {
const mon = this.state.party[slot];
const choice = yield* this.menu(
[],
this.stringList(MENU["MonMailAction.MenuData"] + 2, 3),
);
if (choice === 0) return yield* this.readMail(mon.mail);
if (choice !== 1) return;
if (yield* this.yesNo(MENU["MonMailAction.MailAskSendToPCText"])) {
if (!mon.mail || this.state.mailbox.length >= MAILBOX_CAPACITY)
return yield* this.print(MENU["MonMailAction.MailboxFullText"]);
this.state.mailbox.push(mon.mail);
delete mon.mail;
mon.item = 0;
return yield* this.print(MENU["MonMailAction.MailSentToPCText"]);
}
// .RemoveMailToBag
if (!(yield* this.yesNo(MENU["MonMailAction.MailLoseMessageText"]))) return;
if (!receiveItem(this.data, this.state.bag, mon.item, 1))
return yield* this.print(MENU["MonMailAction.MailNoSpaceText"]);
mon.item = 0;
delete mon.mail;
this.strings[RAM.stringBuffer1] = mon.nickname;
yield* this.print(MENU["MonMailAction.MailDetachedText"]);
}
/** ReadPartyMonMail: the letter, until a button. */
private *readMail(mail: Mail | undefined): Gen {
if (!mail) throw new Error("The Pokémon holds MAIL without a message.");
yield { kind: "mail", mail: { ...mail } };
}
private giveItemToPokemon(pocket: PocketIndex, index: number, item: number) {
const list = this.pocketSlots(pocket);
+56 -7
View File
@@ -62,6 +62,13 @@ import {
} from "../scripts/start-menu";
import { PartyEvolution } from "../scripts/evolution";
import { fish } from "../scripts/fish";
import {
copyMail,
MAILBOX_CAPACITY,
validMail,
validMailMessage,
type Mail,
} from "../party/mail";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import {
@@ -151,6 +158,8 @@ export interface NativeSave {
noWildEncounters: boolean;
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
registeredItem: { which: number; item: number };
/** sMailboxes: the PC's MAIL. */
mailbox: Mail[];
/** wRepelEffect, wCoins and wBlueCardBalance. */
repel: number;
coins: number;
@@ -255,8 +264,15 @@ export type Choice =
| { kind: "weekday"; names: string[]; selected: number }
/** InitClock's hour or minute: Up and Down wrap through `labels`. */
| { kind: "clock"; labels: string[]; selected: number }
/** The naming screen: an empty answer keeps `current`. */
| { kind: "name"; current: string; max: number; selected?: undefined }
/** The naming screen: an empty answer keeps `current`. With `mail`, the
* mail entry's two lines. */
| {
kind: "name";
current: string;
max: number;
mail?: boolean;
selected?: undefined;
}
/** A vertical menu; `selected` answers with that item's index. */
| {
kind: "menu";
@@ -2520,6 +2536,10 @@ export class GameWorld {
this.statsScreen = effect.mon;
this.dialogue = { pages: ["Stats"], index: 0 };
return true;
case "mail":
this.mailView = { ...effect.mail };
this.dialogue = { pages: ["MAIL"], index: 0 };
return true;
case "pokegear":
this.pokegear = effect.view;
if (!effect.view) return false;
@@ -2585,7 +2605,12 @@ export class GameWorld {
this.dialogue = {
pages: effect.pages,
index: 0,
choice: { kind: "name", current: effect.current, max: effect.max },
choice: {
kind: "name",
current: effect.current,
max: effect.max,
...(effect.mail && { mail: true }),
},
};
return true;
case "martmenu":
@@ -2989,6 +3014,7 @@ export class GameWorld {
this.memScript ??= script.memScript;
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.pokegear = undefined;
this.trainerCard = undefined;
this.dialogue = undefined;
@@ -3041,6 +3067,7 @@ export class GameWorld {
this.script = undefined;
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.pokegear = undefined;
this.trainerCard = undefined;
this.dialogue = undefined;
@@ -3484,6 +3511,8 @@ export class GameWorld {
townMap = false;
/** The Pokémon a script's stats screen shows (Bill's PC). */
statsScreen: PartyMon | undefined;
/** A letter ReadAnyMail shows, until a button closes it. */
mailView: Mail | undefined;
/** The Pokégear card on screen while the Pokégear is open, including
* during the phone calls it makes. */
pokegear: PokegearView | undefined;
@@ -3547,6 +3576,7 @@ export class GameWorld {
if (--dialogue.holdTicks <= 0) {
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.dialogue = undefined;
this.pump();
}
@@ -4002,6 +4032,7 @@ export class GameWorld {
}
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.dialogue = undefined;
this.pump();
}
@@ -4095,9 +4126,11 @@ export class GameWorld {
// The naming screen only offers its own characters; keep it open.
if (
dialogue.choice.kind === "name" &&
(typeof answer !== "string" ||
answer.length > dialogue.choice.max ||
(answer !== "" && !NICKNAME_PATTERN.test(answer)))
(dialogue.choice.mail
? !validMailMessage(answer)
: typeof answer !== "string" ||
answer.length > dialogue.choice.max ||
(answer !== "" && !NICKNAME_PATTERN.test(answer)))
)
return;
if (
@@ -4115,6 +4148,7 @@ export class GameWorld {
return;
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.dialogue = undefined;
this.pump(answer);
}
@@ -4184,6 +4218,7 @@ export class GameWorld {
flash: state.flash,
noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem },
mailbox: state.mailbox.map(copyMail),
repel: state.repel,
coins: state.coins,
blueCardBalance: state.blueCardBalance,
@@ -4255,7 +4290,17 @@ export class GameWorld {
!integer(save.playerId, 0, 0xffff) ||
!Array.isArray(save.party) ||
save.party.length > 6 ||
!save.party.every((mon) => validPartyMon(this.assets.pokemon, mon)) ||
!save.party.every(
(mon) =>
validPartyMon(this.assets.pokemon, mon) &&
// sPartyMail: a message exactly while MAIL is held.
(this.assets.pokemon.itemIsMail(mon.item)
? validMail(this.assets.pokemon, mon.mail)
: !Object.hasOwn(mon, "mail")),
) ||
!Array.isArray(save.mailbox) ||
save.mailbox.length > MAILBOX_CAPACITY ||
!save.mailbox.every((mail) => validMail(this.assets.pokemon, mail)) ||
!validBag(this.assets.pokemon, save.bag) ||
!validStorage(save.storage) ||
!validPCItems(this.assets.pokemon, save.pcItems) ||
@@ -4364,6 +4409,7 @@ export class GameWorld {
flash: save.flash,
noWildEncounters: save.noWildEncounters,
registeredItem: { ...save.registeredItem },
mailbox: save.mailbox.map(copyMail),
repel: save.repel,
coins: save.coins,
blueCardBalance: save.blueCardBalance,
@@ -4416,6 +4462,7 @@ export class GameWorld {
this.playerId = save.playerId;
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.pokegear = undefined;
this.trainerCard = undefined;
this.dialogue = undefined;
@@ -4477,6 +4524,7 @@ export function migrateSave(
...(!Object.hasOwn(save, "registeredItem") && {
registeredItem: { which: 0, item: 0 },
}),
...(!Object.hasOwn(save, "mailbox") && { mailbox: [] }),
...(!Object.hasOwn(save, "repel") && {
repel: 0,
coins: 0,
@@ -4530,6 +4578,7 @@ export function migrateSave(
flash: false,
noWildEncounters: false,
registeredItem: { which: 0, item: 0 },
mailbox: [],
repel: 0,
coins: 0,
blueCardBalance: 0,
+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();
+1
View File
@@ -32,6 +32,7 @@ export function renderShell(root: HTMLElement) {
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
</div>
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
<div id="mail-view" class="mail-view" hidden role="dialog" aria-labelledby="mail-type"><p id="mail-type" class="mail-type"></p><p id="mail-message" class="mail-message"></p><p id="mail-author" class="mail-author"></p><button id="mail-close">Close</button></div>
<div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div>
<div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div>
<div id="options" class="options-screen" hidden aria-label="Options"><ol id="options-list"></ol></div>
+42
View File
@@ -326,6 +326,16 @@ dialog h2 {
max-height: 9.5em;
overflow-y: auto;
}
.dialogue-choice .mail-lines {
display: grid;
gap: 4px;
flex: 1 1 auto;
}
.dialogue-choice .mail-lines input {
width: calc(16ch + 22px);
max-width: 100%;
font-family: ui-monospace, monospace;
}
.dialogue-choice input {
flex: 1 1 12ch;
min-width: 0;
@@ -459,6 +469,38 @@ dialog h2 {
background: #f8f8f0;
border-top: 8px solid var(--stats-page, #e8a0b8);
}
.mail-view {
position: absolute;
inset: 0;
z-index: 4;
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: 12px;
padding: 16px;
color: var(--ink);
background: #f8f8f0;
border: 8px double #a8b8d0;
}
.mail-type {
margin: 0;
font-weight: 700;
}
.mail-message {
margin: 0;
align-self: center;
font-family: ui-monospace, monospace;
font-size: 1.1rem;
line-height: 1.8;
white-space: pre;
overflow-x: auto;
}
.mail-author {
margin: 0;
justify-self: end;
}
.mail-view button {
justify-self: end;
}
.stats-screen[data-page="1"] {
--stats-page: #88c888;
}