Port the introduction's naming and clock screens
Checks / web (push) Canceled after 0s

A new game now runs NewGame's InitGender and OakSpeech natively before
the bedroom: Boy or Girl, InitClock's hour and minute steppers with
their YES/NO confirmations, Oak's speech with Wooper, and NamePlayer's
preset names or naming field (a blank name takes CHRIS or KRIS). Texts,
menus and time strings come from a new INTRO profile table verified by
check:reference; Oak's and the player's pictures decode from
TrainerPicPointers, ChrisPic/KrisPic and TrainerPalettes.

The chosen time is kept as a minute offset from the device clock, which
time of day, the weekday, NPC hours and the daily reset now read. The
intro runs only when nothing is saved; a save made during it starts it
again. Saves gain intro and clock.minuteOffset, migrated by absence. The
shell's name and character fields are gone. Intentional change: the
steppers start at the device time rather than 10:00.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 09:53:52 +02:00
1 parent 5a0fe1e313
commit b13fd20be4
28 files changed
+975 -101

No files matched your search

+44 -2
View File
@@ -1,6 +1,12 @@
import { RomReader } from "./reader";
import { decompress } from "./lz";
import { NUM_ITEMS, NUM_POKEMON, SYMBOLS } from "./profile";
import {
INTRO,
NUM_ITEMS,
NUM_POKEMON,
NUM_TRAINER_CLASSES,
SYMBOLS,
} from "./profile";
import { defaultTextContext, readString } from "../scripts/text";
import type { Palette } from "../world/types";
@@ -254,8 +260,44 @@ export class PokemonData {
/** Normal colors from PokemonPalettes, between white and black. */
palette(species: number, shiny = false): Palette {
this.checkSpecies(species);
return this.middleColors(
SYMBOLS.pokemonPalettes + species * 8 + (shiny ? 4 : 0),
);
}
/** A trainer class's front picture (GetTrainerPic): 7x7 tiles, column
* by column. */
trainerPic(classId: number): Uint8Array {
if (
!Number.isInteger(classId) ||
classId < 1 ||
classId > NUM_TRAINER_CLASSES
)
throw new Error("Unknown trainer class.");
const r = this.reader,
p = SYMBOLS.pokemonPalettes + species * 8 + (shiny ? 4 : 0);
entry = INTRO.TrainerPicPointers + (classId - 1) * 3;
const pics1 = r.byte(SYMBOLS.picsBanks);
const bank = r.byte(SYMBOLS.picsBanks + r.byte(entry) - (pics1 - 0x36));
const tiles = decompress(r, r.address(bank, r.word(entry + 1)), 0x1000);
if (tiles.length < 49 * 16) throw new Error("Picture data is truncated.");
return tiles.slice(0, 49 * 16);
}
/** DrawIntroPlayerPic's uncompressed ChrisPic or KrisPic. */
playerPic(female: boolean): Uint8Array {
return this.reader.slice(female ? INTRO.KrisPic : INTRO.ChrisPic, 49 * 16);
}
/** TrainerPalettes, by class: CHRIS is 0 and KRIS shares class 1. */
trainerPalette(classId: number): Palette {
if (
!Number.isInteger(classId) ||
classId < 0 ||
classId > NUM_TRAINER_CLASSES
)
throw new Error("Unknown trainer class.");
return this.middleColors(INTRO.TrainerPalettes + classId * 4);
}
/** Two stored colors between white and black. */
private middleColors(p: number): Palette {
const r = this.reader;
const color = (value: number) =>
[
Math.round(((value & 31) * 255) / 31),
+38
View File
@@ -281,6 +281,44 @@ export const OVERWORLD = {
MansionPalette2: 0x496fe,
} as const;
/** A new game's introduction (engine/menus/intro_menu.asm NewGame):
* InitGender, OakSpeech with InitClock and NamePlayer, and the pictures
* they show, keyed by their PRET labels for \`npm run check:reference\`. */
export const INTRO = {
TrainerPicPointers: 0x128000,
TrainerPalettes: 0xb0ce,
ChrisPic: 0x888a9,
KrisPic: 0x88bb9,
_AreYouABoyOrAreYouAGirlText: 0x1c0ca3,
"InitGender.MenuData": 0x48e04,
_OakText1: 0x1c1d35,
_OakText2: 0x1c1da4,
_OakText4: 0x1c1de5,
_OakText5: 0x1c1e51,
_OakText6: 0x1c4000,
_OakText7: 0x1c4026,
_OakTimeWokeUpText: 0x1bc29c,
_OakTimeWhatTimeIsItText: 0x1bc2eb,
_OakTimeWhatHoursText: 0x1bc2fd,
_OakTimeHoursQuestionMarkText: 0x1bc305,
_OakTimeHowManyMinutesText: 0x1bc308,
_OakTimeWhoaMinutesText: 0x1bc31b,
_OakTimeMinutesQuestionMarkText: 0x1bc323,
_OakTimeOversleptText: 0x1bc326,
_OakTimeYikesText: 0x1bc336,
_OakTimeSoDarkText: 0x1bc34f,
"ChrisNameMenuHeader.MaleNames": 0x882be,
"KrisNameMenuHeader.FemaleNames": 0x882ee,
"NamingScreenJumptable.PlayerNameString": 0x117a3,
"NamePlayer.Chris": 0x60d3,
"NamePlayer.Kris": 0x60de,
String_oclock: 0x9087e,
String_min: 0x9089f,
"GetTimeOfDayString.nite_string": 0x90b71,
"GetTimeOfDayString.morn_string": 0x90b76,
"GetTimeOfDayString.day_string": 0x90b7b,
} as const;
/** Pokémon Center and house PCs, Bill's PC and Oak's PC: routines, menu
* data, strings and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
+7 -4
View File
@@ -21,7 +21,6 @@ export class NativeRuntime implements GameRuntime {
constructor(
private onUpdate: (world: GameWorld) => void,
private onError: (error: unknown) => void,
private player: () => { name: string; avatar: "chris" | "kris" },
/** Presentation handles menu navigation; battle rules stay in the world. */
private battleInput?: (button: Button) => boolean,
) {}
@@ -30,14 +29,16 @@ export class NativeRuntime implements GameRuntime {
this.fieldMenuOpen = false;
const assets = new CrystalAssets(bytes);
const world = new GameWorld(assets);
const player = this.player();
world.name = player.name;
world.avatar = player.avatar;
this.world = world;
this.renderer = new WorldRenderer(assets, canvas);
this.observeCanvas(canvas);
this.publish();
}
/** A new game starts with its introduction (NewGame's OakSpeech). */
newGame() {
this.world?.beginIntro();
this.publish();
}
play() {
if (!this.world || this.raf) return;
this.lastTime = 0;
@@ -144,6 +145,8 @@ export class NativeRuntime implements GameRuntime {
world.notice,
world.activity,
world.picture,
world.portrait,
world.intro,
world.battle && [
world.battle.message,
world.battle.prompt?.kind,
+14 -6
View File
@@ -55,10 +55,14 @@ import {
} from "./text";
export interface ClockSettings {
/** Days added to the device weekday, chosen through SetDayOfWeek. */
/** Minutes added to the device time, chosen through InitClock. */
minuteOffset: number;
/** Days added to the game clock's weekday, chosen through SetDayOfWeek. */
weekdayOffset: number;
dst: boolean;
}
/** A trainer class's front picture, or the player's (DrawIntroPlayerPic). */
export type Portrait = { trainer: number } | { player: boolean };
export interface ScriptState {
decorations: Decorations;
events: Set<number>;
@@ -106,7 +110,7 @@ export const emptyScriptState = (): ScriptState => ({
variables: {},
scenes: {},
phone: [],
clock: { weekdayOffset: 0, dst: false },
clock: { minuteOffset: 0, weekdayOffset: 0, dst: false },
party: [],
bag: emptyBag(),
storage: emptyStorage(),
@@ -186,6 +190,10 @@ export type ScriptEffect =
| { kind: "pause"; ticks: number }
/** A front picture in a box (pokepic); species 0 closes it. */
| { kind: "picture"; species: number }
/** The introduction's trainer or player picture; none closes it. */
| { 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 }
/** pokemart's BUY/SELL/QUIT box; answered 0/1/2. */
@@ -262,7 +270,8 @@ export interface ScriptHost {
/** wTimeOfDay: morning 0, day 1, night 2. */
timeOfDay(): number;
clock(): { weekday: number; hours: number; minutes: number };
deviceWeekday(): number;
/** The game clock's weekday before SetDayOfWeek's offset. */
clockDay(): number;
text(): TextContext;
data: PokemonData;
/** wPlayerID and wPlayerGender (1 for Kris). */
@@ -1967,8 +1976,7 @@ export class ScriptRun {
pages: [names[day] + readText(r, SYMBOLS.isItDayText, this.text())],
};
if (!confirmed) continue;
this.state.clock.weekdayOffset =
(day - this.host.deviceWeekday() + 7) % 7;
this.state.clock.weekdayOffset = (day - this.host.clockDay() + 7) % 7;
return value;
}
}
@@ -2075,7 +2083,7 @@ export function runScript(
sceneOwner: () => undefined,
timeOfDay: () => 1,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
deviceWeekday: () => 0,
clockDay: () => 0,
text: () => defaultTextContext(name),
data: new PokemonData(reader),
player: () => ({ id: 0, gender: 0 }),
+257
View File
@@ -0,0 +1,257 @@
import type { RomReader } from "../rom/reader";
import { INTRO } from "../rom/profile";
import {
copyScriptState,
type ScriptEffect,
type ScriptState,
} from "./interpreter";
import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** POKEMON_PROF's trainer class, and WOOPER. */
const POKEMON_PROF = 10,
WOOPER = 194;
/** MORN_HOUR, DAY_HOUR and NITE_HOUR (constants/misc_constants.asm). */
const MORN_HOUR = 4,
DAY_HOUR = 10,
NITE_HOUR = 18;
/** PLAYER_NAME_LENGTH - 1. */
const NAME_MAX = 7;
/** What the introduction needs from the world. */
export interface IntroHost {
reader: RomReader;
/** The device's current time, which the game clock is offset from. */
now(): Date;
/** Text values, with `<PLAYER>` read as `player`. */
text(player: string): TextContext;
}
/** A new game's introduction: NewGame's PlayerProfileSetup (InitGender)
* and OakSpeech, with InitClock and NamePlayer
* (engine/menus/intro_menu.asm, engine/menus/init_gender.asm and
* engine/rtc/timeset.asm). The original draws its own screens; here the
* pictures, texts, menus and steppers are presented natively. */
export class IntroRun {
readonly state: ScriptState;
/** Intro scripts never defer another script. */
readonly deferred = undefined;
/** wPlayerGender and wPlayerName, applied when the introduction ends. */
avatar: "chris" | "kris" = "chris";
name = "";
private iterator: Gen;
private done = false;
constructor(
state: ScriptState,
private host: IntroHost,
) {
this.state = copyScriptState(state);
this.iterator = this.run();
}
/** Advance until the next effect, or undefined when the intro ended. */
resume(input?: Input): ScriptEffect | undefined {
if (this.done) return undefined;
const next = this.iterator.next(input);
if (next.done) {
this.done = true;
return undefined;
}
return next.value;
}
private string(address: number) {
return readString(this.host.reader, address, this.host.text(this.name));
}
private text(address: number) {
return readText(this.host.reader, address, this.host.text(this.name));
}
private pages(address: number) {
return this.text(address).split("\n\n");
}
/** Consecutive "@"-terminated strings, as static menus store them. */
private strings(address: number, count: number) {
const r = this.host.reader,
result: string[] = [];
let p = address;
for (let i = 0; i < count; i++) {
result.push(this.string(p));
while (r.byte(p++) !== 0x50);
}
return result;
}
/** A static menu with STATICMENU_DISABLE_B: only a listed item answers. */
private *menu(pages: string[], items: string[]): Gen<number> {
for (;;) {
const choice = yield { kind: "menu", pages, items, selected: 0 };
if (typeof choice === "number" && choice >= 0 && choice < items.length)
return choice;
}
}
private *run(): Gen {
// InitGender: "Boy" or "Girl"; B is disabled.
const gender = yield* this.menu(
this.pages(INTRO._AreYouABoyOrAreYouAGirlText),
this.strings(INTRO["InitGender.MenuData"] + 2, 2),
);
this.avatar = gender === 1 ? "kris" : "chris";
yield* this.initClock();
yield { kind: "portrait", portrait: { trainer: POKEMON_PROF } };
yield { kind: "text", pages: this.pages(INTRO._OakText1) };
// OakText2's text_asm plays Wooper's cry; OakText3 is a button wait.
yield { kind: "portrait" };
yield { kind: "picture", species: WOOPER };
yield { kind: "text", pages: this.pages(INTRO._OakText2) };
yield { kind: "text", pages: this.pages(INTRO._OakText4) };
yield { kind: "picture", species: 0 };
yield { kind: "portrait", portrait: { trainer: POKEMON_PROF } };
yield { kind: "text", pages: this.pages(INTRO._OakText5) };
yield {
kind: "portrait",
portrait: { player: this.avatar === "kris" },
};
yield { kind: "text", pages: this.pages(INTRO._OakText6) };
yield* this.namePlayer();
yield { kind: "text", pages: this.pages(INTRO._OakText7) };
yield { kind: "portrait" };
}
/** NamePlayer: NEW NAME opens the naming screen; the others are the
* gender's preset names (ShowPlayerNamingChoices). */
private *namePlayer(): Gen {
const female = this.avatar === "kris";
const items = this.strings(
female
? INTRO["KrisNameMenuHeader.FemaleNames"] + 2
: INTRO["ChrisNameMenuHeader.MaleNames"] + 2,
5,
);
const choice = yield* this.menu([""], items);
if (choice > 0) {
this.name = items[choice];
return;
}
const fallback = this.string(
female ? INTRO["NamePlayer.Kris"] : INTRO["NamePlayer.Chris"],
);
for (;;) {
const name = yield {
kind: "name",
pages: [this.string(INTRO["NamingScreenJumptable.PlayerNameString"])],
current: fallback,
max: NAME_MAX,
};
if (typeof name !== "string") continue;
// InitName: a blank name takes the gender's default.
this.name = name.trim() || fallback;
return;
}
}
/** GetTimeOfDayString and AdjustHourForAMorPM, as PrintHour prints. */
private hourString(hour: number) {
const period =
hour < MORN_HOUR
? INTRO["GetTimeOfDayString.nite_string"]
: hour < DAY_HOUR
? INTRO["GetTimeOfDayString.morn_string"]
: hour < NITE_HOUR
? INTRO["GetTimeOfDayString.day_string"]
: INTRO["GetTimeOfDayString.nite_string"];
return `${this.string(period)} ${hour % 12 || 12}`;
}
/** InitClock: Oak asks for the hour, then the minutes, each confirmed
* with YES/NO. The chosen time becomes an offset from the device clock.
* Intentional change: the steppers start at the device's time rather
* than 10:00, so confirming keeps the device time. */
private *initClock(): Gen {
const start = this.host.now();
let hour = start.getHours(),
minutes = start.getMinutes();
const oclock = this.string(INTRO.String_oclock),
min = this.string(INTRO.String_min);
const hourLabels = Array.from(
{ length: 24 },
(_, h) => `${this.hourString(h)} ${oclock}`,
);
const minuteLabels = Array.from({ length: 60 }, (_, m) => `${m} ${min}`);
yield { kind: "text", pages: this.pages(INTRO._OakTimeWokeUpText) };
for (;;) {
hour = yield* this.stepper(
this.pages(INTRO._OakTimeWhatTimeIsItText),
hourLabels,
hour,
);
// "What?" with DisplayHourOClock on the second line, then "?".
const hours = yield {
kind: "yesno",
pages: [
`${this.text(INTRO._OakTimeWhatHoursText)}\n${hourLabels[hour]}${this.text(INTRO._OakTimeHoursQuestionMarkText)}`,
],
};
if (!hours) continue;
let confirmed = false;
while (!confirmed) {
minutes = yield* this.stepper(
this.pages(INTRO._OakTimeHowManyMinutesText),
minuteLabels,
minutes,
);
confirmed = Boolean(
yield {
kind: "yesno",
pages: [
`${this.text(INTRO._OakTimeWhoaMinutesText)} ${minuteLabels[minutes]}${this.text(INTRO._OakTimeMinutesQuestionMarkText)}`,
],
},
);
}
break;
}
// InitTimeOfDay: the device clock plus this offset reads the chosen
// time now. The shortest offset keeps the device's date where it can.
const device = start.getHours() * 60 + start.getMinutes();
this.state.clock.minuteOffset =
((((hour * 60 + minutes - device) % 1440) + 1440 + 720) % 1440) - 720;
// OakText_ResponseToSetTime compares against DAY_HOUR + 1, so 10 AM
// still counts as oversleeping in the morning.
const response =
hour < MORN_HOUR
? INTRO._OakTimeSoDarkText
: hour < DAY_HOUR + 1
? INTRO._OakTimeOversleptText
: hour < NITE_HOUR
? INTRO._OakTimeYikesText
: INTRO._OakTimeSoDarkText;
yield {
kind: "text",
pages: [
`${this.hourString(hour)}:${String(minutes).padStart(2, "0")}${this.text(response)}`,
],
};
}
/** SetHour and SetMinutes: Up and Down wrap through the labels; A
* answers with the shown value. */
private *stepper(
pages: string[],
labels: string[],
value: number,
): Gen<number> {
for (;;) {
const answer = yield { kind: "clock", pages, labels, selected: value };
if (
typeof answer === "number" &&
Number.isInteger(answer) &&
answer >= 0 &&
answer < labels.length
)
return answer;
}
}
}
+97 -8
View File
@@ -23,8 +23,10 @@ import {
type MartMode,
type ScriptEffect,
type ScriptHost,
type Portrait,
type ScriptState,
} from "../scripts/interpreter";
import { IntroRun } from "../scripts/intro";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import {
@@ -77,7 +79,9 @@ export interface NativeSave {
/** Queued special call, persisted until its script completes. */
specialPhoneCall: number;
rivalName: string;
clock: { weekdayOffset: number; dst: boolean };
clock: { minuteOffset: number; weekdayOffset: number; dst: boolean };
/** The introduction has not finished: it starts again on load. */
intro: boolean;
/** Current-map objects moved or turned by scripts, as saved object structs. */
objects: { id: number; x: number; y: number; direction: Direction }[];
/** wPlayerID, drawn at a new game. */
@@ -194,6 +198,8 @@ export interface Pose {
export type Choice =
| { kind: "yesno"; selected: 0 | 1 }
| { 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 }
/** A vertical menu; `selected` answers with that item's index. */
@@ -244,7 +250,7 @@ export interface BattleView {
const BATTLE_MESSAGE_TICKS = 30;
interface ActiveScript {
run: ScriptRun;
run: ScriptRun | IntroRun;
effect?: ScriptEffect;
/** Committed save data for the whole interaction, used for mid-script saves. */
committed: NativeSave;
@@ -463,6 +469,10 @@ export class GameWorld {
emote: { object: number; emote: number } | undefined;
/** Species whose front picture is shown (pokepic), if any. */
picture: number | undefined;
/** The introduction's trainer or player picture, if any. */
portrait: Portrait | undefined;
/** True from a new game until the introduction finishes. */
private introPending = false;
/** The battle in progress, if any. */
battle: BattleView | undefined;
private battleRun?: Generator<BattleEffect, BattleOutcome, BattleInput>;
@@ -593,13 +603,21 @@ export class GameWorld {
if (mode === 4) return this.liveState.flash ? 2 : 3;
return this.clockTimeOfDay;
}
/** The introduction is running, or has yet to run after a load. */
get intro() {
return this.introPending;
}
/** The game clock: the device time plus the offset InitClock chose. */
private gameNow(clock = this.liveState.clock) {
return new Date(this.now().getTime() + clock.minuteOffset * 60_000);
}
/** wTimeOfDay from the game clock: morning 4-10, day 10-18, night. */
get clockTimeOfDay(): number {
const hour = this.now().getHours();
const hour = this.gameNow().getHours();
return hour >= 4 && hour < 10 ? 0 : hour >= 10 && hour < 18 ? 1 : 2;
}
clock() {
const now = this.now();
const now = this.gameNow();
return {
weekday: (now.getDay() + this.liveState.clock.weekdayOffset) % 7,
hours: now.getHours(),
@@ -624,7 +642,7 @@ export class GameWorld {
if (object.eventFlag !== 0xffff && state.events.has(object.eventFlag))
return false;
if (!this.assets.sprite(this.spriteOf(object, state))) return false;
const hour = this.now().getHours(),
const hour = this.gameNow().getHours(),
timeMask = 1 << this.clockTimeOfDay;
if (object.hour1 === 0xff)
return object.hour2 === 0xff || Boolean(object.hour2 & timeMask);
@@ -1470,7 +1488,7 @@ export class GameWorld {
sceneOwner: (id) => this.assets.sceneOwner(id),
timeOfDay: () => this.clockTimeOfDay,
clock: () => this.clock(),
deviceWeekday: () => this.now().getDay(),
clockDay: () => this.gameNow().getDay(),
text: () => ({
...defaultTextContext(this.name),
rival: this.state.rivalName,
@@ -1581,6 +1599,35 @@ export class GameWorld {
this.pump();
}
/** NewGame's PlayerProfileSetup and OakSpeech, before the player
* appears in the bedroom. Saves made meanwhile start it again. */
beginIntro() {
if (this.script) throw new Error("The introduction needs an idle world.");
this.introPending = true;
this.script = {
run: new IntroRun(this.state, {
reader: this.assets.reader,
now: () => this.now(),
text: (player) => ({ ...this.host(PLAYER).text(), player }),
}),
committed: this.save(),
rollback: {
state: this.state,
mapId: this.mapId,
player: copyActor(this.player),
actors: new Map(
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
),
spawned: new Set(this.spawned),
blocks: new Uint8Array(this.map.blocks),
},
waitTicks: 0,
moving: new Map(),
scene: false,
};
this.pump();
}
/** Run the active script until it needs presentation time or input. */
private pump(input?: number | string) {
const script = this.script;
@@ -1717,6 +1764,20 @@ export class GameWorld {
case "picture":
this.picture = effect.species || undefined;
return false;
case "portrait":
this.portrait = effect.portrait;
return false;
case "clock":
this.dialogue = {
pages: effect.pages,
index: 0,
choice: {
kind: "clock",
labels: effect.labels,
selected: effect.selected,
},
};
return true;
case "battle": {
const draft = script.run.state;
const trainers = new TrainerData(this.assets.reader);
@@ -2190,6 +2251,12 @@ export class GameWorld {
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
this.portrait = undefined;
if (script.run instanceof IntroRun) {
this.name = script.run.name;
this.avatar = script.run.avatar;
this.introPending = false;
}
if (!script.scene || script.run.deferred !== undefined)
this.sceneCheck = true;
if (script.run.deferred !== undefined)
@@ -2211,6 +2278,7 @@ export class GameWorld {
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
this.portrait = undefined;
if (script.sighted !== undefined)
this.refusedSighting = {
map: this.mapId,
@@ -2305,7 +2373,7 @@ export class GameWorld {
/** CheckDailyResetTimer, from CheckTimeEvents after scene scripts: the
* first idle check on a new game day clears the daily engine flags. */
private checkDailyReset() {
const now = this.now();
const now = this.gameNow(this.state.clock);
const day =
Math.floor(
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86_400_000,
@@ -3098,6 +3166,11 @@ export class GameWorld {
choice.items.length - 1,
choice.selected + 1,
);
} else if (choice.kind === "clock") {
const length = choice.labels.length;
if (direction === "up") choice.selected = (choice.selected + 1) % length;
if (direction === "down")
choice.selected = (choice.selected + length - 1) % length;
} else if (choice.kind === "martlist") {
const length = choice.entries.length;
if (!length) return;
@@ -3165,6 +3238,7 @@ export class GameWorld {
specialPhoneCall: state.specialPhoneCall,
rivalName: state.rivalName,
clock: { ...state.clock },
intro: this.introPending,
objects: [...this.actors]
.filter(([, actor]) => actor.moved)
.map(([id, actor]) => ({
@@ -3249,7 +3323,9 @@ export class GameWorld {
!save.phone.every((id) => integer(id, 0, 255)) ||
new Set(save.phone).size !== save.phone.length ||
!save.clock ||
!integer(save.clock.minuteOffset, -720, 719) ||
!integer(save.clock.weekdayOffset, 0, 6) ||
typeof save.intro !== "boolean" ||
typeof save.clock.dst !== "boolean" ||
!Array.isArray(save.objects) ||
save.objects.length > 16 ||
@@ -3396,10 +3472,14 @@ export class GameWorld {
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
this.portrait = undefined;
this.battle = undefined;
this.battleRun = undefined;
this.memScript = undefined;
this.clearNotice();
this.introPending = false;
// A save made during the introduction starts it again.
if (save.intro) this.beginIntro();
}
}
@@ -3437,6 +3517,13 @@ export function migrateSave(
...(!Object.hasOwn(save, "noWildEncounters") && {
noWildEncounters: false,
}),
...(!Object.hasOwn(save, "intro") && { intro: false }),
// Clocks saved before InitClock was ported follow the device time.
...(save.clock !== null &&
typeof save.clock === "object" &&
!Object.hasOwn(save.clock, "minuteOffset") && {
clock: { minuteOffset: 0, ...(save.clock as object) },
}),
} as unknown as NativeSave;
}
if (save.version !== 1 && save.version !== 2) return undefined;
@@ -3446,13 +3533,14 @@ export function migrateSave(
...save,
scenes: {},
phone: [],
clock: { weekdayOffset: 0, dst: false },
clock: { minuteOffset: 0, weekdayOffset: 0, dst: false },
objects: [],
}
: save;
return {
...previous,
version: 3,
clock: { minuteOffset: 0, ...(previous.clock as Record<string, unknown>) },
decorations: initialDecorations(),
playerId: newPlayerId(),
party: [],
@@ -3470,5 +3558,6 @@ export function migrateSave(
backupWarp: null,
flash: false,
noWildEncounters: false,
intro: false,
} as unknown as NativeSave;
}