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:
1 parent
ee55d344af
commit
f259163da9
18 files changed
+789
-36
No files matched your search
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user