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

+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);