Complete the overworld foundation
Checks / web (push) Canceled after 0s

Port forced movement (turn delays, bumps, ice, currents, pits), dynamic
return warps, elevators, Surf, Cut, Strength and cycling rules, working
map blocks with changeblock/changemapblocks, and the remaining NPC
movement types and movement commands.

Add tileset animation from the ROM's command tables, grass sprite
priority and special map palettes; room decorations with ownership, the
bedroom PC and save migration; and Key Items access to the Bicycle.

Fourteen original-engine traces compare positions, facing, actors, event
flags and scripted poses. Tile animation matches original VRAM for four
tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now
keeps the $60 sprite offset its step function leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 16:47:29 +02:00
1 parent 9ac111f262
commit 451464f7c6
38 files changed
+5600 -490

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
import { RomReader } from "../rom/reader";
import { OVERWORLD } from "../rom/profile";
import { PokemonData } from "../rom/pokemon";
import { defaultTextContext, readString } from "../scripts/text";
export const DECORATION_SLOTS = [
"bed",
"carpet",
"plant",
"poster",
"console",
"bigDoll",
"left",
"right",
] as const;
export type DecorationSlot = (typeof DECORATION_SLOTS)[number];
export type Decorations = Record<DecorationSlot, number>;
export const emptyDecorations = (): Decorations => ({
bed: 0,
carpet: 0,
plant: 0,
poster: 0,
console: 0,
bigDoll: 0,
left: 0,
right: 0,
});
export const initialDecorations = (): Decorations => ({
...emptyDecorations(),
bed: 2,
poster: 16,
});
/** DecorationAttributes and GetDecoName, read from the user's ROM. */
export class DecorationData {
constructor(
private reader: RomReader,
private pokemon: PokemonData,
) {}
attribute(id: number) {
if (!Number.isInteger(id) || id < 0 || id > 52)
throw new Error("Invalid decoration.");
const p = OVERWORLD.DecorationAttributes + id * 6,
r = this.reader;
return {
type: r.byte(p),
name: r.byte(p + 1),
action: r.byte(p + 2),
event: r.word(p + 3),
sprite: r.byte(p + 5),
};
}
private word(index: number) {
let p = OVERWORLD.DecorationNames;
for (let i = 0; i < index; i++) {
while (this.reader.byte(p++) !== 0x50) {}
}
return readString(this.reader, p, defaultTextContext("PLAYER"));
}
name(id: number) {
const a = this.attribute(id);
if (a.type === 1) return this.word(a.name);
if (a.type === 2 || a.type === 3)
return this.word(a.name) + this.word(a.type === 2 ? 13 : 14);
const mon = this.pokemon.speciesName(a.name);
return a.type === 6
? this.word(17) + mon
: mon + this.word(a.type === 4 ? 15 : 16);
}
owned(events: Set<number>, action: number) {
return Array.from({ length: 52 }, (_, i) => i + 1).filter((id) => {
const a = this.attribute(id);
return a.action === action && events.has(a.event);
});
}
valid(value: unknown, events: Set<number>): value is Decorations {
if (
!value ||
typeof value !== "object" ||
Array.isArray(value) ||
Object.keys(value).length !== 8
)
return false;
const slots = value as Decorations;
return (
DECORATION_SLOTS.every((slot, i) => {
const id = slots[slot];
return (
Number.isInteger(id) &&
id >= 0 &&
id <= 52 &&
(id === 0 ||
(this.attribute(id).action === (i < 6 ? i * 2 + 1 : 13) &&
events.has(this.attribute(id).event)))
);
}) &&
(!slots.left || slots.left !== slots.right)
);
}
}