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
+386 -20

No files matched your search

+14
View File
@@ -5,6 +5,7 @@ import {
MAP_GROUP_COUNT,
NATIVE_MAPS,
NUM_ENGINE_FLAGS,
NUM_SPRITEMOVEDATA,
RAM,
SYMBOLS,
} from "./profile";
@@ -165,6 +166,7 @@ export class CrystalAssets {
y: r.byte(p + 1) - 4,
x: r.byte(p + 2) - 4,
movement: r.byte(p + 3),
radius: r.byte(p + 4),
hour1: r.byte(p + 5),
hour2: r.byte(p + 6),
palette: r.byte(p + 7) >> 4,
@@ -374,6 +376,18 @@ export class CrystalAssets {
}
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(index: number): Uint8Array {
const r = this.reader,
+2
View File
@@ -52,6 +52,7 @@ export const SYMBOLS = {
fruitTreeScript: 0x44000,
fruitTreeItems: 0x44097,
engineFlags: 0x80462,
spriteMovementData: 0x4273,
marts: 0x160a9,
martWelcomeText: 0x15f83,
martAskMoreText: 0x15fb9,
@@ -256,6 +257,7 @@ export const NUM_POKEMON = 251;
export const NUM_ITEMS = 190;
export const NUM_ENGINE_FLAGS = 162;
export const NUM_FRUIT_TREES = 30;
export const NUM_SPRITEMOVEDATA = 37;
/** The last group has no following pointer to bound it. */
export const LAST_MAP_GROUP_SIZE = 11;
+2
View File
@@ -24,6 +24,8 @@ export interface MapObject {
x: number;
y: number;
movement: number;
/** Movement radius as stored: high nibble y, low nibble x. */
radius: number;
palette: number;
kind: number;
script: number;
+205 -4
View File
@@ -98,6 +98,14 @@ interface Actor {
hidden: boolean;
fixedFacing: 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 {
direction: Direction;
@@ -221,11 +229,28 @@ const spawnActor = (object: MapObject): Actor => ({
hidden: false,
fixedFacing: false,
moved: false,
npc: { homeX: object.x, homeY: object.y, step: "reset", wait: 0 },
});
const copyActor = (actor: Actor): Actor => ({
...actor,
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) =>
effect?.kind === "move" ||
effect?.kind === "emote" ||
@@ -421,8 +446,17 @@ export class GameWorld {
this.notice = "";
this.diagnostic = "";
}
private occupied(x: number, y: number) {
return this.objects().some((object) => object.x === x && object.y === y);
/** IsNPCAtCoord: an object blocks its tile and, mid-step, the tile it
* 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 {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return false;
@@ -735,6 +769,8 @@ export class GameWorld {
if (effect.kind === "moveobject") {
actor.x = effect.x;
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;
} else if (effect.kind === "turn") {
actor.direction = effect.direction;
@@ -935,6 +971,9 @@ export class GameWorld {
actor.x = effect.x;
actor.y = effect.y;
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;
return false;
}
@@ -1053,6 +1092,8 @@ export class GameWorld {
const step = movement.steps[movement.index++];
if (!step) {
script.moving.delete(id);
// step_end returns the object to its own movement function.
if (actor.npc) actor.npc.step = "reset";
break;
}
if (step.kind === "turn") {
@@ -1250,6 +1291,157 @@ export class GameWorld {
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
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). */
private checkScene() {
if (!this.sceneCheck || this.busy) return;
@@ -1294,6 +1486,9 @@ export class GameWorld {
this.advanceMovement(1);
if (!script.moving.size && !this.anyMotion()) this.pump();
} 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.effect?.kind === "emote") this.emote = undefined;
this.pump();
@@ -1304,6 +1499,8 @@ export class GameWorld {
const player = this.player;
if (player.motion) {
const previous = { x: player.motion.fromX, y: player.motion.fromY };
// HandleMapObjects: HandleNPCStep precedes _HandlePlayerStep.
this.stepObjects();
this.animate(player);
if (player.motion) return;
// 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))
)
held = "down";
if (!held) return;
// 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() {
return (
@@ -1683,6 +1882,8 @@ export class GameWorld {
actor.y = object.y;
actor.direction = object.direction;
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 = {
x: save.x,