Let NPCs roam as HandleNPCStep does
Checks / web (push) Canceled after 0s

- Random walks and spins from SpriteMovementData, with random sleeps.
- CanObjectMoveInDirection: side walls, land, objects' current and
  previous tiles, the radius (stored plus one) and the screen edge.
- Objects freeze while text is open and during applymovement, and
  return home when both they and their home leave the object window.
- Wandering positions are not saved; test helpers replan around NPCs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-23 17:40:12 +02:00
1 parent 796fe8f6d5
commit 6a74f59e46
12 files changed
+381 -15

No files matched your search

+2 -2
View File
@@ -6,9 +6,9 @@ A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassem
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming. Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
With a Pokémon, head west along Route 29 to Cherrygrove City. Wild Pokémon appear in the grass and battle you with their original moves. The route has ledges, signs, an item ball and a Berry tree that regrows daily. In Cherrygrove, the Guide Gent gives a tour, the Poké Mart buys and sells items, the Pokémon Center nurse heals your party, and a whiteout returns you to the last Pokémon Center you entered. With a Pokémon, head west along Route 29 to Cherrygrove City. Wild Pokémon appear in the grass and battle you with their original moves. The route has ledges, signs, an item ball and a Berry tree that regrows daily. Townspeople wander and look around as in the original. In Cherrygrove, the Guide Gent gives a tour, the Poké Mart buys and sells items, the Pokémon Center nurse heals your party, and a whiteout returns you to the last Pokémon Center you entered.
Catching, trainer battles, Route 30 and beyond, party and bag menus, random NPC movement, decorations and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. Catching, trainer battles, Route 30 and beyond, party and bag menus, decorations and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
## Run ## Run
+2 -2
View File
@@ -27,7 +27,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `game/rom/assets.ts` maps/events | `data/maps/maps.asm`, `data/maps/attributes.asm`, `data/maps/scenes.asm`, `data/maps/landmarks.asm`, `macros/scripts/maps.asm` | Any map header in the ROM's 26 groups decodes; only the six ported maps can be entered. Location names, scene-variable owners and connection offsets come from the ROM. | | `game/rom/assets.ts` maps/events | `data/maps/maps.asm`, `data/maps/attributes.asm`, `data/maps/scenes.asm`, `data/maps/landmarks.asm`, `macros/scripts/maps.asm` | Any map header in the ROM's 26 groups decodes; only the six ported maps can be entered. Location names, scene-variable owners and connection offsets come from the ROM. |
| Tiles/palettes | `home/map.asm`, `engine/gfx/color.asm`, `engine/tilesets/mapgroup_roofs.asm` | Graphics match rebuilt reference bytes. Connected maps draw beyond the edges with the current tileset, like the original strips. Terrain animation and other special palettes pending. | | Tiles/palettes | `home/map.asm`, `engine/gfx/color.asm`, `engine/tilesets/mapgroup_roofs.asm` | Graphics match rebuilt reference bytes. Connected maps draw beyond the edges with the current tileset, like the original strips. Terrain animation and other special palettes pending. |
| Sprites/rendering | `data/sprites/sprites.asm`, `data/sprites/facings.asm`, `data/sprites/emotes.asm` | Chris/Kris, standard NPCs, emote bubbles and jump shadows decoded. Walking frames follow `SetFacingStepAction`. Dynamic Pokémon icons and room decorations pending. | | Sprites/rendering | `data/sprites/sprites.asm`, `data/sprites/facings.asm`, `data/sprites/emotes.asm` | Chris/Kris, standard NPCs, emote bubbles and jump shadows decoded. Walking frames follow `SetFacingStepAction`. Dynamic Pokémon icons and room decorations pending. |
| `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. Surfing and NPC roaming remain absent; turn timing still needs comparison. | | `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. NPC random walks and spins follow `HandleNPCStep`. Surfing remains absent; turn timing still needs comparison. |
| Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Resumable; yields text, choices, movement, emotes and pauses. Scenes, phone contacts, string buffers and object commands. Unknown commands and specials abort without committing. | | Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Resumable; yields text, choices, movement, emotes and pauses. Scenes, phone contacts, string buffers and object commands. Unknown commands and specials abort without committing. |
| Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` moves the follower into the leader's previous square. | | Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` moves the follower into the leader's previous square. |
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), the empty-room decoration toggles, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. | | Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), the empty-room decoration toggles, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
@@ -69,4 +69,4 @@ Native saves are version 3. Version 2 added scene values by owning map, phone co
## Next slice ## Next slice
Route 29 and Cherrygrove are enabled with item balls, fruit trees and the daily reset, the Pokémon Center nurse and the recorded whiteout spawn. Next: NPC roaming, trainer sight lines and trainer battles, capture with the catching tutorial, and party, bag and Pokégear menus over the native models. Other specials stay refused until translated. Route 29 and Cherrygrove are enabled with item balls, fruit trees and the daily reset, the Pokémon Center nurse and the recorded whiteout spawn. NPCs roam. Next: trainer sight lines and trainer battles, capture with the catching tutorial, and party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
+4 -3
View File
@@ -56,19 +56,20 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
- `VAR_BOXSPACE` reads an empty box: nothing can be caught or deposited yet. - `VAR_BOXSPACE` reads an empty box: nothing can be caught or deposited yet.
- The battle engine gained `recoil` (Struggle). A local stress run of 360 Route 29 battles across all starters and times of day finishes without refusals. - The battle engine gained `recoil` (Struggle). A local stress run of 360 Route 29 battles across all starters and times of day finishes without refusals.
- Save format 3 gains `fruitTrees`, `dailyReset` and `lastSpawnMap`; older backups migrate by absence. - Save format 3 gains `fruitTrees`, `dailyReset` and `lastSpawnMap`; older backups migrate by absence.
- NPCs roam as `HandleNPCStep` does: random walks (Y, X, XY) and slow/fast spins from `SpriteMovementData`, `CanObjectMoveInDirection` (side walls, land, objects' current and previous tiles, radius stored plus one, screen edge), random sleeps, freezing during text and `applymovement`, and deletion/respawn at home beyond the object window. Wandering positions are not saved; objects start from home on reload.
- Still refused: the catching tutorial (`catchtutorial`) and Cherrygrove's rival scene, both reachable only after the Mystery Egg errand. - Still refused: the catching tutorial (`catchtutorial`) and Cherrygrove's rival scene, both reachable only after the Mystery Egg errand.
## In progress: native battles ## In progress: native battles
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience and move learning; reference tests compare damage and type effectiveness. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Capture, switching, items in battle, trainer battles and the rest of the opening quest remain unfinished. The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience and move learning; reference tests compare damage and type effectiveness. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Capture, switching, items in battle, trainer battles and the rest of the opening quest remain unfinished.
This is still not a complete new game. Party/bag/Pokégear menus, story progression beyond Cherrygrove, NPC roaming, decorations, animated terrain, surfing and audio remain unfinished. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later. This is still not a complete new game. Party/bag/Pokégear menus, story progression beyond Cherrygrove, decorations, animated terrain, surfing and audio remain unfinished. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
## 1. Complete the overworld foundation ## 1. Complete the overworld foundation
Map headers, location names, connection offsets, connected-border drawing, coordinate events, scene scripts, map callbacks and scripted movement are in place (see above). Next: enable connected transitions as their maps are ported; add dynamic warps, elevators, ice, currents, remaining forced movement, surfing, cycling, cut trees and boulders. Player turn timing still needs a reference comparison. Map headers, location names, connection offsets, connected-border drawing, coordinate events, scene scripts, map callbacks and scripted movement are in place (see above). Next: enable connected transitions as their maps are ported; add dynamic warps, elevators, ice, currents, remaining forced movement, surfing, cycling, cut trees and boulders. Player turn timing still needs a reference comparison.
Port decorations, tile changes (`changeblock`), NPC roaming movement types, trainer sight lines and the remaining movement commands. Complete terrain animation, layering and special palettes. Record deviations from the original until a comparison test closes them. Port decorations, tile changes (`changeblock`), the remaining NPC movement types (followers, bouncing, Strength boulders), trainer sight lines and the remaining movement commands. Complete terrain animation, layering and special palettes. Record deviations from the original until a comparison test closes them.
Acceptance: representative outdoor/interior/cave/connected-map traces produce matching positions, collisions, transitions and event changes in native and reference engines. Do not enable an area merely because its image can be decoded. Acceptance: representative outdoor/interior/cave/connected-map traces produce matching positions, collisions, transitions and event changes in native and reference engines. Do not enable an area merely because its image can be decoded.
@@ -78,7 +79,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game. Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29 and Cherrygrove run now. Next: NPC roaming movement, trainer sight lines and trainer battles, Route 30 and Mr. Pokémon, the Mystery Egg round trip with the catching tutorial and the rival battle; then the introduction's naming and clock screens. Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29 and Cherrygrove run now. Next: trainer sight lines and trainer battles, Route 30 and Mr. Pokémon, the Mystery Egg round trip with the catching tutorial and the rival battle; then the introduction's naming and clock screens.
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior. Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
+3
View File
@@ -87,6 +87,7 @@ const names = {
fruitTreeScript: "FruitTreeScript", fruitTreeScript: "FruitTreeScript",
fruitTreeItems: "FruitTreeItems", fruitTreeItems: "FruitTreeItems",
engineFlags: "EngineFlags", engineFlags: "EngineFlags",
spriteMovementData: "SpriteMovementData",
getCurTreeFruit: "GetCurTreeFruit", getCurTreeFruit: "GetCurTreeFruit",
tryResetFruitTrees: "TryResetFruitTrees", tryResetFruitTrees: "TryResetFruitTrees",
checkFruitTree: "CheckFruitTree", checkFruitTree: "CheckFruitTree",
@@ -280,6 +281,8 @@ for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?
} }
if (!profile.includes(`allFruitTrees: ${pretConstants.get("ENGINE_ALL_FRUIT_TREES")},`)) if (!profile.includes(`allFruitTrees: ${pretConstants.get("ENGINE_ALL_FRUIT_TREES")},`))
throw new Error("ENGINE_ALL_FRUIT_TREES does not match the reference."); throw new Error("ENGINE_ALL_FRUIT_TREES does not match the reference.");
if (!profile.includes(`NUM_SPRITEMOVEDATA = ${pretConstants.get("NUM_SPRITEMOVEDATA")};`))
throw new Error("NUM_SPRITEMOVEDATA does not match the reference.");
if (!profile.includes(`NUM_ENGINE_FLAGS = ${pretConstants.get("NUM_ENGINE_FLAGS")};`)) if (!profile.includes(`NUM_ENGINE_FLAGS = ${pretConstants.get("NUM_ENGINE_FLAGS")};`))
throw new Error("NUM_ENGINE_FLAGS does not match the reference."); throw new Error("NUM_ENGINE_FLAGS does not match the reference.");
const groups = readFileSync(`${root}/data/maps/maps.asm`, "utf8") const groups = readFileSync(`${root}/data/maps/maps.asm`, "utf8")
+14
View File
@@ -5,6 +5,7 @@ import {
MAP_GROUP_COUNT, MAP_GROUP_COUNT,
NATIVE_MAPS, NATIVE_MAPS,
NUM_ENGINE_FLAGS, NUM_ENGINE_FLAGS,
NUM_SPRITEMOVEDATA,
RAM, RAM,
SYMBOLS, SYMBOLS,
} from "./profile"; } from "./profile";
@@ -165,6 +166,7 @@ export class CrystalAssets {
y: r.byte(p + 1) - 4, y: r.byte(p + 1) - 4,
x: r.byte(p + 2) - 4, x: r.byte(p + 2) - 4,
movement: r.byte(p + 3), movement: r.byte(p + 3),
radius: r.byte(p + 4),
hour1: r.byte(p + 5), hour1: r.byte(p + 5),
hour2: r.byte(p + 6), hour2: r.byte(p + 6),
palette: r.byte(p + 7) >> 4, palette: r.byte(p + 7) >> 4,
@@ -374,6 +376,18 @@ export class CrystalAssets {
} }
return flags; return flags;
} }
/** SpriteMovementData for a SPRITEMOVEDATA_* type: the movement
* function, flags1 (FIXED_FACING, NOCLIP_*, ...) and palette flags. */
spriteMovement(type: number): {
fn: number;
flags: number;
paletteFlags: number;
} {
if (type >= NUM_SPRITEMOVEDATA) throw new Error("Unknown movement type.");
const r = this.reader,
p = SYMBOLS.spriteMovementData + type * 6;
return { fn: r.byte(p), flags: r.byte(p + 3), paletteFlags: r.byte(p + 5) };
}
/** Emote bubble graphics (data/sprites/emotes.asm). */ /** Emote bubble graphics (data/sprites/emotes.asm). */
emote(index: number): Uint8Array { emote(index: number): Uint8Array {
const r = this.reader, const r = this.reader,
+2
View File
@@ -52,6 +52,7 @@ export const SYMBOLS = {
fruitTreeScript: 0x44000, fruitTreeScript: 0x44000,
fruitTreeItems: 0x44097, fruitTreeItems: 0x44097,
engineFlags: 0x80462, engineFlags: 0x80462,
spriteMovementData: 0x4273,
marts: 0x160a9, marts: 0x160a9,
martWelcomeText: 0x15f83, martWelcomeText: 0x15f83,
martAskMoreText: 0x15fb9, martAskMoreText: 0x15fb9,
@@ -256,6 +257,7 @@ export const NUM_POKEMON = 251;
export const NUM_ITEMS = 190; export const NUM_ITEMS = 190;
export const NUM_ENGINE_FLAGS = 162; export const NUM_ENGINE_FLAGS = 162;
export const NUM_FRUIT_TREES = 30; export const NUM_FRUIT_TREES = 30;
export const NUM_SPRITEMOVEDATA = 37;
/** The last group has no following pointer to bound it. */ /** The last group has no following pointer to bound it. */
export const LAST_MAP_GROUP_SIZE = 11; export const LAST_MAP_GROUP_SIZE = 11;
+2
View File
@@ -24,6 +24,8 @@ export interface MapObject {
x: number; x: number;
y: number; y: number;
movement: number; movement: number;
/** Movement radius as stored: high nibble y, low nibble x. */
radius: number;
palette: number; palette: number;
kind: number; kind: number;
script: number; script: number;
+205 -4
View File
@@ -98,6 +98,14 @@ interface Actor {
hidden: boolean; hidden: boolean;
fixedFacing: boolean; fixedFacing: boolean;
moved: boolean; moved: boolean;
/** HandleNPCStep state for map objects: OBJECT_INIT_X/Y, the step type
* and OBJECT_STEP_DURATION. "reset" runs the movement function next. */
npc?: {
homeX: number;
homeY: number;
step: "reset" | "sleep" | "walk" | "stand";
wait: number;
};
} }
export interface VisibleObject extends MapObject { export interface VisibleObject extends MapObject {
direction: Direction; direction: Direction;
@@ -221,11 +229,28 @@ const spawnActor = (object: MapObject): Actor => ({
hidden: false, hidden: false,
fixedFacing: false, fixedFacing: false,
moved: false, moved: false,
npc: { homeX: object.x, homeY: object.y, step: "reset", wait: 0 },
}); });
const copyActor = (actor: Actor): Actor => ({ const copyActor = (actor: Actor): Actor => ({
...actor, ...actor,
motion: actor.motion && { ...actor.motion }, motion: actor.motion && { ...actor.motion },
npc: actor.npc && { ...actor.npc },
}); });
/** SPRITEMOVEFN_* functions that move an object on their own. */
const RANDOM_WALK_Y = 1,
RANDOM_WALK_X = 2,
RANDOM_WALK_XY = 3,
SLOW_RANDOM_SPIN = 4,
FAST_RANDOM_SPIN = 5;
/** SpriteMovementData flags1 and palette flag bits. */
const WONT_DELETE = 1 << 1,
FIXED_FACING = 1 << 2,
NOCLIP_TILES = 1 << 4,
MOVE_ANYWHERE = 1 << 5,
NOCLIP_OBJS = 1 << 6,
SWIMMING = 1 << 5;
/** STEP_SLOW: random walkers take 16 ticks per tile. */
const NPC_STEP_TICKS = 16;
const isTimed = (effect: ScriptEffect | undefined) => const isTimed = (effect: ScriptEffect | undefined) =>
effect?.kind === "move" || effect?.kind === "move" ||
effect?.kind === "emote" || effect?.kind === "emote" ||
@@ -421,8 +446,17 @@ export class GameWorld {
this.notice = ""; this.notice = "";
this.diagnostic = ""; this.diagnostic = "";
} }
private occupied(x: number, y: number) { /** IsNPCAtCoord: an object blocks its tile and, mid-step, the tile it
return this.objects().some((object) => object.x === x && object.y === y); * is leaving (OBJECT_LAST_MAP_X/Y). */
private occupied(x: number, y: number, except?: number) {
return this.objects().some((object) => {
if (object.id === except) return false;
const motion = this.actor(object).motion;
return (
(object.x === x && object.y === y) ||
(motion !== undefined && motion.fromX === x && motion.fromY === y)
);
});
} }
private walkable(map = this.map, x: number, y: number): boolean { private walkable(map = this.map, x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return false; if (x < 0 || y < 0 || x >= map.width || y >= map.height) return false;
@@ -735,6 +769,8 @@ export class GameWorld {
if (effect.kind === "moveobject") { if (effect.kind === "moveobject") {
actor.x = effect.x; actor.x = effect.x;
actor.y = effect.y; actor.y = effect.y;
// moveobject writes the map object's coordinates: its new home.
if (actor.npc) Object.assign(actor.npc, { homeX: effect.x, homeY: effect.y });
actor.moved = true; actor.moved = true;
} else if (effect.kind === "turn") { } else if (effect.kind === "turn") {
actor.direction = effect.direction; actor.direction = effect.direction;
@@ -935,6 +971,9 @@ export class GameWorld {
actor.x = effect.x; actor.x = effect.x;
actor.y = effect.y; actor.y = effect.y;
actor.motion = undefined; actor.motion = undefined;
// moveobject writes the map object's coordinates: its new home.
if (actor.npc)
Object.assign(actor.npc, { homeX: effect.x, homeY: effect.y, step: "reset" });
actor.moved = true; actor.moved = true;
return false; return false;
} }
@@ -1053,6 +1092,8 @@ export class GameWorld {
const step = movement.steps[movement.index++]; const step = movement.steps[movement.index++];
if (!step) { if (!step) {
script.moving.delete(id); script.moving.delete(id);
// step_end returns the object to its own movement function.
if (actor.npc) actor.npc.step = "reset";
break; break;
} }
if (step.kind === "turn") { if (step.kind === "turn") {
@@ -1250,6 +1291,157 @@ export class GameWorld {
for (const flag of this.dailyFlags) this.state.flags.delete(flag); for (const flag of this.dailyFlags) this.state.flags.delete(flag);
this.state.dailyReset = day; this.state.dailyReset = day;
} }
// ---------------------------------------------------------------- NPCs
/** HandleNPCStep: every spawned object runs its step type once per tick
* (engine/overworld/map_objects.asm). */
private stepObjects() {
for (const object of this.map.objects) {
if (!this.spawned.has(object.id)) continue;
const actor = this.actor(object),
npc = actor.npc;
if (!npc || !this.stillVisible(object, actor)) continue;
if (npc.step === "walk") {
// StepFunction_ContinueWalk, then RandomStepDuration_Slow.
this.animate(actor);
if (!actor.motion) this.randomSleep(actor, 0x7f, false);
} else if (npc.step === "sleep") this.sleep(actor);
else if (npc.step === "reset") this.movementFunction(object, actor);
}
}
/** CheckObjectStillVisible: an object whose tile and home are both beyond
* the object window is deleted, and respawns at home when it returns. */
private stillVisible(object: MapObject, actor: Actor): boolean {
const npc = actor.npc!;
const inWindow = (x: number, y: number) =>
x >= this.player.x - 5 &&
x <= this.player.x + 6 &&
y >= this.player.y - 5 &&
y <= this.player.y + 5;
if (inWindow(actor.x, actor.y)) return true;
if (inWindow(npc.homeX, npc.homeY)) return true;
if (this.assets.spriteMovement(object.movement).flags & WONT_DELETE)
return true;
if (actor.x !== npc.homeX || actor.y !== npc.homeY || npc.step !== "reset")
Object.assign(actor, {
...spawnActor(object),
npc: { ...npc, step: "reset", wait: 0 },
x: npc.homeX,
y: npc.homeY,
moved: actor.moved,
});
return false;
}
/** StepFunction_Sleep: OBJECT_STEP_DURATION counts down as a byte. */
private sleep(actor: Actor) {
const npc = actor.npc!;
npc.wait = (npc.wait - 1) & 0xff;
if (!npc.wait) npc.step = "reset";
}
/** RandomStepDuration_Slow/_Fast. From a movement function the new sleep
* step also runs in the same tick (HandleStepType's jumptable call). */
private randomSleep(actor: Actor, mask: number, now: boolean) {
const npc = actor.npc!;
npc.step = "sleep";
npc.wait = this.random() & mask;
if (now) this.sleep(actor);
}
/** StepFunction_FromMovement for the random walk and spin functions;
* other functions leave the object standing. */
private movementFunction(object: MapObject, actor: Actor) {
const npc = actor.npc!,
movement = this.assets.spriteMovement(object.movement);
switch (movement.fn) {
case RANDOM_WALK_Y:
case RANDOM_WALK_X:
case RANDOM_WALK_XY: {
const roll = this.random();
const index =
movement.fn === RANDOM_WALK_Y
? roll & 1
: movement.fn === RANDOM_WALK_X
? (roll & 1) | 2
: roll & 3;
const direction = DIRECTION_ORDER[index];
// InitStep turns the object even when the step is then refused.
if (!(movement.flags & FIXED_FACING) && !actor.fixedFacing)
actor.direction = direction;
if (!this.canObjectMove(object, actor, direction, movement)) {
this.randomSleep(actor, 0x7f, true);
return;
}
const [dx, dy] = VECTORS[direction];
this.startMotion(actor, actor.x + dx, actor.y + dy, NPC_STEP_TICKS, true);
npc.step = "walk";
this.animate(actor);
return;
}
case SLOW_RANDOM_SPIN:
actor.direction = DIRECTION_ORDER[(this.random() & 0x0c) >> 2];
this.randomSleep(actor, 0x7f, true);
return;
case FAST_RANDOM_SPIN: {
const current = DIRECTION_ORDER.indexOf(actor.direction) << 2;
let facing = this.random() & 0x0c;
if (facing === current) facing ^= 0x0c;
actor.direction = DIRECTION_ORDER[facing >> 2];
this.randomSleep(actor, 0x1f, true);
return;
}
default:
npc.step = "stand";
}
}
/** CanObjectMoveInDirection (engine/overworld/npc_movement.asm). */
private canObjectMove(
object: MapObject,
actor: Actor,
direction: Direction,
movement: { flags: number; paletteFlags: number },
): boolean {
const [dx, dy] = VECTORS[direction],
x = actor.x + dx,
y = actor.y + dy,
map = this.map;
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return false;
const source = this.assets.collision(map, actor.x, actor.y),
target = this.assets.collision(map, x, y);
const swimming = Boolean(movement.paletteFlags & SWIMMING);
if (swimming || !(movement.flags & NOCLIP_TILES)) {
// WillObjectBumpIntoWater/Land, CanObjectLeaveTile, WillObjectBumpIntoTile.
const edges = (tile: number) =>
(tile & 0xf0) === 0xb0 ? EDGE_DIRECTIONS[tile & 7] : [];
if (edges(source).includes(direction)) return false;
if (this.assets.permission(target) !== (swimming ? 1 : 0)) return false;
if (edges(target).includes(OPPOSITE[direction])) return false;
}
if (!(movement.flags & NOCLIP_OBJS)) {
const player = this.player,
from = player.motion;
if (
(player.x === x && player.y === y) ||
(from && from.fromX === x && from.fromY === y) ||
this.occupied(x, y, object.id)
)
return false;
}
if (movement.flags & MOVE_ANYWHERE) return true;
// HasObjectReachedMovementLimit: .InitRadius stored each nibble plus
// one, so the tile at home ± (radius + 1) is refused.
const npc = actor.npc!,
rx = ((object.radius & 0x0f) + 1) & 0x0f,
ry = ((object.radius >> 4) + 1) & 0x0f;
if (rx && (x === npc.homeX - rx || x === npc.homeX + rx)) return false;
if (ry && (y === npc.homeY - ry || y === npc.homeY + ry)) return false;
// IsObjectMovingOffEdgeOfScreen: stay within the visible screen.
return (
x >= this.player.x - 4 &&
x <= this.player.x + 5 &&
y >= this.player.y - 4 &&
y <= this.player.y + 4
);
}
/** Scene scripts run while the player is idle (RunSceneScript). */ /** Scene scripts run while the player is idle (RunSceneScript). */
private checkScene() { private checkScene() {
if (!this.sceneCheck || this.busy) return; if (!this.sceneCheck || this.busy) return;
@@ -1294,6 +1486,9 @@ export class GameWorld {
this.advanceMovement(1); this.advanceMovement(1);
if (!script.moving.size && !this.anyMotion()) this.pump(); if (!script.moving.size && !this.anyMotion()) this.pump();
} else if (isTimed(script.effect)) { } else if (isTimed(script.effect)) {
// Pauses and emotes keep the map loop running; applymovement and
// open text freeze other objects.
this.stepObjects();
if (--script.waitTicks <= 0) { if (--script.waitTicks <= 0) {
if (script.effect?.kind === "emote") this.emote = undefined; if (script.effect?.kind === "emote") this.emote = undefined;
this.pump(); this.pump();
@@ -1304,6 +1499,8 @@ export class GameWorld {
const player = this.player; const player = this.player;
if (player.motion) { if (player.motion) {
const previous = { x: player.motion.fromX, y: player.motion.fromY }; const previous = { x: player.motion.fromX, y: player.motion.fromY };
// HandleMapObjects: HandleNPCStep precedes _HandlePlayerStep.
this.stepObjects();
this.animate(player); this.animate(player);
if (player.motion) return; if (player.motion) return;
// The step ends here; tile events and the next step follow next tick. // The step ends here; tile events and the next step follow next tick.
@@ -1322,9 +1519,11 @@ export class GameWorld {
FORCED_WARP_EXITS.has(this.assets.collision(this.map, player.x, player.y)) FORCED_WARP_EXITS.has(this.assets.collision(this.map, player.x, player.y))
) )
held = "down"; held = "down";
if (!held) return;
// PlayerEvents precede HandleMapObjects: a step starts and moves at once. // PlayerEvents precede HandleMapObjects: a step starts and moves at once.
if (this.beginStep(held)) this.animate(this.player); const stepped = held !== undefined && this.beginStep(held);
if (this.busy && !stepped) return;
this.stepObjects();
if (stepped) this.animate(this.player);
} }
private anyMotion() { private anyMotion() {
return ( return (
@@ -1683,6 +1882,8 @@ export class GameWorld {
actor.y = object.y; actor.y = object.y;
actor.direction = object.direction; actor.direction = object.direction;
actor.moved = true; actor.moved = true;
// Saved objects were placed by scripts; they roam from there.
if (actor.npc) Object.assign(actor.npc, { homeX: object.x, homeY: object.y });
} }
this.player = { this.player = {
x: save.x, x: save.x,
+2 -1
View File
@@ -320,8 +320,9 @@ describe.skipIf(!existsSync(romPath))(
expect(pages).toContain("It's dangerous to\ngo out without a\nPOKéMON!"); expect(pages).toContain("It's dangerous to\ngo out without a\nPOKéMON!");
// The teacher runs over and leads the player back east. // The teacher runs over and leads the player back east.
expect([world.x, world.y, world.direction]).toEqual([5, 8, "right"]); expect([world.x, world.y, world.direction]).toEqual([5, 8, "right"]);
// Back home, SPRITEMOVEDATA_SPINRANDOM_SLOW spins her again at once.
const teacher = world.objects().find((object) => object.id === 2)!; const teacher = world.objects().find((object) => object.id === 2)!;
expect([teacher.x, teacher.y, teacher.direction]).toEqual([6, 8, "left"]); expect([teacher.x, teacher.y]).toEqual([6, 8]);
expect(world.save().events).toEqual(events); expect(world.save().events).toEqual(events);
walkTo(world, 2, 2); walkTo(world, 2, 2);
expect(world.move("right")).toBe(false); expect(world.move("right")).toBe(false);
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { walkTo } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const ROUTE_29 = "24:3";
// object_event ids are their index + 2 (maps/Route29.asm).
const YOUNGSTER = 3; // 27,16 SPRITEMOVEDATA_WALK_UP_DOWN, radius 0, 1
const TEACHER = 4; // 15,11 SPRITEMOVEDATA_WALK_LEFT_RIGHT, radius 1, 0
const DUDE = 2; // 50,12 SPRITEMOVEDATA_SPINRANDOM_SLOW
describe.skipIf(!existsSync(romPath))("NPC movement with the local ROM", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
/** A seeded Random so every run takes the same steps. */
const at = (x: number, y: number, direction: Direction = "down") => {
let seed = 7;
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
world.restore({ ...world.save(), map: ROUTE_29, x, y, direction });
return world;
};
const object = (world: GameWorld, id: number) =>
world.objects().find((candidate) => candidate.id === id)!;
const track = (world: GameWorld, id: number, ticks: number) => {
const seen = new Set<string>(),
facings = new Set<Direction>();
for (let tick = 0; tick < ticks; tick++) {
world.tick();
const { x, y, direction } = object(world, id);
seen.add(`${x},${y}`);
facings.add(direction);
}
return { seen, facings };
};
it("walks up and down within the object's radius", () => {
const world = at(29, 16);
const { seen, facings } = track(world, YOUNGSTER, 3000);
// Radius 1: home and one tile either way, never off the column.
expect(seen.size).toBeGreaterThan(1);
for (const tile of seen) {
const [x, y] = tile.split(",").map(Number);
expect(x).toBe(27);
expect(Math.abs(y - 16)).toBeLessThanOrEqual(1);
}
expect([...facings].every((facing) => facing === "up" || facing === "down")).toBe(true);
expect(world.notice, world.diagnostic).toBe("");
});
it("walks left and right for WALK_LEFT_RIGHT", () => {
const world = at(13, 12);
const { seen } = track(world, TEACHER, 3000);
expect(seen.size).toBeGreaterThan(1);
for (const tile of seen) {
const [x, y] = tile.split(",").map(Number);
expect(y).toBe(11);
expect(Math.abs(x - 15)).toBeLessThanOrEqual(1);
}
});
it("spins in place for SPINRANDOM_SLOW", () => {
const world = at(52, 12);
const { seen, facings } = track(world, DUDE, 3000);
expect([...seen]).toEqual(["50,12"]);
expect(facings.size).toBeGreaterThan(2);
});
it("never steps onto the player, and freezes while text is open", () => {
// Standing on the youngster's lower tile blocks that step.
const world = at(27, 17, "up");
const { seen } = track(world, YOUNGSTER, 3000);
expect(seen.has("27,17")).toBe(false);
// Talk to the youngster once he stands next to the player.
for (let tick = 0; tick < 5000; tick++) {
const youngster = object(world, YOUNGSTER);
if (youngster.x === 27 && youngster.y === 16 && !world.busy) break;
world.tick();
}
world.interact();
expect(world.dialogue).toBeDefined();
const frozen = world.objects().map(({ id, x, y, direction }) => [id, x, y, direction]);
for (let tick = 0; tick < 500; tick++) world.tick();
expect(world.objects().map(({ id, x, y, direction }) => [id, x, y, direction])).toEqual(frozen);
});
it("stays put while off screen and returns home once far away", () => {
// Nine tiles east: the youngster's steps would leave the screen.
const far = at(36, 16);
const { seen } = track(far, YOUNGSTER, 2000);
expect([...seen]).toEqual(["27,16"]);
// Let him wander, then walk away until home and tile leave the window.
const world = at(29, 16);
for (let tick = 0; tick < 5000 && object(world, YOUNGSTER).y === 16; tick++)
world.tick();
expect(object(world, YOUNGSTER).y).not.toBe(16);
const wandered = [object(world, YOUNGSTER).x, object(world, YOUNGSTER).y];
// Walking east leaves both his tile and home beyond the object window.
walkTo(world, 40, 16);
world.tick();
expect(wandered).not.toEqual([27, 16]);
expect([object(world, YOUNGSTER).x, object(world, YOUNGSTER).y]).toEqual([27, 16]);
});
it("does not save wandering positions", () => {
const world = at(29, 16);
for (let tick = 0; tick < 5000 && object(world, YOUNGSTER).y === 16; tick++)
world.tick();
const saved = world.save();
expect(saved.objects).toEqual([]);
world.restore(saved);
expect([object(world, YOUNGSTER).x, object(world, YOUNGSTER).y]).toEqual([27, 16]);
});
});
+3
View File
@@ -111,6 +111,9 @@ describe.skipIf(!existsSync(romPath))("Route 29 with the local ROM", () => {
expect(world.notice, world.diagnostic).toBe(""); expect(world.notice, world.diagnostic).toBe("");
}; };
crossWest(ROUTE_29); crossWest(ROUTE_29);
// Route29TuscanyCallback hides Tuscany without the Zephyr Badge, even on
// a Tuesday (2026-09-22).
expect(world.objects().some((object) => object.id === 8)).toBe(false);
crossWest("26:3"); crossWest("26:3");
walkTo(world, 17, 8); walkTo(world, 17, 8);
expect(world.move("up")).toBe(true); expect(world.move("up")).toBe(true);
+18 -3
View File
@@ -96,7 +96,8 @@ export function findPath(
throw new Error(`No route to ${targetX},${targetY}`); throw new Error(`No route to ${targetX},${targetY}`);
} }
/** Walk to a tile, settling any scene that a step triggers. */ /** Walk to a tile, settling any scene that a step triggers. A roaming NPC
* that steps into the way is waited out, then the route is planned again. */
export function walkTo( export function walkTo(
world: GameWorld, world: GameWorld,
targetX: number, targetX: number,
@@ -104,12 +105,26 @@ export function walkTo(
answers: (number | string)[] = [], answers: (number | string)[] = [],
): string[] { ): string[] {
const shown: string[] = []; const shown: string[] = [];
for (let attempt = 0; attempt < 20; attempt++) {
let blocked = false;
for (const direction of findPath(world, targetX, targetY)) { for (const direction of findPath(world, targetX, targetY)) {
if (!world.move(direction)) if (!world.move(direction)) {
const [, dx, dy] = DIRECTIONS.find(([name]) => name === direction)!;
const blocker = world
.objects()
.some((object) => object.x === world.x + dx && object.y === world.y + dy);
if (!blocker)
throw new Error( throw new Error(
`Blocked moving ${direction} at ${world.x},${world.y}: ${world.notice}`, `Blocked moving ${direction} at ${world.x},${world.y}: ${world.notice}`,
); );
for (let tick = 0; tick < 64; tick++) world.tick();
blocked = true;
break;
}
shown.push(...settle(world, answers)); shown.push(...settle(world, answers));
} }
return shown; // A warp target leaves the map, so a completed path is the success test.
if (!blocked) return shown;
}
throw new Error(`Could not reach ${targetX},${targetY}`);
} }