- Enable Route 29, its Route 46 gate, Cherrygrove City and its buildings. - Run item balls through FindItemInBallScript and fruit trees through FruitTreeScript, with a native daily reset of the daily engine flags. - Port the Pokémon Center nurse (HealParty with the PP Up clamp, CheckPokerus, phone-call checks) and record the whiteout spawn on entering a Pokémon Center, as EnterMapWarp does. - Port standard Poké Marts with keyboard, D-pad and pointer menus. - Add the recoil battle command (Struggle) and VAR_BOXSPACE. - Save fruit trees, the daily reset day and the last spawn map; older version-3 backups migrate by absence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11d1153802
commit
796fe8f6d5
24 files changed
+1549
-59
No files matched your search
+166
-11
@@ -1,10 +1,11 @@
|
||||
import { CrystalAssets } from "../rom/assets";
|
||||
import { INITIAL_POSITION, SYMBOLS } from "../rom/profile";
|
||||
import { INITIAL_POSITION, NUM_FRUIT_TREES, SYMBOLS } from "../rom/profile";
|
||||
import {
|
||||
PLAYER,
|
||||
ScriptRun,
|
||||
copyScriptState,
|
||||
emptyScriptState,
|
||||
type MartEntry,
|
||||
type ScriptEffect,
|
||||
type ScriptHost,
|
||||
type ScriptState,
|
||||
@@ -57,6 +58,12 @@ export interface NativeSave {
|
||||
bag: Bag;
|
||||
pokedex: { seen: number[]; caught: number[] };
|
||||
money: number;
|
||||
fruitTrees: number[];
|
||||
dailyReset: number | null;
|
||||
/** wLastSpawnMapGroup/Number: the outdoor map last entered through a
|
||||
* Pokémon Center door, for the whiteout/blackout spawn. Null on early
|
||||
* version-3 backups and before any Center has been entered. */
|
||||
lastSpawnMap: string | null;
|
||||
}
|
||||
|
||||
/** A random byte from the platform's secure generator. */
|
||||
@@ -112,7 +119,22 @@ export type Choice =
|
||||
| { kind: "yesno"; selected: 0 | 1 }
|
||||
| { kind: "weekday"; names: string[]; selected: number }
|
||||
/** The naming screen: an empty answer keeps `current`. */
|
||||
| { kind: "name"; current: string; max: number; selected?: undefined };
|
||||
| { kind: "name"; current: string; max: number; selected?: undefined }
|
||||
/** The mart's BUY/SELL/QUIT box. */
|
||||
| { kind: "martmenu"; prompt: string; selected: number }
|
||||
/** The mart's item list; `selected` answers with that entry's index. */
|
||||
| { kind: "martlist"; mode: "buy" | "sell"; entries: MartEntry[]; selected: number }
|
||||
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
|
||||
| {
|
||||
kind: "martquantity";
|
||||
mode: "buy" | "sell";
|
||||
item: number;
|
||||
name: string;
|
||||
price: number;
|
||||
max: number;
|
||||
prompt: string;
|
||||
selected: number;
|
||||
};
|
||||
export interface Dialogue {
|
||||
pages: string[];
|
||||
index: number;
|
||||
@@ -247,6 +269,7 @@ export class GameWorld {
|
||||
private script?: ActiveScript;
|
||||
/** Scene scripts are re-evaluated only after the map or state changes. */
|
||||
private sceneCheck = true;
|
||||
private dailyFlags?: number[];
|
||||
|
||||
constructor(
|
||||
readonly assets: CrystalAssets,
|
||||
@@ -524,7 +547,18 @@ export class GameWorld {
|
||||
const arrival = target.warps[warp.targetWarp - 1];
|
||||
if (!arrival || !this.walkable(target, arrival.x, arrival.y))
|
||||
throw new Error("Warp destination is invalid.");
|
||||
const entry = this.prepareMap(target.id, "warp");
|
||||
// EnterMapWarp's SetSpawn: walking from an outdoor map into a Pokémon
|
||||
// Center records that outdoor map as the whiteout/blackout spawn.
|
||||
let sourceState = this.state;
|
||||
if (
|
||||
(target.tileset === 7 || target.tileset === 21) && // TILESET_POKECENTER, TILESET_POKECOM_CENTER
|
||||
(this.map.environment === 1 || this.map.environment === 2) // TOWN, ROUTE
|
||||
)
|
||||
sourceState = {
|
||||
...copyScriptState(sourceState),
|
||||
lastSpawnMap: this.mapId,
|
||||
};
|
||||
const entry = this.prepareMap(target.id, "warp", sourceState);
|
||||
return {
|
||||
entry,
|
||||
arrival,
|
||||
@@ -769,6 +803,7 @@ export class GameWorld {
|
||||
committedPosition?: { x: number; y: number },
|
||||
scene = false,
|
||||
wild?: { species: number; level: number },
|
||||
itemBall?: { item: number; quantity: number },
|
||||
) {
|
||||
const committed = this.save();
|
||||
if (committedPosition) {
|
||||
@@ -785,6 +820,7 @@ export class GameWorld {
|
||||
this.host(lastTalked),
|
||||
{
|
||||
wild,
|
||||
itemBall,
|
||||
},
|
||||
),
|
||||
committed,
|
||||
@@ -957,6 +993,45 @@ export class GameWorld {
|
||||
choice: { kind: "name", current: effect.current, max: effect.max },
|
||||
};
|
||||
return true;
|
||||
case "martmenu":
|
||||
this.dialogue = {
|
||||
pages: [""],
|
||||
index: 0,
|
||||
choice: {
|
||||
kind: "martmenu",
|
||||
prompt: effect.prompt,
|
||||
selected: effect.selected,
|
||||
},
|
||||
};
|
||||
return true;
|
||||
case "martlist":
|
||||
this.dialogue = {
|
||||
pages: [""],
|
||||
index: 0,
|
||||
choice: {
|
||||
kind: "martlist",
|
||||
mode: effect.mode,
|
||||
entries: effect.entries,
|
||||
selected: effect.selected,
|
||||
},
|
||||
};
|
||||
return true;
|
||||
case "martquantity":
|
||||
this.dialogue = {
|
||||
pages: [""],
|
||||
index: 0,
|
||||
choice: {
|
||||
kind: "martquantity",
|
||||
mode: effect.mode,
|
||||
item: effect.item,
|
||||
name: effect.name,
|
||||
price: effect.price,
|
||||
max: effect.max,
|
||||
prompt: effect.prompt,
|
||||
selected: effect.selected,
|
||||
},
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1162,6 +1237,19 @@ export class GameWorld {
|
||||
if (!prompt && view.message?.prompt) this.pumpBattle();
|
||||
}
|
||||
|
||||
/** 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 day =
|
||||
Math.floor(
|
||||
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86_400_000,
|
||||
) + this.state.clock.weekdayOffset;
|
||||
if (this.state.dailyReset === day) return;
|
||||
this.dailyFlags ??= this.assets.dailyEngineFlags();
|
||||
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
|
||||
this.state.dailyReset = day;
|
||||
}
|
||||
/** Scene scripts run while the player is idle (RunSceneScript). */
|
||||
private checkScene() {
|
||||
if (!this.sceneCheck || this.busy) return;
|
||||
@@ -1229,6 +1317,7 @@ export class GameWorld {
|
||||
if (this.dialogue) return;
|
||||
this.checkScene();
|
||||
if (this.busy) return;
|
||||
this.checkDailyReset();
|
||||
if (
|
||||
FORCED_WARP_EXITS.has(this.assets.collision(this.map, player.x, player.y))
|
||||
)
|
||||
@@ -1270,6 +1359,21 @@ export class GameWorld {
|
||||
);
|
||||
let script: number | undefined;
|
||||
if (object) {
|
||||
if (object.kind === 1) {
|
||||
// OBJECTTYPE_ITEMBALL: the script field names raw data (the itemball
|
||||
// macro's item, quantity), not a script; FindItemInBallScript always
|
||||
// runs, with hLastTalked left on this object as TryObjectEvent does.
|
||||
const r = this.assets.reader;
|
||||
this.startScript(
|
||||
SYMBOLS.findItemInBallScript,
|
||||
object.id,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
{ item: r.byte(object.script), quantity: r.byte(object.script + 1) },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (object.kind !== 0) {
|
||||
this.notice = "This interaction is not ported yet.";
|
||||
return;
|
||||
@@ -1323,8 +1427,14 @@ export class GameWorld {
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (last && dialogue.holdTicks !== undefined) return;
|
||||
if (last && dialogue.choice) {
|
||||
// B answers NO on a yes/no box; other choices need their own answer.
|
||||
if (dialogue.choice.kind === "yesno") this.choose(0);
|
||||
// B answers NO on a yes/no box, quits the mart menu (matching the
|
||||
// ROM's VerticalMenu carry) or backs out of a mart list/quantity
|
||||
// screen; other choices need their own answer.
|
||||
const choice = dialogue.choice;
|
||||
if (choice.kind === "yesno") this.choose(0);
|
||||
else if (choice.kind === "martmenu") this.choose(2);
|
||||
else if (choice.kind === "martlist" || choice.kind === "martquantity")
|
||||
this.choose(-1);
|
||||
return;
|
||||
}
|
||||
if (!last) {
|
||||
@@ -1348,6 +1458,29 @@ export class GameWorld {
|
||||
if (choice.kind === "yesno") {
|
||||
if (direction === "up") choice.selected = 1;
|
||||
if (direction === "down") choice.selected = 0;
|
||||
} else if (choice.kind === "martmenu") {
|
||||
if (direction === "up") choice.selected = (choice.selected + 2) % 3;
|
||||
if (direction === "down") choice.selected = (choice.selected + 1) % 3;
|
||||
} else if (choice.kind === "martlist") {
|
||||
const length = choice.entries.length;
|
||||
if (!length) return;
|
||||
if (direction === "up")
|
||||
choice.selected = (choice.selected + length - 1) % length;
|
||||
if (direction === "down")
|
||||
choice.selected = (choice.selected + 1) % length;
|
||||
} else if (choice.kind === "martquantity") {
|
||||
// BuySellToss_InterpretJoypad: Up/Down step by 1 and wrap; Left/Right
|
||||
// step by 10 and clamp to the 1-max range.
|
||||
if (direction === "down")
|
||||
choice.selected =
|
||||
choice.selected > 1 ? choice.selected - 1 : choice.max;
|
||||
if (direction === "up")
|
||||
choice.selected =
|
||||
choice.selected < choice.max ? choice.selected + 1 : 1;
|
||||
if (direction === "left")
|
||||
choice.selected = Math.max(1, choice.selected - 10);
|
||||
if (direction === "right")
|
||||
choice.selected = Math.min(choice.max, choice.selected + 10);
|
||||
} else if (direction === "up") choice.selected = (choice.selected + 1) % 7;
|
||||
else if (direction === "down") choice.selected = (choice.selected + 6) % 7;
|
||||
}
|
||||
@@ -1408,6 +1541,9 @@ export class GameWorld {
|
||||
caught: [...state.pokedex.caught].sort((a, b) => a - b),
|
||||
},
|
||||
money: state.money,
|
||||
fruitTrees: [...state.fruitTrees].sort((a, b) => a - b),
|
||||
dailyReset: state.dailyReset,
|
||||
lastSpawnMap: state.lastSpawnMap,
|
||||
};
|
||||
}
|
||||
restore(value: unknown) {
|
||||
@@ -1470,7 +1606,14 @@ export class GameWorld {
|
||||
!species(save.pokedex.seen) ||
|
||||
!species(save.pokedex.caught) ||
|
||||
!save.pokedex.caught.every((id) => save.pokedex.seen.includes(id)) ||
|
||||
!integer(save.money, 0, C.MAX_MONEY)
|
||||
!integer(save.money, 0, C.MAX_MONEY) ||
|
||||
!Array.isArray(save.fruitTrees) ||
|
||||
!save.fruitTrees.every((id) => integer(id, 1, NUM_FRUIT_TREES)) ||
|
||||
new Set(save.fruitTrees).size !== save.fruitTrees.length ||
|
||||
!(save.dailyReset === null || integer(save.dailyReset, 0, 0xffffff)) ||
|
||||
(save.lastSpawnMap !== null &&
|
||||
(typeof save.lastSpawnMap !== "string" ||
|
||||
!/^\d{1,2}:\d{1,3}$/.test(save.lastSpawnMap)))
|
||||
) {
|
||||
throw new Error(
|
||||
"Invalid native save. Emulator snapshots cannot be imported into this port.",
|
||||
@@ -1517,6 +1660,10 @@ export class GameWorld {
|
||||
caught: new Set(save.pokedex.caught),
|
||||
},
|
||||
money: save.money,
|
||||
fruitTrees: new Set(save.fruitTrees),
|
||||
dailyReset: save.dailyReset,
|
||||
lastSpawnMap: save.lastSpawnMap,
|
||||
specialPhoneCall: 0,
|
||||
};
|
||||
let entry;
|
||||
try {
|
||||
@@ -1571,11 +1718,16 @@ export function migrateSave(
|
||||
if (!save || typeof save !== "object" || save.format !== "crystal-native")
|
||||
return undefined;
|
||||
if (save.version === 3) {
|
||||
// Early version-3 backups predate battle rewards and the money field.
|
||||
// Only absence migrates; an explicitly invalid value must still be rejected.
|
||||
return (Object.hasOwn(save, "money")
|
||||
? save
|
||||
: { ...save, money: C.START_MONEY }) as unknown as NativeSave;
|
||||
// Early version-3 backups predate battle rewards, the money field, daily
|
||||
// state and the recorded whiteout spawn. Only absence migrates; an
|
||||
// explicitly invalid value must still be rejected.
|
||||
return {
|
||||
...save,
|
||||
...(!Object.hasOwn(save, "money") && { money: C.START_MONEY }),
|
||||
...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }),
|
||||
...(!Object.hasOwn(save, "dailyReset") && { dailyReset: null }),
|
||||
...(!Object.hasOwn(save, "lastSpawnMap") && { lastSpawnMap: null }),
|
||||
} as unknown as NativeSave;
|
||||
}
|
||||
if (save.version !== 1 && save.version !== 2) return undefined;
|
||||
const previous =
|
||||
@@ -1595,6 +1747,9 @@ export function migrateSave(
|
||||
party: [],
|
||||
bag: emptyBag(),
|
||||
pokedex: { seen: [], caught: [] },
|
||||
lastSpawnMap: null,
|
||||
money: C.START_MONEY,
|
||||
fruitTrees: [],
|
||||
dailyReset: null,
|
||||
} as unknown as NativeSave;
|
||||
}
|
||||
Reference in new issue
Block a user