- 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:
1 parent
796fe8f6d5
commit
6a74f59e46
12 files changed
+386
-20
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user