Port Headbutt and Rock Smash encounters

Facing a Headbutt tree asks to HEADBUTT it, and HEADBUTT from the party
menu skips the question. ShakeHeadbuttTree shakes the tree as a sprite over
grass, then TreeMonEncounter scores the facing tile against the trainer ID
and draws from the map's common or rare tree table. A Pokémon that falls
fights as BATTLETYPE_TREE: "fell out of the tree!", and the time of day's
AsleepTreeMons species start asleep.

Smashable rocks run SmashRockScript, and ROCK SMASH from the party menu
acts on the facing rock; after the rock breaks, RockMonEncounter gives the
map's rock set a 40% chance of a wild battle.

Closes #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-02 12:20:13 +02:00
1 parent ee55d344af
commit f259163da9
18 files changed
+789 -36

No files matched your search

+26 -2
View File
@@ -175,6 +175,8 @@ export interface BattleOptions {
caughtData?: (level: number) => number;
/** BATTLETYPE_TUTORIAL: the Dude's catching demonstration. */
tutorial?: boolean;
/** BATTLETYPE_TREE: a wild Pokémon that fell from a Headbutt tree. */
tree?: boolean;
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
landmark?: number;
/** wPartyMenuCursor as the last party menu outside battle left it. */
@@ -476,6 +478,7 @@ export class Battle {
this.partyMenuCursor = options.partyMenuCursor ?? 0;
if (options.trainer) this.battleMode = C.TRAINER_BATTLE;
if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL;
else if (options.tree) this.battleType = C.BATTLETYPE_TREE;
}
private get reader(): RomReader {
return this.options.data.reader;
@@ -659,7 +662,8 @@ export class Battle {
pp: moves.map((move) => (move ? data.move(move).pp : 0)),
happiness: C.BASE_HAPPINESS,
level,
status: 0,
// .TreeMon: some Pokémon fall from trees asleep.
status: this.sleepingTreeMon(species) ? C.TREEMON_SLEEP_TURNS : 0,
hp: stats[0],
maxHp: stats[0],
stats: stats.slice(1),
@@ -676,6 +680,21 @@ export class Battle {
this.wildMoves = [...moves];
this.wildPP = [...this.enemy.mon.pp];
}
/** CheckSleepingTreeMon: in a tree battle, the time of day's
* AsleepTreeMons list names the species that sleep. */
private sleepingTreeMon(species: number) {
if (this.battleType !== C.BATTLETYPE_TREE) return false;
const time = this.options.timeOfDay;
let p =
time < C.DAY_F
? BATTLE.AsleepTreeMonsMorn
: time === C.DAY_F
? BATTLE.AsleepTreeMonsDay
: BATTLE.AsleepTreeMonsNite;
for (const r = this.reader; r.byte(p) !== 0xff; p++)
if (r.byte(p) === species) return true;
return false;
}
/** InitBattleMon from the party, then status penalties. */
private initBattleMon() {
const mon = this.partyMon,
@@ -773,7 +792,12 @@ export class Battle {
if (trainer) {
yield* this.say(BATTLE.WantsToBattleText);
yield* this.say(BATTLE.BattleText_EnemySentOut);
} else yield* this.say(BATTLE.WildPokemonAppearedText);
} else
yield* this.say(
this.battleType === C.BATTLETYPE_TREE
? BATTLE.PokemonFellFromTreeText
: BATTLE.WildPokemonAppearedText,
);
this.resetBattleParticipants();
this.initBattleMon();
const amuletCoin =
+2
View File
@@ -313,6 +313,8 @@ export const C = {
BATTLETYPE_TRAP: 9,
BATTLETYPE_FORCEITEM: 10,
BATTLETYPE_CELEBI: 11,
BATTLETYPE_TREE: 8,
TREEMON_SLEEP_TURNS: 7,
BATTLETYPE_SUICUNE: 12,
WIN: 0,
LOSE: 1,
+40 -1
View File
@@ -10,6 +10,11 @@ import {
type RenderViewport,
} from "./viewport";
/** Video frames before LoadOverworldTilemapAndAttrmapPals puts the
* Headbutt tree back: HideHeadbuttTree's WaitBGMap and the 32 shaking
* frames. */
const HEADBUTT_HIDDEN_FRAMES = 36;
export class WorldRenderer {
width: number = BASE_VIEWPORT.width;
height: number = BASE_VIEWPORT.height;
@@ -91,6 +96,10 @@ export class WorldRenderer {
const cameraX = Math.round(player.x - (this.width / 2 - 8)),
cameraY = Math.round(player.y - (this.height / 2 - 8)) + world.screenShake;
this.image.data.fill(255);
// HideHeadbuttTree puts tile $05 (grass) in the tree's four tilemap
// places, keeping their attributes, until the shaking ends.
const tree = world.headbuttTree;
const hidden = tree && tree.frames < HEADBUTT_HIDDEN_FRAMES ? tree : undefined;
for (
let tileY = Math.floor(cameraY / 8);
tileY < Math.ceil((cameraY + this.height) / 8);
@@ -111,7 +120,12 @@ export class WorldRenderer {
const encoded = tileset.metatiles[block * 16 + localY * 4 + localX];
const packed = tileset.paletteMap[encoded >> 1],
attribute = encoded & 1 ? packed >> 4 : packed & 15;
const tile = (encoded & 0x7f) + (attribute & 8 ? 0x60 : 0);
const covered =
hidden &&
tileX >> 1 === hidden.x &&
tileY >> 1 === hidden.y;
const tile =
(covered ? 0x05 : encoded & 0x7f) + (attribute & 8 ? 0x60 : 0);
drawTile(
this.image.data,
this.width,
@@ -200,6 +214,31 @@ export class WorldRenderer {
);
}
}
if (tree) {
// SPRITE_ANIM_OBJ_HEADBUTT over the tree's square, in PAL_OW_TREE:
// the two frames alternate every three animation steps, the second
// mirrored on every other pass. The first step lasts through
// HideHeadbuttTree's WaitBGMap, the last until the sprite is cleared.
const graphics = this.assets.headbuttTree(),
step = Math.min(32, Math.max(0, tree.frames - 4)),
phase = Math.floor(step / 3) % 4,
flip = phase === 3,
palette = this.assets.objectPalette(6, time);
for (let tile = 0; tile < 4; tile++) {
const column = tile & 1;
drawTile(
this.image.data,
this.width,
this.height,
graphics,
(phase & 1) * 4 + tile,
tree.x * 16 - cameraX + (flip ? 8 - column * 8 : column * 8),
tree.y * 16 - cameraY + (tile >> 1) * 8,
palette,
{ transparent: true, flipX: flip },
);
}
}
this.context.putImageData(this.image, 0, 0);
}
private actor(
+4
View File
@@ -500,6 +500,10 @@ export class CrystalAssets {
r.byte(entry + 2),
);
}
/** HeadbuttTreeGFX: the shaking tree's two 2x2 frames, eight tiles. */
headbuttTree(): Uint8Array {
return this.reader.slice(OVERWORLD.HeadbuttTreeGFX, 8 * 16);
}
/** GetIcon: a species' two-frame party menu icon (ReadMonMenuIcon and
* IconPointers), eight tiles. */
monIcon(species: number): Uint8Array {
+16
View File
@@ -159,6 +159,7 @@ export const RAM = {
strengthSpecies: 0xd1ef,
curHPAnimDeltaHP: 0xd1f3,
visitedSpawns: 0xdca5,
tempWildMonSpecies: 0xd22e,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3,
} as const;
@@ -233,6 +234,17 @@ export const OVERWORLD = {
Script_WaterfallFromMenu: 0xcb1c,
"SweetScentFromMenu.SweetScent": 0x506c8,
SweetScentEncounter: 0x506ef,
HeadbuttFromMenuScript: 0xcea7,
AskHeadbuttScript: 0xcedc,
ShakeHeadbuttTree: 0x8c80a,
HeadbuttTreeGFX: 0x8c893,
TreeMonEncounter: 0xb81ea,
RockMonEncounter: 0xb8219,
TreeMonMaps: 0xb825e,
RockMonMaps: 0xb82c5,
TreeMons: 0xb82e8,
RockSmashFromMenuScript: 0xcf2e,
HasRockSmash: 0xcf7c,
"CheckBadge.BadgeRequiredText": 0xc73d,
"FieldMoveFailed.CantUseItemText": 0xc780,
CutNothingText: 0xc7c9,
@@ -685,6 +697,10 @@ export const BATTLE = {
WasBurnedText: 0x8123c,
WasPoisonedText: 0x811d5,
WildPokemonAppearedText: 0x80746,
PokemonFellFromTreeText: 0x80778,
AsleepTreeMonsNite: 0x3eb5d,
AsleepTreeMonsDay: 0x3eb69,
AsleepTreeMonsMorn: 0x3eb6f,
WokeUpText: 0x80cfa,
WontDropAnymoreText: 0x8128f,
WontRiseAnymoreText: 0x81272,
+48 -1
View File
@@ -53,6 +53,7 @@ import { PcSession } from "./pc";
import { bankOfMom, type MomBankView } from "./mom";
import type { PokegearView } from "./pokegear";
import type { FlyMapView } from "./fly-map";
import { rockMonEncounter, treeMonEncounter } from "./treemons";
import type { TrainerCardView } from "./trainer-card";
import {
defaultOptions,
@@ -214,6 +215,13 @@ export interface FieldMoveContext {
* objects. LAST_TALKED is resolved before an effect is emitted. */
export const PLAYER = 0;
const LAST_TALKED = 0xfe;
/** Map steps for wPlayerDirection's DOWN, UP, LEFT and RIGHT. */
const FACING_VECTORS = [
[0, 1],
[0, -1],
[-1, 0],
[1, 0],
] as const;
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
const CONTACT_LIST_SIZE = 10;
@@ -286,6 +294,8 @@ export type ScriptEffect =
canLose?: boolean;
/** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */
tutorial?: boolean;
/** BATTLETYPE_TREE: a Pokémon that fell from a Headbutt tree. */
tree?: boolean;
}
/** warp and newloadmap: the world enters this map after the script
* ends, facing `facing` (warpfacing) or down (SpawnInFacingDown).
@@ -306,6 +316,8 @@ export type ScriptEffect =
* the player's place or lands on it, with the leaves; HideSprites and
* the animation loops leave no other object on screen meanwhile. */
| { kind: "fly"; landing: boolean; species: number }
/** ShakeHeadbuttTree: the facing tree shakes as a sprite over grass. */
| { kind: "headbutt" }
/** A vertical menu (the elevator's floors); answered with the chosen
* item's index, or -1 when cancelled with B. */
| {
@@ -591,6 +603,10 @@ export class ScriptRun {
// wDefaultSpawnpoint: set by Fly and Teleport before their scripts.
spawn = this.options.fieldMove?.spawn ?? 0;
let wild = this.options.wild;
// wTempWildMonSpecies/wCurPartyLevel, as the encounter routines leave
// them for randomwildmon, and wBattleType's BATTLETYPE_TREE.
let tempWild = this.options.wild,
treeBattle = false;
let trainer: { classId: number; id: number } | undefined;
// LoadTrainer_continue's copy fills wWinTextPointer/wLossTextPointer too.
let winText = this.tempTrainer?.beatenText || undefined,
@@ -880,6 +896,31 @@ export class ScriptRun {
// Chosen by the world when the move was used (CanEncounterWildMon,
// GetMapEncounterRate and ChooseWildEncounter).
value = Number(this.options.wild !== undefined);
} else if (routine === OVERWORLD.ShakeHeadbuttTree) {
yield* flush();
yield synchronous({ kind: "headbutt" });
} else if (routine === OVERWORLD.TreeMonEncounter) {
const [dx, dy] = FACING_VECTORS[this.host.facing()],
at = this.host.position();
tempWild = treeMonEncounter(
r,
this.host.mapId,
// GetFacingTileCoord: wPlayerMapX/Y count the map's border.
{ x: at.x + dx + 4, y: at.y + dy + 4 },
this.host.player().id,
(range) => this.randomRange(range),
);
treeBattle = tempWild !== undefined;
value = Number(treeBattle);
} else if (routine === OVERWORLD.RockMonEncounter) {
tempWild = rockMonEncounter(r, this.host.mapId, (range) =>
this.randomRange(range),
);
} else if (routine === OVERWORLD.HasRockSmash) {
// CheckPartyMove leaves the Pokémon in wCurPartyMon.
const mon = this.partyMove(OW.ROCK_SMASH);
if (mon >= 0) this.curPartyMon = mon;
value = mon < 0 ? 1 : 0;
} else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) {
// StubbedTrainerRankings_Surf: the cut Trainer Rankings feature.
} else if (
@@ -917,6 +958,8 @@ export class ScriptRun {
value = this.options.fieldMove.surfState;
else if (address === RAM.strengthSpecies)
value = this.strengthSpecies;
else if (address === RAM.tempWildMonSpecies)
value = tempWild?.species ?? 0;
else throw new UnsupportedScript(offset, opcode);
break;
}
@@ -971,7 +1014,7 @@ export class ScriptRun {
}
case 0x5b:
trainer = undefined;
wild = this.options.wild;
wild = tempWild;
break; // randomwildmon clears wBattleScriptFlags.
case 0x5d:
wild = { species: byte(), level: byte() };
@@ -1049,11 +1092,15 @@ export class ScriptRun {
wild,
trainer: trainer && { ...trainer, winText, lossText },
canLose,
tree: treeBattle || undefined,
});
if (typeof result !== "number")
throw new Error("Invalid battle result.");
battleResult = result;
value = result & 0x3f; // Script_startbattle also updates wScriptVar.
// CleanUpBattleRAM clears wBattleType and wTempWildMonSpecies.
treeBattle = false;
tempWild = undefined;
break;
}
case 0x61: {
+2
View File
@@ -96,6 +96,8 @@ export type MenuFieldMove =
script: number;
fieldMove: FieldMoveContext;
wild?: { species: number; level: number };
/** hLastTalked, when the move acts on a facing object. */
lastTalked?: number;
}
| { texts: number[] }
| { notice: string }
+97
View File
@@ -0,0 +1,97 @@
import type { RomReader } from "../rom/reader";
import { OVERWORLD } from "../rom/profile";
import { OW } from "../world/constants";
/** Headbutt trees and Rock Smash rocks (engine/events/treemons.asm). */
export interface TreeMon {
species: number;
level: number;
}
/** TREEMON_SCORE_BAD, _GOOD and _RARE. */
const SCORE_BAD = 0,
SCORE_GOOD = 1,
SCORE_RARE = 2;
/** TreeMonEncounter: the map's TreeMonMaps set, scored by the facing tile
* (in wPlayerMapX/Y coordinates, four more than the map's) against the
* trainer ID, then an encounter roll and SelectTreeMon. Undefined when
* nothing falls; a Pokémon found fights as BATTLETYPE_TREE. */
export function treeMonEncounter(
r: RomReader,
mapId: string,
facing: { x: number; y: number },
playerId: number,
randomRange: (range: number) => number,
): TreeMon | undefined {
let table = treeMons(r, treeMonSet(r, OVERWORLD.TreeMonMaps, mapId));
if (table === undefined) return undefined;
// GetTreeMon: 10% for a bad score, 50% for a good one, 80% for a rare
// one, which also skips past the common table to the rare one.
const score = treeScore(facing.x, facing.y, playerId);
const draw = randomRange(10);
if (score === SCORE_BAD && draw !== 0) return undefined;
if (score === SCORE_GOOD && draw >= 5) return undefined;
if (score === SCORE_RARE) {
if (draw >= 8) return undefined;
while (r.byte(table++) !== 0xff);
}
return selectTreeMon(r, table, randomRange);
}
/** RockMonEncounter: the map's RockMonMaps set, a 40% chance, then
* SelectTreeMon. Undefined when the rock hides nothing. */
export function rockMonEncounter(
r: RomReader,
mapId: string,
randomRange: (range: number) => number,
): TreeMon | undefined {
const table = treeMons(r, treeMonSet(r, OVERWORLD.RockMonMaps, mapId));
if (table === undefined) return undefined;
if (randomRange(10) >= 4) return undefined;
return selectTreeMon(r, table, randomRange);
}
/** GetTreeScore: .CoordScore is (x × y + x + y) / 5 modulo 10 and
* .OTIDScore the trainer ID modulo 10; equal scores are rare, and a
* coordinate score less than five above the ID's (wrapping by ten) good. */
export function treeScore(x: number, y: number, playerId: number): number {
const coord = Math.floor(((x * y + x + y) & 0xffff) / 5) % 10;
let difference = coord - (playerId % 10);
if (difference === 0) return SCORE_RARE;
if (difference < 0) difference += 10;
return difference < 5 ? SCORE_GOOD : SCORE_BAD;
}
/** GetTreeMonSet: the TREEMON_SET_* of map `mapId` in TreeMonMaps or
* RockMonMaps (group, number, set entries ending with -1). */
function treeMonSet(r: RomReader, table: number, mapId: string) {
const [group, number] = mapId.split(":").map(Number);
for (let p = table; r.byte(p) !== 0xff; p += 3)
if (r.byte(p) === group && r.byte(p + 1) === number) return r.byte(p + 2);
return undefined;
}
/** GetTreeMons: the set's common table in TreeMons; TREEMON_SET_NONE and
* sets past NUM_TREEMON_SETS have none. */
function treeMons(r: RomReader, set: number | undefined) {
if (!set || set >= OW.NUM_TREEMON_SETS) return undefined;
return r.near(OVERWORLD.TreeMons + set * 2, r.bank(OVERWORLD.TreeMons));
}
/** SelectTreeMon: 0-99 against the table's percentages (percent, species,
* level entries ending with -1). */
function selectTreeMon(
r: RomReader,
table: number,
randomRange: (range: number) => number,
): TreeMon | undefined {
let roll = randomRange(100),
p = table;
while (roll >= r.byte(p)) {
roll -= r.byte(p);
p += 3;
}
if (r.byte(p) === 0xff) return undefined;
return { species: r.byte(p + 1), level: r.byte(p + 2) };
}
+2
View File
@@ -178,6 +178,8 @@ export const OW = {
NUM_FLYPOINTS: 24,
// SpriteMovementData (constants/map_object_constants.asm).
SPRITEMOVEDATA_SMASHABLE_ROCK: 0x18,
// Headbutt and Rock Smash sets (constants/pokemon_data_constants.asm).
NUM_TREEMON_SETS: 8,
BICYCLE: 7,
PIKACHU: 25,
EGG: 0xfd,
+51 -12
View File
@@ -551,7 +551,12 @@ const isTimed = (effect: ScriptEffect | undefined) =>
effect?.kind === "move" ||
effect?.kind === "emote" ||
effect?.kind === "pause" ||
effect?.kind === "fly";
effect?.kind === "fly" ||
effect?.kind === "headbutt";
/** ShakeHeadbuttTree after its graphics load: HideHeadbuttTree's WaitBGMap
* (4 frames), 32 frames of shaking, then the WaitBGMap that restores the
* tree, 40 frames in all. */
const HEADBUTT_TICKS = 20;
/** Deterministic native world rules. Rendering, browser APIs and emulator
* registers are deliberately absent; callers provide time and input. */
@@ -639,6 +644,9 @@ export class GameWorld {
private warpSettle?: { script?: number };
/** FlyFromAnim or FlyToAnim while it plays, with the frames shown. */
private flight?: { landing: boolean; species: number; frames: number };
/** ShakeHeadbuttTree while it plays: the facing square and the frames
* shown. */
private shakingTree?: { x: number; y: number; frames: number };
constructor(
readonly assets: CrystalAssets,
@@ -828,6 +836,12 @@ export class GameWorld {
};
}
/** The tree ShakeHeadbuttTree shakes: its map square and the video
* frames since the shaking sprite appeared. */
get headbuttTree(): { x: number; y: number; frames: number } | undefined {
return this.shakingTree && { ...this.shakingTree };
}
/** Pixel positions for drawing, including partial steps. */
poses(): Pose[] {
// HideSprites and the Fly animations' loops leave only their icon.
@@ -1697,12 +1711,19 @@ export class GameWorld {
case OW.HEADBUTT:
return tile === OW.COLL_HEADBUTT_TREE ||
tile === OW.COLL_HEADBUTT_TREE_1D
? { notice: "Headbutt trees are not ported yet." }
? { script: OVERWORLD.HeadbuttFromMenuScript, fieldMove: { mon } }
: failed;
case OW.ROCK_SMASH:
return facingObject()?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK
? { notice: "Smashing rocks is not ported yet." }
case OW.ROCK_SMASH: {
// GetFacingObject leaves the rock in hLastTalked.
const rock = facingObject();
return rock?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK
? {
script: OVERWORLD.RockSmashFromMenuScript,
fieldMove: { mon },
lastTalked: rock.id,
}
: failed;
}
case OW.SWEET_SCENT: {
// SweetScentEncounter, drawn now: no rate roll, and no battle
// without a rate or a valid Pokémon.
@@ -2307,6 +2328,7 @@ export class GameWorld {
wild: effect.wild,
trainer,
canLose: effect.canLose,
tree: effect.tree,
text: () => this.host(PLAYER).text(),
bag: draft.bag,
storage: draft.storage,
@@ -2350,6 +2372,16 @@ export class GameWorld {
this.flight = { ...effect, frames: 0 };
script.waitTicks = effect.landing ? 33 : 65;
return true;
case "headbutt": {
const [dx, dy] = VECTORS[this.player.direction];
this.shakingTree = {
x: this.player.x + dx,
y: this.player.y + dy,
frames: 0,
};
script.waitTicks = HEADBUTT_TICKS;
return true;
}
case "warpcheck":
script.warpCheck = true;
return false;
@@ -2858,7 +2890,9 @@ export class GameWorld {
if (script.run.deferred !== undefined)
this.startScript(
script.run.deferred,
PLAYER,
(script.run instanceof StartMenuRun &&
script.run.deferredMove?.lastTalked) ||
PLAYER,
script.run instanceof StartMenuRun
? {
usedItem: script.run.deferredItem,
@@ -2890,6 +2924,7 @@ export class GameWorld {
this.portrait = undefined;
this.evolution = undefined;
this.flight = undefined;
this.shakingTree = undefined;
if (script.sighted !== undefined)
this.refusedSighting = {
map: this.mapId,
@@ -3402,13 +3437,15 @@ export class GameWorld {
else this.advanceMovement(1);
} else if (isTimed(script.effect)) {
// Pauses and emotes keep the map loop running; applymovement and
// open text freeze other objects, and the Fly animations run their
// own loops.
// open text freeze other objects, and the Fly animations and the
// Headbutt shake run their own loops.
if (this.flight) this.flight.frames += 2;
else if (this.shakingTree) this.shakingTree.frames += 2;
else this.stepObjects();
if (--script.waitTicks <= 0) {
if (script.effect?.kind === "emote") this.emote = undefined;
this.flight = undefined;
this.shakingTree = undefined;
this.pump();
}
}
@@ -3649,10 +3686,12 @@ export class GameWorld {
return true;
}
if (tile === OW.COLL_HEADBUTT_TREE || tile === OW.COLL_HEADBUTT_TREE_1D) {
// TryHeadbuttOW: without Headbutt nothing happens; tree encounters
// (TreeMonEncounter) are not ported.
if (this.partyMove(OW.HEADBUTT) < 0) return false;
this.notice = "Headbutt trees are not ported yet.";
// TryHeadbuttOW: without Headbutt nothing happens.
const mon = this.partyMove(OW.HEADBUTT);
if (mon < 0) return false;
this.startScript(OVERWORLD.AskHeadbuttScript, PLAYER, {
fieldMove: { mon },
});
return true;
}
return this.trySurfOW(tile);