Play the music and sound effects from the ROM's sound engine (Closes #34)
The sound engine (audio/engine.asm, home/audio.asm) is ported on a byte copy of its audio WRAM and a sound register model. A reference test runs the cartridge's routines beside it and matches the WRAM and registers after every UpdateSound for all songs, effects and cries, fades, the danger tone and effect priority. apu.ts synthesizes the four channels in an AudioWorklet; its output matches the reference emulator's APU by spectrum. The world runs the engine twice per tick; Play or the first press unlocks sound, pausing suspends it, Settings sets the volume. Wired in: map music by map setup and special map music, script audio commands, text fanfares, WaitSFX and cries that hold the script frozen (the last page stays shown), battle music, cries, fainting, victory, capture and the low HP tone, Pokémon Center healing, evolution, the intro, the Pokédex's cries, PCs, marts, healing items, talk and menu clicks, bumps, ledges, warps, boulders, the Bicycle and Surf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aff8391e55
commit
7f30c3f450
34 files changed
+3961
-74
No files matched your search
+251
-20
@@ -4,6 +4,9 @@ import {
|
||||
type Decorations,
|
||||
} from "./decorations";
|
||||
import { CrystalAssets } from "../rom/assets";
|
||||
import { GameSound } from "../audio/sound";
|
||||
import { SOUND } from "../audio/constants";
|
||||
import { battleMusic, hpBarPixels, inKanto, victoryMusic } from "../audio/battle-music";
|
||||
import { TrainerData } from "../rom/trainers";
|
||||
import {
|
||||
ENGINE_FLAGS,
|
||||
@@ -57,6 +60,8 @@ import {
|
||||
type Portrait,
|
||||
type ScriptState,
|
||||
type DayTimerName,
|
||||
pageCues,
|
||||
type SoundCue,
|
||||
} from "../scripts/interpreter";
|
||||
import { initialMagikarpRecord, type MagikarpRecord } from "../scripts/lake-of-rage";
|
||||
import {
|
||||
@@ -117,6 +122,7 @@ import {
|
||||
type BattleInput,
|
||||
type BattleOutcome,
|
||||
type RoamMon,
|
||||
type BattleSound,
|
||||
} from "../battle/battle";
|
||||
import { C } from "../battle/constants";
|
||||
import {
|
||||
@@ -240,6 +246,21 @@ function randomByte(): number {
|
||||
}
|
||||
|
||||
/** One overworld tick is two video frames (MaxOverworldDelay). */
|
||||
/** Choices answered through the menu routines that click (MenuClickSound). */
|
||||
const CLICKING_CHOICES = new Set<string>([
|
||||
"yesno",
|
||||
"weekday",
|
||||
"menu",
|
||||
"martmenu",
|
||||
"martlist",
|
||||
"martquantity",
|
||||
"list",
|
||||
"pack",
|
||||
]);
|
||||
|
||||
/** Which map setup script loads a map, for its music. */
|
||||
type MapSetup = "warp" | "door" | "connection" | "continue" | "none";
|
||||
|
||||
export const TICKS_PER_SECOND = 59.7275 / 2;
|
||||
/** STEP_WALK: 8 ticks at 2 pixels per tick. */
|
||||
export const WALK_TICKS = 8;
|
||||
@@ -443,6 +464,8 @@ export interface BattleView {
|
||||
>;
|
||||
/** Yes/No cursor, as on the original boxes. */
|
||||
selected: 0 | 1;
|
||||
/** wOtherTrainerClass, for PlayVictoryMusic; absent in a wild battle. */
|
||||
trainerClass?: number;
|
||||
}
|
||||
/** EvolutionAnimation in overworld ticks (two frames each): 80 frames on
|
||||
* the old species, then .AnimationSequence's seven rounds of a B-checking
|
||||
@@ -793,12 +816,13 @@ export class GameWorld {
|
||||
/** A source of random bytes, like the game's Random routine. */
|
||||
private random: () => number = randomByte,
|
||||
) {
|
||||
this.sound = new GameSound(assets.reader);
|
||||
this.state.decorations = initialDecorations();
|
||||
this.playerId = (this.random() << 8) | this.random();
|
||||
// A new game warps into the bedroom. Its NEWMAP callback runs the
|
||||
// original InitializeEventsScript, which supplies initial NPC flags.
|
||||
const entry = this.prepareMap(INITIAL_POSITION.map, "warp");
|
||||
this.applyMap(entry);
|
||||
this.applyMap(entry, "none");
|
||||
}
|
||||
|
||||
get x() {
|
||||
@@ -1253,7 +1277,11 @@ export class GameWorld {
|
||||
// .NotMoving: bump into the obstacle (not into an edge warp), or stand.
|
||||
this.turning = 0;
|
||||
this.bumping = Boolean(held) && WARP_DIRECTIONS[tile] !== held;
|
||||
if (this.bumping) player.stepFrame = (player.stepFrame + 1) & 0xff;
|
||||
if (this.bumping) {
|
||||
player.stepFrame = (player.stepFrame + 1) & 0xff;
|
||||
// .BumpSound, unless a sound effect already plays.
|
||||
if (!this.sound.sfxPlaying) this.sound.playSFX(SOUND.SFX_BUMP);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1386,7 +1414,11 @@ export class GameWorld {
|
||||
)
|
||||
return false;
|
||||
if (!this.enterable(x, y)) return false;
|
||||
if (permission === OW.LAND_TILE) this.state.playerState = OW.PLAYER_NORMAL;
|
||||
if (permission === OW.LAND_TILE) {
|
||||
// .ExitWater: GetOutOfWater, then PlayMapMusic.
|
||||
this.state.playerState = OW.PLAYER_NORMAL;
|
||||
this.sound.playMapMusic(this.mapMusicId());
|
||||
}
|
||||
if (!player.fixedFacing) player.direction = direction;
|
||||
this.turning = 0x80 | DIRECTION_ORDER.indexOf(direction);
|
||||
this.startMotion(player, x, y, WALK_TICKS, true);
|
||||
@@ -1416,6 +1448,7 @@ export class GameWorld {
|
||||
if (!player.fixedFacing) player.direction = direction;
|
||||
this.turning = 0x80 | DIRECTION_ORDER.indexOf(direction);
|
||||
// SetMinTwoStepWildEncounterCooldown is a no-op in Crystal.
|
||||
this.sound.playSFX(SOUND.SFX_JUMP_OVER_LEDGE);
|
||||
this.startMotion(player, landingX, landingY, WALK_TICKS * 2, true, true);
|
||||
return true;
|
||||
}
|
||||
@@ -1507,12 +1540,23 @@ export class GameWorld {
|
||||
private enterWarp(warp: Warp): boolean {
|
||||
const destination = this.prepareWarp(warp);
|
||||
if (!destination) return false;
|
||||
// WarpToNewMapScript's warpsound: GetWarpSFX for the tile left.
|
||||
const left = this.assets.collision(this.map, this.player.x, this.player.y);
|
||||
// A pit's FallIntoMapScript plays its own sounds on arrival.
|
||||
if (!isPit(left))
|
||||
this.sound.playSFX(
|
||||
left === OW.COLL_DOOR
|
||||
? SOUND.SFX_ENTER_DOOR
|
||||
: left === OW.COLL_WARP_PANEL
|
||||
? SOUND.SFX_WARP_TO
|
||||
: SOUND.SFX_EXIT_BUILDING,
|
||||
);
|
||||
this.player.x = destination.arrival.x;
|
||||
this.player.y = destination.arrival.y;
|
||||
this.player.motion = undefined;
|
||||
if (FACE_DOWN_ON_WARP.has(destination.collision))
|
||||
this.player.direction = "down";
|
||||
this.applyMap(destination.entry);
|
||||
this.applyMap(destination.entry, "door");
|
||||
// WarpToNewMapScript's MAPSETUP_DOOR and FallIntoMapScript's
|
||||
// MAPSETUP_FALL both fall through into MAPSETUP_TRAIN's own
|
||||
// mapsetup UpdateRoamMons.
|
||||
@@ -1543,7 +1587,7 @@ export class GameWorld {
|
||||
direction: "down",
|
||||
motion: undefined,
|
||||
};
|
||||
this.applyMap(entry);
|
||||
this.applyMap(entry, "warp");
|
||||
this.turning = 0;
|
||||
this.checkUpdatePlayerSprite(this.state);
|
||||
}
|
||||
@@ -1601,7 +1645,7 @@ export class GameWorld {
|
||||
const entry = this.prepareMap(target.map.id, "connection");
|
||||
player.x = target.x;
|
||||
player.y = target.y;
|
||||
this.applyMap(entry);
|
||||
this.applyMap(entry, "connection");
|
||||
// MapSetupScript_Connection's own mapsetup UpdateRoamMons.
|
||||
this.applyRoamMons(this.state, target.map.id, false);
|
||||
return;
|
||||
@@ -2144,13 +2188,16 @@ export class GameWorld {
|
||||
}
|
||||
}
|
||||
}
|
||||
private applyMap(entry: {
|
||||
id: string;
|
||||
state: ScriptState;
|
||||
actors: Map<number, Actor>;
|
||||
map: GameMap;
|
||||
stoneTables: number[];
|
||||
}) {
|
||||
private applyMap(
|
||||
entry: {
|
||||
id: string;
|
||||
state: ScriptState;
|
||||
actors: Map<number, Actor>;
|
||||
map: GameMap;
|
||||
stoneTables: number[];
|
||||
},
|
||||
setup: MapSetup,
|
||||
) {
|
||||
this.mapId = entry.id;
|
||||
this.currentMap = entry.map;
|
||||
this.stoneTables = entry.stoneTables;
|
||||
@@ -2163,6 +2210,61 @@ export class GameWorld {
|
||||
);
|
||||
this.sceneCheck = true;
|
||||
this.encounterCooldown = 5; // SetUpFiveStepWildEncounterCooldown
|
||||
this.mapSetupMusic(setup);
|
||||
}
|
||||
|
||||
/** Sounds the script started after the text it is showing. */
|
||||
private soundCues: SoundCue[] = [];
|
||||
/** The last page a script showed, kept on screen during sound waits. */
|
||||
private lastPage?: string;
|
||||
/** The page left in the text box while a script waits for its sound
|
||||
* (WaitSFX) or a delay: presentation only, it takes no buttons. */
|
||||
waitingPage: string | undefined = undefined;
|
||||
/** Play the sounds whose page has come up. */
|
||||
private playCues(page: number) {
|
||||
const due = this.soundCues.filter((entry) => entry.page <= page);
|
||||
if (!due.length) return;
|
||||
this.soundCues = this.soundCues.filter((entry) => entry.page > page);
|
||||
for (const entry of due) entry.play();
|
||||
}
|
||||
|
||||
/** The map setup scripts' music (data/maps/setup_scripts.asm). */
|
||||
private mapSetupMusic(setup: MapSetup) {
|
||||
const sound = this.sound,
|
||||
music = this.mapMusicId();
|
||||
sound.setPlayerState(this.state.playerState);
|
||||
if (setup === "warp") {
|
||||
// MAPSETUP_WARP, _TELEPORT and _FLY: PlayMapMusicBike, FadeInToMusic.
|
||||
sound.playMapMusicBike(music, this.state.playerState);
|
||||
sound.fadeInToMusic();
|
||||
} else if (setup === "door") {
|
||||
// MAPSETUP_DOOR, _FALL and _TRAIN: FadeOutMapMusic, FadeToMapMusic.
|
||||
sound.skipMusic(6);
|
||||
sound.fadeToMapMusic(music);
|
||||
} else if (setup === "connection") sound.fadeToMapMusic(music);
|
||||
else if (setup === "continue") sound.playMapMusicBike(music, this.state.playerState);
|
||||
}
|
||||
|
||||
/** GetMapMusic_MaybeSpecial: Surf's music, the contest gates' ranking
|
||||
* music, then the map's own, which the Rockets can change. */
|
||||
mapMusicId(state = this.state, map = this.map) {
|
||||
if (state.playerState === OW.PLAYER_SURF || state.playerState === OW.PLAYER_SURF_PIKA)
|
||||
return SOUND.MUSIC_SURF;
|
||||
if (
|
||||
state.flags.has(ENGINE_FLAGS.bugContestTimer) &&
|
||||
(map.id === "10:15" || map.id === "10:17")
|
||||
)
|
||||
return SOUND.MUSIC_BUG_CATCHING_CONTEST_RANKING;
|
||||
const music = map.music;
|
||||
if (music === SOUND.MUSIC_MAHOGANY_MART)
|
||||
return state.flags.has(ENGINE_FLAGS.rocketsInMahogany)
|
||||
? SOUND.MUSIC_ROCKET_HIDEOUT
|
||||
: SOUND.MUSIC_CHERRYGROVE_CITY;
|
||||
if (music & 0x80)
|
||||
return state.flags.has(ENGINE_FLAGS.rocketsInRadioTower)
|
||||
? SOUND.MUSIC_ROCKET_OVERTURE
|
||||
: music & 0x7f;
|
||||
return music;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- scripts
|
||||
@@ -2188,6 +2290,8 @@ export class GameWorld {
|
||||
gender: this.avatar === "kris" ? 1 : 0,
|
||||
}),
|
||||
random: () => this.random() & 0xff,
|
||||
sound: this.sound,
|
||||
mapMusic: (state) => this.mapMusicId(state),
|
||||
landmark: () => this.assets.map(mapId).landmark,
|
||||
facing: () => DIRECTION_ORDER.indexOf(this.player.direction),
|
||||
position: () => ({ x: this.player.x, y: this.player.y }),
|
||||
@@ -2392,6 +2496,8 @@ export class GameWorld {
|
||||
}
|
||||
if (this.busy || this.introPending) return false;
|
||||
this.clearNotice();
|
||||
// StartMenu's SFX_MENU; SelectMenu's PlayTalkObject.
|
||||
this.sound.playSFX(select ? SOUND.SFX_READ_TEXT_2 : SOUND.SFX_MENU);
|
||||
this.script = {
|
||||
run: new StartMenuRun(
|
||||
this.state,
|
||||
@@ -2451,6 +2557,7 @@ export class GameWorld {
|
||||
this.script = {
|
||||
run: new IntroRun(this.state, {
|
||||
reader: this.assets.reader,
|
||||
sound: this.sound,
|
||||
now: () => this.now(),
|
||||
text: (player) => ({ ...this.host(PLAYER).text(), player }),
|
||||
}),
|
||||
@@ -2510,7 +2617,21 @@ export class GameWorld {
|
||||
// Commands after a warp would run in the new map in the original.
|
||||
if (script.warp)
|
||||
throw new Error("Script commands after a warp are not ported.");
|
||||
const pages = (effect as { pages?: string[] }).pages;
|
||||
if (pages) {
|
||||
this.soundCues = pageCues(pages);
|
||||
this.playCues(0);
|
||||
this.lastPage = pages.at(-1);
|
||||
} else if (effect.kind !== "waitsfx" && effect.kind !== "delay") this.lastPage = undefined;
|
||||
switch (effect.kind) {
|
||||
case "waitsfx":
|
||||
case "delay": {
|
||||
script.waitTicks = effect.kind === "delay" ? effect.ticks : 0;
|
||||
if (effect.kind === "waitsfx" && !this.sound.sfxPlaying) return false;
|
||||
// The text box keeps its last page meanwhile, as in the original.
|
||||
this.waitingPage = this.lastPage;
|
||||
return true;
|
||||
}
|
||||
case "text":
|
||||
if (!effect.pages.length) return false;
|
||||
this.dialogue = {
|
||||
@@ -2688,6 +2809,7 @@ export class GameWorld {
|
||||
});
|
||||
const engine = new Battle({
|
||||
data: this.assets.pokemon,
|
||||
sound: (sound) => this.battleSound(sound),
|
||||
party: draft.party,
|
||||
pokedex: draft.pokedex,
|
||||
playerId: this.playerId,
|
||||
@@ -2745,8 +2867,19 @@ export class GameWorld {
|
||||
draft.flags.has(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K + bit) ? 1 << bit : 0,
|
||||
).reduce((bits, bit) => bits | bit, 0),
|
||||
});
|
||||
this.battle = { engine, selected: 1 };
|
||||
const trainerClass = tower ? tower.trainerClass : effect.trainer?.classId;
|
||||
this.battle = { engine, selected: 1, trainerClass };
|
||||
this.battleRun = engine.run();
|
||||
// PlayBattleMusic, before the battle transition.
|
||||
this.sound.playBattleMusic(
|
||||
battleMusic({
|
||||
battleType: effect.battleType,
|
||||
trainerClass,
|
||||
trainerId: effect.trainer?.id,
|
||||
night: this.clockTimeOfDay === 2,
|
||||
kanto: inKanto(this.assets.map(this.mapId).landmark),
|
||||
}),
|
||||
);
|
||||
this.pumpBattle();
|
||||
return true;
|
||||
}
|
||||
@@ -2927,7 +3060,15 @@ export class GameWorld {
|
||||
},
|
||||
};
|
||||
return true;
|
||||
case "evolve":
|
||||
case "evolve": {
|
||||
// EvolutionAnimation: silence, the old species' cry (not when it
|
||||
// is asleep or frozen), then MUSIC_EVOLUTION.
|
||||
const mon = script.run.state.party.find((candidate) => candidate.species === effect.from);
|
||||
const sound = this.sound;
|
||||
sound.clearQueue();
|
||||
sound.playMusic(SOUND.MUSIC_NONE);
|
||||
if (!mon || !(mon.status & (C.SLP_MASK | (1 << C.FRZ)))) sound.playMonCry2(effect.from);
|
||||
sound.afterSFX(() => sound.playMusic(SOUND.MUSIC_EVOLUTION));
|
||||
this.picture = effect.from;
|
||||
this.evolution = {
|
||||
from: effect.from,
|
||||
@@ -2937,6 +3078,7 @@ export class GameWorld {
|
||||
schedule: evolutionSchedule(effect.from, effect.to),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
case "pack":
|
||||
this.dialogue = {
|
||||
pages: [""],
|
||||
@@ -3268,6 +3410,8 @@ export class GameWorld {
|
||||
|
||||
private finishScript() {
|
||||
const script = this.script!;
|
||||
this.soundCues = [];
|
||||
this.waitingPage = undefined;
|
||||
if (script.warp) {
|
||||
const { map, x, y, facing, door, roam } = script.warp;
|
||||
const target = this.assets.isPorted(map)
|
||||
@@ -3289,7 +3433,7 @@ export class GameWorld {
|
||||
direction: facing ?? (keep ? this.player.direction : "down"),
|
||||
motion: undefined,
|
||||
};
|
||||
this.applyMap(entry);
|
||||
this.applyMap(entry, door ? "door" : "warp");
|
||||
if (roam) this.applyRoamMons(this.state, map, roam === "jump");
|
||||
this.turning = 0;
|
||||
this.checkUpdatePlayerSprite(this.state);
|
||||
@@ -3317,6 +3461,9 @@ export class GameWorld {
|
||||
this.name = script.run.name;
|
||||
this.avatar = script.run.avatar;
|
||||
this.introPending = false;
|
||||
// ShrinkPlayer's SFX_ESCAPE_ROPE, then the new game's MAPSETUP_WARP.
|
||||
this.sound.playSFX(SOUND.SFX_ESCAPE_ROPE);
|
||||
this.mapSetupMusic("warp");
|
||||
}
|
||||
if (!script.scene || script.run.deferred !== undefined)
|
||||
this.sceneCheck = true;
|
||||
@@ -3347,6 +3494,8 @@ export class GameWorld {
|
||||
}
|
||||
private abortScript(error: unknown) {
|
||||
const script = this.script!;
|
||||
this.soundCues = [];
|
||||
this.waitingPage = undefined;
|
||||
this.battle = undefined;
|
||||
this.battleRun = undefined;
|
||||
this.state = script.rollback.state;
|
||||
@@ -3413,6 +3562,7 @@ export class GameWorld {
|
||||
}
|
||||
this.battle = undefined;
|
||||
this.battleRun = undefined;
|
||||
this.sound.setDanger(false);
|
||||
// ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM.
|
||||
const { result, evolvable } = next.value;
|
||||
this.menuCursors = {
|
||||
@@ -3434,7 +3584,10 @@ export class GameWorld {
|
||||
return;
|
||||
}
|
||||
const effect = next.value;
|
||||
if (effect.kind === "hud") continue;
|
||||
if (effect.kind === "hud") {
|
||||
this.checkDanger();
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "text") {
|
||||
view.message = { text: effect.text, prompt: effect.prompt };
|
||||
this.battleHold = effect.prompt ? 0 : BATTLE_MESSAGE_TICKS;
|
||||
@@ -3452,6 +3605,25 @@ export class GameWorld {
|
||||
"This battle needs game logic that has not been ported yet. Your progress is unchanged.";
|
||||
}
|
||||
}
|
||||
/** A battle's sound: PlayStereoCry, PlaySFX, PlayVictoryMusic. */
|
||||
private battleSound(sound: BattleSound) {
|
||||
const view = this.battle;
|
||||
if (!view) return;
|
||||
if ("cry" in sound) this.sound.playStereoCry(sound.cry, sound.side === "player" ? 0xf0 : 0x0f);
|
||||
else if ("sfx" in sound) this.sound.queueSFX(sound.sfx);
|
||||
else if ("stopDanger" in sound) this.sound.setDanger(false);
|
||||
else if (sound.music === "capture") this.sound.playMusicAfterFrame(SOUND.MUSIC_CAPTURE);
|
||||
else this.sound.playMusicAfterFrame(victoryMusic(view.trainerClass));
|
||||
}
|
||||
/** CheckDanger: the low HP tone while the player's HP bar is red. */
|
||||
private checkDanger() {
|
||||
const view = this.battle;
|
||||
if (!view) return;
|
||||
const mon = view.engine.player.mon;
|
||||
const red =
|
||||
!!mon?.hp && hpBarPixels(mon.hp, mon.maxHp) < 10 && !view.engine.lowHealthAlarmOff;
|
||||
this.sound.setDanger(red);
|
||||
}
|
||||
/** FIGHT with a move slot, or RUN, from the battle menu. */
|
||||
battleAction(action: BattleAction) {
|
||||
if (this.battle?.prompt?.kind === "action") this.pumpBattle(action);
|
||||
@@ -3693,6 +3865,7 @@ export class GameWorld {
|
||||
false,
|
||||
);
|
||||
npc.step = "boulder";
|
||||
this.sound.playSFX(SOUND.SFX_STRENGTH);
|
||||
this.animate(actor);
|
||||
return;
|
||||
}
|
||||
@@ -3866,9 +4039,16 @@ export class GameWorld {
|
||||
trainerCard: TrainerCardView | undefined;
|
||||
private cardFrames = 0;
|
||||
ticks = 0;
|
||||
/** The sound engine and the home routines that drive it. */
|
||||
readonly sound: GameSound;
|
||||
|
||||
tick(held?: Direction) {
|
||||
this.ticks++;
|
||||
// VBlank's UpdateSound, once per video frame.
|
||||
this.sound.setPlayerState(this.state.playerState);
|
||||
this.sound.setStereo(((this.state.options.options >> OW.STEREO) & 1) === 1);
|
||||
this.sound.frame();
|
||||
this.sound.frame();
|
||||
// GameTimer: two frames per overworld tick, once the game has started.
|
||||
if (!this.introPending) {
|
||||
advanceGameTime(this.gameTime);
|
||||
@@ -3906,6 +4086,18 @@ export class GameWorld {
|
||||
this.cardFrames += 2;
|
||||
refreshTrainerCard(card, this.cardFrames, this.gameTime);
|
||||
}
|
||||
if (script.effect?.kind === "delay" || script.effect?.kind === "waitsfx") {
|
||||
// WaitSFX holds everything but the sound; a long wait gives up.
|
||||
const done =
|
||||
script.effect.kind === "delay"
|
||||
? --script.waitTicks <= 0
|
||||
: !this.sound.sfxPlaying || ++script.waitTicks > 900;
|
||||
if (done) {
|
||||
this.waitingPage = undefined;
|
||||
this.pump();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const evolution = this.evolution;
|
||||
if (evolution) {
|
||||
const frame = evolution.schedule[++evolution.step];
|
||||
@@ -4065,7 +4257,14 @@ export class GameWorld {
|
||||
(object) => object.x === x && object.y === y,
|
||||
);
|
||||
let script: number | undefined;
|
||||
// PlayTalkObject (and PlayClickSFX for tile scripts) as an event starts.
|
||||
const talk = () => this.sound.playSFX(SOUND.SFX_READ_TEXT_2);
|
||||
if (object) {
|
||||
if (object.kind !== 0 && object.kind !== 1 && object.kind !== OBJECTTYPE_TRAINER) {
|
||||
this.notice = "This interaction is not ported yet.";
|
||||
return;
|
||||
}
|
||||
talk();
|
||||
if (object.kind === 1) {
|
||||
// OBJECTTYPE_ITEMBALL: the script field names raw data (the itemball
|
||||
// macro's item, quantity), not a script; FindItemInBallScript always
|
||||
@@ -4115,6 +4314,7 @@ export class GameWorld {
|
||||
item: r.byte(event.script + 2),
|
||||
};
|
||||
if (this.state.events.has(hidden.event)) return;
|
||||
talk();
|
||||
this.startScript(SYMBOLS.hiddenItemScript, PLAYER, {
|
||||
hiddenItem: hidden,
|
||||
});
|
||||
@@ -4137,11 +4337,14 @@ export class GameWorld {
|
||||
}
|
||||
if (script === undefined) {
|
||||
const [fx, fy] = VECTORS[this.player.direction];
|
||||
if (this.tryTileCollisionEvent(this.x + fx, this.y + fy, collision))
|
||||
if (this.tryTileCollisionEvent(this.x + fx, this.y + fy, collision)) {
|
||||
if (this.script) talk();
|
||||
return;
|
||||
}
|
||||
this.notice = "Nothing to interact with here.";
|
||||
return;
|
||||
}
|
||||
talk();
|
||||
this.startScript(script, PLAYER);
|
||||
}
|
||||
|
||||
@@ -4290,10 +4493,16 @@ export class GameWorld {
|
||||
OW.LAND_TILE;
|
||||
if (!allowed) return undefined;
|
||||
// .CheckIfRegistered: Select uses the scripts without a message.
|
||||
if (state.playerState === OW.PLAYER_NORMAL)
|
||||
if (state.playerState === OW.PLAYER_NORMAL) {
|
||||
// .GetOnBike: MUSIC_BICYCLE at once, as wMapMusic.
|
||||
this.sound.engine.musicFade = 0;
|
||||
this.sound.playMusicAfterFrame(SOUND.MUSIC_BICYCLE);
|
||||
this.sound.engine.maxVolume();
|
||||
this.sound.engine.mapMusic = SOUND.MUSIC_BICYCLE;
|
||||
return select
|
||||
? OVERWORLD.Script_GetOnBike_Register
|
||||
: OVERWORLD.Script_GetOnBike;
|
||||
}
|
||||
if (state.playerState === OW.PLAYER_BIKE)
|
||||
return state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
|
||||
? OVERWORLD.Script_CantGetOffBike
|
||||
@@ -4347,6 +4556,23 @@ export class GameWorld {
|
||||
private finishEvolution(stopped: boolean) {
|
||||
const evolution = this.evolution!;
|
||||
this.evolution = undefined;
|
||||
// .PlayEvolvedSFX and the new picture's cry, or the old one's when
|
||||
// stopped; then EvolvePokemon's jingle under EvolvedIntoText. Outside
|
||||
// a battle's aftermath, ReturnToMap's RestartMapMusic follows.
|
||||
const sound = this.sound,
|
||||
afterBattle = Boolean(this.script?.interlude);
|
||||
sound.clearQueue();
|
||||
if (stopped) sound.playMonCry2(evolution.from);
|
||||
else
|
||||
sound.afterSFX(
|
||||
() => sound.playSFX(SOUND.SFX_EVOLVED),
|
||||
() => sound.playMonCry2(evolution.to),
|
||||
() => {
|
||||
sound.playMusic(SOUND.MUSIC_NONE);
|
||||
sound.playSFX(SOUND.SFX_CAUGHT_MON);
|
||||
},
|
||||
);
|
||||
if (!afterBattle) sound.afterSFX(() => sound.restartMapMusic());
|
||||
this.picture = stopped ? evolution.from : evolution.to;
|
||||
this.pump(stopped ? 1 : 0);
|
||||
}
|
||||
@@ -4383,7 +4609,10 @@ export class GameWorld {
|
||||
return;
|
||||
}
|
||||
if (!last) {
|
||||
// PromptButton's SFX_READ_TEXT_2 after the button.
|
||||
this.sound.playSFX(SOUND.SFX_READ_TEXT_2);
|
||||
dialogue.index++;
|
||||
this.playCues(dialogue.index);
|
||||
return;
|
||||
}
|
||||
this.townMap = false;
|
||||
@@ -4510,6 +4739,8 @@ export class GameWorld {
|
||||
!(dialogue.choice.realtime && answer === ""))
|
||||
)
|
||||
return;
|
||||
// MenuClickSound: menus click on A or B.
|
||||
if (CLICKING_CHOICES.has(dialogue.choice.kind)) this.sound.playSFX(SOUND.SFX_READ_TEXT_2);
|
||||
this.townMap = false;
|
||||
this.statsScreen = undefined;
|
||||
this.mailView = undefined;
|
||||
@@ -4913,7 +5144,7 @@ export class GameWorld {
|
||||
throw error;
|
||||
}
|
||||
this.script = undefined;
|
||||
this.applyMap(entry);
|
||||
this.applyMap(entry, continued ? "continue" : "none");
|
||||
for (const object of save.objects) {
|
||||
const source = map.objects.find(
|
||||
(candidate) => candidate.id === object.id,
|
||||
|
||||
Reference in new issue
Block a user