Run the forced-shiny, trap, forced-item, Celebi, Suicune, roaming and Bug-Catching Contest battle types. Roaming battles keep their struct's DVs and HP byte, and every other battle makes CleanUpBattleRAM's roaming roll. The contest shows PARKBALL×, counts down Park Balls, ends when they run out, keeps its catch outside the party and asks before switching. Scripts load the special types with loadvar, and while a contest runs, encounters use BugCatchingContestBattleScript and ContestMons. Validated with 596 unit tests, 155 browser tests (including the contest menu and switch screen), native/reference builds and reference/vendor/release checks. National Park's contest event, the roaming release and the Ilex Forest Celebi event come with their maps. Closes #28 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b547ca29ff
commit
8fae1f0c0f
15 files changed
+787
-42
No files matched your search
+55
-4
@@ -348,7 +348,17 @@ export interface BattleView {
|
||||
/** A choice the battle is waiting for, if any. */
|
||||
prompt?: Extract<
|
||||
BattleEffect,
|
||||
{ kind: "action" | "yesno" | "forget" | "stats" | "name" | "party" | "move" }
|
||||
{
|
||||
kind:
|
||||
| "action"
|
||||
| "yesno"
|
||||
| "contestswitch"
|
||||
| "forget"
|
||||
| "stats"
|
||||
| "name"
|
||||
| "party"
|
||||
| "move";
|
||||
}
|
||||
>;
|
||||
/** Yes/No cursor, as on the original boxes. */
|
||||
selected: 0 | 1;
|
||||
@@ -1565,6 +1575,7 @@ export class GameWorld {
|
||||
)
|
||||
return;
|
||||
if (isIce(collision)) return;
|
||||
if (state.bugContest) return this.bugContestEncounter(previous, collision);
|
||||
const table = this.encounterTable(state);
|
||||
if (!(this.random() < table.rate)) return;
|
||||
const chosen = this.chooseWildEncounter(table);
|
||||
@@ -1581,6 +1592,40 @@ export class GameWorld {
|
||||
// The step replays after a reload, so it keeps its Repel step.
|
||||
if (state.repel && this.script) this.script.committed.repel++;
|
||||
}
|
||||
/** _TryWildEncounter_BugContest: 40% in super tall grass and 20%
|
||||
* elsewhere (halved by a Cleanse Tag; no radio music changes it yet),
|
||||
* a ContestMons pick, the Repel check, then
|
||||
* BugCatchingContestBattleScript. */
|
||||
private bugContestEncounter(previous: { x: number; y: number }, collision: number) {
|
||||
const state = this.state;
|
||||
let rate = Math.floor(
|
||||
((collision === OW.COLL_LONG_GRASS || collision === OW.COLL_LONG_GRASS_1C ? 40 : 20) * 255) / 100,
|
||||
);
|
||||
if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1;
|
||||
if (!(this.random() < rate)) return;
|
||||
// ChooseWildEncounter_BugContest: 0-99, then percentages in ContestMons.
|
||||
let roll: number;
|
||||
do roll = this.random();
|
||||
while (roll >= 200);
|
||||
roll >>= 1;
|
||||
const r = this.assets.reader;
|
||||
let entry = OVERWORLD.ContestMons;
|
||||
while (roll >= r.byte(entry)) {
|
||||
roll -= r.byte(entry);
|
||||
entry += 4;
|
||||
}
|
||||
const species = r.byte(entry + 1),
|
||||
min = r.byte(entry + 2),
|
||||
span = r.byte(entry + 3) - min;
|
||||
const level = span ? min + (this.random() % (span + 1)) : min;
|
||||
const lead = state.party.find((mon) => mon.hp > 0);
|
||||
if (!lead || (state.repel && level < lead.level)) return;
|
||||
this.startScript(OVERWORLD.BugCatchingContestBattleScript, PLAYER, {
|
||||
committedPosition: previous,
|
||||
wild: { species, level },
|
||||
});
|
||||
if (state.repel && this.script) this.script.committed.repel++;
|
||||
}
|
||||
/** GetMapEncounterRate: CheckOnWater picks the water table and rate,
|
||||
* which a Cleanse Tag halves. */
|
||||
private encounterTable(state: ScriptState) {
|
||||
@@ -2456,6 +2501,8 @@ export class GameWorld {
|
||||
canLose: effect.canLose,
|
||||
tree: effect.tree,
|
||||
fish: effect.fish,
|
||||
battleType: effect.battleType,
|
||||
contest: draft.bugContest,
|
||||
mapId: this.mapId,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
bag: draft.bag,
|
||||
@@ -3172,6 +3219,7 @@ export class GameWorld {
|
||||
const prompt = this.battle?.prompt;
|
||||
if (
|
||||
prompt?.kind === "yesno" ||
|
||||
prompt?.kind === "contestswitch" ||
|
||||
prompt?.kind === "forget" ||
|
||||
prompt?.kind === "party" ||
|
||||
prompt?.kind === "move"
|
||||
@@ -3182,7 +3230,8 @@ export class GameWorld {
|
||||
private battleA() {
|
||||
const view = this.battle!;
|
||||
const prompt = view.prompt;
|
||||
if (prompt?.kind === "yesno") return this.pumpBattle(view.selected);
|
||||
if (prompt?.kind === "yesno" || prompt?.kind === "contestswitch")
|
||||
return this.pumpBattle(view.selected);
|
||||
if (prompt?.kind === "stats") return this.pumpBattle();
|
||||
if (!prompt && view.message?.prompt) this.pumpBattle();
|
||||
}
|
||||
@@ -3190,7 +3239,8 @@ export class GameWorld {
|
||||
private battleB() {
|
||||
const view = this.battle!;
|
||||
const prompt = view.prompt;
|
||||
if (prompt?.kind === "yesno") return this.pumpBattle(0);
|
||||
if (prompt?.kind === "yesno" || prompt?.kind === "contestswitch")
|
||||
return this.pumpBattle(0);
|
||||
if (prompt?.kind === "forget") return this.pumpBattle(4);
|
||||
if (prompt?.kind === "party" && prompt.cancel) return this.pumpBattle(-1);
|
||||
if (prompt?.kind === "move") return this.pumpBattle(-1);
|
||||
@@ -4046,7 +4096,8 @@ export class GameWorld {
|
||||
}
|
||||
/** Change the highlighted answer (up/down on the original menus). */
|
||||
moveCursor(direction: Direction) {
|
||||
if (this.battle?.prompt?.kind === "yesno") {
|
||||
const battlePrompt = this.battle?.prompt?.kind;
|
||||
if (this.battle && (battlePrompt === "yesno" || battlePrompt === "contestswitch")) {
|
||||
if (direction === "up") this.battle.selected = 1;
|
||||
if (direction === "down") this.battle.selected = 0;
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user