Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
This commit is contained in:
1 parent
8b80d05ca2
commit
c08d250cf6
39 files changed
+10727
-1524
No files matched your search
+870
-182
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,75 @@
|
||||
import { RomReader } from "../rom/reader";
|
||||
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
||||
|
||||
/** One command from the game's own movement data (macros/scripts/movement.asm).
|
||||
* Durations are overworld ticks: the original loop advances objects once per
|
||||
* two video frames. Commands without a duration take effect immediately. */
|
||||
export type MovementStep =
|
||||
| { kind: "turn"; direction: Direction; ticks: 1 }
|
||||
| { kind: "step"; direction: Direction; ticks: number; animate: boolean }
|
||||
/** Two tiles in two phases of `ticks` each, with a shadow and arc. */
|
||||
| { kind: "jump"; direction: Direction; ticks: number }
|
||||
| { kind: "sleep"; ticks: number }
|
||||
| { kind: "fix-facing"; fixed: boolean }
|
||||
| { kind: "visibility"; hidden: boolean }
|
||||
| { kind: "emote"; shown: boolean };
|
||||
|
||||
export class UnsupportedMovement extends Error {
|
||||
constructor(
|
||||
readonly offset: number,
|
||||
readonly command: number,
|
||||
) {
|
||||
super(
|
||||
`Movement command 0x${command.toString(16)} at 0x${offset.toString(16)} is not ported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const STEP_TICKS = [16, 8, 4] as const; // slow, walk, big (bike speed)
|
||||
|
||||
/** Decode movement data until step_end. Unknown commands abort the script
|
||||
* rather than approximating a jump, slide or special effect. */
|
||||
export function readMovement(reader: RomReader, start: number): MovementStep[] {
|
||||
const steps: MovementStep[] = [];
|
||||
let p = start;
|
||||
for (let count = 0; count < 256; count++) {
|
||||
const offset = p,
|
||||
command = reader.byte(p++);
|
||||
const direction = DIRECTION_ORDER[command & 3];
|
||||
if (command < 0x04) steps.push({ kind: "turn", direction, ticks: 1 });
|
||||
else if (command >= 0x08 && command < 0x14)
|
||||
steps.push({
|
||||
kind: "step",
|
||||
direction,
|
||||
ticks: STEP_TICKS[(command - 0x08) >> 2],
|
||||
animate: true,
|
||||
});
|
||||
else if (command >= 0x14 && command < 0x20)
|
||||
// Slides move without the walking animation (e.g. being pushed).
|
||||
steps.push({
|
||||
kind: "step",
|
||||
direction,
|
||||
ticks: STEP_TICKS[(command - 0x14) >> 2],
|
||||
animate: false,
|
||||
});
|
||||
else if (command >= 0x2c && command < 0x38)
|
||||
steps.push({
|
||||
kind: "jump",
|
||||
direction,
|
||||
ticks: STEP_TICKS[(command - 0x2c) >> 2],
|
||||
});
|
||||
else if (command === 0x3a || command === 0x3b)
|
||||
steps.push({ kind: "fix-facing", fixed: command === 0x3b });
|
||||
else if (command === 0x3c || command === 0x3d)
|
||||
steps.push({ kind: "visibility", hidden: command === 0x3d });
|
||||
else if (command >= 0x3e && command <= 0x45)
|
||||
steps.push({ kind: "sleep", ticks: command - 0x3d });
|
||||
else if (command === 0x46)
|
||||
steps.push({ kind: "sleep", ticks: reader.byte(p++) });
|
||||
else if (command === 0x47) return steps;
|
||||
else if (command === 0x53 || command === 0x54)
|
||||
steps.push({ kind: "emote", shown: command === 0x54 });
|
||||
else throw new UnsupportedMovement(offset, command);
|
||||
}
|
||||
throw new Error("Movement data is unterminated.");
|
||||
}
|
||||
+185
-32
@@ -1,17 +1,16 @@
|
||||
import { RomReader } from "../rom/reader";
|
||||
import { RAM } from "../rom/profile";
|
||||
|
||||
const CHARACTERS: Record<number, string> = {
|
||||
0x1f: " ",
|
||||
0x22: "\n",
|
||||
0x25: "",
|
||||
0x49: "MOM",
|
||||
0x4a: "Pokémon",
|
||||
0x4b: "\n",
|
||||
0x4c: "\n",
|
||||
0x4e: "\n",
|
||||
0x4f: "\n",
|
||||
0x51: "\n\n",
|
||||
0x53: "???",
|
||||
0x54: "POKé",
|
||||
0x55: "\n",
|
||||
0x56: "……",
|
||||
@@ -68,6 +67,42 @@ const CHARACTERS: Record<number, string> = {
|
||||
0xf5: "♀",
|
||||
};
|
||||
|
||||
/** Values that original text reads from work RAM, supplied by native state. */
|
||||
export interface TextContext {
|
||||
player: string;
|
||||
rival: string;
|
||||
mom: string;
|
||||
/** Native strings stored at the RAM addresses named by text_ram. */
|
||||
strings: Readonly<Record<number, string>>;
|
||||
weekday: string;
|
||||
/** Numbers that text_decimal reads from RAM, by address. */
|
||||
numbers?: Readonly<Record<number, number>>;
|
||||
/** Battle names for <USER> and <TARGET>: whose turn it is, and the
|
||||
* enemy prefix the game prints before the enemy's nickname. */
|
||||
battle?: { turn: number; player: string; enemy: string; enemyPrefix: string };
|
||||
/** Native handlers for text_asm, keyed by the command's ROM offset. They
|
||||
* return where text continues, as the original routine returns in hl. */
|
||||
asm?: (offset: number) => number;
|
||||
}
|
||||
|
||||
export const defaultTextContext = (player: string): TextContext => ({
|
||||
player,
|
||||
// New games initialize these names before the player can choose them.
|
||||
rival: "???",
|
||||
mom: "MOM",
|
||||
strings: {},
|
||||
weekday: "",
|
||||
});
|
||||
|
||||
/** text_buffer indices select these buffers (data/text_buffers.asm). */
|
||||
const STRING_BUFFERS = [
|
||||
RAM.stringBuffer3,
|
||||
RAM.stringBuffer4,
|
||||
RAM.stringBuffer5,
|
||||
RAM.stringBuffer2,
|
||||
RAM.stringBuffer1,
|
||||
];
|
||||
|
||||
export class UnsupportedText extends Error {
|
||||
constructor(
|
||||
readonly offset: number,
|
||||
@@ -79,49 +114,167 @@ export class UnsupportedText extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function battler(context: TextContext, enemy: boolean): string | undefined {
|
||||
const names = context.battle;
|
||||
if (!names) return undefined;
|
||||
return enemy ? names.enemyPrefix + names.enemy : names.player;
|
||||
}
|
||||
|
||||
function character(code: number, context: TextContext): string | undefined {
|
||||
// PlaceMoveTargetsName / PlaceMoveUsersName
|
||||
if (code === 0x59) return battler(context, context.battle?.turn === 0);
|
||||
if (code === 0x5a) return battler(context, context.battle?.turn === 1);
|
||||
if (code === 0x52 || code === 0x14) return context.player;
|
||||
if (code === 0x53) return context.rival;
|
||||
if (code === 0x49) return context.mom;
|
||||
if (code >= 0x80 && code <= 0x99)
|
||||
return String.fromCharCode(65 + code - 0x80);
|
||||
if (code >= 0xa0 && code <= 0xb9)
|
||||
return String.fromCharCode(97 + code - 0xa0);
|
||||
if (code >= 0xf6) return String(code - 0xf6);
|
||||
return CHARACTERS[code];
|
||||
}
|
||||
|
||||
/** Decode a plain "@"-terminated string such as a name or item label. */
|
||||
export function readString(
|
||||
reader: RomReader,
|
||||
start: number,
|
||||
context: TextContext,
|
||||
limit = 32,
|
||||
): string {
|
||||
let output = "";
|
||||
for (let p = start; p < start + limit; p++) {
|
||||
const code = reader.byte(p);
|
||||
if (code === 0x50) return output;
|
||||
const value = character(code, context);
|
||||
if (value === undefined) throw new UnsupportedText(p, code);
|
||||
output += value;
|
||||
}
|
||||
throw new Error("Unterminated string.");
|
||||
}
|
||||
|
||||
export function readText(
|
||||
reader: RomReader,
|
||||
start: number,
|
||||
playerName: string,
|
||||
context: TextContext,
|
||||
depth = 0,
|
||||
): string {
|
||||
return readTextEnd(reader, start, context, depth).text;
|
||||
}
|
||||
|
||||
/** Read text and report whether it ended with `prompt`, which waits for a
|
||||
* button in battle, rather than `done` or `text_end`. */
|
||||
export function readTextEnd(
|
||||
reader: RomReader,
|
||||
start: number,
|
||||
context: TextContext,
|
||||
depth = 0,
|
||||
): { text: string; prompt: boolean } {
|
||||
if (depth > 8) throw new Error("Text calls exceed the recursion limit.");
|
||||
let p = start,
|
||||
output = "";
|
||||
output = "",
|
||||
prompt = false;
|
||||
// Only the outermost text is trimmed; nested fragments keep their spacing.
|
||||
const finish = (value: string) => (depth === 0 ? value.trim() : value);
|
||||
const ram = (address: number) => {
|
||||
const value = context.strings[address];
|
||||
if (value === undefined)
|
||||
throw new Error(
|
||||
`Text reads RAM 0x${address.toString(16)}, which has no native value.`,
|
||||
);
|
||||
return value;
|
||||
};
|
||||
for (let count = 0; count < 8192; count++) {
|
||||
const commandOffset = p,
|
||||
command = reader.byte(p++);
|
||||
if (command === 0x50) return output.trim();
|
||||
if (command === 0x16) {
|
||||
output += readText(
|
||||
reader,
|
||||
reader.near(p, reader.byte(p + 2)),
|
||||
playerName,
|
||||
depth + 1,
|
||||
);
|
||||
p += 3;
|
||||
continue;
|
||||
}
|
||||
if (command !== 0) throw new UnsupportedText(commandOffset, command);
|
||||
let ended = false;
|
||||
for (let length = 0; length < 8192; length++) {
|
||||
const offset = p,
|
||||
code = reader.byte(p++);
|
||||
if (code === 0x57 || code === 0x58) return output.trim();
|
||||
if (code === 0x50) {
|
||||
ended = true;
|
||||
switch (command) {
|
||||
case 0x50:
|
||||
return { text: finish(output), prompt };
|
||||
case 0x00: {
|
||||
let ended = false;
|
||||
for (let length = 0; length < 8192; length++) {
|
||||
const offset = p,
|
||||
code = reader.byte(p++);
|
||||
if (code === 0x57 || code === 0x58)
|
||||
return { text: finish(output), prompt: code === 0x58 };
|
||||
if (code === 0x50) {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
const value = character(code, context);
|
||||
if (value === undefined) throw new UnsupportedText(offset, code);
|
||||
output += value;
|
||||
}
|
||||
if (!ended) throw new Error("Unterminated text.");
|
||||
break;
|
||||
}
|
||||
if (code === 0x52 || code === 0x14) output += playerName;
|
||||
else if (code >= 0x80 && code <= 0x99)
|
||||
output += String.fromCharCode(65 + code - 0x80);
|
||||
else if (code >= 0xa0 && code <= 0xb9)
|
||||
output += String.fromCharCode(97 + code - 0xa0);
|
||||
else if (code >= 0xf6) output += String(code - 0xf6);
|
||||
else if (CHARACTERS[code] !== undefined) output += CHARACTERS[code];
|
||||
else throw new UnsupportedText(offset, code);
|
||||
case 0x01:
|
||||
output += ram(reader.word(p));
|
||||
p += 2;
|
||||
break;
|
||||
case 0x06:
|
||||
case 0x0d:
|
||||
// Button waits inside one message become page boundaries in the DOM.
|
||||
output = output.trimEnd() + "\n\n";
|
||||
break;
|
||||
case 0x07:
|
||||
output += "\n";
|
||||
break;
|
||||
case 0x08: {
|
||||
const next = context.asm?.(commandOffset);
|
||||
if (next === undefined) throw new UnsupportedText(commandOffset, command);
|
||||
p = next;
|
||||
break;
|
||||
}
|
||||
case 0x09: {
|
||||
const address = reader.word(p);
|
||||
p += 3; // address, then bytes and digits (left-aligned printing)
|
||||
const value = context.numbers?.[address];
|
||||
if (value === undefined)
|
||||
throw new Error(
|
||||
`Text reads number 0x${address.toString(16)}, which has no native value.`,
|
||||
);
|
||||
output += String(value);
|
||||
break;
|
||||
}
|
||||
case 0x0a:
|
||||
case 0x0b:
|
||||
case 0x0e:
|
||||
case 0x0f:
|
||||
case 0x10:
|
||||
case 0x11:
|
||||
case 0x12:
|
||||
case 0x13:
|
||||
break; // Timed pauses and sound cues belong to presentation/audio.
|
||||
case 0x0c:
|
||||
output += "…".repeat(reader.byte(p++));
|
||||
break;
|
||||
case 0x14: {
|
||||
const index = reader.byte(p++);
|
||||
const address = STRING_BUFFERS[index];
|
||||
if (address === undefined)
|
||||
throw new UnsupportedText(commandOffset, command);
|
||||
output += ram(address);
|
||||
break;
|
||||
}
|
||||
case 0x15:
|
||||
output += context.weekday;
|
||||
break;
|
||||
case 0x16: {
|
||||
const nested = readTextEnd(
|
||||
reader,
|
||||
reader.near(p, reader.byte(p + 2)),
|
||||
context,
|
||||
depth + 1,
|
||||
);
|
||||
output += nested.text;
|
||||
prompt = nested.prompt;
|
||||
p += 3;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new UnsupportedText(commandOffset, command);
|
||||
}
|
||||
if (!ended) throw new Error("Unterminated text.");
|
||||
}
|
||||
throw new Error("Text command limit exceeded.");
|
||||
}
|
||||
Reference in new issue
Block a user