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>
156 lines
7.9 KiB
TypeScript
156 lines
7.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import { CrystalAssets } from "../src/game/rom/assets";
|
||
import { GameWorld } from "../src/game/world/world";
|
||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||
import { SOUND } from "../src/game/audio/constants";
|
||
import { battleMusic, hpBarPixels, victoryMusic, MUSIC_TRAINERS as T } from "../src/game/audio/battle-music";
|
||
import { battleFixtureWorld, ENCOUNTER_WALK } from "./battle-fixture";
|
||
import { settle } from "./world-driver";
|
||
|
||
const romPath =
|
||
process.env.CRYSTAL_ROM_PATH ??
|
||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||
|
||
describe("battle music choices (PlayBattleMusic, PlayVictoryMusic)", () => {
|
||
it("picks the wild, trainer, leader, rival and Rocket songs", () => {
|
||
expect(battleMusic({ night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_WILD_BATTLE);
|
||
expect(battleMusic({ night: true, kanto: false })).toBe(SOUND.MUSIC_JOHTO_WILD_BATTLE_NIGHT);
|
||
expect(battleMusic({ night: true, kanto: true })).toBe(SOUND.MUSIC_KANTO_WILD_BATTLE);
|
||
expect(battleMusic({ battleType: T.BATTLETYPE_ROAMING, night: false, kanto: false })).toBe(SOUND.MUSIC_SUICUNE_BATTLE);
|
||
expect(battleMusic({ trainerClass: T.FALKNER, night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_GYM_LEADER_BATTLE);
|
||
expect(battleMusic({ trainerClass: T.BROCK, night: false, kanto: true })).toBe(SOUND.MUSIC_KANTO_GYM_LEADER_BATTLE);
|
||
expect(battleMusic({ trainerClass: T.GRUNTM, night: false, kanto: false })).toBe(SOUND.MUSIC_ROCKET_BATTLE);
|
||
expect(battleMusic({ trainerClass: T.RIVAL1, night: false, kanto: false })).toBe(SOUND.MUSIC_RIVAL_BATTLE);
|
||
expect(battleMusic({ trainerClass: T.RIVAL2, trainerId: 4, night: false, kanto: false })).toBe(SOUND.MUSIC_CHAMPION_BATTLE);
|
||
expect(battleMusic({ trainerClass: T.RED, night: false, kanto: true })).toBe(SOUND.MUSIC_CHAMPION_BATTLE);
|
||
// Youngsters and the like: by region.
|
||
expect(battleMusic({ trainerClass: 30, night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_TRAINER_BATTLE);
|
||
expect(battleMusic({ trainerClass: 30, night: false, kanto: true })).toBe(SOUND.MUSIC_KANTO_TRAINER_BATTLE);
|
||
expect(victoryMusic(undefined)).toBe(SOUND.MUSIC_WILD_VICTORY);
|
||
expect(victoryMusic(T.MORTY)).toBe(SOUND.MUSIC_GYM_VICTORY);
|
||
// IsGymLeader's list runs on into the Kanto leaders.
|
||
expect(victoryMusic(T.BLUE)).toBe(SOUND.MUSIC_GYM_VICTORY);
|
||
expect(victoryMusic(30)).toBe(SOUND.MUSIC_TRAINER_VICTORY);
|
||
});
|
||
it("measures the HP bar as ComputeHPBarPixels does", () => {
|
||
expect(hpBarPixels(0, 20)).toBe(0);
|
||
expect(hpBarPixels(1, 200)).toBe(1);
|
||
expect(hpBarPixels(20, 20)).toBe(48);
|
||
expect(hpBarPixels(4, 20)).toBe(9);
|
||
expect(hpBarPixels(5, 20)).toBe(12);
|
||
// Above 255 HP both sides are divided by four first.
|
||
expect(hpBarPixels(100, 400)).toBe(12);
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!existsSync(romPath))("sound in the world with the local ROM", () => {
|
||
const load = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||
/** Every song and sound effect the engine is asked to start. */
|
||
const record = (world: GameWorld) => {
|
||
const music: number[] = [],
|
||
sfx: number[] = [],
|
||
cries: number[] = [];
|
||
const engine = world.sound.engine;
|
||
const playMusic = engine.playMusic.bind(engine),
|
||
playSFX = engine.playSFX.bind(engine),
|
||
playCry = engine.playCryWith.bind(engine);
|
||
engine.playMusic = (id) => {
|
||
if (id) music.push(id);
|
||
playMusic(id);
|
||
};
|
||
engine.playSFX = (id) => {
|
||
sfx.push(id);
|
||
playSFX(id);
|
||
};
|
||
engine.playCryWith = (id, pitch, length) => {
|
||
cries.push(id);
|
||
playCry(id, pitch, length);
|
||
};
|
||
return { music, sfx, cries };
|
||
};
|
||
|
||
it("plays a wild battle's music, cries and victory, then the map's music again", () => {
|
||
const world = battleFixtureWorld(load(), "win");
|
||
world.restore(world.save());
|
||
const heard = record(world);
|
||
const mapMusic = world.sound.engine.mapMusic;
|
||
expect(mapMusic).toBe(world.mapMusicId());
|
||
for (const direction of ENCOUNTER_WALK) world.move(direction);
|
||
expect(world.battle).toBeDefined();
|
||
// PlayBattleMusic (noon: day music), then the wild Pokémon's cry.
|
||
expect(heard.music).toContain(SOUND.MUSIC_JOHTO_WILD_BATTLE);
|
||
const pidgey = world.sound.engine.cryEntry(16).id;
|
||
expect(heard.cries[0]).toBe(pidgey);
|
||
for (let tick = 0; tick < 10000 && (world.battle || world.busy); tick++) {
|
||
const view = world.battle;
|
||
if (view?.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
|
||
else if (view?.prompt?.kind === "yesno") world.battleChoose(1);
|
||
else if (view?.prompt?.kind === "forget") world.battleChoose(3);
|
||
else if (view && (view.prompt?.kind === "stats" || view.message?.prompt)) world.interact();
|
||
else if (world.dialogue) world.advanceDialogue();
|
||
else world.tick();
|
||
}
|
||
expect(world.battle).toBeUndefined();
|
||
// The enemy fainted (SFX_KINESIS, SFX_FAINT), the wild victory played,
|
||
// and reloadmapafterbattle restarted the map's music.
|
||
expect(heard.music).toContain(SOUND.MUSIC_WILD_VICTORY);
|
||
for (let tick = 0; tick < 200; tick++) world.tick();
|
||
expect(heard.sfx).toContain(SOUND.SFX_KINESIS);
|
||
expect(heard.sfx).toContain(SOUND.SFX_FAINT);
|
||
expect(heard.music.at(-1)).toBe(mapMusic);
|
||
expect(world.sound.engine.channelMusicId(0)).toBe(mapMusic);
|
||
expect(world.sound.engine.lowHealthAlarm).toBe(false);
|
||
});
|
||
|
||
it("sounds the low HP tone while the player's HP bar is red", () => {
|
||
const world = battleFixtureWorld(load(), "loss");
|
||
for (const direction of ENCOUNTER_WALK) world.move(direction);
|
||
for (let tick = 0; tick < 400 && world.battle && world.battle.prompt?.kind !== "action"; tick++)
|
||
if (world.battle.message?.prompt) world.interact();
|
||
else world.tick();
|
||
expect(world.battle?.prompt?.kind).toBe("action");
|
||
expect(world.sound.engine.lowHealthAlarm).toBe(true);
|
||
});
|
||
|
||
it("heals with the Pokémon Center's jingle, then the map's music returns", () => {
|
||
const assets = load();
|
||
const world = new GameWorld(assets, () => new Date("2026-09-22T13:00:00"));
|
||
const mon = generatePartyMon(assets.pokemon, {
|
||
species: 155, level: 10, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
||
});
|
||
mon.hp = 1;
|
||
world.state.party.push(mon, structuredClone(mon));
|
||
world.restore({ ...world.save(), map: "26:5", x: 3, y: 3, direction: "up" });
|
||
const heard = record(world);
|
||
world.interact();
|
||
// PlayTalkObject
|
||
expect(heard.sfx).toEqual([SOUND.SFX_READ_TEXT_2]);
|
||
const ticksBefore = world.ticks;
|
||
const pages = settle(world, [1]);
|
||
expect(pages).toContain("Your POKéMON are\nfully healed.");
|
||
// A ball sound for each Pokémon, the heal jingle, and the map's music.
|
||
expect(heard.sfx.filter((id) => id === SOUND.SFX_SECOND_PART_OF_ITEMFINDER)).toHaveLength(2);
|
||
expect(heard.music).toContain(SOUND.MUSIC_HEAL);
|
||
expect(heard.music.at(-1)).toBe(SOUND.MUSIC_POKEMON_CENTER);
|
||
// HealMachineAnim's 2 × 30 + 80 frames, then pause 30.
|
||
expect(world.ticks - ticksBefore).toBeGreaterThan(70 + 15);
|
||
});
|
||
|
||
it("plays the warp's sound and fades to the next map's music", () => {
|
||
const world = new GameWorld(load(), () => new Date("2026-09-22T13:00:00"));
|
||
// New Bark Town, below Elm's lab door.
|
||
world.restore({ ...world.save(), map: "24:4", x: 6, y: 4, direction: "up" });
|
||
const heard = record(world);
|
||
expect(world.sound.engine.mapMusic).toBe(SOUND.MUSIC_NEW_BARK_TOWN);
|
||
world.move("up");
|
||
for (let tick = 0; tick < 40; tick++) world.tick();
|
||
expect(world.mapId).toBe("24:5");
|
||
expect(heard.sfx[0]).toBe(SOUND.SFX_ENTER_DOOR);
|
||
// FadeToMapMusic: Elm's lab plays MUSIC_PROF_ELM once the fade ends.
|
||
for (let tick = 0; tick < 200; tick++) world.tick();
|
||
expect(world.sound.engine.mapMusic).toBe(SOUND.MUSIC_PROF_ELM);
|
||
expect(world.sound.engine.channelMusicId(0)).toBe(SOUND.MUSIC_PROF_ELM);
|
||
});
|
||
});
|