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:
1 parent
5a0fe1e313
commit
b13fd20be4
28 files changed
+975
-101
No files matched your search
+44
-2
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user