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

+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;
}